Integration API
Create and track parcels with Drop-Off programmatically, from your online store, order system, or any backend. Just an API key and HTTP.
On this page
Overview
An API key and HTTP are all you need to book a shipment, quote a fee before booking, list and inspect shipments, cancel one that hasn't moved, follow tracking, print a label, and read your COD and settlement records.
Machine-readable: OpenAPI 3 spec · Postman collection. Import the collection into Postman, set the base_url and api_key variables (start with a dk_test_ key), and run.
Every request is authenticated with a per-business API key and returns JSON (label endpoints return a PDF). The dashboard stays available for everything else; this API is purely additive.
A key issued for one business can never read or act on another's. It never sees another business's shipments or money. The settlement figures themselves are available over the API; only the downloadable statement PDF and your account settings stay in the dashboard.
How the integration works
The API is headless. Your own system (your online store, order admin, or warehouse app) talks to Drop-Off over HTTP. You do not rebuild the Drop-Off dashboard or match its look. You add a few calls to the workflow you already have, and Drop-Off handles the deliveries.
Your system does
- Holds the API key as a server-side secret
- Quotes a delivery fee at checkout
- Creates a shipment when an order is confirmed
- Prints the waybill label
- Shows delivery status on the order, from tracking
- Reconciles COD and settlements in your accounts
Drop-Off handles
- Pricing and invoicing
- Pickup, hubs, sorting and dispatch
- Rider assignment and routing
- Delivery and proof of delivery
- COD collection and settlement statements
- Customer SMS notifications
Your developer wires this into whatever stack you run, such as a Shopify or WooCommerce plugin or a custom backend. The only interface you might add is small and in your own branding: a "Ship with Drop-Off" button on an order, a status badge on the customer's order page, or a "Print label" action. Nothing needs to resemble Drop-Off.
You do not have to automate anything. The Drop-Off dashboard stays available for creating shipments, printing labels, and viewing COD and settlements visually. The API is simply the automation layer for teams that want orders to become deliveries without manual steps.
Getting started
Create a key
In your dashboard open API / Integrations, name a key, pick its permissions, and generate it. The key is shown once, so copy it then.
Make a call
Send the key as a Bearer token. Try a quote first; it reads pricing without creating anything.
Book & track
Create shipments at checkout, store the tracking_number, and poll tracking for status.
curl https://app.dropoffcouriers.com/api/v1/business/shipments/quote \ -X POST \ -H "Authorization: Bearer $DROPOFF_API_KEY" \ -H "Content-Type: application/json" \ -d '{"service_type": "standard"}'
Quickstart (5 minutes)
With a Test key (dk_test_… from API / Integrations) you can go from nothing to a tracked shipment in three calls. Test shipments are isolated: no invoice, no SMS, invisible to the live dashboard.
1. Check the key - list zones (also proves auth):
curl https://app.dropoffcouriers.com/api/v1/business/zones \ -H "Authorization: Bearer $DROPOFF_API_KEY"
2. Quote a fee:
curl https://app.dropoffcouriers.com/api/v1/business/shipments/quote \ -X POST -H "Authorization: Bearer $DROPOFF_API_KEY" -H "Content-Type: application/json" \ -d '{"service_type":"standard"}' # → {"data":{"delivery_fee":4900,"currency":"UGX","service_type":"standard","pricing_mode":"zone"}}
3. Create a shipment (store the tracking_number):
curl https://app.dropoffcouriers.com/api/v1/business/shipments \ -X POST -H "Authorization: Bearer $DROPOFF_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"receiver_name":"Jane Receiver","receiver_phone_number":"0772123456","receiver_address":"Plot 5, Ntinda, Kampala","service_type":"standard","payment_type":"sender_pays","item_name":"Phone case","external_reference":"ORD-5001"}'
Then poll GET /shipments/{id}/tracking or register a webhook. Swap the key to dk_live_… to book real deliveries.
Core concepts
Test & live modes
Mode is decided by the key (dk_test_ / dk_live_), not a separate host. A test key persists an isolated test shipment (no invoice, no SMS, excluded from the dashboard, manifest, COD, settlements, reports and n8n) and marks responses with meta.test:true. Test data is deleted after 14 days. A live response has no meta block.
Pricing modes
Each business is priced one of two ways (set by Drop-Off at approval), and both quote and the charged fee use the same engine:
- Flat (
pricing_mode: weight) - your negotiatedstandard/expressrate per parcel, plus any excess-weight surcharge above the included 5 kg. - Zone (
pricing_mode: zone, default) - a per-zone, per-size price from the zone matrix. If no zone resolves, the call fails visibly with422 pricing_unavailablerather than guessing a price; pass azone_id(or acitythat maps to one) and asize_category.
quote returns the active pricing_mode. Amounts are whole UGX integers (no decimals, no currency symbol); currency is always "UGX".
Shipment lifecycle & status mapping
A shipment's status moves forward through this machine. Only the milestones below emit a webhook; others are internal hub steps. "Customer SMS" is what the receiver is texted (not you).
API status | Webhook event | Tracking label | Customer SMS |
|---|---|---|---|
created | shipment.created | Created | Shipment registered |
collected | shipment.picked_up | Collected | Picked up from sender |
received_at_hub | - | Received at hub | - |
sorted | - | Sorted | - |
dispatched | - | Dispatched | - |
handed_to_partner | - | Handed to partner | - |
received_from_partner | - | Received from partner | - |
out_for_delivery | shipment.out_for_delivery | Out for delivery | Out for delivery |
delivered | shipment.delivered | Delivered | Delivered |
failed | shipment.failed | Failed | Attempt failed; re-attempt/return |
returned | shipment.returned | Returned | - |
cancelled | shipment.cancelled | Cancelled | - |
Delivery targets (target, not guaranteed): Standard 6–24 h; Express 30–60 min within Kampala. Up-country depends on the zone's stated transit_time (e.g. "Same day"), surfaced as estimated_delivery when the parcel resolves to a zone.
Idempotency
Send a unique Idempotency-Key per order on create. A retry with the same key and body returns the original shipment (200); the key is remembered 24 hours. Same key + different body → 409 idempotency_key_reused. Never reuse one key across different orders.
Pagination & ordering
Lists return 25 per page, newest first (by created_at). Request a page with ?page=N; read meta.current_page, meta.total, meta.last_page.
Timestamps, amounts & phone numbers
Timestamps are UTC ISO-8601 (e.g. 2026-10-16T13:56:56+00:00); registration_date/delivery_date are plain dates. Amounts are whole UGX integers. Ugandan phone numbers are normalised to E.164 on save (0772123456 → +256772123456), so a value you read back may differ from what you sent.
Authentication
Every request must carry your API key in the Authorization header as a Bearer token:
Authorization: Bearer dk_live_12|Xy3f...your-key...Z5u6
Keys are created, listed, and revoked from your dashboard (API / Integrations). Each key is issued with a dk_live_ or dk_test_ prefix that marks its mode; send it exactly as shown. (Older keys without a prefix still work.)
| Property | Behaviour |
|---|---|
| Shown once | The full key appears only at creation. Drop-Off stores only a hash, so if you lose it, revoke it and make a new one. |
| Bound to one business | Each key belongs to exactly one business account. There is no "switch business" over the API. |
| Scoped by abilities | A key carries a set of permissions. Calls outside its abilities are rejected. |
| Revocable | Revoking a key in the dashboard stops it working immediately. |
An API key acts for your business. Store it as a server-side secret (an environment variable), never in browser code, a mobile bundle, or a public repository.
Abilities & scopes
When you generate a key you choose which of these it may use. For a read-only integration, uncheck the write abilities.
| Ability | Grants |
|---|---|
shipments:write | Create and cancel shipments |
shipments:read | List, view, quote, and fetch the waybill label |
tracking:read | Read a shipment's tracking timeline |
cod:read | Read the cash-on-delivery ledger |
settlements:read | Read settlement summaries |
returns:read | Read returns raised against your parcels |
A call whose key lacks the needed ability returns 403 insufficient_ability.
Rate limits
Each key is limited independently at 120 requests per minute per key. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; over the limit you get 429 rate_limited with a Retry-After header (seconds to wait). Poll tracking every few minutes, not every second.
Every response (success or error) also carries an X-Request-Id header, and every error body repeats it as request_id. Quote that value if you contact support.
Response format
Every JSON response uses one consistent envelope, so you never guess the shape per endpoint.
A single resource
{ "data": { "id": 123, "tracking_number": "#DOC-000137566286" } }A list (paginated, 25 per page)
{
"data": [ { "..." } ],
"meta": { "current_page": 1, "per_page": 25, "total": 42, "last_page": 2 }
}Request another page with ?page=2.
An error
{ "error": { "code": "validation_failed", "message": "...", "details": { "receiver_name": ["..."] }, "request_id": "532debd1-eba9-421e-acfb-b453dfe1302a" } }The HTTP status carries the category; code is a stable slug you can branch on. details appears only for validation errors. request_id is on every error and matches the X-Request-Id response header.
Error codes
Every error body is { "error": { "code", "message", "details"?, "request_id" } } and carries an X-Request-Id header equal to request_id. Quote that id to support. "Retry-safe" means a blind retry of the identical request is safe; for 500/429 on a create, reuse the same Idempotency-Key.
| HTTP | code | Meaning | Retry-safe? |
|---|---|---|---|
| 401 | unauthenticated | Missing or invalid API key | No - fix the key |
| 401 | key_mode_mismatch | Key prefix (dk_test_/dk_live_) disagrees with the key's mode | No - fix the key |
| 403 | not_a_business_key | Token isn't a business integration key | No |
| 403 | insufficient_ability | Key lacks the ability this call needs | No - mint a key with the scope |
| 403 | business_inactive | The key's business is suspended/not active | No - contact support |
| 403 | test_only | A live key called a test-only endpoint | No |
| 404 | not_found | No such resource for this key | No |
| 405 | method_not_allowed | Wrong HTTP method for the path | No |
| 409 | cannot_cancel | Shipment already moved past "created" | No - it is already collected |
| 409 | idempotency_in_progress | A create with this Idempotency-Key is still processing | Yes - retry the same key shortly |
| 409 | idempotency_key_reused | Same Idempotency-Key re-sent with a different body | No - use a new key for a new order |
| 422 | validation_failed | Invalid request body; see details | No - fix the request |
| 422 | pricing_unavailable | No price could be resolved (missing zone/size, or the business’s rates are unset) | No - supply a zone + size, or have your rates set |
| 429 | rate_limited | Too many requests | Yes - wait Retry-After seconds |
| 500 | internal_error | Unexpected server fault; quote the request_id | Yes - retry with backoff (reuse the Idempotency-Key) |
Test mode (test keys)
Create a Test key from the API / Integrations screen to build your integration safely. Keys are shown with a mode prefix — dk_test_ or dk_live_ — and the key's own mode is authoritative: sending a key with the wrong prefix returns 401 key_mode_mismatch. Keys issued before prefixes existed keep working unprefixed.
A test key behaves like a live key for validation and pricing, and it persists a real test shipment — but that shipment is isolated: it is visible only to test keys (and never in the dashboard, manifest, reports, COD, settlements or n8n), it raises no invoice, and it sends no SMS. A test create returns the shipment with meta.test: true:
{
"data": { "id": 18, "tracking_number": "#DOC-0002807921284", "status": "created", "delivery_fee": 4900 },
"meta": { "test": true }
}A test key reads only its own test data: cod-ledger, settlements and returns come back empty with meta.test: true. Test shipments are deleted automatically after 14 days.
Drive the flow (and webhooks) for a test shipment
shipments:writeMoves a test shipment one step forward (or send {"to":"delivered"} to jump to a status), so you can exercise your webhook receiver end to end. A live key is refused with 403 test_only.
Switch to a Live key when you're ready to book real deliveries.
Create a shipment
shipments:writeBooks a parcel. It's priced automatically and an invoice is raised per the payment type; the receiver SMS and your own alert are sent just after the response, so the call returns quickly. Returns the created shipment including its tracking_number, tracking_url and delivery_fee. Send an external_reference (your own order id) to look the parcel up later and to keep retries idempotent.
Body parameters
| Field | Notes | |
|---|---|---|
receiver_name | required | Recipient's name |
receiver_phone_number | required | Recipient phone (Ugandan format accepted) |
receiver_address | required | Delivery address |
item_name | required | What's being sent |
service_type | required | standard or express |
payment_type | required | sender_pays, receiver_pays, cod, business_credit |
cod_amount | cod only | Goods value to collect, UGX integer. Required when payment_type=cod |
receiver_email / city / state / country | optional | Destination detail; city can resolve the pricing zone |
zone_id, size_category | optional | Zone-pricing inputs; size is small/medium/large |
actual_weight, length_cm, width_cm, height_cm | optional | Weight (kg) and dimensions |
description | optional | Rider note; appears on the waybill |
external_reference | optional | Your own order id. Up to 64 chars, letters/digits/./-/_. Unique per business; returned on every shipment and filterable |
custom_pickup, pickup_address, pickup_contact_name, pickup_contact_phone | optional | Pick up from a different point (e.g. a supplier) instead of your business address |
custom_pickup | optional | true to collect from a custom location instead of your address on file |
pickup_address, pickup_contact_name, pickup_contact_phone | optional | Used only when custom_pickup is true |
curl https://app.dropoffcouriers.com/api/v1/business/shipments \ -X POST \ -H "Authorization: Bearer $DROPOFF_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "receiver_name": "Jane Receiver", "receiver_phone_number": "0772123456", "receiver_address": "Plot 5, Ntinda, Kampala", "item_name": "Phone case", "service_type": "standard", "payment_type": "sender_pays", "external_reference": "ORD-5001" }'
# pip install requests==2.32.3 import os, uuid, requests base = "https://app.dropoffcouriers.com/api/v1/business" r = requests.post( f"{base}/shipments", headers={ "Authorization": f"Bearer {os.environ['DROPOFF_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "receiver_name": "Jane Receiver", "receiver_phone_number": "0772123456", "receiver_address": "Plot 5, Ntinda, Kampala", "service_type": "standard", "payment_type": "sender_pays", "item_name": "Phone case", "external_reference": "ORD-5001", }, timeout=10, ) if r.ok: print("created", r.json()["data"]["tracking_number"]) else: err = r.json().get("error", {}) if err.get("code") == "validation_failed": raise SystemExit(f"check fields: {err.get('details')}") if err.get("code") == "pricing_unavailable": raise SystemExit("no price for this route; check zone/size or the business rates") raise SystemExit(f"{err.get('code')} (request_id {err.get('request_id')})")
import { randomUUID } from "node:crypto"; const base = "https://app.dropoffcouriers.com/api/v1/business"; const res = await fetch(`${base}/shipments`, { method: "POST", headers: { Authorization: `Bearer ${process.env.DROPOFF_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": randomUUID(), }, body: JSON.stringify({ receiver_name: "Jane Receiver", receiver_phone_number: "0772123456", receiver_address: "Plot 5, Ntinda, Kampala", service_type: "standard", payment_type: "sender_pays", item_name: "Phone case", external_reference: "ORD-5001", }), }); const body = await res.json(); if (!res.ok) { const e = body.error ?? {}; if (e.code === "validation_failed") throw new Error(`check fields: ${JSON.stringify(e.details)}`); if (e.code === "pricing_unavailable") throw new Error("no price for this route; check zone/size or rates"); throw new Error(`${e.code} (request_id ${e.request_id})`); } console.log("created", body.data.tracking_number);
$base = "https://app.dropoffcouriers.com/api/v1/business"; $ch = curl_init("$base/shipments"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("DROPOFF_API_KEY"), "Content-Type: application/json", "Idempotency-Key: " . bin2hex(random_bytes(16)), ], CURLOPT_POSTFIELDS => json_encode([ "receiver_name" => "Jane Receiver", "receiver_phone_number" => "0772123456", "receiver_address" => "Plot 5, Ntinda, Kampala", "service_type" => "standard", "payment_type" => "sender_pays", "item_name" => "Phone case", "external_reference" => "ORD-5001", ]), ]); $raw = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); $body = json_decode($raw, true); if ($status >= 400) { $e = $body["error"] ?? []; throw new RuntimeException(($e["code"] ?? $status) . ": " . ($e["message"] ?? "failed")); } echo $body["data"]["tracking_number"];
The same pattern (set the Authorization header, send an Idempotency-Key, branch on error.code) applies to every endpoint; only the method, path and body change. These three samples were each run against the API; the Python and Node versions created live-schema shipments returning delivery_fee: 4900.
{
"data": {
"id": 18,
"tracking_number": "#DOC-0002807921284",
"tracking_url": "https://app.dropoffcouriers.com/tracking/%23DOC-0002807921284",
"external_reference": "ORD-5001",
"status": "created",
"service_type": "standard",
"payment_type": "sender_pays",
"currency": "UGX",
"delivery_fee": 4900,
"cod_amount": null,
"receiver": { "name": "Jane Receiver", "phone": "+256772123456", "email": null, "address": "Plot 5, Ntinda, Kampala", "city": null, "state": null, "country": null },
"pickup": { "address": "Kampala, Uganda", "contact_name": "Test Business", "contact_phone": "+256700000000" },
"invoice_id": 0,
"registration_date": "2026-10-16",
"delivery_date": "2026-10-17",
"estimated_delivery": null,
"created_at": "2026-10-16T10:08:11+00:00"
}
}tracking_url is the public tracking page (the # is percent-encoded). estimated_delivery is the zone's stated transit time when the parcel resolves to a zone, otherwise null. invoice_id is 0 when no invoice was raised (test parcels, or business_credit). All timestamps are UTC ISO-8601.
Safe retries (Idempotency-Key)
If a create times out, you can safely retry it. Send an Idempotency-Key header with any unique value per order (a UUID is ideal). The first call books the shipment and remembers the key; any retry with the same key returns that same shipment instead of booking (and billing) a second one.
curl https://app.dropoffcouriers.com/api/v1/business/shipments \ -X POST \ -H "Authorization: Bearer $DROPOFF_API_KEY" \ -H "Idempotency-Key: 7b2f4c1e-...your-order-id..." \ -H "Content-Type: application/json" \ -d '{ ...shipment fields... }'
A retry returns 200 OK with the original shipment. If the first call is still in flight you may briefly get 409 idempotency_in_progress — wait a moment and retry the same key. Reusing a key with a different body returns 409 idempotency_key_reused. Keys are remembered for 24 hours.
Quote a delivery fee
shipments:readReturns the delivery fee for a combination without creating anything, ideal for showing a cost at checkout. Uses the exact engine POST /shipments would.
| Field | Notes | |
|---|---|---|
service_type | required | standard or express |
zone_id, size_category | optional | Zone-pricing inputs |
actual_weight, length_cm, width_cm, height_cm | optional | For weight-based pricing |
curl https://app.dropoffcouriers.com/api/v1/business/shipments/quote \ -X POST \ -H "Authorization: Bearer $DROPOFF_API_KEY" \ -H "Content-Type: application/json" \ -d '{"service_type": "standard", "zone_id": 3, "size_category": "small"}'
{ "data": { "delivery_fee": 4000, "currency": "UGX", "service_type": "standard", "pricing_mode": "zone" } }Zones & pricing
shipments:readLists your delivery zones with the areas each covers and the price per parcel size, for both standard and express. Use it to let a customer pick a destination and see the fee, or to resolve the zone_id you pass to POST /shipments and quote.
curl https://app.dropoffcouriers.com/api/v1/business/zones \ -H "Authorization: Bearer $DROPOFF_API_KEY"
{
"data": [
{
"id": 37,
"code": "A",
"name": "Zone A - Kampala (City)",
"currency": "UGX",
"areas": ["Bugolobi", "Ntinda", "Nakawa", "..."],
"prices": {
"small": { "standard": 4900, "express": 9900 },
"medium": { "standard": 4900, "express": 9900 }
}
}
]
}A null express price means express isn't offered for that zone/size. Pass the zone's id as zone_id when creating or quoting.
List shipments
shipments:readYour shipments, newest first, 25 per page.
| Query param | Notes | |
|---|---|---|
status | optional | Filter by flow status (e.g. created, out_for_delivery) |
service_type | optional | standard or express |
external_reference | optional | Filter by your own order id |
page | optional | Page number |
curl "https://app.dropoffcouriers.com/api/v1/business/shipments?status=created&page=1" \ -H "Authorization: Bearer $DROPOFF_API_KEY"
Look up a shipment
shipments:readFetch a single shipment by tracking_number or external_reference (your own order id) — handy when you stored your reference, not our numeric id. Provide at least one; an unknown value returns 404 not_found.
curl "https://app.dropoffcouriers.com/api/v1/business/shipments/lookup?external_reference=ORD-5001" \ -H "Authorization: Bearer $DROPOFF_API_KEY"
Get a shipment
shipments:readReturns one shipment by its numeric id. A shipment that isn't yours returns 404 not_found.
curl https://app.dropoffcouriers.com/api/v1/business/shipments/124 \ -H "Authorization: Bearer $DROPOFF_API_KEY"
Cancel a shipment
shipments:writeCancels a shipment still in the created stage. Once collected or moved further, it's refused with 409 cannot_cancel.
curl https://app.dropoffcouriers.com/api/v1/business/shipments/124/cancel \ -X POST \ -H "Authorization: Bearer $DROPOFF_API_KEY"
Track a shipment
tracking:readCurrent status plus the event timeline for one shipment.
{
"data": {
"tracking_number": "#DOC-000137566286",
"current_status": "out_for_delivery",
"events": [
{ "status": "Registered", "notes": "Shipment created by business.", "at": "2026-10-01 09:20" },
{ "status": "Collected", "notes": null, "at": "2026-10-01 11:05" }
]
}
}Waybill (shipping label)
shipments:readReturns the parcel's printable label as a PDF (A6 landscape) with the tracking barcode, a scan-to-track QR, sender, receiver, pickup, and item details. Unlike the other endpoints this responds with application/pdf, not the JSON envelope; errors (401/403/404) still use the envelope. Save it, print it, and attach it to the parcel.
curl https://app.dropoffcouriers.com/api/v1/business/shipments/124/waybill \ -H "Authorization: Bearer $DROPOFF_API_KEY" \ -o waybill-124.pdf
Fetch the waybill right after creating a shipment, store or print the PDF, and you have a ready-to-attach label for each parcel, with no dashboard visit needed.
COD ledger
cod:readCash-on-delivery entries per parcel: what was collected, the handling fee, and what you're due.
| Query param | Notes | |
|---|---|---|
status | optional | e.g. pending, collected, reconciled, settled |
from, to | optional | Date range (YYYY-MM-DD) |
page | optional | Page number |
{
"data": [
{ "id": 9, "courier_id": 124, "status": "collected", "currency": "UGX",
"cod_amount": 50000, "payout_amount": 48000, "paid_at": null }
],
"meta": { "total": 1, "current_page": 1, "per_page": 25, "last_page": 1 }
}Settlements
settlements:readSettlement summaries per period: totals, the net you were paid, and a statement_available boolean. The statement PDF is not served over the API — download it from the dashboard (Settlements), which needs a signed-in browser session.
{
"data": [
{ "id": 4, "reference": "SETL-7-2026-09-01", "status": "generated",
"period_start": "2026-09-01", "period_end": "2026-09-30", "parcel_count": 37,
"total_cod": 1850000, "net_paid": 1665000,
"statement_available": true }
],
"meta": { "total": 1, "current_page": 1, "per_page": 25, "last_page": 1 }
}Returns
returns:readRead-only access to returns raised against your parcels (failed deliveries, customer returns, exchanges, warranty). You can see them and their status in your own system; advancing a return through its stages is handled by Drop-Off operations.
| Query param | Notes | |
|---|---|---|
status | optional | e.g. requested, collected, received_at_hub, returned_to_merchant, completed, cancelled |
page | optional | Page number |
{
"data": [
{ "id": 3, "tracking_number": "#DOC-0003733299387", "courier_id": 7,
"type": "customer_return", "status": "requested", "reason": "Customer changed mind",
"requested_at": "2026-10-01T21:51:56+00:00", "completed_at": null,
"created_at": "2026-10-01T21:51:56+00:00" }
],
"meta": { "total": 1, "current_page": 1, "per_page": 25, "last_page": 1 }
}Get one return
returns:readReturns one record by its numeric id. A return that isn't against one of your parcels gives 404 not_found.
curl https://app.dropoffcouriers.com/api/v1/business/returns/3 \ -H "Authorization: Bearer $DROPOFF_API_KEY"
Webhooks
Instead of polling tracking, register a URL and we POST a signed event whenever a shipment changes state. Add your endpoint on the Webhooks screen (under API / Integrations), choose the events you want, and copy the signing secret. The URL must be a public https endpoint (private, loopback and reserved addresses are rejected).
You can keep a separate Live and Test endpoint. Events from a test key (and the test/advance endpoint) go only to the Test endpoint; live events only to the Live one. Every payload carries livemode so you can tell them apart.
Events
| Event | Fires when |
|---|---|
shipment.created | A shipment is booked |
shipment.picked_up | The rider collects it |
shipment.out_for_delivery | Out for delivery |
shipment.delivered | Delivered |
shipment.failed | A delivery attempt failed |
shipment.returned | Returned to you |
shipment.cancelled | Cancelled |
Payload
{
"id": "evt_7b2f...",
"type": "shipment.delivered",
"livemode": true,
"created_at": "2026-10-16T12:00:00+00:00",
"data": { /* the same shipment object the API returns */ }
}Verifying the signature
Each request carries these headers:
| Header | Value |
|---|---|
X-DropOff-Signature | sha256=<hex> |
X-DropOff-Signature-Previous | sha256=<hex>, only during a 24h secret-rotation grace window |
X-DropOff-Timestamp | Unix seconds |
X-DropOff-Event / X-DropOff-Event-Id | Event type and its unique id |
Compute HMAC-SHA256 of "{timestamp}.{raw request body}" with your signing secret and compare it (constant-time) to the hex in X-DropOff-Signature. Reject the request if it doesn't match, or if the timestamp is more than 5 minutes old. When you rotate the secret, the previous secret stays valid for 24 hours and its signature arrives in X-DropOff-Signature-Previous — accept either during that window.
const crypto = require("crypto"); function verify(rawBody, headers, secret) { const ts = headers["x-dropoff-timestamp"]; const expected = "sha256=" + crypto.createHmac("sha256", secret) .update(ts + "." + rawBody).digest("hex"); return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(headers["x-dropoff-signature"])); }
function verify(string $rawBody, array $headers, string $secret): bool { $ts = $headers["x-dropoff-timestamp"] ?? ""; // reject if older than 5 minutes if (abs(time() - (int) $ts) > 300) return false; $expected = "sha256=" . hash_hmac("sha256", $ts . "." . $rawBody, $secret); return hash_equals($expected, $headers["x-dropoff-signature"] ?? ""); }
import hmac, hashlib, time def verify(raw_body: bytes, headers: dict, secret: str) -> bool: ts = headers.get("x-dropoff-timestamp", "") if abs(time.time() - int(ts)) > 300: # 5-minute tolerance return False mac = hmac.new(secret.encode(), (ts + ".").encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest("sha256=" + mac, headers.get("x-dropoff-signature", ""))
During a 24h secret rotation, also accept a match against X-DropOff-Signature-Previous.
Receiver recipes
A complete endpoint in each framework: it reads the raw body (never a re-serialised JSON string, or the signature won't match), checks the 5-minute timestamp window, verifies the HMAC in constant time, and returns 200 only when both pass. Each was run locally against a real signed event, a tampered body, and an expired timestamp — 200 / 400 / 400 respectively.
# pip install flask==3.0.3 · run: WH_SECRET=whsec_... flask --app app run --port 9001 import hmac, hashlib, time, os from flask import Flask, request app = Flask(__name__) SECRET = os.environ["WH_SECRET"] @app.post("/webhooks/dropoff") def hook(): raw = request.get_data() # exact raw bytes ts = request.headers.get("X-DropOff-Timestamp", "") sig = request.headers.get("X-DropOff-Signature", "") if not ts.isdigit() or abs(time.time() - int(ts)) > 300: return "stale timestamp", 400 # 5-minute tolerance mac = hmac.new(SECRET.encode(), (ts + ".").encode() + raw, hashlib.sha256).hexdigest() if not hmac.compare_digest("sha256=" + mac, sig): return "bad signature", 400 event = request.get_json(silent=True) or {} # dedupe on event["id"], then handle event["type"] return "ok", 200
// npm install express@4.21.2 · run: WH_SECRET=whsec_... node receiver.mjs import express from "express"; import crypto from "node:crypto"; const app = express(); const SECRET = process.env.WH_SECRET; app.use(express.raw({ type: "*/*" })); // keep the raw bytes app.post("/webhooks/dropoff", (req, res) => { const raw = req.body; // Buffer const ts = req.get("X-DropOff-Timestamp") ?? ""; const sig = req.get("X-DropOff-Signature") ?? ""; if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(400).send("stale timestamp"); const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(ts + ".").update(raw).digest("hex"); const a = Buffer.from(expected), b = Buffer.from(sig); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(400).send("bad signature"); const event = JSON.parse(raw.toString("utf8")); // dedupe on event.id, then handle event.type return res.status(200).send("ok"); }); app.listen(9002);
// routes/web.php: Route::post('/webhooks/dropoff', WebhookController::class)->withoutMiddleware(VerifyCsrfToken::class); public function __invoke(Request $request) { $raw = $request->getContent(); // exact raw body $ts = $request->header("X-DropOff-Timestamp", ""); $sig = $request->header("X-DropOff-Signature", ""); if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300) { return response("stale timestamp", 400); // 5-minute tolerance } $expected = "sha256=" . hash_hmac("sha256", $ts . "." . $raw, config("services.dropoff.webhook_secret")); if (!hash_equals($expected, $sig)) { return response("bad signature", 400); } $event = json_decode($raw, true); // dedupe on $event["id"], then handle $event["type"] return response("ok", 200); }
Delivery & retries
Respond 2xx within 10 seconds to acknowledge. Deliveries run from a scheduler, so the shortest retry delay is about one minute; failed deliveries back off (about 1m, 5m, 30m, 2h, 6h) up to 6 attempts, then are marked failed. Delivery is at-least-once and unordered — dedupe on the event id and order by created_at. After 5 consecutive failed deliveries an endpoint is auto-disabled and we email you; re-enable it from the Webhooks screen, which offers to resend the missed events. The screen also has a per-endpoint delivery log, a "rotate secret" action and a "send test event" button.
Integration notes (checkout)
| Topic | What to know |
|---|---|
| Scopes for a checkout | A store needs shipments:write (create/cancel), shipments:read (quote, list, lookup, waybill) and tracking:read. Mint the narrowest key that covers them. |
| Keys don't expire | A key is valid until you revoke it in the dashboard. There is no token expiry to refresh. |
| One base URL, mode by key | There is no separate test host. The same base URL serves both; the dk_test_/dk_live_ key decides the mode. Going live = swap the key. |
| Phone numbers | Ugandan numbers are normalised to E.164 on save (e.g. 0772123456 → +256772123456), so the value you read back may differ from what you sent. |
| Resolving a zone | Call GET /zones, match the customer's area to a zone's areas, and pass that zone_id to quote/create. For a zone-priced business, if no zone resolves the call returns 422 pricing_unavailable instead of guessing — so resolve the zone (or send a city that maps to one) before you create. |
| Quotes aren't locked | quote is indicative and uses the same engine as create; the fee is finalised at create time. Don't treat a quote as a held price. |
| Shipments are immutable | There is no update endpoint. After create you can read, track, fetch the waybill, and cancel (only while created). To change anything else, cancel and re-create. |
| One parcel, one item | Each create is one parcel with one item_name. For several parcels in an order, create several shipments (each with its own external_reference or a suffix). |
| meta.test | Only a test key ever returns a meta block. Create / get / cancel return meta.test:true; a live response has no meta on a single resource. |
| Which errors to retry | Retry 429 (honour Retry-After) and 5xx with backoff, reusing the same Idempotency-Key on a create. Do not retry 4xx (validation_failed, cannot_cancel, not_found, insufficient_ability) — fix the request first. |
| Idempotency keys are per order | Use a unique key per create (your order id or a UUID). Reusing one key across different orders returns the first order (or 409 idempotency_key_reused if the body differs). Never share a key between orders. |
| Don't poll hard | Prefer webhooks. If you must poll tracking, every few minutes, not every second — you share a 120/min budget across all your keys' calls. |
| Going live checklist | 1) Build and test with a dk_test_ key and a Test webhook endpoint. 2) Verify your signature check and your created→delivered handling via test/advance. 3) Add a Live webhook endpoint. 4) Swap to a dk_live_ key. 5) Confirm the first live create raises an invoice and the receiver gets the SMS. |
A typical integration flow
For an online store sending parcels on delivery, the lifecycle usually looks like this:
| # | When | Call |
|---|---|---|
| 1 | To show destinations & prices | GET /zones |
| 2 | At checkout, to show a delivery cost | POST /shipments/quote |
| 3 | When the order is confirmed | POST /shipments with an Idempotency-Key → store the tracking_number |
| 4 | To print the label | GET /shipments/{id}/waybill |
| 5 | If the customer cancels before pickup | POST /shipments/{id}/cancel |
| 6 | To update order status | Webhooks (pushed to you) -or poll GET /shipments/{id}/tracking |
| 7 | For COD orders, to reconcile money | GET /cod-ledger and GET /settlements |
| 8 | To surface returns in your system | GET /returns |
Node.js: create a shipment
const res = await fetch("https://app.dropoffcouriers.com/api/v1/business/shipments", { method: "POST", headers: { "Authorization": `Bearer ${process.env.DROPOFF_API_KEY}`, "Content-Type": "application/json", "Accept": "application/json", }, body: JSON.stringify({ receiver_name: "Jane Doe", receiver_phone_number: "0772000000", receiver_address: "Plot 9, Nakawa, Kampala", item_name: "Phone case", service_type: "standard", payment_type: "cod", cod_amount: 50000, }), }); const body = await res.json(); if (!res.ok) throw new Error(body.error.code); const tracking = body.data.tracking_number;
Fields & enums
Service types
standard · express
Payment types
| Value | Who pays the delivery fee |
|---|---|
sender_pays | You (invoiced at creation) |
receiver_pays | The recipient, on delivery |
cod | Cash on delivery: the rider collects cod_amount; the delivery fee is invoiced separately |
business_credit | Billed to your account (no per-parcel invoice) |
Flow statuses
A shipment's status moves through these stages:
created → collected → received_at_hub → sorted → dispatched → out_for_delivery → delivered
with failed, returned, and cancelled as end states. You can cancel over the API only while a shipment is created.
FAQ
Can a business create waybills through the API?
Every shipment you create already has a waybill. It's the printable label built from the parcel's data, so creating the shipment creates the waybill. To fetch the label as a PDF, call GET /shipments/{id}/waybill.
Can one key serve several businesses?
No. A key is bound to exactly one business. If you operate multiple businesses, generate a key from each dashboard.
I lost a key. Can I retrieve it?
No. Keys are shown once and stored only as a hash. Revoke the old one and generate a new one.
How should I detect failures?
Check the HTTP status, then branch on error.code (retry on rate_limited, surface details on validation_failed). The slug is stable; message text may change.
Guides
Checkout integration
- At cart, call
GET /zonesonce and cache it; match the customer's area to a zone to getzone_id. - At checkout, call
POST /shipments/quotewithservice_type(+zone_id/size_categoryif zone-priced) to show the fee. Treat it as indicative, not locked. - On order confirmation, call
POST /shipmentswith anIdempotency-Keyand your order id asexternal_reference; store the returnedtracking_number. - Print the label from
GET /shipments/{id}/waybill(it can take a few seconds). - Track via webhooks (preferred) or
GET /shipments/{id}/tracking; show the customer thetracking_url. - If the buyer cancels before pickup, call
POST /shipments/{id}/cancel(only whilecreated).
Scopes needed: shipments:write, shipments:read, tracking:read.
COD reconciliation
For cash-on-delivery orders, poll GET /cod-ledger (or read it after a settlement). Each row carries tracking_number and your external_reference, so you can match a payout back to your own order. Payout per parcel:
payout_amount = cod_amount − handling_fee_amount (the handling fee is never more than the goods value).
The handling fee is a flat UGX amount per COD parcel, set by Drop-Off on your business at approval (default 0). Two worked examples:
| Field (UGX) | No handling fee (default) | With a 2,000 handling fee |
|---|---|---|
cod_amount (goods collected) | 50,000 | 50,000 |
handling_fee_amount | 0 | 2,000 |
payout_amount (what you’re paid) | 50,000 | 48,000 |
A settlement's net_paid is the sum of its rows' payout_amount. The statement PDF downloads from the dashboard (the API exposes statement_available only).
Handling returns
Read returns raised against your parcels with GET /returns and GET /returns/{id} (scope returns:read). Returns are advanced by Drop-Off operations, not over the API. A shipment.returned webhook fires when a parcel is returned to you.
Moving from test to live & go-live checklist
- Build against a
dk_test_key and a Test webhook endpoint. - Verify your signature check and your
created→deliveredhandling usingPOST /shipments/{id}/test/advance. - Confirm idempotency (unique key per order) and your retry policy (retry 429/5xx only).
- Add a Live webhook endpoint; store its signing secret as a server secret.
- Swap to a
dk_live_key (same base URL). - Place one live order end-to-end; confirm the invoice is raised and the receiver gets the SMS.
Changelog
| Version | Date | Changes |
|---|---|---|
| v1.0.0 | 2026-10-16 | Initial public release: shipments (create, quote, list, lookup, get, cancel, tracking, waybill), zones, COD ledger, settlements, returns, test mode, idempotency, and webhooks. |
Versioning policy
The API is versioned in the path (/api/v1/business). Additive changes are not breaking and ship without a version bump: new endpoints, new optional request fields, and new fields on existing responses. Always ignore response fields you don't recognise. Breaking changes (removing/renaming a field, changing a type, tightening validation, changing an error code's meaning) ship under a new version path with at least 90 days' notice and a migration note in this changelog.
Support & legal
| Topic | Detail |
|---|---|
| Support contact | info@dropoffcouriers.com · (+256) 760 212 600 |
| Support hours | 9am–5pm East Africa Time (UTC+3) |
| Data protection & retention | Shipment data, including the receiver’s name, phone and address, is processed to fulfil deliveries and retained for 3 years. Test data is deleted automatically after 14 days. |
| Terms of use | DropOff Couriers API Terms of Use |
Need a key? Open API / Integrations in your Drop-Off business dashboard. Questions about an integration? Talk to our team.