Developers · API v1

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
API v1

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.

Base URL https://app.dropoffcouriers.com/api/v1/business

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.

It only ever sees your data

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.

Prefer to do it by hand?

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

Step 1

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.

Step 2

Make a call

Send the key as a Bearer token. Try a quote first; it reads pricing without creating anything.

Step 3

Book & track

Create shipments at checkout, store the tracking_number, and poll tracking for status.

Your first request
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 negotiated standard / express rate 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 with 422 pricing_unavailable rather than guessing a price; pass a zone_id (or a city that maps to one) and a size_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 statusWebhook eventTracking labelCustomer SMS
createdshipment.createdCreatedShipment registered
collectedshipment.picked_upCollectedPicked 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_deliveryshipment.out_for_deliveryOut for deliveryOut for delivery
deliveredshipment.deliveredDeliveredDelivered
failedshipment.failedFailedAttempt failed; re-attempt/return
returnedshipment.returnedReturned-
cancelledshipment.cancelledCancelled-

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.)

PropertyBehaviour
Shown onceThe 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 businessEach key belongs to exactly one business account. There is no "switch business" over the API.
Scoped by abilitiesA key carries a set of permissions. Calls outside its abilities are rejected.
RevocableRevoking a key in the dashboard stops it working immediately.
Keep keys secret

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.

AbilityGrants
shipments:writeCreate and cancel shipments
shipments:readList, view, quote, and fetch the waybill label
tracking:readRead a shipment's tracking timeline
cod:readRead the cash-on-delivery ledger
settlements:readRead settlement summaries
returns:readRead 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

200 OK
{ "data": { "id": 123, "tracking_number": "#DOC-000137566286" } }

A list (paginated, 25 per page)

200 OK
{
  "data": [ { "..." } ],
  "meta": { "current_page": 1, "per_page": 25, "total": 42, "last_page": 2 }
}

Request another page with ?page=2.

An error

