sendam
Sendam Courier API · v1

Add Sendam delivery to your checkout.

One small REST API for online stores, marketplaces, ERPs and food apps. Quote a delivery, book a rider and follow the parcel to the door. Cashless from end to end: shipments are paid from your prepaid wallet and riders never collect money.

  • Plain JSON over HTTPS
  • Signed webhooks
  • Sandbox key on this page
Book a rider
curl https://YOUR-SENDAM-DOMAIN/api/v1/shipments \
  -H "Authorization: Bearer sk_test_sendam_demo_4f9a2c7e1b8d3f60" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "dropoff": { "name": "Ngozi Nwosu", "phone": "08031234567", "place_id": "lekki-admiralty" },
    "parcel": { "description": "2 Ankara gowns" }
  }'
Response (trimmed)201
{
  "id": "SND-TXSXS",
  "status": "ready",
  "status_label": "Finding a rider",
  "price": { "currency": "NGN", "total": 4850, "vat": 350, "delivery_fee": 4500, "protection_fee": 0, "discount": 500 },
  "delivery_window": "Tomorrow, 9am – 1pm",
  "tracking_url": "https://YOUR-SENDAM-DOMAIN/track/SND-TXSXS"
  // …other shipment fields
}

Base URL

/api/v1 on the site’s own origin. The production domain is not final, so samples use YOUR-SENDAM-DOMAIN.

Authentication

Send your secret key on every request: Authorization: Bearer sk_test_…. Keep it on your server.

Money

Whole naira as integers, currency NGN. total already includes VAT. No kobo, no decimals.

Format

JSON in, JSON out, snake_case fields. Timestamps are ISO 8601 in UTC. Shipment ids look like SND-4KQ7P.

This build is a sandbox

Riders are simulated, and a shipment moves from “Finding a rider” to “Delivered” on its own within a few minutes, so you can watch the whole flow without anyone leaving their house. Keys start with sk_test_.

The key printed in the samples below is a public sandbox key that everyone reading this page shares. Use it to try things out, expect to see other people’s test shipments, and create your own key before you build anything real.

Quickstart

Three calls to your first delivery.

