# Delivahere Partner API: full reference > Book, pay for and track Delivahere deliveries for your own customers. Generated from the same sources as https://developers.delivahere.com and https://developers.delivahere.com/openapi.json (spec version 1.0.0). ## Notes for coding agents Read these before writing integration code. - Two environments with separate keys: QA (`http://103.187.101.50:8721/request/public`) for building and testing, Live (`https://admin.delivahere.com/request/public`) for real money. Make the base URL configuration, never a constant. - Every call sends `client_id` and `secret_key` headers. The secret is server-side only: read it from an environment variable or secrets manager, never put it in code, a mobile app, a browser bundle, logs or a repo. - Send a unique `Idempotency-Key` (built from the partner's own order ID plus the action) on every create, accept and cancel, and reuse it on retries. - Branch on `error.code`, not on the message. Back off on `429` and honour `Retry-After`. - Webhooks: verify `X-Webhook-Signature` (HMAC-SHA256 of the **raw** body) with a constant-time compare, reject events older than 10 minutes, de-duplicate on `idempotencyKey`, reply exactly `200` quickly and process on a queue. Ignore unknown events and fields. - Build delivery logic on `publicStatus`, not internal statuses. - A new sender is created by their first `POST /createRequestWithUser` (send `fullName` and `email` with a mobile that isn't on Delivahere). There is no separate sign-up call. Use a mobile and email never used on Delivahere; an account the partner can't act for can't be given a session. - Drop-off codes are secrets: never log them; pass them only to the receiver. - In payloads, fields starting with `partner` or `da` describe the delivery partner agent (the courier), not the API partner. ## Guides ## Delivahere Partner API Book deliveries for your customers, pay for them and follow each one from pickup to drop-off. Delivery partner agents bid on every job; you accept a bid (or let Delivahere accept the first one under your ceiling), pay from a prepaid wallet, card or payment link, and receive every change as a signed webhook. In payloads, fields about the delivery partner agent start with `partner` or `da` (for example `partnerName`, `daId`). There, "partner" means the agent, never you. - [Quickstart](https://developers.delivahere.com/#/guide/quickstart): Your first delivery in five calls. - [Authentication](https://developers.delivahere.com/#/guide/authentication): Keys, sessions and who you can act for. - [Webhooks](https://developers.delivahere.com/#/guide/webhooks): Verify, de-duplicate and react to events. - [API reference](https://developers.delivahere.com/#/op/createDelivery): Every endpoint, with a live console. ### How it fits together 1. **Create** the delivery with `POST /createRequestWithUser`. It opens to delivery partner agents straight away. 2. **Delivery partner agents bid.** Each bid arrives as `delivery.bid_submitted`. 3. **Accept and pay.** Either accept a bid with `POST /deliveries/{idOrCode}/bids/{bidId}/accept`, or create the job with `autoBidAccept` so the first bid under `maxBidAmount` is accepted and paid for you. 4. **Hand over codes.** `delivery.codes_issued` gives you the drop-off code for the receiver. 5. **Track.** `delivery.in_transit`, `delivery.location_updated`, then `delivery.delivered`. Share `/track/{trackingToken}` links with your customers. ### Environments | Environment | Base URL | Use it for | |---|---|---| | **QA** | `http://103.187.101.50:8721/request/public` | Building and testing, with separate keys. Ask Delivahere for test card details before paying for anything. | | **Live** | `https://admin.delivahere.com/request/public` | Real deliveries, real delivery partner agents, real money. | Keys are per environment: QA keys don't work on Live and the other way round. Switch environment at the top of this page. > **Warning:** > QA currently runs over plain HTTP. Use QA keys only, never real customer data, and expect QA to move to an HTTPS address before general availability. ### Conventions - **JSON over HTTPS**, UTF-8. Photo upload is the only `multipart/form-data` call. - **Phones** are E.164 without the `+`: `2348012345678`. - **Money** is in major units of the delivery currency (`5000` = ₦5,000). - **Times** are RFC 3339 in UTC. Parse them with a real date parser: the offset may be written `Z` or `+0000`. - **IDs**: a delivery has a numeric `requestId`, a display code `DLV-000123` and an unguessable `trackingToken`. Store all three. - **Be tolerant.** New fields can appear in any response or webhook. Ignore what you don't know; never fail on an extra field. ## Quickstart This walks through one delivery on QA. Add your QA keys first (**Add keys**, top right); each step links to the endpoint, where **Send** runs it for real. ### 1. Check the price `POST /pricing/recommend` returns a low / optimal / high range. Use `optimal` as your `offerAmount` if you have no price of your own. [Open](https://developers.delivahere.com/#/op/recommendPrice) ```bash curl -X POST "$BASE/pricing/recommend" \ -H "client_id: $CLIENT_ID" -H "secret_key: $SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"currency":"NGN","pickup":{"latitude":6.5244,"longitude":3.3792},"dropoff":{"latitude":6.4531,"longitude":3.3958},"weightClass":"light"}' ``` ### 2. Create the delivery `POST /createRequestWithUser`. If the sender's phone is new to Delivahere, include `fullName` and `email`, or you'll get `422 USER_REGISTRATION_REQUIRED`. Always send an `Idempotency-Key`. [Open](https://developers.delivahere.com/#/op/createDelivery) There's no separate sign-up call: a new sender is created by their first delivery. To try it, open Create a delivery, press **New test sender** (QA only), **Check** that the number is free, and send. The console then offers to open a session for that sender. ```bash curl -X POST "$BASE/createRequestWithUser" \ -H "client_id: $CLIENT_ID" -H "secret_key: $SECRET_KEY" \ -H "Idempotency-Key: order-5521-create" \ -H "Content-Type: application/json" \ -d '{ "mobile": "2348012345678", "fullName": "Ada Obi", "email": "ada@example.com", "title": "Order #5521", "weightClass": "light", "vehicleType": "MOTORCYCLE", "declaredValueAmount": 50000, "offerAmount": 5000, "sourceStreetAddress": "12 Marina Rd", "sourceCity": "Lagos", "sourceState": "Lagos", "sourceCountry": "NG", "sourceLatitude": 6.45, "sourceLongitude": 3.39, "destinationStreetAddress": "5 Ring Rd", "destinationCity": "Ibadan", "destinationState": "Oyo", "destinationCountry": "NG", "destinationLatitude": 7.38, "destinationLongitude": 3.94, "receiverName": "Bola A.", "receiverMobile": "2348098765432", "autoVerifyPickup": true }' ``` Save `requestId`, `trackingCode` and `trackingToken` from the response. ### 3. Open a session Reading bids and accepting one are done on behalf of the sender, so they need a session. [Open](https://developers.delivahere.com/#/op/createSession) ```bash curl -X POST "$BASE/session" \ -H "client_id: $CLIENT_ID" -H "secret_key: $SECRET_KEY" \ -H "Content-Type: application/json" -d '{"mobile":"2348012345678"}' ``` The console fills `X-Session-Token` in for you after a successful call. ### 4. Accept a bid Wait for `delivery.bid_submitted` (or poll `GET /deliveries/{idOrCode}/bids`), then accept with `AUTO`: wallet first, then saved card, then a payment link. [Open](https://developers.delivahere.com/#/op/acceptBid) ```bash curl -X POST "$BASE/deliveries/DLV-000123/bids/9876/accept" \ -H "client_id: $CLIENT_ID" -H "secret_key: $SECRET_KEY" \ -H "X-Session-Token: $SESSION" -H "Idempotency-Key: order-5521-accept-9876" \ -H "Content-Type: application/json" -d '{"paymentMethod":"AUTO"}' ``` ### 5. Follow it Your webhook receives `delivery.bid_accepted`, `delivery.codes_issued`, `delivery.in_transit` and finally `delivery.delivered`. Give your customer `/track/{trackingToken}` for a live, redacted tracking view. [Open](https://developers.delivahere.com/#/op/trackDelivery) > **Tip:** > To skip step 4 entirely, create the job with `"autoBidAccept": true, "maxBidAmount": 4500`. The first bid at or under ₦4,500 is accepted and paid automatically. ## Authentication ### Client credentials Delivahere issues each partner, per environment: | Credential | What it is | |---|---| | `client_id` | Identifies you. Send it on every request as the `client_id` header. | | `secret_key` | Proves it's you. Send it on every request as the `secret_key` header. Shown **once**; Delivahere stores only a hash and can't show it again. | | Webhook signing secret | Signs the webhooks we send you. See [Webhooks](https://developers.delivahere.com/#/guide/webhooks). | | Channel | Your uppercase channel name (e.g. `ACME`). Every delivery you create is stamped with it. | ```http POST /request/public/createRequestWithUser HTTP/1.1 Host: admin.delivahere.com client_id: acme-Xk9fQ2aB secret_key: Content-Type: application/json ``` > **Danger:** > **The secret is server-side only.** Never put it in a mobile app, a browser bundle, a public repository or a support ticket. Anyone holding it can book deliveries and spend your wallet. If it leaks, ask Delivahere to rotate it: the old one stops working immediately. A missing, wrong or deactivated credential returns `401 UNAUTHORIZED`. **Proxies.** The header names contain underscores. Some proxies (nginx by default, some load balancers) silently drop such headers. If you call through one, allow underscores in header names. ### Sessions: acting for a sender Deliveries belong to a **sender** account. Reading a sender's deliveries, their bids and wallet, accepting and cancelling all act for that sender, so they also need a session: 1. `POST /session` with the sender's `mobile` → `sessionToken` (valid 24 hours). 2. Send it as `X-Session-Token` alongside your client credentials. A session only works with the `client_id` that opened it. When it expires (`401`), open a new one; there's no refresh. Treat tokens like passwords and don't log them. ### Who you can act for You see and act for only: - senders **created through your channel** (by `POST /createRequestWithUser`), and - business accounts Delivahere has **linked** to you. Deliveries are visible only if they were created through your channel. Anything else behaves exactly like something that doesn't exist (`404`), so a `404` on a real ID means "not yours". ### Two common set-ups - **Your customers as senders.** Each customer becomes a Delivahere sender on their first delivery. Good when customers should get Delivahere's own notifications. - **One business sender for every job.** Ask Delivahere to link your business account, book every job under it with per-job `pickupName`/`pickupMobile` and `receiverName`/`receiverMobile`, and set `notifySender: false`. You notify customers yourself from webhooks. ## Retries and idempotency Networks fail. To retry safely, send an `Idempotency-Key` header on every state-changing call: | Endpoint | Key example | |---|---| | `POST /createRequestWithUser` | `order-5521-create` | | `POST /deliveries/{idOrCode}/bids/{bidId}/accept` | `order-5521-accept-9876` | | `POST /deliveries/{idOrCode}/cancel` | `order-5521-cancel` | How it behaves: - **Same key, same body, within 24 hours:** the first response is replayed. No second delivery, no second charge. - **Same key, request still running:** `409 IDEMPOTENCY_IN_PROGRESS`. Wait a second and retry. - **Same key, different body:** `409 IDEMPOTENCY_CONFLICT`. You've reused a key by mistake. - **A request that failed** releases its key, so retrying with the same key is safe. > **Warning:** > Keys are shared across **all** endpoints for your client. Build them from your own IDs plus the action, as above, so a key can never be reused for a different delivery. In particular, never use a fixed key for cancels. ### When to retry | Response | Retry? | |---|---| | Network error, timeout | Yes, with the same key. | | `429 RATE_LIMITED` | Yes, after `Retry-After` seconds. | | `500 INTERNAL_ERROR` | Yes, with the same key, backing off (1s, 2s, 4s…). | | `409 IDEMPOTENCY_IN_PROGRESS` | Yes, shortly. | | Other `4xx` | No. Fix the request. | ### Rate limits Each caller IP may make 300 requests a minute and each `client_id` 1,200. Over the limit you get `429 RATE_LIMITED` with a `Retry-After` header in seconds. Prefer webhooks to polling. ## Responses and errors ### Success Every successful call (except `GET /track`) returns HTTP `200` and this envelope: ```json { "responseMessage": "Local Delivery Request Success", "status": 200, "resourceData": { "requestId": 123, "trackingCode": "DLV-000123" } } ``` `resourceData` holds the result. Paginated lists also carry `totalRecords` and `noOfRecords`. > **Note:** > The HTTP status of a success is always `200`. Two results are signalled in the body instead: a card payment that is still processing (`status: 202`, `resourceData.status: "PAYMENT_PENDING"`), and a cancel that lost a race with the delivery partner agent (`status: 400`, `resourceData: null`). Read the body's `status` on accept and cancel. ### Errors Errors use a non-2xx HTTP status and this envelope: ```json { "success": false, "error": { "code": "USER_REGISTRATION_REQUIRED", "message": "No user found for this mobile. Provide fullName and email to register.", "requiredFields": ["fullName", "email"] }, "path": "/request/public/createRequestWithUser", "timestamp": "2026-11-03T10:15:30.123Z" } ``` Branch on `error.code`; `message` is for people and its wording can change. | HTTP | `error.code` | Meaning | |---|---|---| | 400 | `BAD_REQUEST` | Invalid input; `message` names the field. | | 401 | `UNAUTHORIZED` | Bad credentials, or a missing / expired session. | | 402 | `INSUFFICIENT_WALLET_BALANCE`, `NO_WALLET`, `AUTO_BID_HOLD_USED`, `WALLET_LIMIT_EXCEEDED`, `PAYMENT_FAILED` | The wallet couldn't pay. | | 404 | `NOT_FOUND` | Doesn't exist, or isn't yours. | | 404 | `TRACKING_NOT_FOUND` | No delivery for this tracking reference. | | 409 | `IDEMPOTENCY_IN_PROGRESS` | Same key still running. Retry shortly. | | 409 | `IDEMPOTENCY_CONFLICT` | Same key, different body. | | 409 | `DELIVERY_NOT_CANCELLABLE` | Too late: picked up already, or a delivery partner agent is already confirmed. | | 422 | `USER_REGISTRATION_REQUIRED` | New sender: send `requiredFields`. | | 429 | `RATE_LIMITED` | Slow down; see `Retry-After`. | | 500 | `INTERNAL_ERROR` | Unexpected. Retry with the same `Idempotency-Key`. | > **Note:** > A non-numeric delivery ID, bid ID or page number currently returns `500` rather than `400`. Validate IDs on your side. ## Paying for deliveries A bid is only accepted once it's paid. You choose how: | `paymentMethod` | What happens | Result | |---|---|---| | `AUTO` (recommended) | Wallet, then the saved card, then a payment link. | `ACCEPTED`, `PAYMENT_PENDING`, or a link with `fallbackReason`. | | `WALLET` | Wallet only. | `ACCEPTED`, or `402` with the reason. | | `CARD` | Saved card; if it fails, a payment link. | `ACCEPTED`, `PAYMENT_PENDING`, or a link. | | `LINK` (default) | A payment link. | A link. | **Wallet.** Your prepaid balance (`GET /wallet/balance`). Fund it by bank transfer and Delivahere credits it. A bid is debited in full, never split. Many jobs accepting at once can never take the balance below zero; the ones it can't cover fall through to the next source. **Payment link.** The bid waits until the link (`paymentUrl`) is paid. Links expire after an hour. You receive it in the response and as `payment.required`. **Auto-accept.** Create the job with `autoBidAccept: true` and `maxBidAmount`. The first bid at or under the ceiling is accepted and paid without any call from you. Add `autoBidPaymentMode: "WALLET"` to reserve the money from the wallet at creation time (`402` if the balance is short). ### Events Each source that's skipped or fails sends `payment.failed` (with `method` and `reason`); the next source is then tried. The source that pays sends `payment.successful`. In every case, **`delivery.bid_accepted` is the signal that the delivery partner agent is booked and paid**: some payment-link successes send only that. ### Refunds Cancelling after acceptance (before pickup) refunds to the source that paid: the wallet in full; card and link payments through the gateway, minus the gateway fee. You receive `payment.refund_initiated`, then `payment.refund_completed`. ## Delivery lifecycle ### Public status Build your logic on `publicStatus` (on `GET /deliveries/{idOrCode}` and every `delivery.*` webhook). Internal statuses also appear in some responses; they change more often and aren't part of the contract. `PENDING → AWAITING_BIDS → ACCEPTED → PICKED_UP → IN_TRANSIT → DELIVERED` | `publicStatus` | Meaning | |---|---| | `PENDING` | Created. A job created through the API is already open to delivery partner agents even while it reads `PENDING`. | | `AWAITING_BIDS` | Open to delivery partner agents (for example after a rejected pickup). | | `ACCEPTED` | A delivery partner agent is booked and paid; not collected yet. | | `PICKED_UP` | The delivery partner agent has the package. | | `IN_TRANSIT` | On the way. | | `DELIVERED` | Done. | | `CANCELLED` | Cancelled. | Treat an unknown value as "no change", not as an error. ### Pickup and drop-off codes - **Pickup.** You're not at the handover, so create jobs with `autoVerifyPickup: true`. The pickup then counts as verified when the bid is accepted (`delivery.pickup_verified` with `autoVerified: true`). The delivery partner agent actually collecting the package shows as `delivery.in_transit`. - **Drop-off.** The receiver gives the delivery partner agent a drop-off code. `delivery.codes_issued` gives it to you whenever it's created or regenerated. By default Delivahere also texts it to the receiver; with `sendDropOffCode: false` you send it yourself. > **Danger:** > A drop-off code releases the package. Don't log it, don't store it in plain text, and only show it to the receiver. ### When things go wrong | Event | What happens next | |---|---| | `delivery.partner_cancelled` | The delivery partner agent dropped the job; it goes back out for bids (`delivery.published`). | | `delivery.pickup_rejected` | The bid is cancelled, you're refunded and the job reopens (`delivery.published`). Wait for a new delivery partner agent or cancel. | | `delivery.dropoff_rejected` | The delivery partner agent retries with a fresh code (`delivery.codes_issued`, `regenerated: true`). | | `delivery.return_requested` / `delivery.return_agreed` | The package is going back to the sender. | ### Cancelling `POST /deliveries/{idOrCode}/cancel` works until the delivery partner agent collects the package; after acceptance you're refunded. After pickup it returns `409 DELIVERY_NOT_CANCELLABLE`. ### Tracking links `GET /track/{trackingToken}` needs no credentials and is redacted (first names, city-level places, masked delivery partner agent phone), so it's safe to put behind a link for your customer. Use the `trackingToken`, never the `DLV-` code: codes are sequential and guessable. ## Webhooks Delivahere `POST`s every event about your deliveries and users to the HTTPS URL you registered. Webhooks are the source of truth for what happens after you create a job; poll only to reconcile. ```http POST /webhooks/delivahere HTTP/1.1 Content-Type: application/json User-Agent: Delivahere-Webhook/1.0 X-Webhook-Signature: sha256=5d41402abc4b2a76b9719d911017c592... {"event":"delivery.bid_accepted","timestamp":"2026-11-03T10:15:30.517Z","idempotencyKey":"evt_3f1c2a9e-...","channel":"ACME","data":{...}} ``` | Field | Meaning | |---|---| | `event` | Event name, e.g. `delivery.bid_accepted`. See the event list in the sidebar. | | `timestamp` | When we sent it (UTC). The same on every retry. | | `idempotencyKey` | Unique per event, the same on every retry. De-duplicate on it. | | `channel` | Your channel. | | `data` | The event payload. | ### Receiving an event 1. **Read the raw body** before any JSON parsing. The signature covers the exact bytes. 2. **Verify the signature** (below). Reject with `401` if it doesn't match. 3. **Check freshness.** Reject events whose `timestamp` is more than 10 minutes old. Retries span about two minutes, so this allows them while stopping replays. 4. **De-duplicate** on `idempotencyKey`. Store each key for at least 24 hours; skip any you've seen. 5. **Answer `200` immediately**, then process the event on a queue. ### Responding - Reply **exactly `200`** within 5 seconds. Any other 2xx (`201`, `202`, `204`) counts as a failure and is **not** retried. - `5xx`, `429`, timeouts and connection errors are retried with the same body after 2, 4, 8, 16 and 32 seconds (6 attempts in all). Other `4xx` are not retried. - Don't redirect. A redirect is treated as a failure. ### Signature `X-Webhook-Signature` is `sha256=` followed by the lowercase hex HMAC-SHA256 of the raw request body, keyed with your webhook signing secret. Compare it in constant time. **Node.js** ```js import crypto from "node:crypto"; import express from "express"; const app = express(); const SECRET = process.env.DELIVAHERE_WEBHOOK_SECRET; // Keep the raw bytes: the signature is over them, not over parsed JSON. app.post("/webhooks/delivahere", express.raw({ type: "application/json" }), async (req, res) => { const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex"); const received = req.get("X-Webhook-Signature") ?? ""; const ok = received.length === expected.length && crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected)); if (!ok) return res.sendStatus(401); const event = JSON.parse(req.body.toString("utf8")); if (Date.now() - Date.parse(event.timestamp) > 10 * 60 * 1000) return res.sendStatus(400); if (await alreadySeen(event.idempotencyKey)) return res.sendStatus(200); await queue.add(event); // process asynchronously res.sendStatus(200); // exactly 200 }); ``` **Python** ```python import hashlib, hmac, json, os from datetime import datetime, timezone from flask import Flask, request, abort app = Flask(__name__) SECRET = os.environ["DELIVAHERE_WEBHOOK_SECRET"].encode() @app.post("/webhooks/delivahere") def delivahere_webhook(): raw = request.get_data() # raw bytes, before JSON parsing expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, request.headers.get("X-Webhook-Signature", "")): abort(401) event = json.loads(raw) sent = datetime.fromisoformat(event["timestamp"].replace("Z", "+00:00")) if (datetime.now(timezone.utc) - sent).total_seconds() > 600: abort(400) if already_seen(event["idempotencyKey"]): return "", 200 enqueue(event) # process asynchronously return "", 200 # exactly 200 ``` **PHP** ```php 600) { http_response_code(400); exit; } if (!already_seen($event['idempotencyKey'])) { enqueue($event); // process asynchronously } http_response_code(200); // exactly 200 ``` **Java** ```java import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.HexFormat; public final class DelivahereSignature { /** rawBody: the exact request bytes, read before any JSON binding. */ public static boolean isValid(byte[] rawBody, String header, String secret) throws Exception { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(rawBody)); return header != null && MessageDigest.isEqual( expected.getBytes(StandardCharsets.UTF_8), header.getBytes(StandardCharsets.UTF_8)); } } ``` **Go** ```go func delivahereWebhook(w http.ResponseWriter, r *http.Request) { raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // raw bytes if err != nil { http.Error(w, "bad body", http.StatusBadRequest) return } mac := hmac.New(sha256.New, []byte(os.Getenv("DELIVAHERE_WEBHOOK_SECRET"))) mac.Write(raw) expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Webhook-Signature"))) { w.WriteHeader(http.StatusUnauthorized) return } var ev struct { Timestamp time.Time `json:"timestamp"` IdempotencyKey string `json:"idempotencyKey"` } if json.Unmarshal(raw, &ev) != nil || time.Since(ev.Timestamp) > 10*time.Minute { w.WriteHeader(http.StatusBadRequest) return } if !alreadySeen(ev.IdempotencyKey) { enqueue(raw) // process asynchronously } w.WriteHeader(http.StatusOK) // exactly 200 } ``` Try it: the [signature checker](https://developers.delivahere.com/#/guide/signature-checker) computes the signature for any body and secret, in your browser. ### Delivery guarantees - **At least once.** The same event can arrive twice; de-duplicate. - **No ordering.** Events can arrive out of order (for example `delivery.codes_issued` before `delivery.bid_accepted`). Use `publicStatus` and `timestamp`, and never move a delivery backwards. - **Not guaranteed to arrive.** A rare event can be lost. Reconcile open deliveries with `GET /deliveries/{idOrCode}` now and then (hourly is plenty), and when an expected event is late. - **Fields can be missing or null.** Payloads differ slightly by payment gateway and flow. Code defensively and look values up by `requestId`. ### Rotating the signing secret Rotate the secret if it may have leaked, when someone who knew it leaves, or on a schedule you choose. On our side a rotation takes effect immediately: new events are signed with the new secret, while retries already in flight keep the old signature. So for a short while you'll receive both. 1. **Agree a time with Delivahere.** At that time we issue the new secret and send it to you once. 2. **Accept both secrets straight away.** Keep the secrets in configuration as a list, `new,old`, so adding one is a config change, not a code change. Your check passes if **either** matches. 3. **Remove the old secret after 10 minutes.** Retries stop after about two minutes, and your freshness check rejects anything older than 10. 4. **Reconcile.** Any event that failed your check before you added the new secret was answered `401`, which isn't retried. Fetch your open deliveries with `GET /deliveries/{idOrCode}` to catch up. **Node.js** ```js import crypto from "node:crypto"; // While rotating: DELIVAHERE_WEBHOOK_SECRETS="new-secret,old-secret". Afterwards, only the new one. const SECRETS = process.env.DELIVAHERE_WEBHOOK_SECRETS.split(",").map((s) => s.trim()).filter(Boolean); function isValid(rawBody, header) { const received = Buffer.from(header ?? ""); return SECRETS.some((secret) => { const expected = Buffer.from("sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex")); return received.length === expected.length && crypto.timingSafeEqual(received, expected); }); } // In the handler: if (!isValid(req.body, req.get("X-Webhook-Signature"))) return res.sendStatus(401); ``` **Python** ```python import hashlib, hmac, os # While rotating: DELIVAHERE_WEBHOOK_SECRETS="new-secret,old-secret". Afterwards, only the new one. SECRETS = [s.strip() for s in os.environ["DELIVAHERE_WEBHOOK_SECRETS"].split(",") if s.strip()] def is_valid(raw_body: bytes, header: str | None) -> bool: received = header or "" return any( hmac.compare_digest(received, "sha256=" + hmac.new(s.encode(), raw_body, hashlib.sha256).hexdigest()) for s in SECRETS ) # In the view: if not is_valid(request.get_data(), request.headers.get("X-Webhook-Signature")): abort(401) ``` **PHP** ```php SECRETS = Arrays.stream(System.getenv("DELIVAHERE_WEBHOOK_SECRETS").split(",")) .map(String::trim).filter(s -> !s.isEmpty()).toList(); /** rawBody: the exact request bytes, read before any JSON binding. */ public static boolean isValid(byte[] rawBody, String header) throws Exception { if (header == null) return false; byte[] received = header.getBytes(StandardCharsets.UTF_8); for (String secret : SECRETS) { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(rawBody)); if (MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), received)) return true; } return false; } } ``` **Go** ```go // While rotating: DELIVAHERE_WEBHOOK_SECRETS="new-secret,old-secret". Afterwards, only the new one. var webhookSecrets = strings.Split(os.Getenv("DELIVAHERE_WEBHOOK_SECRETS"), ",") func isValid(raw []byte, header string) bool { for _, secret := range webhookSecrets { secret = strings.TrimSpace(secret) if secret == "" { continue } mac := hmac.New(sha256.New, []byte(secret)) mac.Write(raw) expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) if hmac.Equal([]byte(expected), []byte(header)) { return true } } return false } // In the handler: if !isValid(raw, r.Header.Get("X-Webhook-Signature")) { w.WriteHeader(http.StatusUnauthorized); return } ``` ## Go-live checklist Before asking Delivahere for Live keys: - [ ] Keys live in a secrets manager or environment variables, never in code, apps or logs. - [ ] Every create, accept and cancel sends a unique `Idempotency-Key` built from your own IDs. - [ ] Retries back off and honour `Retry-After`. - [ ] You branch on `error.code`, not on `message`. - [ ] Your webhook endpoint is HTTPS, verifies `X-Webhook-Signature` on the raw body, rejects stale `timestamp`s, de-duplicates on `idempotencyKey`, and replies `200` quickly. - [ ] You handle events out of order and ignore unknown events and fields. - [ ] A job reconciles itself with `GET /deliveries/{idOrCode}` if events stop. - [ ] Drop-off codes are never logged and only reach the receiver. - [ ] Customer tracking links use `/track/{trackingToken}`, not the `DLV-` code. - [ ] You tested on QA: create, auto-accept, manual accept with `AUTO`, cancel before and after acceptance, a delivery partner agent cancelling, and a rejected pickup. - [ ] You told Delivahere your Live webhook URL and a technical contact for incidents. ## Endpoints Base URLs: QA `http://103.187.101.50:8721/request/public`, Live `https://admin.delivahere.com/request/public`. Paths below are relative to the base URL. ### Check whether a sender exists `POST /users/lookup` (operationId `lookupUser`) Auth: headers `client_id` and `secret_key`. Never creates an account. Users you can't act for (not created through your channel and not linked to you) are reported as `exists: false`. Request body (application/json): - `mobile` (string, required): Phone number. Send it with the country code and no `+` (`2348012345678`), or send the national number with `countryCode`. - `countryCode` (string): Country calling code, when `mobile` is a national number. Example request body: ```json { "mobile": "2348012345678" } ``` Response 200: Lookup result. `resourceData` (object): - `exists` (boolean, required): Whether the sender exists and is yours to act for. - `userId` (integer): When `exists`. - `name` (string | null): When `exists`. - `mobile` (string | null): When `exists`: E.164 without `+`. - `email` (string | null): When `exists`. Sensitive: never log. - `userReferralCode` (string | null): When `exists`. Example response: ```json { "responseMessage": "Account found", "status": 200, "resourceData": { "exists": true, "userId": 4567, "name": "Ada Obi", "userReferralCode": "ADA123", "mobile": "2348012345678", "email": "ada@example.com" } } ``` Errors: - 400 `BAD_REQUEST`: Missing or invalid input. `error.message` says which field. - 401 `UNAUTHORIZED`: Missing or wrong `client_id` / `secret_key`, a deactivated client, or a missing, expired or foreign `X-Session-Token`. - 429 `RATE_LIMITED`: Too many requests. Wait for `Retry-After` seconds. - 500 `INTERNAL_ERROR`: Unexpected error (also returned for a non-numeric id or page). Safe to retry with the same `Idempotency-Key`. ### Open a session for a sender `POST /session` (operationId `createSession`) Auth: headers `client_id` and `secret_key`. Returns a `sessionToken` for user-scoped calls (`X-Session-Token`). Valid for 24 hours and only with your `client_id`. Open a new one when it expires; there's no refresh. Treat it like a password. Request body (application/json): - `mobile` (string, required): Phone number. Send it with the country code and no `+` (`2348012345678`), or send the national number with `countryCode`. - `countryCode` (string): Country calling code, when `mobile` is a national number. Example request body: ```json { "mobile": "2348012345678" } ``` Response 200: Session created. `resourceData` (object): - `sessionToken` (string, required): Send as `X-Session-Token`. Sensitive: never log. - `userId` (integer, required): Sender's user ID. - `name` (string | null): Sender's name. - `expiresAt` (string (date-time), required): Expiry (24 hours). Example response: ```json { "responseMessage": "Session created", "status": 200, "resourceData": { "sessionToken": "ses_Hk3pQ9zR2vW8yLm4Xc7Tb1Nd6Fg0Js5Ae2Ku9Io3Py8", "userId": 4567, "name": "Ada Obi", "expiresAt": "2026-11-04T10:15:30.000+0000" } } ``` Errors: - 400 `BAD_REQUEST`: Missing or invalid input. `error.message` says which field. - 401 `UNAUTHORIZED`: Missing or wrong `client_id` / `secret_key`, a deactivated client, or a missing, expired or foreign `X-Session-Token`. - 404 `NOT_FOUND`: Not found, or not yours. Deliveries and users you can't act for look exactly like ones that don't exist. - 429 `RATE_LIMITED`: Too many requests. Wait for `Retry-After` seconds. - 500 `INTERNAL_ERROR`: Unexpected error (also returned for a non-numeric id or page). Safe to retry with the same `Idempotency-Key`. ### Upload a package photo `POST /media` (operationId `uploadMedia`) Auth: headers `client_id` and `secret_key`. Upload a photo, then pass its `url` in `mediaUrls` when creating the delivery. Keep files under 1 MB (JPEG, PNG or WebP). Request body (multipart/form-data): - `file` (string (binary), required): The image. - `purpose` (string): Leave as `packagePhoto`. Default `"packagePhoto"`. Response 200: Uploaded. `resourceData` (object): - `mediaId` (integer): Media ID. - `url` (string (uri)): Pass this in `mediaUrls`. - `purpose` (string): Purpose. - `contentType` (string): MIME type. - `size` (integer): Bytes. Example response: ```json { "responseMessage": "Media uploaded", "status": 200, "resourceData": { "mediaId": 8812, "url": "https://prod-deliva.s3.amazonaws.com/images/public-media/6f1c0e.jpg", "purpose": "packagePhoto", "contentType": "image/jpeg", "size": 81234 } } ``` Errors: - 400 `BAD_REQUEST`: Missing or invalid input. `error.message` says which field. - 401 `UNAUTHORIZED`: Missing or wrong `client_id` / `secret_key`, a deactivated client, or a missing, expired or foreign `X-Session-Token`. - 429 `RATE_LIMITED`: Too many requests. Wait for `Retry-After` seconds. - 500 `INTERNAL_ERROR`: Unexpected error (also returned for a non-numeric id or page). Safe to retry with the same `Idempotency-Key`. ### Create a delivery `POST /createRequestWithUser` (operationId `createDelivery`) Auth: headers `client_id` and `secret_key`. Creates the delivery and opens it to delivery partner agents straight away. If `mobile` isn't a Delivahere customer yet, send `fullName` and `email` to register them (otherwise `422`). Send an `Idempotency-Key` so a retry never books twice. Parameters: - `Idempotency-Key` (header, string): Strongly recommended. A unique key per action (e.g. your order ID plus the action). A retry with the same key and body replays the first response for 24 hours. Keys are shared across endpoints for your client, so never reuse one for a different action. Request body (application/json): - `mobile` (string, required): Sender's phone (`2348012345678`, or national number with `countryCode`). An existing customer is used as-is; a new one needs `fullName` and `email`. - `countryCode` (string): Country calling code when `mobile` is a national number. - `fullName` (string): New senders only: full name. - `email` (string (email)): New senders only: email. Delivahere emails them their account details. - `title` (string): Short package title. - `description` (string): Package description. - `additionalNotes` (string): Notes for the delivery partner agent. - `pkgType` (string): Package type, free text. - `weightClass` (string, required): `light` under 5 kg, `medium` 5–15 kg, `heavy` 15–30 kg, `extra_heavy` 30 kg+. Case-insensitive. Values: `light`, `medium`, `heavy`, `extra_heavy`. - `vehicleType` (string): Vehicle to request. Case-insensitive; also accepts `CAR`, `VAN`, `SMALL_TRUCK`, `MEDIUM_TRUCK`, `LARGE_TRUCK`, `TRAILER`. Unknown values are `400`. Send it explicitly: there is no default. Values: `MOTORCYCLE`, `BICYCLE`, `TRICYCLE`, `SEDAN`, `SUV`, `MINIVAN`, `PANEL_VAN`, `PICKUP_TRUCK`, `MINI_TRUCK_6T`, `LIGHT_TRUCK_OPEN`, `LIGHT_TRUCK_CONTAINER`, `MEDIUM_TRUCK_10T`, `MEDIUM_TRUCK_12T`, `HEAVY_TRUCK_16T`, `HEAVY_TRUCK_18T_CONTAINER`, `HEAVY_TRUCK_25T`, `EXTRA_HEAVY_30T`, `EXTRA_HEAVY_35T`, `TRAILER_20FT`, `TRAILER_40FT`, `NONE_OR_ANY_VEHICLE`. - `declaredValueAmount` (integer): Declared package value, in whole units of the delivery currency. - `offerAmount` (integer): The price you offer delivery partner agents, in whole units of the delivery currency. - `currency` (string): ISO currency. The pickup country's currency wins when `sourceCountry` is recognised; this is the fallback. - `insured` (boolean): Insure the package at its declared value (NGN: declared value of at least ₦50,000). Note the name: `insured`, not `isInsured`. Default `false`. - `sourceLocation` (string): Pickup: free-text location / landmark. - `sourceHouseNo` (string): Pickup: house number. - `sourceStreetAddress` (string): Pickup: street address. - `sourceLocality` (string): Pickup: area. - `sourceCity` (string): Pickup: city. - `sourceState` (string): Pickup: state. - `sourceCountry` (string): Pickup: ISO country code. Decides the delivery currency. - `sourceLatitude` (number): Pickup latitude. - `sourceLongitude` (number): Pickup longitude. - `pickupName` (string): Pickup contact name. - `pickupMobile` (string): Pickup contact phone. Sensitive: never log. - `destinationLocation` (string): Drop-off: free-text location / landmark. - `destinationHouseNo` (string): Drop-off: house number. - `destinationStreetAddress` (string): Drop-off: street address. - `destinationLocality` (string): Drop-off: area. - `destinationCity` (string): Drop-off: city. - `destinationState` (string): Drop-off: state. - `destinationCountry` (string): Drop-off: ISO country code. - `destinationLatitude` (number): Drop-off latitude. - `destinationLongitude` (number): Drop-off longitude. - `receiverName` (string): Receiver name. - `receiverMobile` (string): Receiver phone. Sensitive: never log. - `mediaUrls` (one of): Package photos: the `url` values returned by `POST /media`, as an array or a comma-separated string. - Variant 1 - Variant 2 - `pickupDate` (string (date-time)): Scheduled pickup date. - `pickupTime` (string (date-time)): Scheduled pickup time. - `expectedDeliveryTime` (string (date-time)): Requested delivery time. - `isPublished` (boolean): `false` saves a draft that can't be accepted or cancelled through the API. Leave it out. Default `true`. - `autoVerifyPickup` (boolean): Skip the pickup code: the pickup counts as verified when a bid is accepted. Recommended, since you aren't at the handover. Default `false`. - `autoBidAccept` (boolean): Accept the first bid at or under `maxBidAmount` automatically and pay for it. Default `false`. - `maxBidAmount` (number): Required (> 0) with `autoBidAccept`: the most you'll pay a delivery partner agent. - `autoBidPaymentMode` (string): With `autoBidAccept`: pay auto-accepted bids from the card or the wallet. `WALLET` reserves the amount at creation (`402` if the balance is short). Values: `CARD`, `WALLET`. - `notifySender` (boolean): `false`: Delivahere sends the sender account no SMS, WhatsApp, email or push about this job. Use it when one business account books every job and you notify customers from webhooks. Default `true`. - `sendDropOffCode` (boolean): `false`: Delivahere doesn't text the receiver the drop-off code; you deliver it from `delivery.codes_issued`. Default `true`. Example request body: ```json { "mobile": "2348012345678", "fullName": "Ada Obi", "email": "ada@example.com", "title": "Order #5521", "weightClass": "light", "vehicleType": "MOTORCYCLE", "declaredValueAmount": 50000, "offerAmount": 5000, "currency": "NGN", "sourceStreetAddress": "12 Marina Rd", "sourceCity": "Lagos", "sourceState": "Lagos", "sourceCountry": "NG", "sourceLatitude": 6.45, "sourceLongitude": 3.39, "pickupName": "Acme Warehouse", "pickupMobile": "2348011111111", "destinationStreetAddress": "5 Ring Rd", "destinationCity": "Ibadan", "destinationState": "Oyo", "destinationCountry": "NG", "destinationLatitude": 7.38, "destinationLongitude": 3.94, "receiverName": "Bola A.", "receiverMobile": "2348098765432", "autoVerifyPickup": true, "autoBidAccept": true, "maxBidAmount": 4500, "notifySender": false, "sendDropOffCode": false } ``` Response 200: Created. `resourceData` (object): - `requestId` (integer, required): Delivery ID. - `deliveryId` (integer): Same as `requestId`. - `trackingCode` (string, required): Display code, e.g. `DLV-000123`. - `trackingToken` (string, required): Unguessable reference for tracking links. Store it. - `status` (string, required): Always `PENDING` at creation. - `currency` (string | null): Delivery currency. - `userId` (integer, required): Sender's user ID (new or existing). - `delivaPkgProtectedInsurance` (boolean): Always false here. - `mediaUrls` (array of object): Present when photos were attached. - `originalMediaUrl` (string): Photo. - `thumbnailUrl80X80` (string): Thumbnail. - `thumbnailUrl350X350` (string): Thumbnail. Example response: ```json { "responseMessage": "Local Delivery Request Success", "status": 200, "resourceData": { "requestId": 123, "deliveryId": 123, "trackingCode": "DLV-000123", "trackingToken": "q3Zr8Kd0aP1xYt7bWm2cLs4e", "status": "PENDING", "currency": "NGN", "userId": 4567, "delivaPkgProtectedInsurance": false } } ``` Errors: - 400 `BAD_REQUEST`: Missing or invalid input. `error.message` says which field. - 401 `UNAUTHORIZED`: Missing or wrong `client_id` / `secret_key`, a deactivated client, or a missing, expired or foreign `X-Session-Token`. - 402 `INSUFFICIENT_WALLET_BALANCE`: The wallet couldn't pay. `error.code` is the reason: `INSUFFICIENT_WALLET_BALANCE`, `NO_WALLET`, `AUTO_BID_HOLD_USED`, `WALLET_LIMIT_EXCEEDED`, or `PAYMENT_FAILED`. - 409 `IDEMPOTENCY_CONFLICT`: `IDEMPOTENCY_IN_PROGRESS` (retry shortly), `IDEMPOTENCY_CONFLICT` (key reused with a different body) or `DELIVERY_NOT_CANCELLABLE`. - 422 `USER_REGISTRATION_REQUIRED`: New sender: send the fields listed in `error.requiredFields` and retry. - 429 `RATE_LIMITED`: Too many requests. Wait for `Retry-After` seconds. - 500 `INTERNAL_ERROR`: Unexpected error (also returned for a non-numeric id or page). Safe to retry with the same `Idempotency-Key`. ### List a sender's deliveries `GET /deliveries` (operationId `listDeliveries`) Auth: headers `client_id` and `secret_key`, plus `X-Session-Token` from `POST /session`. The session user's deliveries created through your channel, newest first. Parameters: - `status` (query, string): Filter on the list's `status` (internal values, e.g. `PUBLISHED`, `IN_TRANSIT`, `DELIVERED`). - `page` (query, integer): Zero-based page. - `size` (query, integer): Page size. Response 200: A page of deliveries. `resourceData` (object): - `items` (array of object) - `requestId` (integer): Delivery ID. - `customerId` (integer): Sender's user ID. - `title` (string): Package title. - `status` (string): Internal status (not `publicStatus`), e.g. `PUBLISHED`, `ACCEPTED_BID`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. Use `GET /deliveries/{idOrCode}` for `publicStatus`. - `requestType` (string) Values: `LOCAL`, `INTERSTATE`, `INTERNATIONAL`. - `sourceLocation` (string): Pickup. - `sourceCity` (string): Pickup city. - `destinationLocation` (string): Drop-off. - `destinationCity` (string): Drop-off city. - `daName` (string): Name of the last delivery partner agent who bid. - `deliveryDate` (string (date-time)): When it was delivered. - `bundle` (boolean): Multi-drop job. - `favourited` (boolean): Sender favourited it. Example response: ```json { "responseMessage": "Success", "status": 200, "resourceData": { "items": [ { "customerId": 4567, "requestId": 123, "title": "Order #5521", "status": "PUBLISHED", "requestType": "INTERSTATE", "sourceCity": "Lagos", "destinationCity": "Ibadan", "favourited": false, "bundle": false } ] }, "totalRecords": 1, "noOfRecords": 1 } ``` Errors: - 401 `UNAUTHORIZED`: Missing or wrong `client_id` / `secret_key`, a deactivated client, or a missing, expired or foreign `X-Session-Token`. - 429 `RATE_LIMITED`: Too many requests. Wait for `Retry-After` seconds. - 500 `INTERNAL_ERROR`: Unexpected error (also returned for a non-numeric id or page). Safe to retry with the same `Idempotency-Key`. ### Get a delivery `GET /deliveries/{idOrCode}` (operationId `getDelivery`) Auth: headers `client_id` and `secret_key`, plus `X-Session-Token` from `POST /session`. Full detail, including codes and contacts, for the delivery's sender. Only deliveries created through your channel. Parameters: - `idOrCode` (path, string, required): Delivery ID (`123`) or tracking code (`DLV-000123`). Not the `trackingToken`. Response 200: The delivery. `resourceData` (object): - `requestId` (integer): Delivery ID. - `customerId` (integer): Sender's user ID. - `title` (string | null): Title. - `description` (string | null): Description. - `publicStatus` (string): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `status` (string | null): Internal status. Prefer `publicStatus`. - `currency` (string | null): ISO currency. - `value` (number): Declared value. - `bidAmount` (integer | null): The sender's original **offer** (despite the name). - `acceptedBidAmount` (number | null): Accepted bid amount, once accepted. - `weightClass` (string | null): `light`, `medium`, `heavy` or `extra_heavy`. - `vehicleType` (string | null): Vehicle type. - `vehicleTypeLabel` (string | null): Vehicle display name. - `sourceLocation` (string | null): Pickup. - `sourceStreetAddress` (string | null): Pickup street. - `sourceCity` (string | null): Pickup city. - `sourceState` (string | null): Pickup state. - `sourceCountry` (string | null): Pickup country. - `sourceLatitude` (number): Pickup latitude. - `sourceLongitude` (number): Pickup longitude. - `destinationLocation` (string | null): Drop-off. - `destinationStreetAddress` (string | null): Drop-off street. - `destinationCity` (string | null): Drop-off city. - `destinationState` (string | null): Drop-off state. - `destinationCountry` (string | null): Drop-off country. - `destinationLatitude` (number): Drop-off latitude. - `destinationLongitude` (number): Drop-off longitude. - `senderName` (string | null): Sender. - `pickupName` (string | null): Pickup contact. - `pickupMobile` (string | null): Pickup contact phone (`+` prefixed). Sensitive: never log. - `receiverName` (string | null): Receiver. - `receiverMobile` (string | null): Receiver phone. Sensitive: never log. - `pickupDate` (string | null (date-time)): Scheduled pickup. - `expectedDeliveryDate` (string | null (date-time)): Expected delivery. - `orderDate` (string | null (date-time)): Created. - `partnerCode` (integer | null): Delivery partner agent's user ID, once a bid is accepted. - `partnerName` (string | null): Delivery partner agent's name. - `partnerContact` (string | null): Delivery partner agent's phone. Sensitive: never log. - `partnerProfileImageUrl` (string | null): Delivery partner agent's photo. - `pickupCode` (string | null): Pickup code. Treat as a secret. Sensitive: never log. - `dropOffCode` (string | null): Drop-off code. Treat as a secret. Sensitive: never log. - `orderCancelReason` (string | null): Cancellation reason. - `isBundle` (boolean): Multi-drop job. Example response: ```json { "responseMessage": "SUCCESS", "status": 200, "resourceData": { "requestId": 123, "customerId": 4567, "title": "Order #5521", "publicStatus": "ACCEPTED", "status": "ACCEPTED_BID", "currency": "NGN", "value": 50000, "bidAmount": 5000, "acceptedBidAmount": 4200, "weightClass": "light", "vehicleType": "MOTORCYCLE", "sourceCity": "Lagos", "destinationCity": "Ibadan", "senderName": "Ada Obi", "receiverName": "Bola A.", "receiverMobile": "+2348098765432", "partnerName": "Emeka O.", "partnerContact": "+2348033333333", "dropOffCode": "4821", "isBundle": false } } ``` Errors: - 400 `BAD_REQUEST`: Missing or invalid input. `error.message` says which field. - 401 `UNAUTHORIZED`: Missing or wrong `client_id` / `secret_key`, a deactivated client, or a missing, expired or foreign `X-Session-Token`. - 404 `NOT_FOUND`: Not found, or not yours. Deliveries and users you can't act for look exactly like ones that don't exist. - 429 `RATE_LIMITED`: Too many requests. Wait for `Retry-After` seconds. - 500 `INTERNAL_ERROR`: Unexpected error (also returned for a non-numeric id or page). Safe to retry with the same `Idempotency-Key`. ### Get a delivery's transit legs `GET /deliveries/{idOrCode}/timeline` (operationId `getTimeline`) Auth: headers `client_id` and `secret_key`, plus `X-Session-Token` from `POST /session`. When the package reached each leg. For a full, ordered history use `GET /track/{trackingToken}`. Parameters: - `idOrCode` (path, string, required): Delivery ID (`123`) or tracking code (`DLV-000123`). Not the `trackingToken`. Response 200: Transit legs. `resourceData` (object): - `requestId` (integer): Delivery ID. - `title` (string | null): Title. - `requestType` (string | null): `LOCAL`, `INTERSTATE` or `INTERNATIONAL`. - `expectedDeliveryDate` (string | null (date-time)): Expected delivery. - `docketNumber` (string): Parcel-centre docket (`0` when none). - `opcDetail` (object | null): The latest transit on this leg: `opc` origin parcel centre, `da` with the delivery partner agent, `dpc` destination parcel centre, `rc` receiver. - `transitDate` (string | null (date-time)): When the package reached this leg. - `mediaUrls` (array of object) - `daDetail` (object | null): The latest transit on this leg: `opc` origin parcel centre, `da` with the delivery partner agent, `dpc` destination parcel centre, `rc` receiver. - `transitDate` (string | null (date-time)): When the package reached this leg. - `mediaUrls` (array of object) - `dpcDetail` (object | null): The latest transit on this leg: `opc` origin parcel centre, `da` with the delivery partner agent, `dpc` destination parcel centre, `rc` receiver. - `transitDate` (string | null (date-time)): When the package reached this leg. - `mediaUrls` (array of object) - `rcDetail` (object | null): The latest transit on this leg: `opc` origin parcel centre, `da` with the delivery partner agent, `dpc` destination parcel centre, `rc` receiver. - `transitDate` (string | null (date-time)): When the package reached this leg. - `mediaUrls` (array of object) Example response: ```json { "responseMessage": "success", "status": 200, "resourceData": { "requestId": 123, "title": "Order #5521", "requestType": "INTERSTATE", "expectedDeliveryDate": "2026-11-04T16:00:00.000+0000", "docketNumber": "0", "opcDetail": null, "daDetail": { "transitDate": "2026-11-03T11:02:00.000+0000", "mediaUrls": [] }, "dpcDetail": null, "rcDetail": null } } ``` Errors: - 400 `BAD_REQUEST`: Missing or invalid input. `error.message` says which field. - 401 `UNAUTHORIZED`: Missing or wrong `client_id` / `secret_key`, a deactivated client, or a missing, expired or foreign `X-Session-Token`. - 404 `NOT_FOUND`: Not found, or not yours. Deliveries and users you can't act for look exactly like ones that don't exist. - 429 `RATE_LIMITED`: Too many requests. Wait for `Retry-After` seconds. - 500 `INTERNAL_ERROR`: Unexpected error (also returned for a non-numeric id or page). Safe to retry with the same `Idempotency-Key`. ### Cancel a delivery `POST /deliveries/{idOrCode}/cancel` (operationId `cancelDelivery`) Auth: headers `client_id` and `secret_key`, plus `X-Session-Token` from `POST /session`. Allowed until the delivery partner agent collects the package. After acceptance the payment is refunded to the source that paid (gateway fees on card and link payments aren't refundable). After pickup: `409 DELIVERY_NOT_CANCELLABLE`. Use a fresh `Idempotency-Key` per delivery. Parameters: - `idOrCode` (path, string, required): Delivery ID (`123`) or tracking code (`DLV-000123`). Not the `trackingToken`. - `Idempotency-Key` (header, string): Strongly recommended. A unique key per action (e.g. your order ID plus the action). A retry with the same key and body replays the first response for 24 hours. Keys are shared across endpoints for your client, so never reuse one for a different action. Request body (application/json, optional): - `reason` (string): Why you're cancelling. Example request body: ```json { "reason": "Customer cancelled the order" } ``` Response 200: Cancelled. `resourceData` is the delivery ID. Check the body `status`: in a rare race it is 400 with HTTP 200, meaning not cancelled. `resourceData` (integer) Example response: ```json { "responseMessage": "Cancelled Request Successfully", "status": 200, "resourceData": 123 } ``` Errors: - 400 `BAD_REQUEST`: Missing or invalid input. `error.message` says which field. - 401 `UNAUTHORIZED`: Missing or wrong `client_id` / `secret_key`, a deactivated client, or a missing, expired or foreign `X-Session-Token`. - 404 `NOT_FOUND`: Not found, or not yours. Deliveries and users you can't act for look exactly like ones that don't exist. - 409 `IDEMPOTENCY_CONFLICT`: `IDEMPOTENCY_IN_PROGRESS` (retry shortly), `IDEMPOTENCY_CONFLICT` (key reused with a different body) or `DELIVERY_NOT_CANCELLABLE`. - 429 `RATE_LIMITED`: Too many requests. Wait for `Retry-After` seconds. - 500 `INTERNAL_ERROR`: Unexpected error (also returned for a non-numeric id or page). Safe to retry with the same `Idempotency-Key`. ### List bids on a delivery `GET /deliveries/{idOrCode}/bids` (operationId `listBids`) Auth: headers `client_id` and `secret_key`, plus `X-Session-Token` from `POST /session`. Open bids from delivery partner agents. `totalAmountToBePaid` is what accepting the bid costs. Parameters: - `idOrCode` (path, string, required): Delivery ID (`123`) or tracking code (`DLV-000123`). Not the `trackingToken`. Response 200: Bids. `resourceData` (array of object): - `bidId` (integer): Bid ID. Accept with `POST /deliveries/{idOrCode}/bids/{bidId}/accept`. - `daId` (integer): Delivery partner agent's user ID. - `name` (string | null): Delivery partner agent's name. - `rating` (number): Delivery partner agent's average rating. - `bidAmount` (number): The delivery partner agent's price. - `currency` (string | null): ISO currency. - `comment` (string | null): Delivery partner agent's note. - `totalAmountToBePaid` (string): What accepting this bid costs in total, as numeric text (e.g. `"4620"`, `"12.50"`). - `platformFee` (number): Platform fee. - `insuranceAmount` (number): Insurance amount. - `vehicleType` (string | null): Delivery partner agent's vehicle. - `vehicleTypeLabel` (string | null): Vehicle display name. - `profileImageUrl` (string | null): Delivery partner agent's photo. - `pickupDateAndTime` (string | null (date-time)): Delivery partner agent's proposed pickup time. - `expectedDateAndTime` (string | null (date-time)): Delivery partner agent's proposed delivery time. - `partnerDistanceFromPickupKm` (number): Delivery partner agent's distance from the pickup, when known. Example response: ```json { "responseMessage": "Success", "status": 200, "resourceData": [ { "bidId": 9876, "daId": 3321, "name": "Emeka O.", "rating": 4.6, "bidAmount": 4200, "currency": "NGN", "comment": "Can pick up in 20 minutes", "totalAmountToBePaid": "4620", "platformFee": 420, "insuranceAmount": 0, "vehicleType": "MOTORCYCLE" } ] } ``` Errors: - 400 `BAD_REQUEST`: Missing or invalid input. `error.message` says which field. - 401 `UNAUTHORIZED`: Missing or wrong `client_id` / `secret_key`, a deactivated client, or a missing, expired or foreign `X-Session-Token`. - 404 `NOT_FOUND`: Not found, or not yours. Deliveries and users you can't act for look exactly like ones that don't exist. - 429 `RATE_LIMITED`: Too many requests. Wait for `Retry-After` seconds. - 500 `INTERNAL_ERROR`: Unexpected error (also returned for a non-numeric id or page). Safe to retry with the same `Idempotency-Key`. ### Accept a bid `POST /deliveries/{idOrCode}/bids/{bidId}/accept` (operationId `acceptBid`) Auth: headers `client_id` and `secret_key`, plus `X-Session-Token` from `POST /session`. Accepts and pays for a bid. `paymentMethod`: - `AUTO` (recommended): wallet, then saved card, then a payment link. - `WALLET`: wallet only; `402` if it can't pay. - `CARD`: saved card; falls back to a payment link if the card fails. - `LINK` (default when omitted): a payment link; the bid is accepted once it's paid. The result is one of three shapes; check `resourceData.status` (`ACCEPTED`, `PAYMENT_PENDING`) or `paymentUrl` (link). Parameters: - `idOrCode` (path, string, required): Delivery ID (`123`) or tracking code (`DLV-000123`). Not the `trackingToken`. - `bidId` (path, integer, required): Bid ID. - `Idempotency-Key` (header, string): Strongly recommended. A unique key per action (e.g. your order ID plus the action). A retry with the same key and body replays the first response for 24 hours. Keys are shared across endpoints for your client, so never reuse one for a different action. Request body (application/json, optional): - `paymentMethod` (string) Values: `AUTO`, `WALLET`, `CARD`, `LINK`. Default `"LINK"`. Example request body: ```json { "paymentMethod": "AUTO" } ``` Response 200: Accepted and paid, card payment processing (body `status: 202`), or a payment link to pay. `resourceData` (one of) Example response: ```json { "responseMessage": "Bid accepted and paid.", "status": 200, "resourceData": { "bidId": 9876, "status": "ACCEPTED", "method": "WALLET" } } ``` Errors: - 400 `BAD_REQUEST`: Missing or invalid input. `error.message` says which field. - 401 `UNAUTHORIZED`: Missing or wrong `client_id` / `secret_key`, a deactivated client, or a missing, expired or foreign `X-Session-Token`. - 402 `INSUFFICIENT_WALLET_BALANCE`: The wallet couldn't pay. `error.code` is the reason: `INSUFFICIENT_WALLET_BALANCE`, `NO_WALLET`, `AUTO_BID_HOLD_USED`, `WALLET_LIMIT_EXCEEDED`, or `PAYMENT_FAILED`. - 404 `NOT_FOUND`: Not found, or not yours. Deliveries and users you can't act for look exactly like ones that don't exist. - 409 `IDEMPOTENCY_CONFLICT`: `IDEMPOTENCY_IN_PROGRESS` (retry shortly), `IDEMPOTENCY_CONFLICT` (key reused with a different body) or `DELIVERY_NOT_CANCELLABLE`. - 429 `RATE_LIMITED`: Too many requests. Wait for `Retry-After` seconds. - 500 `INTERNAL_ERROR`: Unexpected error (also returned for a non-numeric id or page). Safe to retry with the same `Idempotency-Key`. ### Get the sender's wallet balance `GET /wallet/balance` (operationId `getWalletBalance`) Auth: headers `client_id` and `secret_key`, plus `X-Session-Token` from `POST /session`. Prepaid balance used by `AUTO` and `WALLET` payments. Fund it by bank transfer; Delivahere credits it. Parameters: - `currency` (query, string): Wallet currency (default: the most recently used wallet). Response 200: Balance, in major units. `resourceData` (object): - `balance` (number): Spendable balance. - `availableBalance` (number): Same as `balance`. - `reservedBalance` (number): Held for auto-accept jobs. - `totalBalance` (number): Available plus reserved. - `maxBalance` (number): Wallet cap. - `currency` (string | null): ISO currency (null when there's no wallet yet). - `updatedAt` (string | null (date-time)): Last change. Example response: ```json { "responseMessage": "Wallet balance retrieved", "status": 200, "resourceData": { "balance": 25000, "availableBalance": 25000, "reservedBalance": 4500, "totalBalance": 29500, "maxBalance": 5000000, "currency": "NGN", "updatedAt": "2026-11-03T09:00:00.000+0000" } } ``` Errors: - 401 `UNAUTHORIZED`: Missing or wrong `client_id` / `secret_key`, a deactivated client, or a missing, expired or foreign `X-Session-Token`. - 429 `RATE_LIMITED`: Too many requests. Wait for `Retry-After` seconds. - 500 `INTERNAL_ERROR`: Unexpected error (also returned for a non-numeric id or page). Safe to retry with the same `Idempotency-Key`. ### Recommend an offer `POST /pricing/recommend` (operationId `recommendPrice`) Auth: headers `client_id` and `secret_key`. A quick low / optimal / high range for `offerAmount`. Request body (application/json): - `currency` (string, required) Values: `NGN`, `USD`. - `pickup` (object, required) - `latitude` (number, required): WGS84 latitude. - `longitude` (number, required): WGS84 longitude. - `dropoff` (object, required) - `latitude` (number, required): WGS84 latitude. - `longitude` (number, required): WGS84 longitude. - `weightClass` (string): `light`, `medium`, `heavy`, `extra_heavy` (or `SMALL`, `MEDIUM`, `LARGE`, `EXTRA_LARGE`, `FREIGHT`). - `weight` (number): Weight, instead of `weightClass`. - `weightUnit` (string) Values: `KG`, `LBS`. - `vehicleType` (string) Values: `MOTORCYCLE`, `BICYCLE`, `TRICYCLE`, `SEDAN`, `SUV`, `MINIVAN`, `PANEL_VAN`, `PICKUP_TRUCK`, `MINI_TRUCK_6T`, `LIGHT_TRUCK_OPEN`, `LIGHT_TRUCK_CONTAINER`, `MEDIUM_TRUCK_10T`, `MEDIUM_TRUCK_12T`, `HEAVY_TRUCK_16T`, `HEAVY_TRUCK_18T_CONTAINER`, `HEAVY_TRUCK_25T`, `EXTRA_HEAVY_30T`, `EXTRA_HEAVY_35T`, `TRAILER_20FT`, `TRAILER_40FT`, `NONE_OR_ANY_VEHICLE`. - `declaredValue` (number): Declared value. - `distance` (number): Known distance, instead of coordinates. - `distanceUnit` (string) Values: `Km`, `Mi`. Example request body: ```json { "currency": "NGN", "pickup": { "latitude": 6.5244, "longitude": 3.3792 }, "dropoff": { "latitude": 6.4531, "longitude": 3.3958 }, "weightClass": "light", "vehicleType": "MOTORCYCLE", "declaredValue": 15000 } ``` Response 200: Price range. `resourceData` (object): - `low` (number, required): Lower bound (0.80 × optimal). - `optimal` (number, required): Recommended offer. - `high` (number, required): Upper bound (1.50 × optimal). - `currency` (string, required): ISO currency. - `distance` (object) - `value` (number): Distance. - `unit` (string) Values: `Km`, `Mi`. Example response: ```json { "responseMessage": "Recommended price fetched successfully", "status": 200, "resourceData": { "low": 1280, "optimal": 1600, "high": 2400, "currency": "NGN", "distance": { "value": 8.2, "unit": "Km" } } } ``` Errors: - 400 `BAD_REQUEST`: Missing or invalid input. `error.message` says which field. - 401 `UNAUTHORIZED`: Missing or wrong `client_id` / `secret_key`, a deactivated client, or a missing, expired or foreign `X-Session-Token`. - 429 `RATE_LIMITED`: Too many requests. Wait for `Retry-After` seconds. - 500 `INTERNAL_ERROR`: Unexpected error (also returned for a non-numeric id or page). Safe to retry with the same `Idempotency-Key`. ### Estimate a price with a breakdown `POST /pricing/estimate` (operationId `estimatePrice`) Auth: headers `client_id` and `secret_key`. The same range as `/pricing/recommend`, plus the cost breakdown and which vehicles fit. Request body (application/json): - `currency` (string, required) Values: `NGN`, `USD`. - `pickup` (object, required) - `latitude` (number, required): WGS84 latitude. - `longitude` (number, required): WGS84 longitude. - `dropoff` (object, required) - `latitude` (number, required): WGS84 latitude. - `longitude` (number, required): WGS84 longitude. - `weight` (number): Weight. - `weightUnit` (string) Values: `KG`, `LBS`. - `weightClass` (string): Instead of `weight`: `light`, `medium`, `heavy`, `extra_heavy`. - `dimensions` (object): Volumetric weight (L×W×H / 5000, in cm → kg) is used when larger than `weight`. - `length` (number) - `width` (number) - `height` (number) - `unit` (string): `cm` or `in`. - `declaredValue` (number): Declared value. - `vehicleType` (string): Leave out to let Delivahere pick a vehicle. Values: `MOTORCYCLE`, `BICYCLE`, `TRICYCLE`, `SEDAN`, `SUV`, `MINIVAN`, `PANEL_VAN`, `PICKUP_TRUCK`, `MINI_TRUCK_6T`, `LIGHT_TRUCK_OPEN`, `LIGHT_TRUCK_CONTAINER`, `MEDIUM_TRUCK_10T`, `MEDIUM_TRUCK_12T`, `HEAVY_TRUCK_16T`, `HEAVY_TRUCK_18T_CONTAINER`, `HEAVY_TRUCK_25T`, `EXTRA_HEAVY_30T`, `EXTRA_HEAVY_35T`, `TRAILER_20FT`, `TRAILER_40FT`, `NONE_OR_ANY_VEHICLE`. - `scheduledPickupTime` (string (date-time)): Pickup time, if not now. - `originCity` (string): Pickup city (sharpens interstate pricing). - `originState` (string): Pickup state. - `destCity` (string): Drop-off city. - `destState` (string): Drop-off state. - `country` (string): ISO country code. - `distance` (number): Known distance, instead of coordinates. - `distanceUnit` (string) Values: `Km`, `Mi`. Example request body: ```json { "currency": "NGN", "pickup": { "latitude": 6.5244, "longitude": 3.3792 }, "dropoff": { "latitude": 7.3775, "longitude": 3.947 }, "weight": 2.5, "weightUnit": "KG", "declaredValue": 15000, "originCity": "Lagos", "originState": "Lagos", "destCity": "Ibadan", "destState": "Oyo", "country": "NG" } ``` Response 200: Estimate. `resourceData` (object): - `low` (number, required): Lower bound. - `optimal` (number, required): Recommended offer. - `high` (number, required): Upper bound. - `currency` (string, required): ISO currency. - `confidence` (string): How specific the pricing data for this route is. Values: `HIGH`, `MEDIUM`, `LOW`. - `configScope` (string) Values: `LOCALITY_PAIR`, `CITY_PAIR`, `CITY`, `STATE`, `COUNTRY`, `CURRENCY`, `GLOBAL`. - `distance` (object) - `value` (number): Distance. - `unit` (string): `Km` or `Mi`. - `timeMinutes` (integer): Estimated travel time. - `source` (string): How distance was measured. - `vehicleType` (string): Vehicle the price is for. - `vehicleInferred` (boolean): True when Delivahere picked the vehicle. - `cost` (object): `baseFare + distanceCost + weightCost = subtotal`; `subtotal × factorsMultiplier (+ pcCharge) = total` (= `optimal`). - `baseFare` (number) - `distanceCost` (number) - `weightCost` (number) - `subtotal` (number) - `factorsMultiplier` (number) - `pcCharge` (number): Parcel-centre charge. - `total` (number) - `factors` (object): Multipliers applied (vehicle, item value, time of day). - `eligibleVehicles` (array of object) - `vehicleType` (string): Vehicle. - `displayName` (string): Name. - `low` (number) - `mid` (number) - `high` (number) - `maxWeightKg` (number) - `suitable` (boolean) - `recommended` (boolean) - `excludedVehicles` (array of object) - `type` (string): Vehicle. - `reason` (string) Values: `WEIGHT_EXCEEDED`, `DISTANCE_EXCEEDED`. - `estimationId` (string): Reference for this estimate. - `expiresAt` (string (date-time)): Estimate guidance is valid for about 10 minutes. Example response: ```json { "responseMessage": "Price estimate generated successfully", "status": 200, "resourceData": { "low": 5600, "optimal": 7000, "high": 10500, "currency": "NGN", "confidence": "HIGH", "configScope": "CITY_PAIR", "distance": { "value": 128.4, "unit": "Km", "timeMinutes": 140, "source": "HAVERSINE" }, "vehicleType": "MOTORCYCLE", "vehicleInferred": true, "cost": { "baseFare": 500, "distanceCost": 5650, "weightCost": 125, "subtotal": 6275, "factorsMultiplier": 1.1, "pcCharge": 0, "total": 7000 }, "estimationId": "EST-9F3KX7QZ", "expiresAt": "2026-11-03T10:25:30Z" } } ``` Errors: - 400 `BAD_REQUEST`: Missing or invalid input. `error.message` says which field. - 401 `UNAUTHORIZED`: Missing or wrong `client_id` / `secret_key`, a deactivated client, or a missing, expired or foreign `X-Session-Token`. - 429 `RATE_LIMITED`: Too many requests. Wait for `Retry-After` seconds. - 500 `INTERNAL_ERROR`: Unexpected error (also returned for a non-numeric id or page). Safe to retry with the same `Idempotency-Key`. ### Public tracking `GET /track/{trackingToken}` (operationId `trackDelivery`) Auth: none (public). Needs no credentials and is redacted (first name and initial, city-level places, masked delivery partner agent phone), so it's safe behind a link you send customers. Always use the `trackingToken`: `DLV-` codes are guessable and may stop working here. This endpoint answers `{success, data}` instead of the usual envelope. Parameters: - `trackingToken` (path, string, required): The delivery's `trackingToken`. Response 200: Tracking view. `resourceData` (object): - `trackingCode` (string): Display code. - `status` (string): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `title` (string | null): Title. - `senderName` (string | null): Sender, as "First L.". - `receiverName` (string | null): Receiver, as "First L.". - `sourceLocation` (string | null): Pickup, city level. - `destinationLocation` (string | null): Drop-off, city level. - `sourceLatitude` (number): Pickup, rounded to 2 decimals. - `sourceLongitude` (number): Pickup, rounded to 2 decimals. - `destinationLatitude` (number): Drop-off, rounded to 2 decimals. - `destinationLongitude` (number): Drop-off, rounded to 2 decimals. - `createdAt` (string | null (date-time)): Created. - `estimatedDelivery` (string | null (date-time)): Expected delivery. - `completedAt` (string | null (date-time)): Delivered. - `cancelledAt` (string | null (date-time)): Cancelled. - `cancellationReason` (string | null): Why it was cancelled. - `streamChannel` (string | null): Live-update channel name, when live tracking is on. - `partner` (object | null): The delivery partner agent, once booked. - `name` (string | null): Delivery partner agent's name. - `contact` (string | null): Masked phone, e.g. `+234 *** *** 5678`. - `vehicleType` (string | null): Vehicle type. - `latitude` (number | null): Live position. - `longitude` (number | null): Live position. - `estimatedArrivalMinutes` (integer | null): Minutes to arrival. - `etaConfidence` (string | null): `medium` or `low`. - `timeline` (array of object) - `status` (string): Public status at this point. - `timestamp` (string | null (date-time)): When. - `message` (string): Human-readable description. - `location` (object | null) - `locationType` (string) Values: `sender`, `receiver`, `parcel_center`, `partner`. - `locationLabel` (string | null): Label. - `latitude` (number | null) - `longitude` (number | null) - `notices` (array of object) - `id` (string): Notice ID. - `type` (string): `payment_required`, `customs_hold`, `return_fee`, `address_correction` or `info`. - `title` (string | null) - `message` (string | null) - `actionUrl` (string | null) - `actionLabel` (string | null) - `createdAt` (string | null (date-time)) - `resolvedAt` (string | null (date-time)) Example response: ```json { "success": true, "data": { "trackingCode": "DLV-000123", "status": "IN_TRANSIT", "title": "Order #5521", "senderName": "Ada O.", "receiverName": "Bola A.", "sourceLocation": "Lagos, Lagos", "destinationLocation": "Ibadan, Oyo", "createdAt": "2026-11-03T10:15:30.000+0000", "partner": { "name": "Emeka Okafor", "contact": "+234 *** *** 3333", "vehicleType": "MOTORCYCLE", "latitude": 6.5012, "longitude": 3.3654, "estimatedArrivalMinutes": 18, "etaConfidence": "medium" }, "timeline": [ { "status": "PENDING", "timestamp": "2026-11-03T10:15:30.000+0000", "message": "Delivery request created", "location": { "locationType": "sender", "locationLabel": "Lagos, Lagos", "latitude": 6.45, "longitude": 3.39 } }, { "status": "ACCEPTED", "timestamp": "2026-11-03T10:31:02.000+0000", "message": "Emeka Okafor accepted the delivery", "location": { "locationType": "partner", "locationLabel": "Emeka Okafor", "latitude": 6.47, "longitude": 3.38 } } ], "notices": [] } } ``` Errors: - 404 `TRACKING_NOT_FOUND`: No delivery for this reference. - 429 `RATE_LIMITED`: Too many requests. Wait for `Retry-After` seconds. - 500 `INTERNAL_ERROR`: Unexpected error (also returned for a non-numeric id or page). Safe to retry with the same `Idempotency-Key`. ## Webhook events Every event is a `POST` of this envelope to your registered HTTPS URL, signed with `X-Webhook-Signature`: - `event` (string, required): Event name, e.g. `delivery.bid_accepted`. - `timestamp` (string (date-time), required): When Delivahere dispatched the event (UTC, RFC 3339). Milliseconds are left out when they are zero, so parse it as RFC 3339 rather than a fixed pattern. Retries reuse the same value. - `idempotencyKey` (string, required): Unique per event and the same on every retry. De-duplicate on it. - `channel` (string | null, required): The channel the delivery or user belongs to (your channel for your own jobs). - `data` (object, required): Event payload; see each event. ### `delivery.created` A delivery was created. Fires once per new delivery, including deliveries you create through the API. A job created through the API is open to bids straight away but still reports `publicStatus: PENDING` at this point; no separate `delivery.published` follows. `data` fields: - `requestId` (integer, required): Delivery ID. - `trackingCode` (string, required): Human-readable code for display, e.g. `DLV-000123`. Not for links. - `trackingToken` (string | null): Unguessable 24-character reference for tracking links (`GET /track/{trackingToken}`). Can be null on `delivery.created`; read it from the create response instead. - `userPhone` (string | null): Sender's phone, E.164 without `+`. - `senderUserId` (integer | null): Sender's user ID. - `publicStatus` (string, required): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `newStatus` (string, required): Same value as `publicStatus` (kept for older integrations). Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `title` (string | null): Package title. - `description` (string | null): Package description. - `additionalNotes` (string | null): Free-text notes for the delivery partner agent. - `senderName` (string | null): Sender's name. - `senderEmail` (string | null): Sender's email. Sensitive: never log. - `sourceCity` (string | null): Pickup city. - `sourceState` (string | null): Pickup state. - `destinationCity` (string | null): Drop-off city. - `destinationState` (string | null): Drop-off state. - `offerAmount` (number): The sender's offered price, in major units. - `currency` (string): ISO currency code. - `isNewSender` (boolean): True when the sender has no completed deliveries yet. - `senderTotalCompletedRequests` (integer): How many deliveries this sender has completed. - `sender` (object): The sender account. - `userId` (integer): Sender's user ID. - `name` (string | null): Name. - `email` (string | null): Email. Sensitive: never log. - `mobile` (string | null): Phone, E.164 without `+`. Sensitive: never log. - `country` (string | null): Country. - `state` (string | null): State. - `city` (string | null): City. - `address` (string | null): Street address. - `houseNo` (string | null): House number. - `zipcode` (string | null): Postal code. - `latitude` (number | null): Latitude. - `longitude` (number | null): Longitude. - `pickup` (object) - `location` (string | null): Free-text pickup location. - `streetAddress` (string | null): Street address. - `houseNo` (string | null): House number. - `locality` (string | null): Locality / area. - `city` (string | null): City. - `state` (string | null): State. - `country` (string | null): Country. - `zipCode` (string | null): Postal code. - `latitude` (number | null): Latitude. - `longitude` (number | null): Longitude. - `contactName` (string | null): Pickup contact name. - `contactMobile` (string | null): Pickup contact phone, international digits without `+`. Sensitive: never log. - `dropoff` (object): The drop-off (the first stop on a multi-drop job). - `dropOffId` (integer): Multi-drop only: the stop ID. - `sequenceNo` (integer): Multi-drop only: the stop's position in the route. - `location` (string | null): Free-text drop-off location. - `streetAddress` (string | null): Street address. - `houseNo` (string | null): House number. - `locality` (string | null): Locality / area. - `city` (string | null): City. - `state` (string | null): State. - `country` (string | null): Country. - `zipCode` (string | null): Multi-drop only: postal code. - `latitude` (number | null): Latitude. - `longitude` (number | null): Longitude. - `receiverName` (string | null): Receiver name. Sensitive: never log. - `receiverMobile` (string | null): Receiver phone, international digits without `+`. Sensitive: never log. - `dropoffs` (array of object): Multi-drop jobs only, in route order. Absent on single-drop jobs. - `dropOffId` (integer): Multi-drop only: the stop ID. - `sequenceNo` (integer): Multi-drop only: the stop's position in the route. - `location` (string | null): Free-text drop-off location. - `streetAddress` (string | null): Street address. - `houseNo` (string | null): House number. - `locality` (string | null): Locality / area. - `city` (string | null): City. - `state` (string | null): State. - `country` (string | null): Country. - `zipCode` (string | null): Multi-drop only: postal code. - `latitude` (number | null): Latitude. - `longitude` (number | null): Longitude. - `receiverName` (string | null): Receiver name. Sensitive: never log. - `receiverMobile` (string | null): Receiver phone, international digits without `+`. Sensitive: never log. Example: ```json { "event": "delivery.created", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "requestId": 123, "trackingCode": "DLV-000123", "trackingToken": "q3Zr8Kd0aP1xYt7bWm2cLs4e", "userPhone": "2348012345678", "senderUserId": 4567, "publicStatus": "PENDING", "newStatus": "PENDING", "title": "Order #5521", "description": "Two pairs of shoes", "additionalNotes": null, "senderName": "Ada Obi", "senderEmail": "ada@example.com", "sourceCity": "Lagos", "sourceState": "Lagos", "destinationCity": "Ibadan", "destinationState": "Oyo", "offerAmount": 5000, "currency": "NGN", "isNewSender": false, "senderTotalCompletedRequests": 14, "sender": { "userId": 4567, "name": "Ada Obi", "email": "ada@example.com", "mobile": "2348012345678", "country": "NG", "state": "Lagos", "city": "Lagos", "address": "12 Marina Rd", "houseNo": null, "zipcode": null, "latitude": 6.45, "longitude": 3.39 }, "pickup": { "location": "Acme Warehouse", "streetAddress": "12 Marina Rd", "houseNo": null, "locality": "Marina", "city": "Lagos", "state": "Lagos", "country": "NG", "zipCode": null, "latitude": 6.45, "longitude": 3.39, "contactName": "Acme Warehouse", "contactMobile": "2348011111111" }, "dropoff": { "location": "5 Ring Rd", "streetAddress": "5 Ring Rd", "houseNo": null, "locality": null, "city": "Ibadan", "state": "Oyo", "country": "NG", "latitude": 7.38, "longitude": 3.94, "receiverName": "Bola A.", "receiverMobile": "2348098765432" } } } ``` ### `delivery.published` The job is open to delivery partner agents again. Fires when a delivery goes back out for bidding: after a delivery partner agent cancels, after a pickup is rejected, or when a saved draft is published. `publicStatus` is `PENDING` even though delivery partner agents can bid. `data` fields: - `requestId` (integer, required): Delivery ID. - `trackingCode` (string, required): Human-readable code for display, e.g. `DLV-000123`. Not for links. - `trackingToken` (string | null): Unguessable 24-character reference for tracking links (`GET /track/{trackingToken}`). Can be null on `delivery.created`; read it from the create response instead. - `userPhone` (string | null): Sender's phone, E.164 without `+`. - `senderUserId` (integer | null): Sender's user ID. - `publicStatus` (string, required): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `newStatus` (string, required): Same value as `publicStatus` (kept for older integrations). Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `reason` (string | null): Why it was reopened (after a rejected pickup). Example: ```json { "event": "delivery.published", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "requestId": 123, "trackingCode": "DLV-000123", "trackingToken": "q3Zr8Kd0aP1xYt7bWm2cLs4e", "userPhone": "2348012345678", "senderUserId": 4567, "publicStatus": "PENDING", "newStatus": "PENDING", "reason": "Package not ready at pickup" } } ``` ### `delivery.bid_submitted` A delivery partner agent bid on the delivery. Fires for every new bid and every revised bid. With `autoBidAccept`, a bid at or under `maxBidAmount` is accepted for you; otherwise accept the one you want. `data` fields: - `requestId` (integer, required): Delivery ID. - `trackingCode` (string, required): Human-readable code for display, e.g. `DLV-000123`. Not for links. - `trackingToken` (string | null): Unguessable 24-character reference for tracking links (`GET /track/{trackingToken}`). Can be null on `delivery.created`; read it from the create response instead. - `userPhone` (string | null): Sender's phone, E.164 without `+`. - `senderUserId` (integer | null): Sender's user ID. - `publicStatus` (string, required): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `newStatus` (string, required): Same value as `publicStatus` (kept for older integrations). Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `bidId` (integer, required): Bid ID. Use it with `POST /deliveries/{idOrCode}/bids/{bidId}/accept`. - `partnerName` (string | null): Delivery partner agent's display name. - `partnerRating` (number): Delivery partner agent's average rating (0 when unrated). - `bidAmount` (number): The delivery partner agent's price, in major currency units. - `totalAmountToBePaid` (number): What accepting this bid costs in total, fees included. - `currency` (string | null): ISO currency code. - `vehicleType` (string | null): Vehicle type of the delivery. - `comment` (string | null): Delivery partner agent's note on the bid. Example: ```json { "event": "delivery.bid_submitted", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "requestId": 123, "trackingCode": "DLV-000123", "trackingToken": "q3Zr8Kd0aP1xYt7bWm2cLs4e", "userPhone": "2348012345678", "senderUserId": 4567, "publicStatus": "PENDING", "newStatus": "PENDING", "bidId": 9876, "partnerName": "Emeka O.", "partnerRating": 4.6, "bidAmount": 4200, "totalAmountToBePaid": 4620, "currency": "NGN", "vehicleType": "MOTORCYCLE", "comment": "Can pick up in 20 minutes" } } ``` ### `delivery.bid_accepted` A bid was accepted and paid for. The delivery partner agent is booked. The fields after `bidId` depend on how the bid was paid: expect any of them to be missing. `data` fields: - `requestId` (integer, required): Delivery ID. - `trackingCode` (string, required): Human-readable code for display, e.g. `DLV-000123`. Not for links. - `trackingToken` (string | null): Unguessable 24-character reference for tracking links (`GET /track/{trackingToken}`). Can be null on `delivery.created`; read it from the create response instead. - `userPhone` (string | null): Sender's phone, E.164 without `+`. - `senderUserId` (integer | null): Sender's user ID. - `publicStatus` (string, required): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `newStatus` (string, required): Same value as `publicStatus` (kept for older integrations). Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `bidId` (integer, required): Bid ID. Use it with `POST /deliveries/{idOrCode}/bids/{bidId}/accept`. - `partnerName` (string | null): Delivery partner agent's display name. - `partnerRating` (number): Delivery partner agent's average rating (0 when unrated). - `bidAmount` (number): The delivery partner agent's price, in major currency units. - `totalAmountToBePaid` (number): What accepting this bid costs in total, fees included. - `currency` (string | null): ISO currency code. - `vehicleType` (string | null): Vehicle type of the delivery. - `comment` (string | null): Delivery partner agent's note on the bid. - `partnerPhone` (string | null): Delivery partner agent's phone (on payment-link and card payments only). Sensitive: never log. Example: ```json { "event": "delivery.bid_accepted", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "requestId": 123, "trackingCode": "DLV-000123", "trackingToken": "q3Zr8Kd0aP1xYt7bWm2cLs4e", "userPhone": "2348012345678", "senderUserId": 4567, "publicStatus": "ACCEPTED", "newStatus": "ACCEPTED", "bidId": 9876, "partnerName": "Emeka O.", "partnerRating": 4.6, "bidAmount": 4200, "totalAmountToBePaid": 4620, "currency": "NGN", "vehicleType": "MOTORCYCLE" } } ``` ### `delivery.codes_issued` Drop-off code created or regenerated. Carries the code the receiver gives the delivery partner agent at drop-off. Fires when a bid is accepted and again with `regenerated: true` whenever the code changes (for example after a rejected drop-off). Always sent, whatever `sendDropOffCode` is set to. **Never log or store this code in plain text.** Contains secrets: never log this payload in plain text. `data` fields: - `requestId` (integer, required): Delivery ID. - `trackingCode` (string, required): Human-readable code for display, e.g. `DLV-000123`. Not for links. - `trackingToken` (string | null): Unguessable 24-character reference for tracking links (`GET /track/{trackingToken}`). Can be null on `delivery.created`; read it from the create response instead. - `userPhone` (string | null): Sender's phone, E.164 without `+`. - `senderUserId` (integer | null): Sender's user ID. - `publicStatus` (string, required): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `newStatus` (string, required): Same value as `publicStatus` (kept for older integrations). Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `dropOffCode` (string | null): Single-drop jobs: the drop-off code. Null on multi-drop jobs. Sensitive: never log. - `bundleDropOffCodes` (array of object): Multi-drop jobs: one code per stop, in route order. Null on single-drop jobs. - `dropOffId` (integer): Stop ID. - `code` (string): Drop-off code for this stop. Sensitive: never log. - `pickupVerificationSkipped` (boolean): True when the job was created with `autoVerifyPickup`. - `regenerated` (boolean): True when this replaces an earlier code. Example: ```json { "event": "delivery.codes_issued", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "requestId": 123, "trackingCode": "DLV-000123", "trackingToken": "q3Zr8Kd0aP1xYt7bWm2cLs4e", "userPhone": "2348012345678", "senderUserId": 4567, "publicStatus": "ACCEPTED", "newStatus": "ACCEPTED", "dropOffCode": "4821", "bundleDropOffCodes": null, "pickupVerificationSkipped": true, "regenerated": false } } ``` ### `delivery.pickup_verified` Pickup verified. The pickup code was entered, or (with `autoVerifyPickup`) the pickup was verified automatically when the bid was accepted. An auto-verified pickup does not mean the delivery partner agent has the package yet: wait for `delivery.in_transit`. `data` fields: - `requestId` (integer, required): Delivery ID. - `trackingCode` (string, required): Human-readable code for display, e.g. `DLV-000123`. Not for links. - `trackingToken` (string | null): Unguessable 24-character reference for tracking links (`GET /track/{trackingToken}`). Can be null on `delivery.created`; read it from the create response instead. - `userPhone` (string | null): Sender's phone, E.164 without `+`. - `senderUserId` (integer | null): Sender's user ID. - `publicStatus` (string, required): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `newStatus` (string, required): Same value as `publicStatus` (kept for older integrations). Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `autoVerified` (boolean): Present and true when no code was entered. Example: ```json { "event": "delivery.pickup_verified", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "requestId": 123, "trackingCode": "DLV-000123", "trackingToken": "q3Zr8Kd0aP1xYt7bWm2cLs4e", "userPhone": "2348012345678", "senderUserId": 4567, "publicStatus": "ACCEPTED", "newStatus": "ACCEPTED", "autoVerified": true } } ``` ### `delivery.pickup_confirmed` The delivery partner agent confirmed collecting the package. No extra fields. `data` fields: - `requestId` (integer, required): Delivery ID. - `trackingCode` (string, required): Human-readable code for display, e.g. `DLV-000123`. Not for links. - `trackingToken` (string | null): Unguessable 24-character reference for tracking links (`GET /track/{trackingToken}`). Can be null on `delivery.created`; read it from the create response instead. - `userPhone` (string | null): Sender's phone, E.164 without `+`. - `senderUserId` (integer | null): Sender's user ID. - `publicStatus` (string, required): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `newStatus` (string, required): Same value as `publicStatus` (kept for older integrations). Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. Example: ```json { "event": "delivery.pickup_confirmed", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "requestId": 123, "trackingCode": "DLV-000123", "trackingToken": "q3Zr8Kd0aP1xYt7bWm2cLs4e", "userPhone": "2348012345678", "senderUserId": 4567, "publicStatus": "ACCEPTED", "newStatus": "ACCEPTED" } } ``` ### `delivery.in_transit` The package is on its way. Fires when the delivery moves into transit (once per leg on parcel-centre routes). `data` fields: - `requestId` (integer, required): Delivery ID. - `trackingCode` (string, required): Human-readable code for display, e.g. `DLV-000123`. Not for links. - `trackingToken` (string | null): Unguessable 24-character reference for tracking links (`GET /track/{trackingToken}`). Can be null on `delivery.created`; read it from the create response instead. - `userPhone` (string | null): Sender's phone, E.164 without `+`. - `senderUserId` (integer | null): Sender's user ID. - `publicStatus` (string, required): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `newStatus` (string, required): Same value as `publicStatus` (kept for older integrations). Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `currentLocation` (string | null): Where the package is: `OPC` origin parcel centre, `DA` with the delivery partner agent, `DPC` destination parcel centre. Values: `OPC`, `DPC`, `DA`, `RC`, `CUST`, `null`. Example: ```json { "event": "delivery.in_transit", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "requestId": 123, "trackingCode": "DLV-000123", "trackingToken": "q3Zr8Kd0aP1xYt7bWm2cLs4e", "userPhone": "2348012345678", "senderUserId": 4567, "publicStatus": "IN_TRANSIT", "newStatus": "IN_TRANSIT", "currentLocation": "DA" } } ``` ### `delivery.location_updated` The delivery partner agent's position. At most about once a minute per delivery while a delivery partner agent is assigned. `data` fields: - `requestId` (integer, required): Delivery ID. - `trackingCode` (string, required): Human-readable code for display, e.g. `DLV-000123`. Not for links. - `trackingToken` (string | null): Unguessable 24-character reference for tracking links (`GET /track/{trackingToken}`). Can be null on `delivery.created`; read it from the create response instead. - `userPhone` (string | null): Sender's phone, E.164 without `+`. - `senderUserId` (integer | null): Sender's user ID. - `publicStatus` (string, required): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `newStatus` (string, required): Same value as `publicStatus` (kept for older integrations). Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `latitude` (number, required): Latitude (never 0,0). - `longitude` (number, required): Longitude. - `estimatedArrivalMinutes` (integer | null): Estimated minutes to the drop-off. - `recordedAt` (string (date-time), required): When the position was recorded. Example: ```json { "event": "delivery.location_updated", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "requestId": 123, "trackingCode": "DLV-000123", "trackingToken": "q3Zr8Kd0aP1xYt7bWm2cLs4e", "userPhone": "2348012345678", "senderUserId": 4567, "publicStatus": "IN_TRANSIT", "newStatus": "IN_TRANSIT", "latitude": 6.5012, "longitude": 3.3654, "estimatedArrivalMinutes": 18, "recordedAt": "2026-11-03T10:42:07Z" } } ``` ### `delivery.pickup_rejected` The pickup failed verification. For jobs created through the API, the accepted bid is cancelled, you are refunded (`payment.refund_initiated`) and the job reopens (`delivery.published`). Wait for a new delivery partner agent or cancel. `data` fields: - `requestId` (integer, required): Delivery ID. - `trackingCode` (string, required): Human-readable code for display, e.g. `DLV-000123`. Not for links. - `trackingToken` (string | null): Unguessable 24-character reference for tracking links (`GET /track/{trackingToken}`). Can be null on `delivery.created`; read it from the create response instead. - `userPhone` (string | null): Sender's phone, E.164 without `+`. - `senderUserId` (integer | null): Sender's user ID. - `publicStatus` (string, required): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `newStatus` (string, required): Same value as `publicStatus` (kept for older integrations). Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `reason` (string | null): Why it was rejected. Example: ```json { "event": "delivery.pickup_rejected", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "requestId": 123, "trackingCode": "DLV-000123", "trackingToken": "q3Zr8Kd0aP1xYt7bWm2cLs4e", "userPhone": "2348012345678", "senderUserId": 4567, "publicStatus": "AWAITING_BIDS", "newStatus": "AWAITING_BIDS", "reason": "Package larger than described" } } ``` ### `delivery.dropoff_rejected` The drop-off failed verification. The delivery partner agent will retry with a new code, which arrives as `delivery.codes_issued` with `regenerated: true`. `data` fields: - `requestId` (integer, required): Delivery ID. - `trackingCode` (string, required): Human-readable code for display, e.g. `DLV-000123`. Not for links. - `trackingToken` (string | null): Unguessable 24-character reference for tracking links (`GET /track/{trackingToken}`). Can be null on `delivery.created`; read it from the create response instead. - `userPhone` (string | null): Sender's phone, E.164 without `+`. - `senderUserId` (integer | null): Sender's user ID. - `publicStatus` (string, required): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `newStatus` (string, required): Same value as `publicStatus` (kept for older integrations). Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `reason` (string | null): Why it was rejected. Example: ```json { "event": "delivery.dropoff_rejected", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "requestId": 123, "trackingCode": "DLV-000123", "trackingToken": "q3Zr8Kd0aP1xYt7bWm2cLs4e", "userPhone": "2348012345678", "senderUserId": 4567, "publicStatus": "IN_TRANSIT", "newStatus": "IN_TRANSIT", "reason": "Receiver not available" } } ``` ### `delivery.partner_cancelled` The delivery partner agent cancelled an accepted job. The job usually goes back out for bidding next (`delivery.published`). `data` fields: - `requestId` (integer, required): Delivery ID. - `trackingCode` (string, required): Human-readable code for display, e.g. `DLV-000123`. Not for links. - `trackingToken` (string | null): Unguessable 24-character reference for tracking links (`GET /track/{trackingToken}`). Can be null on `delivery.created`; read it from the create response instead. - `userPhone` (string | null): Sender's phone, E.164 without `+`. - `senderUserId` (integer | null): Sender's user ID. - `publicStatus` (string, required): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `newStatus` (string, required): Same value as `publicStatus` (kept for older integrations). Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `bidId` (integer): The cancelled bid. - `reason` (string | null): Delivery partner agent's reason(s), comma-separated. Example: ```json { "event": "delivery.partner_cancelled", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "requestId": 123, "trackingCode": "DLV-000123", "trackingToken": "q3Zr8Kd0aP1xYt7bWm2cLs4e", "userPhone": "2348012345678", "senderUserId": 4567, "publicStatus": "PENDING", "newStatus": "PENDING", "bidId": 9876, "reason": "Vehicle breakdown" } } ``` ### `delivery.delivered` Delivered. The receiver has the package. `previousStatus` is missing on some completion paths. `data` fields: - `requestId` (integer, required): Delivery ID. - `trackingCode` (string, required): Human-readable code for display, e.g. `DLV-000123`. Not for links. - `trackingToken` (string | null): Unguessable 24-character reference for tracking links (`GET /track/{trackingToken}`). Can be null on `delivery.created`; read it from the create response instead. - `userPhone` (string | null): Sender's phone, E.164 without `+`. - `senderUserId` (integer | null): Sender's user ID. - `publicStatus` (string, required): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `newStatus` (string, required): Same value as `publicStatus` (kept for older integrations). Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `previousStatus` (string): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `completedAt` (one of): When the delivery finished. Usually an RFC 3339 string; some completion paths send epoch milliseconds. Accept both. - Variant 1 - Variant 2: Epoch milliseconds. Example: ```json { "event": "delivery.delivered", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "requestId": 123, "trackingCode": "DLV-000123", "trackingToken": "q3Zr8Kd0aP1xYt7bWm2cLs4e", "userPhone": "2348012345678", "senderUserId": 4567, "publicStatus": "DELIVERED", "newStatus": "DELIVERED", "previousStatus": "IN_TRANSIT", "completedAt": "2026-11-03T11:05:12Z" } } ``` ### `delivery.returned` Returned to the sender. A return-to-sender job was completed. `data` fields: - `requestId` (integer, required): Delivery ID. - `trackingCode` (string, required): Human-readable code for display, e.g. `DLV-000123`. Not for links. - `trackingToken` (string | null): Unguessable 24-character reference for tracking links (`GET /track/{trackingToken}`). Can be null on `delivery.created`; read it from the create response instead. - `userPhone` (string | null): Sender's phone, E.164 without `+`. - `senderUserId` (integer | null): Sender's user ID. - `publicStatus` (string, required): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `newStatus` (string, required): Same value as `publicStatus` (kept for older integrations). Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `previousStatus` (string): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `completedAt` (one of): When the delivery finished. Usually an RFC 3339 string; some completion paths send epoch milliseconds. Accept both. - Variant 1 - Variant 2: Epoch milliseconds. Example: ```json { "event": "delivery.returned", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "requestId": 123, "trackingCode": "DLV-000123", "trackingToken": "q3Zr8Kd0aP1xYt7bWm2cLs4e", "userPhone": "2348012345678", "senderUserId": 4567, "publicStatus": "DELIVERED", "newStatus": "DELIVERED", "previousStatus": "IN_TRANSIT", "completedAt": "2026-11-03T15:20:00Z" } } ``` ### `delivery.cancelled` Cancelled. The delivery was cancelled by its sender (including `POST /deliveries/{idOrCode}/cancel`). `data` fields: - `requestId` (integer, required): Delivery ID. - `trackingCode` (string, required): Human-readable code for display, e.g. `DLV-000123`. Not for links. - `trackingToken` (string | null): Unguessable 24-character reference for tracking links (`GET /track/{trackingToken}`). Can be null on `delivery.created`; read it from the create response instead. - `userPhone` (string | null): Sender's phone, E.164 without `+`. - `senderUserId` (integer | null): Sender's user ID. - `publicStatus` (string, required): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `newStatus` (string, required): Same value as `publicStatus` (kept for older integrations). Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `previousStatus` (string): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. Example: ```json { "event": "delivery.cancelled", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "requestId": 123, "trackingCode": "DLV-000123", "trackingToken": "q3Zr8Kd0aP1xYt7bWm2cLs4e", "userPhone": "2348012345678", "senderUserId": 4567, "publicStatus": "CANCELLED", "newStatus": "CANCELLED", "previousStatus": "AWAITING_BIDS" } } ``` ### `delivery.return_requested` The delivery partner agent asked to return the package to the sender. Sent as a bid with the same fields as `delivery.bid_submitted`. `data` fields: - `requestId` (integer, required): Delivery ID. - `trackingCode` (string, required): Human-readable code for display, e.g. `DLV-000123`. Not for links. - `trackingToken` (string | null): Unguessable 24-character reference for tracking links (`GET /track/{trackingToken}`). Can be null on `delivery.created`; read it from the create response instead. - `userPhone` (string | null): Sender's phone, E.164 without `+`. - `senderUserId` (integer | null): Sender's user ID. - `publicStatus` (string, required): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `newStatus` (string, required): Same value as `publicStatus` (kept for older integrations). Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `bidId` (integer, required): Bid ID. Use it with `POST /deliveries/{idOrCode}/bids/{bidId}/accept`. - `partnerName` (string | null): Delivery partner agent's display name. - `partnerRating` (number): Delivery partner agent's average rating (0 when unrated). - `bidAmount` (number): The delivery partner agent's price, in major currency units. - `totalAmountToBePaid` (number): What accepting this bid costs in total, fees included. - `currency` (string | null): ISO currency code. - `vehicleType` (string | null): Vehicle type of the delivery. - `comment` (string | null): Delivery partner agent's note on the bid. Example: ```json { "event": "delivery.return_requested", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "requestId": 123, "trackingCode": "DLV-000123", "trackingToken": "q3Zr8Kd0aP1xYt7bWm2cLs4e", "userPhone": "2348012345678", "senderUserId": 4567, "publicStatus": "IN_TRANSIT", "newStatus": "IN_TRANSIT", "bidId": 9901, "partnerName": "Emeka O.", "partnerRating": 4.6, "bidAmount": 1500, "totalAmountToBePaid": 1650, "currency": "NGN", "vehicleType": "MOTORCYCLE", "comment": "Receiver refused the package" } } ``` ### `delivery.return_agreed` The return to sender was agreed. Same fields as `delivery.bid_submitted`. `data` fields: - `requestId` (integer, required): Delivery ID. - `trackingCode` (string, required): Human-readable code for display, e.g. `DLV-000123`. Not for links. - `trackingToken` (string | null): Unguessable 24-character reference for tracking links (`GET /track/{trackingToken}`). Can be null on `delivery.created`; read it from the create response instead. - `userPhone` (string | null): Sender's phone, E.164 without `+`. - `senderUserId` (integer | null): Sender's user ID. - `publicStatus` (string, required): Stable, partner-facing delivery status. Build your logic on this, not on internal statuses. Treat an unknown value as "no change" rather than failing. Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `newStatus` (string, required): Same value as `publicStatus` (kept for older integrations). Values: `PENDING`, `AWAITING_BIDS`, `ACCEPTED`, `PICKED_UP`, `IN_TRANSIT`, `DELIVERED`, `CANCELLED`. - `bidId` (integer, required): Bid ID. Use it with `POST /deliveries/{idOrCode}/bids/{bidId}/accept`. - `partnerName` (string | null): Delivery partner agent's display name. - `partnerRating` (number): Delivery partner agent's average rating (0 when unrated). - `bidAmount` (number): The delivery partner agent's price, in major currency units. - `totalAmountToBePaid` (number): What accepting this bid costs in total, fees included. - `currency` (string | null): ISO currency code. - `vehicleType` (string | null): Vehicle type of the delivery. - `comment` (string | null): Delivery partner agent's note on the bid. Example: ```json { "event": "delivery.return_agreed", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "requestId": 123, "trackingCode": "DLV-000123", "trackingToken": "q3Zr8Kd0aP1xYt7bWm2cLs4e", "userPhone": "2348012345678", "senderUserId": 4567, "publicStatus": "IN_TRANSIT", "newStatus": "IN_TRANSIT", "bidId": 9901, "partnerName": "Emeka O.", "partnerRating": 4.6, "bidAmount": 1500, "totalAmountToBePaid": 1650, "currency": "NGN", "vehicleType": "MOTORCYCLE", "comment": null } } ``` ### `payment.required` A payment link must be paid. No wallet balance or saved card covered the bid, so it waits for this link to be paid. Send `paymentUrl` to whoever pays. Links expire after about an hour. `data` fields: - `userPhone` (string | null): Sender's phone. Usually E.164 without `+`; match on `requestId`, not on this. - `requestId` (integer | null): Delivery ID the payment belongs to. - `trackingCode` (string): Delivery tracking code (present when `requestId` is). - `bidId` (integer, required): Bid being paid for. - `amount` (number | null): Amount in major currency units. - `currency` (string | null): ISO currency code. - `paymentUrl` (string (uri), required): Hosted payment page. - `reference` (string): Payment reference (missing for some gateways). - `expiresAt` (string | null (date-time)): When the link expires, when known. - `method` (string | null): `LINK` (missing for some gateways). Values: `WALLET`, `CARD`, `LINK`, `null`. Example: ```json { "event": "payment.required", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "userPhone": "2348012345678", "requestId": 123, "trackingCode": "DLV-000123", "bidId": 9876, "amount": 4620, "currency": "NGN", "paymentUrl": "https://checkout.paystack.com/abc123", "reference": "DLV_123_9876", "expiresAt": "2026-11-03T11:30:00Z", "method": "LINK" } } ``` ### `payment.successful` A payment went through. Paid from wallet, card or link. Some payment-link successes only show up as `delivery.bid_accepted`, so treat that event as proof of payment too. `data` fields: - `userPhone` (string | null): Sender's phone. Usually E.164 without `+`; match on `requestId`, not on this. - `requestId` (integer | null): Delivery ID the payment belongs to. - `trackingCode` (string): Delivery tracking code (present when `requestId` is). - `bidId` (integer, required): Bid being paid for. - `amount` (number | null): Amount in major currency units. - `currency` (string | null): ISO currency code. - `transactionId` (string): Gateway or wallet transaction ID. - `method` (string | null): Payment source. Values: `WALLET`, `CARD`, `LINK`, `null`. Example: ```json { "event": "payment.successful", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "userPhone": "2348012345678", "requestId": 123, "trackingCode": "DLV-000123", "bidId": 9876, "amount": 4620, "currency": "NGN", "transactionId": "WLT-77812", "method": "WALLET" } } ``` ### `payment.failed` A payment source couldn't pay. Fires for each source that is skipped or fails; the next source is then tried. `reason` is usually one of the listed codes but can be gateway text. `data` fields: - `userPhone` (string | null): Sender's phone. Usually E.164 without `+`; match on `requestId`, not on this. - `requestId` (integer | null): Delivery ID the payment belongs to. - `trackingCode` (string): Delivery tracking code (present when `requestId` is). - `bidId` (integer): Bid being paid for. - `amount` (number | null): Amount in major currency units. - `currency` (string | null): ISO currency code. - `transactionId` (string): Gateway transaction ID, when there is one. - `method` (string | null): Payment source. Values: `WALLET`, `CARD`, `LINK`, `null`. - `reason` (string): Why it failed. Known codes: `INSUFFICIENT_WALLET_BALANCE`, `NO_WALLET`, `NO_SAVED_CARD`, `CARD_DECLINED`, `GATEWAY_FAILED`. Other values are gateway messages. - `retryAllowed` (boolean): Whether retrying the same source can work. Example: ```json { "event": "payment.failed", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "userPhone": "2348012345678", "requestId": 123, "trackingCode": "DLV-000123", "bidId": 9876, "amount": 4620, "currency": "NGN", "method": "WALLET", "reason": "INSUFFICIENT_WALLET_BALANCE", "retryAllowed": false } } ``` ### `payment.refund_initiated` A refund was started. Refunds go back to the source that paid. Only identifies the delivery; the amount arrives with `payment.refund_completed`. `data` fields: - `userPhone` (string | null): Sender's phone. Usually E.164 without `+`; match on `requestId`, not on this. - `requestId` (integer | null): Delivery ID the payment belongs to. - `trackingCode` (string): Delivery tracking code (present when `requestId` is). Example: ```json { "event": "payment.refund_initiated", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "userPhone": "2348012345678", "requestId": 123, "trackingCode": "DLV-000123" } } ``` ### `payment.refund_completed` A refund finished. Gateway fees are not refunded on card and link payments. `data` fields: - `userPhone` (string | null): Sender's phone. Usually E.164 without `+`; match on `requestId`, not on this. - `requestId` (integer | null): Delivery ID the payment belongs to. - `trackingCode` (string): Delivery tracking code (present when `requestId` is). - `bidId` (integer): Bid being paid for. - `amount` (number | null): Amount in major currency units. - `currency` (string | null): ISO currency code. - `transactionId` (string): Refund reference. Example: ```json { "event": "payment.refund_completed", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "userPhone": "2348012345678", "requestId": 123, "trackingCode": "DLV-000123", "bidId": 9876, "amount": 4620, "currency": "NGN", "transactionId": "RFD-55120" } } ``` ### `account.signup` A user you onboarded was created. Fires when `POST /createRequestWithUser` registers a new sender on your channel. `data` fields: - `userId` (integer, required): New user's ID. - `userPhone` (string | null): Phone, E.164 without `+`. Sensitive: never log. - `mobile` (string | null): Same as `userPhone`. Sensitive: never log. - `email` (string | null): Email. Sensitive: never log. - `name` (string | null): Name. - `role` (string | null): Account role, e.g. `customer`. - `registrationSource` (string | null): Where the account was created (your channel). - `referralCode` (string | null): Referral code used, if any. - `referrerId` (integer | null): Referrer's user ID, if any. - `referrerMobile` (string | null): Referrer's phone, if any. - `referrerEmail` (string | null): Referrer's email, if any. - `country` (string | null): Country. - `state` (string | null): State. - `city` (string | null): City. - `address` (string | null): Street address. - `houseNo` (string | null): House number. - `zipcode` (string | null): Postal code. - `latitude` (number | null): Latitude (null when unset). - `longitude` (number | null): Longitude (null when unset). Example: ```json { "event": "account.signup", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "userId": 4567, "userPhone": "2348012345678", "mobile": "2348012345678", "email": "ada@example.com", "name": "Ada Obi", "role": "customer", "registrationSource": "ACME", "referralCode": null, "referrerId": null, "referrerMobile": null, "referrerEmail": null, "country": "NG", "state": "Lagos", "city": "Lagos", "address": "12 Marina Rd", "houseNo": null, "zipcode": null, "latitude": 6.45, "longitude": 3.39 } } ``` ### `account.kyc_required` A user you onboarded must complete identity checks. Only for users registered on your channel. `data` fields: - `userId` (integer, required): User ID. - `userPhone` (string | null): Phone. Example: ```json { "event": "account.kyc_required", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "userId": 4567, "userPhone": "2348012345678" } } ``` ### `account.kyc_approved` Identity checks passed. Only for users registered on your channel. `data` fields: - `userId` (integer, required): User ID. - `userPhone` (string | null): Phone. Example: ```json { "event": "account.kyc_approved", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "userId": 4567, "userPhone": "2348012345678" } } ``` ### `account.kyc_rejected` Identity checks failed. Only for users registered on your channel. `data` fields: - `userId` (integer, required): User ID. - `userPhone` (string | null): Phone. - `reason` (string): Why it was rejected (absent when no reason was given). - `retryAllowed` (boolean): Whether the user may resubmit documents. Example: ```json { "event": "account.kyc_rejected", "timestamp": "2026-11-03T10:15:30.517Z", "idempotencyKey": "evt_3f1c2a9e-5b7d-4e1a-9c33-0d2b8f6a71e4", "channel": "ACME", "data": { "userId": 4567, "userPhone": "2348012345678", "reason": "Document unreadable", "retryAllowed": true } } ```