4xx
{ "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.

HTTPcodeMeaningRetry-safe?
401unauthenticatedMissing or invalid API keyNo - fix the key
401key_mode_mismatchKey prefix (dk_test_/dk_live_) disagrees with the key's modeNo - fix the key
403not_a_business_keyToken isn't a business integration keyNo
403insufficient_abilityKey lacks the ability this call needsNo - mint a key with the scope
403business_inactiveThe key's business is suspended/not activeNo - contact support
403test_onlyA live key called a test-only endpointNo
404not_foundNo such resource for this keyNo
405method_not_allowedWrong HTTP method for the pathNo
409cannot_cancelShipment already moved past "created"No - it is already collected
409idempotency_in_progressA create with this Idempotency-Key is still processingYes - retry the same key shortly
409idempotency_key_reusedSame Idempotency-Key re-sent with a different bodyNo - use a new key for a new order
422validation_failedInvalid request body; see detailsNo - fix the request
422pricing_unavailableNo price could be resolved (missing zone/size, or the business’s rates are unset)No - supply a zone + size, or have your rates set
429rate_limitedToo many requestsYes - wait Retry-After seconds
500internal_errorUnexpected server fault; quote the request_idYes - 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:

201 Created (test key)
{
  "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

POST/shipments/{id}/test/advancetest key, requires shipments:write

Moves 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

POST/shipmentsrequires shipments:write

Books 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

FieldNotes
receiver_namerequiredRecipient's name
receiver_phone_numberrequiredRecipient phone (Ugandan format accepted)
receiver_addressrequiredDelivery address
item_namerequiredWhat's being sent
service_typerequiredstandard or express
payment_typerequiredsender_pays, receiver_pays, cod, business_credit
cod_amountcod onlyGoods value to collect, UGX integer. Required when payment_type=cod
receiver_email / city / state / countryoptionalDestination detail; city can resolve the pricing zone
zone_id, size_categoryoptionalZone-pricing inputs; size is small/medium/large
actual_weight, length_cm, width_cm, height_cmoptionalWeight (kg) and dimensions
descriptionoptionalRider note; appears on the waybill
external_referenceoptionalYour 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_phoneoptionalPick up from a different point (e.g. a supplier) instead of your business address
custom_pickupoptionaltrue to collect from a custom location instead of your address on file
pickup_address, pickup_contact_name, pickup_contact_phoneoptionalUsed only when custom_pickup is true
Request
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"
  }'
Python 3.8+ · requests 2.32.3
# 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')})")

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.

201 Created
{
  "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.

Request
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

POST/shipments/quoterequires shipments:read

Returns the delivery fee for a combination without creating anything, ideal for showing a cost at checkout. Uses the exact engine POST /shipments would.

FieldNotes
service_typerequiredstandard or express
zone_id, size_categoryoptionalZone-pricing inputs
actual_weight, length_cm, width_cm, height_cmoptionalFor weight-based pricing
Request
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"}'
200 OK
{ "data": { "delivery_fee": 4000, "currency": "UGX", "service_type": "standard", "pricing_mode": "zone" } }

Zones & pricing

GET/zonesrequires shipments:read

Lists 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.

Request
curl https://app.dropoffcouriers.com/api/v1/business/zones \
  -H "Authorization: Bearer $DROPOFF_API_KEY"
200 OK
{
  "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

GET/shipmentsrequires shipments:read

Your shipments, newest first, 25 per page.

Query paramNotes
statusoptionalFilter by flow status (e.g. created, out_for_delivery)
service_typeoptionalstandard or express
external_referenceoptionalFilter by your own order id
pageoptionalPage number
Request
curl "https://app.dropoffcouriers.com/api/v1/business/shipments?status=created&page=1" \
  -H "Authorization: Bearer $DROPOFF_API_KEY"

Look up a shipment

GET/shipments/lookuprequires shipments:read

Fetch 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.

Request
curl "https://app.dropoffcouriers.com/api/v1/business/shipments/lookup?external_reference=ORD-5001" \
  -H "Authorization: Bearer $DROPOFF_API_KEY"

Get a shipment

GET/shipments/{id}requires shipments:read

Returns one shipment by its numeric id. A shipment that isn't yours returns 404 not_found.

Request
curl https://app.dropoffcouriers.com/api/v1/business/shipments/124 \
  -H "Authorization: Bearer $DROPOFF_API_KEY"

Cancel a shipment

POST/shipments/{id}/cancelrequires shipments:write

Cancels a shipment still in the created stage. Once collected or moved further, it's refused with 409 cannot_cancel.

Request
curl https://app.dropoffcouriers.com/api/v1/business/shipments/124/cancel \
  -X POST \
  -H "Authorization: Bearer $DROPOFF_API_KEY"

Track a shipment

GET/shipments/{id}/trackingrequires tracking:read

Current status plus the event timeline for one shipment.

200 OK
{
  "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)

GET/shipments/{id}/waybillrequires shipments:read

Returns 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.

Request
curl https://app.dropoffcouriers.com/api/v1/business/shipments/124/waybill \
  -H "Authorization: Bearer $DROPOFF_API_KEY" \
  -o waybill-124.pdf
Tip

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

GET/cod-ledgerrequires cod:read

Cash-on-delivery entries per parcel: what was collected, the handling fee, and what you're due.

Query paramNotes
statusoptionale.g. pending, collected, reconciled, settled
from, tooptionalDate range (YYYY-MM-DD)
pageoptionalPage number
200 OK
{
  "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

GET/settlementsrequires settlements:read

Settlement 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.

200 OK
{
  "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

GET/returnsrequires returns:read

Read-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 paramNotes
statusoptionale.g. requested, collected, received_at_hub, returned_to_merchant, completed, cancelled
pageoptionalPage number
200 OK
{
  "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

GET/returns/{id}requires returns:read

Returns one record by its numeric id. A return that isn't against one of your parcels gives 404 not_found.

Request
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

EventFires when
shipment.createdA shipment is booked
shipment.picked_upThe rider collects it
shipment.out_for_deliveryOut for delivery
shipment.deliveredDelivered
shipment.failedA delivery attempt failed
shipment.returnedReturned to you
shipment.cancelledCancelled

Payload

POST to your URL
{
  "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:

HeaderValue
X-DropOff-Signaturesha256=<hex>
X-DropOff-Signature-Previoussha256=<hex>, only during a 24h secret-rotation grace window
X-DropOff-TimestampUnix seconds
X-DropOff-Event / X-DropOff-Event-IdEvent 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.

node -verify
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"]));
}
php -verify
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"] ?? "");
}
python -verify
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.

Python · Flask 3.0.3
# 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

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)

TopicWhat to know
Scopes for a checkoutA 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 expireA key is valid until you revoke it in the dashboard. There is no token expiry to refresh.
One base URL, mode by keyThere 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 numbersUgandan 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 zoneCall 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 lockedquote 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 immutableThere 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 itemEach 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.testOnly 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 retryRetry 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 orderUse 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 hardPrefer 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 checklist1) 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:

#WhenCall
1To show destinations & pricesGET /zones
2At checkout, to show a delivery costPOST /shipments/quote
3When the order is confirmedPOST /shipments with an Idempotency-Key → store the tracking_number
4To print the labelGET /shipments/{id}/waybill
5If the customer cancels before pickupPOST /shipments/{id}/cancel
6To update order statusWebhooks (pushed to you) -or poll GET /shipments/{id}/tracking
7For COD orders, to reconcile moneyGET /cod-ledger and GET /settlements
8To surface returns in your systemGET /returns

Node.js: create a shipment

node
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

ValueWho pays the delivery fee
sender_paysYou (invoiced at creation)
receiver_paysThe recipient, on delivery
codCash on delivery: the rider collects cod_amount; the delivery fee is invoiced separately
business_creditBilled 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

  1. At cart, call GET /zones once and cache it; match the customer's area to a zone to get zone_id.
  2. At checkout, call POST /shipments/quote with service_type (+ zone_id/size_category if zone-priced) to show the fee. Treat it as indicative, not locked.
  3. On order confirmation, call POST /shipments with an Idempotency-Key and your order id as external_reference; store the returned tracking_number.
  4. Print the label from GET /shipments/{id}/waybill (it can take a few seconds).
  5. Track via webhooks (preferred) or GET /shipments/{id}/tracking; show the customer the tracking_url.
  6. If the buyer cancels before pickup, call POST /shipments/{id}/cancel (only while created).

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,00050,000
handling_fee_amount02,000
payout_amount (what you’re paid)50,00048,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

  1. Build against a dk_test_ key and a Test webhook endpoint.
  2. Verify your signature check and your created→delivered handling using POST /shipments/{id}/test/advance.
  3. Confirm idempotency (unique key per order) and your retry policy (retry 429/5xx only).
  4. Add a Live webhook endpoint; store its signing secret as a server secret.
  5. Swap to a dk_live_ key (same base URL).
  6. Place one live order end-to-end; confirm the invoice is raised and the receiver gets the SMS.

Changelog

VersionDateChanges
v1.0.02026-10-16Initial 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

TopicDetail
Support contactinfo@dropoffcouriers.com · (+256) 760 212 600
Support hours9am–5pm East Africa Time (UTC+3)
Data protection & retentionShipment 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 useDropOff 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.

Chat with us