Delivery API Integration Guide
Eziza is a delivery marketplace, not a dispatch service — nearby riders and logistics companies bid on your delivery, you (or your buyer) pick an offer, then Eziza handles live GPS tracking, OTP-verified handoff, and payout.
01. Getting access
Two paths in, depending on what you need.
- Try it yourself, right now — sign up for a free sandbox account. No approval wait, and nothing you do there touches a real rider — see Sandbox mode.
- Going live — email admin@eziza.com.ng once you're ready for real riders and real deliveries, and we'll promote your tenant. If you'd rather skip sandbox entirely, we can also create a live tenant for you directly.
Either way you end up with a tenant record, a partner portal login to manage your own API keys and webhook config, and an API key shown once at creation, then never retrievable again — store it like any other secret. You can issue additional keys at any time (e.g. one for staging, one for production), or revoke one yourself.
02. Sandbox mode
Every tenant starts in sandbox. Sandbox keys are prefixed eziza_test_, live keys eziza_live_. Sandbox deliveries never reach the real job board — a simulator plays only the rider's side of the lifecycle, so your integration code exercises the exact same endpoints and webhooks it would in production:
| Transition | Who drives it in sandbox |
|---|---|
| A rider/company offers on your delivery | Simulated — appears ~10s after creation |
open → assigned | You — call accept-bid yourself |
| Rider "arrives" at pickup | Simulated — ~15s after assignment |
awaiting_pickup_confirm → picked_up | You — call confirm-pickup yourself |
| Rider marks delivered | Simulated — ~20s after pickup |
delivered → confirmed | You — call confirm-receipt yourself |
Every real webhook fires exactly the same way for sandbox deliveries, so you can fully exercise your webhook receiver without a human rider anywhere.
eziza_live_ key right after promotion.03. Authentication
Every request — except incoming webhooks, which are signed instead (see Webhooks) — needs your API key as a bearer token:
Authorization: Bearer <your_api_key>
A missing, wrong, revoked key, or a key belonging to a deactivated tenant, gets a 401: { "error": "Unauthorized" }
04. Delivery lifecycle
open → assigned → awaiting_pickup_confirm → picked_up → delivered → confirmed
open or assigned → cancelled
| Status | Meaning | Who moves it here |
|---|---|---|
open | Live on the job board, accepting bids | create-delivery |
assigned | A bid was accepted, a rider/company is assigned | accept-bid |
awaiting_pickup_confirm | Rider arrived, waiting on your merchant to hand over | the rider's own app |
picked_up | Handoff confirmed, rider en route | confirm-pickup |
delivered | Marked delivered, awaiting OTP confirmation | the rider's own app |
confirmed | Receipt confirmed — rider/company gets paid | confirm-receipt |
cancelled | Cancelled while still open or assigned | cancel-delivery |
open deliveries get a bid_closes_at timestamp (30 min out by default). This is advisory only and not currently enforced — a delivery can still receive and accept bids after this time passes.
Alternative path: instead of waiting for a bid, you can book a third-party courier directly — see External Carriers under Endpoints.
05. Endpoints
All bodies and responses are JSON. OPTIONS is supported for CORS on every endpoint.
POST/create-delivery
Puts a delivery live on the job board.
| Field | Type | Notes |
|---|---|---|
external_order_id | string | required — your own order ID, unique per tenant |
external_ref | string | free-form, echoed back in webhooks |
pickup_address | string | required |
pickup_lat / pickup_lng | number | |
pickup_state | string | used for rider/company geographic matching |
pickup_contact_name / _phone | string | |
delivery_address | string | required |
delivery_lat / delivery_lng | number | |
delivery_contact_name / _phone | string | |
package_description | string | |
package_value | number | |
package_category_id | number | only needed for External Carriers — from tenant-shipbubble-package-options |
package_weight_kg | number | same as above |
package_dimension | object | { "length": 10, "width": 10, "height": 10 } |
fixed_price | number | if set, the first rider/company to respond is auto-assigned at exactly this amount. If nobody responds within 20 minutes you get a delivery.price_too_low webhook. Omit for normal open bidding. |
201
{ "delivery_id": "uuid", "status": "open", "bid_closes_at": "2026-07-13T10:30:00.000Z", "fixed_price": null }
GET/get-delivery?id=<delivery_id>
Full delivery status, assigned rider info, and live GPS if a rider is currently active on it. rider is null until a bid is accepted; rider_location is null unless the delivery is active and the rider has a live GPS row. 404 if the delivery doesn't exist or isn't yours.
POST/cancel-delivery
Only works while status is open or assigned — 409 otherwise.
{ "delivery_id": "uuid", "reason": "optional string" }
POST/accept-bid
Accepts one bid on an open delivery; every other pending bid is automatically rejected. 409 if the delivery isn't open anymore; 404 if the bid doesn't exist or was already resolved.
{ "delivery_id": "uuid", "bid_id": "uuid" }
No endpoint lists bids on a delivery today — the only way to discover them is the bid.placed webhook. Ask us for a list-bids endpoint if you need a polling fallback.
POST/confirm-pickup
Call when your merchant has physically handed the package to the rider. Only valid while status is awaiting_pickup_confirm.
POST/confirm-receipt
Call when your buyer confirms receipt. Only valid while status is delivered. This is what triggers the rider/company getting paid.
External Carriers
Book a real third-party courier directly on an open delivery, instead of waiting for a bid. Requires a prepaid wallet_balance on your tenant, and that the delivery was created with package_category_id, package_weight_kg, and package_dimension set.
- POST
/tenant-shipbubble-package-options— the carrier's live category/box-size presets. - POST
/tenant-shipbubble-rates—{ delivery_id }→ a list of courier quotes. - POST
/tenant-shipbubble-book—{ quote_id }→ books it, debits the wallet, delivery becomesassigned. - POST
/tenant-shipbubble-cancel—{ booking_id }→ cancels and credits the fee back.
06. Webhooks
If your tenant record has a webhook_url set, Eziza POSTs an event to it whenever something changes. Every request carries:
Content-Type: application/json
X-Eziza-Event: <event name>
X-Eziza-Signature: <hex HMAC-SHA256 of the raw request body>
Verify the signature before trusting the payload — compute HMAC-SHA256 of the exact raw body bytes using your WEBHOOK_SIGNING_SECRET, hex-encode it, and compare with a constant-time comparison:
// Node.js
const crypto = require('crypto');
function isValid(rawBody, signatureHeader, secret) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}
get-delivery periodically as a fallback for anything time-sensitive.
delivery.* — status changed
| X-Eziza-Event | status |
|---|---|
delivery.assigned | assigned |
delivery.awaiting_pickup | awaiting_pickup_confirm |
delivery.picked_up | picked_up |
delivery.delivered | delivered |
delivery.confirmed | confirmed |
delivery.cancelled | cancelled |
{
"event": "delivery.picked_up",
"delivery_id": "uuid",
"external_order_id": "…",
"external_ref": "…",
"status": "picked_up",
"rider_id": "uuid",
"agreed_price": 2500,
"timestamp": "2026-07-13T10:12:00.000Z"
}
bid.placed — a rider or company placed a new offer
{
"event": "bid.placed",
"delivery_id": "uuid",
"external_order_id": "…",
"bid": { "id": "uuid", "bidder_type": "rider", "rider_id": "uuid", "company_id": null, "amount": 2500, "status": "pending" },
"timestamp": "…"
}
delivery.price_too_low — a fixed-price delivery got no takers
Fired once, 20 minutes after creation, for a fixed_price delivery still open with no one having accepted it. No auto-fallback happens — raising the price (a new create-delivery call) or switching to External Carriers is your decision.
{ "event": "delivery.price_too_low", "delivery_id": "uuid", "external_order_id": "…", "fixed_price": 1500, "timestamp": "…" }
location.updated — live rider GPS
Fired on every GPS update from a rider currently active on one of your deliveries — the same data get-delivery's rider_location field surfaces.
{ "event": "location.updated", "delivery_id": "uuid", "external_order_id": "…", "latitude": 6.5244, "longitude": 3.3792, "updated_at": "…" }
07. Errors
| Status | Meaning |
|---|---|
400 | Missing required field(s) |
401 | Missing/invalid/revoked API key, or your tenant is deactivated |
404 | Not found, or exists but belongs to a different tenant |
409 | Valid request, but the delivery isn't in a state that allows this action |
500 | Unexpected error — including a reused external_order_id |
All error bodies are { "error": "human-readable message" }.
08. Support
Questions, or requesting a new tenant/key: admin@eziza.com.ng, or visit Support.