Copy each command into a terminal. Swap in your own domain and key when you have them.

  1. Step 1: Get a key

    Open Integrations in the business dashboard and create a secret key. It is shown once, so save it in your server’s environment straight away. A business can hold up to five active keys, and revoking one takes effect at once.

    To try the API this minute, use the public sandbox key sk_test_sendam_demo_4f9a2c7e1b8d3f60. This call checks that a key works: a 200 with a list means yes, a 401 means no.

    curl · check your key
    curl "https://YOUR-SENDAM-DOMAIN/api/v1/shipments?limit=1" \
      -H "Authorization: Bearer sk_test_sendam_demo_4f9a2c7e1b8d3f60"
    
  2. Step 2: Quote the delivery

    Send the drop-off and you get the price your wallet will be charged, VAT included. pickup is optional and defaults to your saved shop address. Show total and delivery_window at checkout.

    curl · POST /quotes
    curl https://YOUR-SENDAM-DOMAIN/api/v1/quotes \
      -H "Authorization: Bearer sk_test_sendam_demo_4f9a2c7e1b8d3f60" \
      -H "Content-Type: application/json" \
      -d '{
        "dropoff": { "lat": 6.447, "lng": 3.473 },
        "parcel": { "size": "small" }
      }'
    
    Response200
    {
      "object": "quote",
      "currency": "NGN",
      "total": 4550,
      "vat": 300,
      "delivery_fee": 4250,
      "protection_fee": 0,
      "discount": 450,
      "distance_km": 17.7,
      "eta_minutes": 89,
      "delivery_window": "Tomorrow, 9am – 1pm",
      "vehicle": "bike",
      "express": false,
      "crosses_bridge": true,
      "wallet_balance": 41400
    }
    
  3. Step 3: Create the shipment

    When the order is paid on your side, book the rider. The delivery fee is debited from your prepaid wallet and the shipment joins the dispatch queue. Always send an Idempotency-Key, so a retry can never book two riders.

    curl https://YOUR-SENDAM-DOMAIN/api/v1/shipments \
      -H "Authorization: Bearer sk_test_sendam_demo_4f9a2c7e1b8d3f60" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d '{
        "reference": "ORDER-10492",
        "dropoff": {
          "name": "Ngozi Nwosu",
          "phone": "08031234567",
          "landmark": "Opposite Mega Chicken, yellow gate",
          "street": "14 Admiralty Way",
          "area": "Lekki Phase 1",
          "lat": 6.447,
          "lng": 3.473
        },
        "parcel": {
          "description": "2 Ankara gowns",
          "size": "small",
          "declared_value": 35000
        }
      }'
    
    Response201
    {
      "id": "SND-V7EGM",
      "object": "shipment",
      "reference": "ORDER-10492",
      "status": "ready",
      "status_label": "Finding a rider",
      "city": "lagos",
      "vehicle": "bike",
      "express": false,
      // pickup: your saved shop address, because none was sent
      "dropoff": {
        "name": "Ngozi Nwosu",
        "phone": "08031234567",
        "landmark": "Opposite Mega Chicken, yellow gate",
        "street": "14 Admiralty Way",
        "area": "Lekki Phase 1",
        "lat": 6.447,
        "lng": 3.473,
        "notes": null
      },
      "parcel": { "description": "2 Ankara gowns", "size": "small", "declared_value": 35000, "protection": false },
      "price": { "currency": "NGN", "total": 4550, "vat": 300, "delivery_fee": 4250, "protection_fee": 0, "discount": 450 },
      "distance_km": 17.7,
      "delivery_window": "Tomorrow, 9am – 1pm",
      "see_am_first": null,
      "rider": null,
      "eta_minutes": null,
      "failure_reason": null,
      "tracking_url": "https://YOUR-SENDAM-DOMAIN/track/SND-V7EGM",
      "buyer_confirmation_url": null,
      "events": [
        { "status": "awaiting_payment", "at": "2026-11-10T22:40:02.154Z", "note": "Order created" },
        { "status": "ready", "at": "2026-11-10T22:40:02.154Z", "note": "Paid from wallet" }
      ],
      "created_at": "2026-11-10T22:40:02.154Z",
      "updated_at": "2026-11-10T22:40:02.154Z"
    }
    

    That is the whole integration. Save id against your order, send your customer the tracking_url (a public live-map page), and either poll GET /shipments/{id} or let webhooks tell you when the status changes.

Endpoint reference

Seven endpoints, one shipment object.

Every shipment endpoint returns the same shipment object, so once you can read one response you can read them all. The machine-readable version of this section is the OpenAPI spec.

Locations

pickup and dropoff are the same kind of object. We need to know where the stop is, in one of three ways, in this order of preference: a lat and lng pin, a place_id, or an area.

Location fields

lat, lngnumber
The pin. Preferred, because it is exact. Must be inside the Lagos service area.
place_idstring
A known landmark from GET /coverage, such as lekki-admiralty. Used when no pin is sent.
areastring
An area name from GET /coverage, such as Lekki Phase 1. The loosest option: we use that area’s landmark as the pin.
landmarkstring
What the rider will see: “Opposite Mega Chicken, yellow gate”. Lagos addresses work landmark-first, so send one whenever you can. Defaults to the nearest known landmark.
streetstring
House number and street, if there is one.
name, phonestring
The contact at this stop. Required on shipments, not on quotes. The phone must be an 11-digit Nigerian mobile number; +234… is accepted too.
notesstring
Anything else for the rider: “Call on arrival”, “Leave with security”.
GET/coveragePublic, no key

Cities and known landmarks

Lists the cities we serve and the landmarks we already know. Use it to fill an area picker at checkout, or to turn an area name into a place_id. Needs no key, so you can call it from anywhere.

