Quick start
Create an Online Store account, then open API keysin your dashboard and create a test key. You can start testing straight away; there's nothing to wait for.
Set $BASE to https://api.drum.ng/api/v1/partner-dispatch and $KEY to your drm_test_… key. Five calls take a test delivery from quote to doorstep. Prefer clicking? The Sandbox console in your dashboard sends the same requests and shows the code for cURL, Python and Node.js.
1. Check your key.
curl -X GET "$BASE/me" \
-H "X-API-Key: $KEY"2. Get a quote. It holds for 15 minutes.
curl -X POST "$BASE/quotes" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"pickup": {
"address": "12 Admiralty Way, Lekki Phase 1",
"lat": 6.4474,
"lng": 3.4723,
"contact_name": "Lekki Store",
"contact_phone": "+2348031234567"
},
"dropoff": {
"address": "5 Allen Avenue, Ikeja",
"lat": 6.6018,
"lng": 3.3515,
"contact_name": "Ada Okafor",
"contact_phone": "+2348098765432"
},
"package_description": "Groceries, 2 bags",
"metadata": {
"order_id": "ORD-10045"
}
}'3. Book it. The rider comes back in the response.
curl -X POST "$BASE/shipments" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2a9e-booking-ORD-10045" \
-d '{
"quote_id": "DRM-SHP-261013-101500-A3F7C1B9E2",
"external_ref": "ORD-10045"
}'4. Add your webhook. Save the signing secret; you only see it once.
curl -X PUT "$BASE/webhooks" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/webhooks/drum"
}'5. Move it along. Test deliveries also advance on their own every 30 seconds.
curl -X POST "$BASE/sandbox/shipments/DRM-SHP-261013-101500-A3F7C1B9E2/advance" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{}'You get an event at each step: shipment.assigned, shipment.picked_up, shipment.in_transit, shipment.delivered. Full reference: $BASE/docs.
Test and live
One base URL. Your key picks the mode.
| Test | Live | |
|---|---|---|
| Key | drm_test_… | drm_live_… |
| Available | As soon as you sign up | After Drum approves your go-live request |
| Riders | Courier sandbox, no real riders | Real riders |
| Cost | Free: test credit only | Charged to your wallet when you book |
| Prices | Courier sandbox rate + Drum platform fee | Live courier rate + Drum platform fee |
| Status updates | Every ~30 s, or on demand with /sandbox/…/advance | As the rider moves |
| Wallet | Separate test wallet, ₦1,000,000 to start, instant free top-ups | Prepaid, topped up with Paystack |
Test and live never mix. They have separate deliveries, separate wallets and separate statements. A live key can’t see test deliveries, test money can never pay for a live delivery, and order references only need to be unique within each mode.
You see a key once, when you create it. Lost or leaked? Revoke it in your dashboard and create a new one. You can run two keys side by side to rotate without downtime.
Keys and limits
Send your key as X-API-Key on every request, from your servers only. Never ship it in a browser or mobile app.
Keys come with every permission. Ask for a read-only key if a system only needs to look things up.
| Permission | Lets you |
|---|---|
dispatch:quote | Get quotes |
dispatch:create | Book deliveries (and advance test deliveries) |
dispatch:read | Look up and list deliveries |
dispatch:cancel | Cancel deliveries |
wallet:read | See your balance and statement |
wallet:fund | Top up |
webhooks:manage | Set, test and rotate your webhook |
- Lock keys to your servers. Send us your outbound IPs and we’ll reject calls from anywhere else.
- 120 requests a minute per key. Go over and you get
429with aRetry-Afterheader. - Give bookings 45 seconds. A live booking waits until a rider is assigned.
GET /meshows which mode and permissions a key has.- Once you’re live, only a live key can change your webhook. Test keys can still read it.
Booking a delivery
Quote with POST /quotes, then book with POST /shipments. The quote id stays the delivery id from then on.
POST /quotes
| Field | Type | Required | Notes |
|---|---|---|---|
pickup, dropoff | object | Yes | See below |
package_description | text, up to 200 | No | Shown to the rider |
weight_kg | 0–50 | No | Default 1 |
declared_value | NGN | No | Default 5,000. Covers the package in transit |
service_level | standard | premium | expedient | No | Default standard (cheapest courier). Other levels aren't offered on every route |
metadata | up to 20 keys | No | Returned on the delivery and every event |
Each stop needs address, lat, lng, contact_name and contact_phone. address_line_2, city, state (both default to Lagos) and contact_email are optional. Send the customer’s map pin: riders navigate by it.
POST /shipments
| Field | Required | Notes |
|---|---|---|
quote_id | Yes | Within 15 minutes of quoting |
external_ref | Recommended | Your order number. Find deliveries by it with GET /shipments?external_ref= |
Add an Idempotency-Key header to every booking. If a call times out, send it again with the same key and body: you get the first answer back and are charged once.
A booked delivery looks like this, everywhere: in responses, lookups and events.
{
"id": "DRM-SHP-261013-101500-A3F7C1B9E2",
"object": "shipment",
"environment": "live",
"status": "assigned",
"external_ref": "ORD-10045",
"pickup": {
"address": "12 Admiralty Way, Lekki Phase 1",
"lat": 6.4474,
"lng": 3.4723,
"contact_name": "Lekki Store",
"contact_phone": "+2348031234567"
},
"dropoff": {
"address": "5 Allen Avenue, Ikeja",
"lat": 6.6018,
"lng": 3.3515,
"contact_name": "Ada Okafor",
"contact_phone": "+2348098765432"
},
"package": {
"description": "Groceries, 2 bags",
"weight_kg": 1,
"value": 5000
},
"pricing": {
"delivery_fee": 2500,
"platform_fee": 500,
"total": 3000,
"currency": "NGN"
},
"eta": {
"pickup": "30 mins",
"delivery": "2 hrs"
},
"rider": {
"name": "Musa Bello",
"phone": "+2348011112222",
"vehicle_type": "Motorcycle",
"fleet": "Kwik"
},
"last_location": null,
"tracking_url": "https://…/track/DRM-SHP-261013-101500-A3F7C1B9E2",
"metadata": {
"order_id": "ORD-10045"
},
"quote_expires_at": null,
"assigned_at": "2026-10-13T10:15:42+00:00",
"picked_up_at": null,
"created_at": "2026-10-13T10:15:00+00:00",
"updated_at": "2026-10-13T10:15:42+00:00"
}A quote has the same shape, with "status": "quoted", no rider yet, and a quote_expires_at. Send tracking_url to your customer.
Look up and cancel
GET /shipments/{id}returns one delivery.GET /shipments?status=&external_ref=&page=1lists them, newest first, up to 100 a page.POST /shipments/{id}/cancelworks until pickup. Once a rider is assigned, the platform fee is kept and the delivery fee is refunded; the response'srefundshows the exact amounts.- While a courier is still being booked (the first minutes after
POST /shipments), a cancel returns409 dispatch_in_progress. Try again shortly.
Delivery status
You can cancel any time before pickup.
If a rider hasn't picked up 30 minutes after being assigned, we send another one. The status stays assigned and you get shipment.rider_reassigned, at no extra cost. If a delivery stops moving after pickup, it shows delayed and we chase the rider.
Webhooks
We post every status change to your URL, signed so you know it came from us. Set it with PUT /webhooks. It must be https and public.
| Event | When |
|---|---|
shipment.assigned | A rider is assigned |
shipment.picked_up | The rider has the package |
shipment.in_transit | On the way to your customer |
shipment.delayed | Stopped moving after pickup |
shipment.rider_reassigned | We swapped in a new rider |
shipment.delivered | Delivered |
shipment.cancelled | Cancelled by you, or no rider found (refunded) |
wallet.funded | Your top-up landed |
ping | You called POST /webhooks/test |
{
"id": "evt_5b0e7c2f9a8d4e1f8c3b2a1d0e9f8a7b",
"type": "shipment.picked_up",
"created_at": "2026-10-13T10:31:07+00:00",
"environment": "live",
"data": {
"shipment": {
"...": "same shape as GET /shipments/{id}"
}
}
}Check the Drum-Signature header before you trust an event. It is an HMAC-SHA256 of "<t>." + raw body using your webhook secret:
import hmac, hashlib, time
def verify(secret: str, raw_body: bytes, header: str, tolerance=300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
ts, sig = int(parts["t"]), parts["v1"]
if abs(time.time() - ts) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)const crypto = require("crypto");
function verify(secret, rawBody /* Buffer */, header, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map(p => p.split("=", 2)));
const ts = Number(parts.t);
if (!parts.v1 || Math.abs(Date.now() / 1000 - ts) > tolerance) return false;
const expected = crypto.createHmac("sha256", secret)
.update(Buffer.concat([Buffer.from(`${ts}.`), rawBody])).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}- Answer with a
2xxwithin 10 seconds. Do the slow work afterwards. - No answer? We retry 8 times over about an hour.
- Events can repeat or arrive out of order. Skip ids you’ve seen and go by
updated_at. - Test and live events share your URL. Check
environmentbefore acting. GET /webhooks/eventsshows recent deliveries and any failures.
Paying for deliveries
Live deliveries are prepaid. Top up your Drum wallet with Paystack and each booking takes its quoted total from the balance. If the balance is short, the booking is declined and the quote stays open.
Top up with POST /wallet/fund and pay at the Paystack link it returns:
{
"amount": 100000,
"callback_url": "https://ops.example.com/drum/funded"
}Top-ups run from ₦1,000 to ₦50,000,000. Bank transfer and USSD are credited in full; cards carry Paystack’s 1.5% fee.
| What happens | Your wallet |
|---|---|
| You book | Charged the quoted total |
| You cancel before a rider is assigned | Refunded in full |
| You cancel after a rider is assigned, before pickup | Delivery fee refunded; the platform fee is kept |
| No rider is found | Refunded in full, straight away |
| We swap riders | No extra charge |
GET /wallet shows your balance. GET /wallet/transactions is your statement.
Your test wallet
Test keys use a separate test wallet that starts with ₦1,000,000 of test credit. Test bookings are charged and refunded exactly like live ones, so you can test low balances and refunds. With a test key, POST /wallet/fundadds test funds instantly. There's no Paystack checkout, and a test wallet holds up to ₦10,000,000. Test money never reaches your live wallet.
Errors
Every error looks the same. Act on error.code:
{
"error": {
"code": "insufficient_funds",
"message": "Prepaid wallet balance is too low for this shipment",
"required": "3000",
"available": "1200",
"currency": "NGN"
}
}| HTTP | Code | What to do |
|---|---|---|
| 401 | invalid_api_key | Check the key, or ask us for a new one |
| 402 | insufficient_funds | Top up, then book the same quote |
| 403 | insufficient_scope | Use a key with that permission |
| 403 | ip_not_allowed | Call from an approved server |
| 403 | live_not_enabled | Keep testing, and request go-live from your dashboard |
| 403 | live_key_required | You’re live: change webhook settings with a live key |
| 403 | partner_suspended | Contact us |
| 403 | live_only / test_only | Use the other key |
| 404 | not_found | Check the id and the mode |
| 409 | already_booked | Already booked. Use the delivery returned |
| 409 | duplicate_external_ref | That order is already booked |
| 409 | not_cancellable | Already picked up |
| 409 | dispatch_in_progress | A courier is being booked. Retry the cancel in a minute |
| 409 | courier_confirmation_pending | We’re confirming the booking with the courier. Contact us if it’s urgent |
| 409 | sandbox_limit | The test wallet is full (₦10,000,000 max) |
| 409 | request_in_progress / idempotency_conflict | Retry shortly with the same Idempotency-Key |
| 409 | webhook_not_configured | Set a URL with PUT /webhooks |
| 409 | missing_contact_email / wallet_frozen | Contact us |
| 409 | cannot_advance | That test delivery is finished |
| 410 | quote_expired | Get a new quote |
| 422 | invalid_request | Fix the fields listed in errors |
| 422 | idempotency_key_reused | Use a new key for a new booking |
| 422 | service_level_unavailable | That level isn't offered on this route. Quote with standard |
| 422 | invalid_idempotency_key / invalid_webhook_url / invalid_status | Fix the value |
| 429 | rate_limited | Wait for Retry-After |
| 502 | quote_unavailable | Try again in a moment |
| 502 | dispatch_failed | No rider found. You’ve been refunded; quote again |
| 502 | cancel_failed | Nothing changed. Try the cancel again |
| 5xx | upstream_error / service_unavailable / internal_error | Retry with the same Idempotency-Key |
Going live
Test as long as you like. When your setup can do the following, click Request live access in your dashboard:
- Book, track and cancel a delivery end to end.
- Check webhook signatures and ignore repeats.
- Send an
Idempotency-Keyand your order number with every booking.
Once Drum approves the request, you can create live keys, top up your live wallet and book real deliveries. To lock your keys to your servers, send us your outbound IPs.