curl
curl https://YOUR-SENDAM-DOMAIN/api/v1/coverage
Response (trimmed)200
{
  "object": "coverage",
  "cities": [
    { "id": "lagos", "name": "Lagos", "status": "live", "clusters": ["Mainland", "Island"] },
    { "id": "abuja", "name": "Abuja", "status": "coming_soon", "clusters": [] },
    { "id": "port-harcourt", "name": "Port Harcourt", "status": "coming_soon", "clusters": [] }
  ],
  "places": [
    { "place_id": "yaba-tejuosho", "name": "Tejuosho Market", "area": "Yaba", "city": "lagos", "cluster": "mainland", "lat": 6.5079, "lng": 3.3717 },
    { "place_id": "lekki-admiralty", "name": "Admiralty Way", "area": "Lekki Phase 1", "city": "lagos", "cluster": "island", "lat": 6.447, "lng": 3.473 }
    // …and the rest of the known landmarks
  ]
}
POST/quotesSecret key

Price a delivery

Returns the price and delivery window without booking or charging anything. A quote is not stored; the shipment is priced again when you create it.

Request fields

dropofflocation · required
Where the parcel is going. See “Locations” above.
pickuplocation
Where to collect from. Leave it out to use your business’s saved shop address.
vehiclebike | van
Defaults to bike.
expressboolean
Delivery within 2 hours. Only applies when pickup and drop-off are on the same side of town; otherwise it is ignored, and express in the response tells you which.
parcel.sizesmall | medium | large
Defaults to medium.
parcel.declared_valueinteger (naira)
What the contents are worth.
parcel.protectionboolean
Add package protection, priced from the declared value and shown as protection_fee.
curl
curl https://YOUR-SENDAM-DOMAIN/api/v1/quotes \
  -H "Authorization: Bearer sk_test_sendam_demo_4f9a2c7e1b8d3f60" \
  -H "Content-Type: application/json" \
  -d '{
    "dropoff": { "lat": 6.447, "lng": 3.473 },
    "parcel": { "size": "small" }
  }'
Response200
{
  "object": "quote",
  "currency": "NGN",
  "total": 4550,
  "vat": 300,
  "delivery_fee": 4250,
  "protection_fee": 0,
  "discount": 450,
  "distance_km": 17.7,
  "eta_minutes": 89,
  "delivery_window": "Tomorrow, 9am – 1pm",
  "vehicle": "bike",
  "express": false,
  "crosses_bridge": true,
  "wallet_balance": 41400
}

total is what your wallet pays: delivery_fee plus protection_fee plus vat. discount is your plan discount, already taken off delivery_fee. crosses_bridge is true when pickup and drop-off are on different sides of town (Mainland and Island), which adds a surcharge. wallet_balance lets you check you can afford it before you book.

POST/shipmentsSecret key

Create a shipment

Debits your wallet and queues the delivery for dispatch. Returns 201 with the shipment, or 402 insufficient_balance if the wallet is too low, in which case nothing is created.

Request fields

Idempotency-Keyheader, string
A unique string per order, such as your order number. Sending it again returns the first shipment instead of creating another.
dropofflocation · required
Where the parcel is going, with the receiver’s name and phone.
parcel.descriptionstring · required
What the rider is carrying: “2 Ankara gowns”.
referencestring
Your own order number, up to 120 characters. Returned on the shipment and in webhooks, and you can filter the list by it.
buyer_confirms_addressboolean
WhatsApp the receiver a link to confirm their pin and landmark before a rider is dispatched. The shipment starts as awaiting_confirmation.
see_am_first.item_priceinteger (naira)
Turn on See Am First: the buyer pays this amount into escrow before dispatch. Minimum 1000.
pickuplocation
Where to collect from. Leave it out to use your business’s saved shop address.
vehiclebike | van
Defaults to bike.
expressboolean
Delivery within 2 hours. Only applies when pickup and drop-off are on the same side of town; otherwise it is ignored, and express in the response tells you which.
parcel.sizesmall | medium | large
Defaults to medium.
parcel.declared_valueinteger (naira)
What the contents are worth.
parcel.protectionboolean
Add package protection, priced from the declared value and shown as protection_fee.
curl https://YOUR-SENDAM-DOMAIN/api/v1/shipments \
  -H "Authorization: Bearer sk_test_sendam_demo_4f9a2c7e1b8d3f60" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "reference": "ORDER-10492",
    "dropoff": {
      "name": "Ngozi Nwosu",
      "phone": "08031234567",
      "landmark": "Opposite Mega Chicken, yellow gate",
      "street": "14 Admiralty Way",
      "area": "Lekki Phase 1",
      "lat": 6.447,
      "lng": 3.473
    },
    "parcel": {
      "description": "2 Ankara gowns",
      "size": "small",
      "declared_value": 35000
    }
  }'
Response201
{
  "id": "SND-V7EGM",
  "object": "shipment",
  "reference": "ORDER-10492",
  "status": "ready",
  "status_label": "Finding a rider",
  "city": "lagos",
  "vehicle": "bike",
  "express": false,
  // pickup: your saved shop address, because none was sent
  "dropoff": {
    "name": "Ngozi Nwosu",
    "phone": "08031234567",
    "landmark": "Opposite Mega Chicken, yellow gate",
    "street": "14 Admiralty Way",
    "area": "Lekki Phase 1",
    "lat": 6.447,
    "lng": 3.473,
    "notes": null
  },
  "parcel": { "description": "2 Ankara gowns", "size": "small", "declared_value": 35000, "protection": false },
  "price": { "currency": "NGN", "total": 4550, "vat": 300, "delivery_fee": 4250, "protection_fee": 0, "discount": 450 },
  "distance_km": 17.7,
  "delivery_window": "Tomorrow, 9am – 1pm",
  "see_am_first": null,
  "rider": null,
  "eta_minutes": null,
  "failure_reason": null,
  "tracking_url": "https://YOUR-SENDAM-DOMAIN/track/SND-V7EGM",
  "buyer_confirmation_url": null,
  "events": [
    { "status": "awaiting_payment", "at": "2026-11-10T22:40:02.154Z", "note": "Order created" },
    { "status": "ready", "at": "2026-11-10T22:40:02.154Z", "note": "Paid from wallet" }
  ],
  "created_at": "2026-11-10T22:40:02.154Z",
  "updated_at": "2026-11-10T22:40:02.154Z"
}

Shipment fields worth knowing

idstring
The Sendam tracking code. Use it in every other shipment call.
status, status_labelstring
The machine status and a ready-made English label for it. See Shipment statuses.
priceobject
What was charged to your wallet, broken down the same way as a quote.
riderobject | null
Name, phone, vehicle, plate, rating and live lat/lng, once a rider has accepted.
eta_minutesinteger | null
Minutes to the rider’s next stop while they are on the move.
tracking_urlstring
A public live-tracking page. Safe to send to your customer.
buyer_confirmation_urlstring | null
The link the receiver uses to confirm their pin. Only set while the status is awaiting_confirmation.
see_am_firstobject | null
item_price, fee and escrow status: pending, held, released or refunded.
eventsarray
The history so far: status, at and an optional note for each step.
GET/shipmentsSecret key

List shipments

Your business’s shipments, newest first, including ones booked from the dashboard. total counts every match; data holds up to limit of them.

Query parameters

limitinteger
How many to return. Defaults to 20, at most 100.
statusstring
Only shipments in this status, such as delivered.
referencestring
Only shipments with exactly this reference. The quickest way to find the shipment for one of your orders.
curl
curl "https://YOUR-SENDAM-DOMAIN/api/v1/shipments?status=delivered&limit=1" \
  -H "Authorization: Bearer sk_test_sendam_demo_4f9a2c7e1b8d3f60"
Response (trimmed)200
{
  "object": "list",
  "total": 23,
  "data": [
    {
      "id": "SND-W74CM",
      "object": "shipment",
      "reference": null,
      "status": "delivered",
      "status_label": "Delivered",
      "see_am_first": { "item_price": 29500, "fee": 450, "status": "released" },
      "tracking_url": "https://YOUR-SENDAM-DOMAIN/track/SND-W74CM",
      "updated_at": "2026-11-10T22:38:59.784Z"
      // …every item is a full shipment object
    }
  ]
}
GET/shipments/{id}Secret key

Retrieve a shipment

The current state of one shipment, with the rider’s live position once one is assigned. Returns 404 not_found for an id that does not belong to your business.

curl
curl https://YOUR-SENDAM-DOMAIN/api/v1/shipments/SND-V7EGM \
  -H "Authorization: Bearer sk_test_sendam_demo_4f9a2c7e1b8d3f60"
Response (trimmed)200
{
  "id": "SND-V7EGM",
  "object": "shipment",
  "reference": "ORDER-10492",
  "status": "assigned",
  "status_label": "Rider on the way to pickup",
  "rider": {
    "name": "Sadiq Bello",
    "vehicle": "bike",
    "plate": "SMK 305 JD",
    "rating": 4.6,
    "lat": 6.517985155287222,
    "lng": 3.375259466571961
    // …plus the rider's phone
  },
  "eta_minutes": 4,
  "tracking_url": "https://YOUR-SENDAM-DOMAIN/track/SND-V7EGM",
  "events": [
    { "status": "awaiting_payment", "at": "2026-11-10T22:40:02.154Z", "note": "Order created" },
    { "status": "ready", "at": "2026-11-10T22:40:02.154Z", "note": "Paid from wallet" },
    { "status": "assigned", "at": "2026-11-10T22:41:17.154Z", "note": "Sadiq Bello accepted" }
  ],
  "updated_at": "2026-11-10T22:41:17.154Z"
  // …and the other shipment fields shown under “Create a shipment”
}
POST/shipments/{id}/cancelSecret key

Cancel before pickup

Works while the shipment is awaiting_confirmation, ready or assigned. The delivery fee goes back to your wallet in full, and any See Am First payment goes back to the buyer. Once the rider has the parcel you get 409 cannot_cancel. Cancelling a shipment that is already cancelled is harmless and returns it unchanged. No body is needed.

curl
curl -X POST https://YOUR-SENDAM-DOMAIN/api/v1/shipments/SND-ASFEZ/cancel \
  -H "Authorization: Bearer sk_test_sendam_demo_4f9a2c7e1b8d3f60"
Response (trimmed)200
{
  "id": "SND-ASFEZ",
  "object": "shipment",
  "reference": "ORDER-10493",
  "status": "cancelled",
  "status_label": "Cancelled",
  "events": [
    { "status": "awaiting_payment", "at": "2026-11-10T22:40:12.770Z", "note": "Order created" },
    { "status": "awaiting_confirmation", "at": "2026-11-10T22:40:12.770Z", "note": "Paid from wallet · waiting for buyer to confirm pin" },
    { "status": "cancelled", "at": "2026-11-10T22:40:13.473Z", "note": "Full refund issued" }
  ],
  "updated_at": "2026-11-10T22:40:13.473Z"
  // …other shipment fields
}
GET/openapi.jsonPublic, no key

The OpenAPI 3.1 spec

The contract for everything on this page. Import it into Postman or Insomnia, or feed it to a client generator. Its server URL is filled in with whichever origin you fetch it from.

curl
curl https://YOUR-SENDAM-DOMAIN/api/v1/openapi.json -o sendam-openapi.json

Shipment statuses

From your shop to their door.

A shipment moves forward through these statuses and never back. Most go straight down the happy path; the others are the ways a delivery can end early.

The happy path

  1. ready
  2. assigned
  3. at_pickup
  4. picked_up
  5. arriving
  6. delivered

With buyer_confirms_address or see_am_first, the shipment waits in awaiting_confirmation before ready.

awaiting_confirmationWaiting for buyer
Paid, and waiting for the receiver to confirm their pin (and to pay the item price, for See Am First). Only happens when you send buyer_confirms_address or see_am_first. buyer_confirmation_url is set while the shipment is here.
readyFinding a rider
Paid and confirmed. In the dispatch queue, waiting for a rider to accept.
assignedRider on the way to pickup
A rider has accepted and is heading to the pickup. rider and eta_minutes are now filled in. This is the last status you can cancel from.
at_pickupRider at pickup
The rider is at the pickup address.
picked_upPicked up
The parcel has been scanned and photographed, and is on its way.
arrivingArriving soon
The rider is about five minutes from the door.
deliveredDelivered
The receiver’s 4-digit delivery code matched. Final. With See Am First, this is the moment the money is released to you.
failedDelivery attempt failed
The delivery attempt did not succeed; failure_reason says why. Any See Am First payment is refunded to the buyer, the parcel heads back to you, and a return fee of 50% of the delivery fee is charged to your wallet.
returnedReturned to sender
A failed parcel is back with the sender. Final.
cancelledCancelled
Cancelled before pickup. Final. The delivery fee is back in your wallet.

The pill beside each status is its status_label, which you can show to customers as it is. The first entry in events is always awaiting_payment (“Order created”). API shipments are paid from your wallet in the same instant, so they never rest there, and no webhook is sent for it. Orders booked in the dashboard for the buyer to pay can show it in the list endpoint.

See Am First

Pay on delivery, without the cash.

Sendam’s signature feature, and one field in the API. The buyer pays the item price into escrow before the rider sets off, inspects the parcel at the door, and their 4-digit delivery code releases the money to you. Nobody hands cash to a rider, and nobody pays for something they have not seen.

1

You create the shipment

Add see_am_first: { item_price }. The delivery fee is debited from your wallet as usual. The shipment starts as awaiting_confirmation with escrow pending.

2

The buyer pays into escrow

They get a link (also returned as buyer_confirmation_url) to confirm their pin and pay the item price. Escrow becomes held and the shipment moves to ready.

3

They inspect at the door

Happy? They give the rider their 4-digit code. The shipment is delivered, escrow is released, and you get an escrow.released webhook.

4

Or they say no

A rejected item is returned to you and the buyer is refunded. The shipment goes to failed then returned, escrow to refunded, with an escrow.refunded webhook.

curl · POST /shipments with See Am First
curl https://YOUR-SENDAM-DOMAIN/api/v1/shipments \
  -H "Authorization: Bearer sk_test_sendam_demo_4f9a2c7e1b8d3f60" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "reference": "ORDER-10493",
    "dropoff": {
      "name": "Ngozi Nwosu",
      "phone": "08031234567",
      "place_id": "lekki-admiralty",
      "landmark": "Opposite Mega Chicken, yellow gate"
    },
    "parcel": { "description": "2 Ankara gowns", "size": "small" },
    "see_am_first": { "item_price": 35000 }
  }'
Response (trimmed)201
{
  "id": "SND-ASFEZ",
  "object": "shipment",
  "reference": "ORDER-10493",
  "status": "awaiting_confirmation",
  "status_label": "Waiting for buyer",
  "parcel": { "description": "2 Ankara gowns", "size": "small", "declared_value": 35000, "protection": false },
  "price": { "currency": "NGN", "total": 4550, "vat": 300, "delivery_fee": 4250, "protection_fee": 0, "discount": 450 },
  "see_am_first": { "item_price": 35000, "fee": 550, "status": "pending" },
  "tracking_url": "https://YOUR-SENDAM-DOMAIN/track/SND-ASFEZ",
  "buyer_confirmation_url": "https://YOUR-SENDAM-DOMAIN/confirm/SND-ASFEZ"
  // …other shipment fields
}
  • The fee is 1.5% of the item price, at least ₦100 and capped at ₦2,000. It is taken from the payout when the money is released, never upfront. On ₦35,000 the fee is ₦550, so ₦34,450 reaches your sales balance.
  • The buyer always confirms their address on a See Am First shipment, whether or not you send buyer_confirms_address. parcel.declared_value is set to the item price.
  • Cancel before pickup and a buyer who has already paid is refunded automatically.
  • In this sandbox no WhatsApp message is actually sent. Open buyer_confirmation_url yourself to play the buyer, and the shipment carries on from there.

Webhooks

We call you when something changes.

Add one HTTPS URL on the Integrations page and we will POST a signed JSON event to it for every status change. The same page shows your signing secret (it starts with whsec_), lets you rotate it, sends a test event and keeps a log of recent deliveries.

What we send · headers
POST /webhooks/sendam HTTP/1.1
Content-Type: application/json
User-Agent: Sendam-Webhooks/1.0
X-Sendam-Event: shipment.delivered
X-Sendam-Delivery: evt_<event id>
X-Sendam-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
What we send · body
{
  "id": "evt_<event id>",
  "type": "shipment.delivered",
  "created_at": "2026-11-10T22:46:05.861Z",
  "data": {
    "id": "SND-V7EGM",
    "object": "shipment",
    "reference": "ORDER-10492",
    "status": "delivered",
    "status_label": "Delivered"
    // …the full shipment object, exactly as GET /shipments/{id} returns it
  }
}

Event types

shipment.<status>
Every status change, one type per status: shipment.ready, shipment.assigned, shipment.delivered, shipment.cancelled and so on.
escrow.released
A See Am First buyer accepted the item and their code released the money to you. Sent alongside shipment.delivered.
escrow.refunded
A See Am First payment went back to the buyer, because the delivery failed or the shipment was cancelled after they had paid.
ping
You pressed “Send test event” on the Integrations page. data is a short message, not a shipment.

For shipment and escrow events, data is the full shipment object as it stood when the event was created.

Verify the signature

Anyone can POST to a public URL, so check every event really came from Sendam. Take t and v1 from X-Sendam-Signature, compute an HMAC-SHA256 of <t>.<raw body> with your signing secret, and compare the hex result with v1. Use the raw request body, byte for byte, before any JSON parsing.

import crypto from "node:crypto";
import express from "express";

const app = express();

// The signature covers the raw bytes, so use express.raw() on this route, not express.json().
app.post("/webhooks/sendam", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("X-Sendam-Signature") ?? "";
  const { t, v1 = "" } = Object.fromEntries(header.split(",").map((part) => part.split("=")));

  const expected = crypto
    .createHmac("sha256", process.env.SENDAM_WEBHOOK_SECRET)
    .update(`${t}.${req.body}`)
    .digest("hex");

  const valid = v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300; // ignore anything older than 5 minutes
  if (!valid || !fresh) return res.status(400).send("Bad signature");

  const event = JSON.parse(req.body);
  // event.id is also in X-Sendam-Delivery: store it and skip ids you have already handled.
  if (event.type === "shipment.delivered") {
    // mark the order with reference event.data.reference as delivered
  }

  res.sendStatus(204); // answer first, do slow work in a queue
});

app.listen(3000);
  • Answer within 8 seconds

    Return any 2xx status. We stop waiting after 8 seconds and mark the delivery as failed, so acknowledge first and do slow work in a queue.

  • Treat webhooks as a nudge

    In this build each event is attempted once, and a failure shows in the delivery log. If you miss one, GET /shipments/{id} always has the truth.

  • Handle each event once

    The event id is repeated in X-Sendam-Delivery. Store the ids you have processed and skip any you see again.

  • Do not rely on order

    Events for quick successive changes can arrive close together or out of order. Trust data.status and data.events, not arrival order.

Webhook URLs must use https://. Plain http:// is accepted only for localhost and 127.0.0.1 while you develop.

Errors

One shape for every error.

Anything other than a 2xx carries an error object with a stable code to branch on and a human message to log. Validation errors add fields.

Response422
{
  "error": {
    "code": "validation_failed",
    "message": "Some fields are missing or invalid.",
    "fields": {
      "dropoff.name": "A contact name is required.",
      "dropoff.phone": "Use an 11-digit Nigerian mobile number, e.g. 08031234567."
    }
  }
}
invalid_json
HTTP status 400
The body is not valid JSON, or is not a JSON object.
invalid_request
HTTP status 400
The request is well formed but cannot be carried out as things stand. Read message.
missing_api_key
HTTP status 401
No Authorization: Bearer … header was sent.
invalid_api_key
HTTP status 401
The key is wrong, has been revoked, or is not attached to an active business.
insufficient_balance
HTTP status 402
Your wallet cannot cover this delivery. Nothing was created or charged. Top up, then retry with the same Idempotency-Key.
not_found
HTTP status 404
No shipment with that id belongs to your business.
cannot_cancel
HTTP status 409
The rider has already picked the parcel up, so it can no longer be cancelled.
validation_failed
HTTP status 422
Fields are missing or invalid. error.fields maps each field path, such as dropoff.phone, to what is wrong with it. An address outside the service area is reported here too.
server_error
HTTP status 500
Something went wrong on our side. Retry with the same Idempotency-Key.

Idempotency and retries

Retry freely. Book once.

Networks drop, especially on mobile data. If a create request times out you cannot tell whether the rider was booked, so the API lets you ask again safely.

  • Send an Idempotency-Key header on every POST /shipments. Your own order number is ideal. Keys are scoped to your business.
  • A repeat returns the first shipment. The same key gives you back the shipment that was already created, in its current state, and your wallet is not charged again. The body of the repeat is not compared, so use a new key for a genuinely new shipment.
  • A key is only used up by a success. If the first attempt failed with validation_failed or insufficient_balance, fix the problem and retry with the same key.
  • Retry on timeouts, network errors and 5xx, with the same key and a growing pause between attempts. Do not retry other 4xx responses unchanged; they will fail the same way.
  • Everything else is naturally safe. Quotes and reads change nothing, and cancelling twice returns the same cancelled shipment. To double-check an order, look it up with GET /shipments?reference=….

One catch with the shared sandbox key

Idempotency keys are scoped to a business, and everyone using the public sandbox key is the same business. If two people send order-1, the second gets the first person’s shipment back. That is why the curl samples use $(uuidgen). With your own key, your order numbers are all you need.

No code?

Plugins are coming soon.

If your shop runs on one of these platforms, you will not need a developer at all. These plugins are planned and not available yet. Until then, the API above works with all three, and the business dashboard needs no code.

  • Coming soon

    Shopify

    Sendam rates at checkout and a shipment booked when an order is paid.

  • Coming soon

    WooCommerce

    A WordPress plugin that adds Sendam as a shipping method.

  • Coming soon

    Bumpa

    Book Sendam riders from the orders you already manage in Bumpa.

Coverage

Live in Lagos.

Lagos is live, Mainland and Island. Abuja and Port Harcourt are coming soon. GET /coverage always has the current list, so read it rather than hard-coding cities.

  • Live

    Lagos

    Mainland and Island

  • Coming soon

    Abuja

    Not yet available

  • Coming soon

    Port Harcourt

    Not yet available

  • A pickup or drop-off outside the Lagos service area is rejected with 422 validation_failed, with the reason under fields.pickup or fields.dropoff. Quote first and you will find out before your customer pays.
  • Crossing between Mainland and Island adds a bridge surcharge to the price (crosses_bridge: true on the quote), and express is only available within one side.

Ready to send am?

Create a key, point a webhook at your server and book your first sandbox delivery. It takes a few minutes to watch one go all the way to the door.