Skip to Content
API Reference

API Reference

The complete IDeliver API reference. Every endpoint includes copy-pasteable code snippets in 8 languages: cURL, Node.js, PHP, Python, Ruby, Go, Java, and C#. Use the API Playground to test requests interactively.

Base URL: https://api.ideliver.ng

Sandbox vs production — Both use the same hostname. Isolation is by API key prefix: ilv_test_… (sandbox / test rows) vs ilv_live_… (production). See Authentication. Customer tracking links depend on TRACKING_PUBLIC_BASE_URL on the API — when set, order APIs include customer_tracking_url.


All Endpoints

MethodPathSummary
GET/healthLiveness
GET/health/readyReadiness (database + optional Redis)
GET/scim/v2/ServiceProviderConfig**P2-A / A1** — SCIM 2.0 Service Provider Config (skeleton). **`404`** unless **`SCIM_ENABLED=1`** and **`SCIM_BEARER_TOKEN`** (≥16).
GET/scim/v2/Users**P2-A / A1** — SCIM Users list (empty skeleton).
POST/scim/v2/Users**P2-A / A1** — User create **501** (not implemented).
POST/webhooks/shopifyShopify order webhooks (raw JSON + HMAC). Configure `merchants.shopify_shop_domain` + `shopify_webhook_secret`.
POST/webhooks/resend/inboundResend **email.received** (Svix headers + raw body). `RESEND_WEBHOOK_SECRET`. Tenant from `store-<merchant_uuid>@...`. **A-10:** optional `RESEND_INBOUND_DOMAIN` exact match; subject keyword gate or allowed PDF/image attachments only; `RESEND_A10_SKIP=1` dev escape. **A-4 (optional):** `RESEND_INBOUND_FETCH_BODY_ENABLED=1` + `RESEND_API_KEY` → **`GET /emails/receiving/:email_id`** merges **`text`/`html`** (+ optional `subject`) into **`unified_json.raw_payload.resend_received_body`** and also stores Resend **`raw.download_url`** + **`raw.expires_at`** (still no attachment byte download).
POST/webhooks/paystack/platform**Phase 2 — platform money:** Raw JSON + **`x-paystack-signature`** verified with **`PLATFORM_PAYSTACK_SECRET_KEY`** only. Configure this URL on the **IDeliver** Paystack app for SaaS billing, **merchant wallet top-up**, and **platform-initiated transfers**. Resolves tenant from payload **`metadata.merchant_id`** (or **`withdrawal_request_id`** → **`withdrawal_requests.merchant_id`** on **`transfer.*`**). Same downstream handlers as **`/webhooks/paystack/{merchantId}`** (billing, wallet credit, withdrawals, order ingest, compliance).
POST/webhooks/paystack/{merchantId}**Legacy / merchant Paystack app:** webhooks (raw JSON + **`x-paystack-signature`**) verified with **`merchants.paystack_secret_key`** and/or **`PLATFORM_PAYSTACK_SECRET_KEY`**. URL embeds tenant id. Prefer **`POST /webhooks/paystack/platform`** for unified platform settlement. **D-9 — compliance / KYB:** **`customeridentification.*`**, **`charge.success`** (wallet / orders), **`transfer.*`**, **E9-1 / E2-6** billing events — same as platform route.
POST/webhooks/flutterwave/{merchantId}Flutterwave webhooks (raw JSON). `flutterwave-signature` (HMAC-SHA256 base64) or legacy `verif-hash` (plain secret). `merchants.flutterwave_webhook_secret`.
POST/webhooks/termii/globalTier 1 — master WABA aggregator. Raw JSON + `X-Termii-Signature` vs **`GLOBAL_TERMII_WEBHOOK_SECRET`**. Resolves merchant via `Order_from_<whatsapp_slug>` in message text or **`termii_global_whatsapp_sessions`** (Redis/DB, 2h TTL). Same ingest → normalization → `createOrderIdempotent` → auto-dispatch path as per-merchant Termii. Orphan messages trigger Termii SMS welcome when **`TERMII_API_KEY`** is set.
POST/webhooks/termii/{merchantId}Termii inbound (`type=inbound`, `channel=whatsapp`). Raw body + `X-Termii-Signature` (HMAC-SHA512, hex or base64). `merchants.termii_webhook_secret`; optional `termii_receiver_phone` (digits) must match payload `receiver`.
POST/webhooks/woocommerce/{merchantId}WooCommerce order webhooks (raw JSON). `X-WC-Webhook-Signature` = base64(HMAC-SHA256(body, secret)). Set `merchants.woocommerce_webhook_secret` to the secret from WC webhook settings.
POST/v1/auth/tokenExchange **`ilv_…` only** (`X-API-Key` or `Authorization: Bearer ilv_…`) for **`access_token`** + **`refresh_token`**. Merchant Bearer JWT is **not** accepted here. Optional JSON body **`grantor_merchant_id`** mints **delegated** tokens (same rules as **`X-Ideliver-Act-As-Merchant`**: grantee key, active workspace grant, scopes = intersection). Access token includes **`grantor_merchant_id`** claim so clients can omit the header on subsequent requests. Requires `MERCHANT_JWT_SECRET` (or `JWT_SECRET`), min 16 chars.
POST/v1/auth/refreshRotate merchant session: body `{ "refresh_token": "…" }` → new **`access_token`** + **`refresh_token`** (revoked key → **401**). Delegated refresh tokens ( **`grantor_merchant_id`** on the refresh JWT) re-validate the workspace grant and emit new access + refresh with the same delegation. Optional TTL: **`MERCHANT_REFRESH_JWT_TTL_SEC`** (default 30d, max 90d).
POST/v1/auth/rider/tokenMerchant backend exchanges **`ilv_…` only** for **rider** `access_token` (`role=rider`, scope **`riders:location`** only). Body: `{ "rider_id": "<uuid>" }` — rider’s user must belong to the key’s merchant. Optional TTL: **`RIDER_JWT_TTL_SEC`**. **`BILLING_ENFORCEMENT_ENABLED`**: **403** `billing_blocked` (same as `POST /v1/orders`).
GET/v1/auth/rider/meRider session: **`rider`** (optional **`riderWallet`**: `balanceMinor`, `updatedAt`) + **`merchant`** (E2-6 fields) from **`RiderAccessJwt`**.
GET/v1/auth/meCurrent merchant from Bearer JWT (revoked keys invalidate token). **`merchant`** includes **E2-6** + **E1-7** outbound webhook flags and **D-9** **`kybStatus`**. **`account_email`** / **`email_verified`**: oldest password **user** on the **grantee** tenant when **`workspace_delegation`** is present; else the JWT merchant. Optional **`X-Ideliver-Act-As-Merchant`** or **delegated JWT** (**`grantor_merchant_id`** claim) — same rules as **`GET /v1/me/billing`** (**`merchant.id`** = grantor when delegating). KYB submit: **`POST /v1/me/kyb/submit`**.
POST/v1/auth/change-passwordChange password for the **users** row whose **current_password** matches (same **merchant** as the Bearer **access_token**). Optional **`MERCHANT_CHANGE_PASSWORD_REQUIRES_KEYS_MANAGE`**: JWT must include **`keys:manage`** (from the minting integration key). Writes **`audit_logs`** (**`user.password_change`**, **`actor_type`**: **`merchant_jwt`**).
POST/v1/me/commission-dispute-intakePhase 2 preview — merchant **commission / payout concern** intake (**`COMMISSION_DISPUTE_INTAKE_ENABLED=1`**). Bearer **merchant access JWT**. Creates **`contact_inquiries`** with **`source`**: **`commission_dispute`** (admin triage via **`GET /v1/admin/contact-inquiries?source=commission_dispute`**). Requires oldest password **user** **`email`** on the tenant.
POST/v1/me/kyb/submit**D-9** — merchant KYB intake: sets **`kyb_status`** **`pending_review`**, stores JSON payload (**`kyb_payload`**). Minting key needs **`orders:read`** or **`orders:write`**.
POST/v1/me/kyb/uploads/presign**D-9 / P2-C** — presigned **PUT** URL for merchant KYB documents (**PDF** / **JPEG** / **PNG** / **WebP**). Browser uploads file bytes to **`upload.url`** with **`upload.headers`**, then sends **`storage_key`** in **`POST /v1/me/kyb/documents`**.
POST/v1/me/kyb/documents**D-9** — append **KYB file metadata** to **`kyb_payload.documents`** (after client upload to your object store). Does not replace **`submit`** payload fields.
GET/v1/me/kyb/scan-jobs**P2-C / C2** — KYB document **virus-scan job** rows (stub queue until a real engine is wired). Newest first.
GET/v1/me/workspace-grants**P2-A A3** — list **merchant workspace grants** where JWT tenant is **grantor** or **grantee**. Requires **`orders:read`** or **`keys:manage`**. *Remainder:* middleware to exercise grants on sibling routes.
POST/v1/me/workspace-grants**P2-A A3** — create or **reactivate** a grant (**grantor** = JWT tenant). Body: **`grantee_merchant_id`**, **`scopes`** (subset of integration-key scopes). Requires **`keys:manage`**. **409** if an **active** grant already exists for the pair.
GET/v1/me/workspace-grants/active/for/{grantorMerchantId}**P2-A A3** — return **active** grant scopes when **JWT tenant** is **grantee** and **`grantorMerchantId`** path matches (**integration / contract test** hook before global enforcement).
DELETE/v1/me/workspace-grants/{grantId}**P2-A A3** — **revoke** grant (**grantor** JWT only). Idempotent if already **revoked**.
GET/v1/me/billing/paystack-plansE2-6 — List Paystack **Plans** (**`GET https://api.paystack.co/plan`**) using **`PLATFORM_PAYSTACK_SECRET_KEY`** (platform SaaS account). Returns sanitized rows for an in-app plan picker; amounts are Paystack minor units (e.g. kobo). Bearer **merchant access JWT**.
POST/v1/me/billing/paystack-subscription-intentE2-6 — Start Paystack **hosted checkout** to subscribe the **billing** tenant to **`plan_code`** (**`POST /transaction/initialize`** with **`plan`**). Customer email is the oldest password user on the **grantee** merchant when **`workspace_delegation`** applies. After successful payment, **`subscription.*` / `invoice.*`** webhooks update **`merchants`** (see **`/webhooks/paystack/:merchantId`**). Body: **`plan_code`** (from **`GET /v1/me/billing/paystack-plans`**), optional **`callback_url`**.
POST/v1/me/billing/paystack-subscription-createE2-6 — Create a Paystack **subscription** (**`POST https://api.paystack.co/subscription`**) when **`merchants.paystack_customer_code`** (**`CUS_…`**) is already set (prior charge / webhooks) and Paystack has a reusable **authorization** on that customer. Body: **`plan_code`**, optional **`authorization`** (**`AUTH_…`**) when the customer has multiple cards. **`subscription.*` / `invoice.*`** webhooks still sync **`merchants`**. Bearer **merchant access JWT**.
GET/v1/me/escrow-ledger**E7-4** — Tenant **commission escrow** ledger (**`commission_hold`** when **`RIDER_COMMISSION_REQUIRES_PAYMENT_VERIFIED`** blocks accrual; **`commission_released`** when rider wallet is credited). Requires **`orders:read`** or **`orders:write`** on the minting key. Query: **`limit`**, **`offset`**, optional **`order_id`**, **`entry_type`**. Optional Act-As — **`#/components/parameters/ActAsMerchantId`**.
GET/v1/me/billingE9-1 / **E2-6** — Tenant **billing** snapshot: **`merchant_tier`**, **`billing_status`**, **`billing_period_end`**, plus **`billing_enforcement_enabled`** (**`BILLING_ENFORCEMENT_ENABLED`**). When **`billing_status`** is **`compliance_hold`** (Paystack KYB / compliance automation), **`billing_compliance_hold`** is **`true`** and **`billing_entitlements_note`** explains the pause. When Paystack subscription webhooks have stored **`paystack_subscription_email_token`**, **`paystack_subscription_manage_url`** is **`https://paystack.com/subscribe/<token>`**. Bearer **merchant access JWT**; thinner than **`GET /v1/auth/me`** for dashboard widgets. V4.1: time-gated trials removed — accounts never expire by date; gating is wallet-based.
POST/v1/me/pricing/quoteDelivery fare estimate (**`PRICING_ENGINE_ENABLED`**) or **internal-fleet** routing micro-fee snapshot. **`orders:read`** on **`ilv_…`** key or merchant access JWT. **`fulfillment_mode: internal_fleet`** skips pickup/distance — returns **`internal_routing`** from **`estimateInternalRoutingFeeMinorForMerchant`**.
GET/v1/me/riders/near**E2-1** — Tenant riders with **`current_location`** within **`metres`** of **`lat`/`lng`**, ordered by distance. Bearer **merchant access JWT**; minting key must include **`orders:read`** or **`orders:write`**. Optional **`online_only`** (**`1`**/**`true`**). Default **`metres`**: **50000**, **`limit`**: **50** (max **100**).
GET/v1/me/dispatch/platform-geofences**Transparency** — Active **platform** geofences (read-only), as GeoJSON. Merchant JWT with **`orders:read`** or **`orders:write`**. Matches admin payload shape (`items[]` with **`geojson`**). Inactive platform zones are omitted.
GET/v1/me/realtime/rider-location**Realtime** — Socket.io handshake hints + **`initial_positions`** from **`riders.current_location`** for **merchant** live maps. Minting key must include **`orders:read`** or **`orders:write`**. Connect with **`auth.merchant_access_token`**; room **`merchant:{id}`** receives **`rider_location`**, **`rider_locations_snapshot`**, **`rider_online_status`**.
GET/v1/me/riders/{riderId}/location/history**Fleet GPS trail** — **`fleet_location_log`** for a tenant rider. Query **`limit`** (1–2000, default 200), **`since`**, **`until`** (ISO), **`order`** **`asc`** (oldest first) / **`desc`**. **`orders:read`** or **`orders:write`**.
GET/v1/me/walletE9-2 — Merchant **wallet** balance + **`ledger_transactions`** (newest first). Bearer **merchant access JWT**; **`merchant_id`** is always the JWT tenant. Same JSON shape as **`GET /v1/admin/merchants/{merchantId}/ledger`**. Query **`limit`** (1–200, default 50), **`offset`**.
GET/v1/me/withdrawalsMerchant **withdrawal queue** (self-serve). Requires **`WITHDRAWAL_REQUESTS_ENABLED=1`**. Bearer **merchant access JWT**; **`limit`**, **`offset`**; returns **`withdrawal_requests`** newest first + **`min_amount_minor`**.
POST/v1/me/withdrawals/requestCreate **`pending_review`** withdrawal; appends **`withdrawal_reserve`** ledger line (funds reserved until **`paid`**, reject, or Paystack failure releases). Optional **`Idempotency-Key`** header (≤255). Optional **`WITHDRAWAL_MERCHANT_KYB_VERIFIED_REQUIRED=1`** → merchant **`kyb_status`** must be **`verified`**.
GET/v1/me/outbound-webhookD-7 / E1-7 — Read tenant outbound webhook URL, **`enabled`**, and whether an HMAC secret is configured (secret value never returned). Bearer **merchant access JWT**; minting key must include **`webhooks:manage`**.
PATCH/v1/me/outbound-webhookD-7 / E1-7 — Merchant self-serve **outbound webhook** URL, HMAC secret, and **`enabled`** flag (same rules as admin **`PATCH /v1/admin/merchants/:id`** subset). Bearer **merchant access JWT**; minting key must include **`webhooks:manage`**. Secret never returned (only **`outboundWebhookSecretConfigured`**). **`audit_logs`**: **`merchant.outbound_webhook_patch`**.
GET/v1/me/integrations/paystack**Optional legacy** — whether **`merchants.paystack_secret_key`** is set (per-tenant Paystack app for **`/webhooks/paystack/:merchantId`** only). **Wallet top-up**, **transfers**, **recipients**, **SaaS**, and **`/webhooks/paystack/platform`** use **`PLATFORM_PAYSTACK_SECRET_KEY`**. Secret never returned.
PATCH/v1/me/integrations/paystack**Disabled** — merchants can no longer set **`merchants.paystack_secret_key`** from the dashboard. Use **`POST /webhooks/paystack/platform`** and **`PLATFORM_PAYSTACK_SECRET_KEY`**. Ops may patch the legacy field via **admin** APIs when needed.
GET/v1/me/whatsapp/meta/status**Meta WhatsApp Cloud** — read-only connection state for Integrations UI (no raw token). Includes **`meta_oauth_pending`** when **`POST …/exchange-token`** returned **`409`** and the merchant must call **`POST …/select-asset`**.
GET/v1/me/whatsapp/meta/sdk-configPublic Meta client settings for Embedded Signup (**`FB.init`**, **`config_id`**, OAuth redirect allowlist). Same values as optional merchant **`VITE_META_*`** build args; returned from API env when the SPA was built without them.
GET/v1/me/whatsapp/meta/candidatesRe-list WhatsApp Business phone assets for a **pending** Embedded Signup session (after **`409 meta_whatsapp_selection_required`**). No OAuth code; uses server-stored pending token until expiry.
POST/v1/me/whatsapp/meta/exchange-token**Embedded Signup** — exchange Facebook **`code`** for long-lived user token; probe WABA + **`phone_number_id`**; subscribe app (best-effort); persist **`meta_*`** and **`whatsapp_engine=meta_cloud`**. Optional **`waba_id`** + **`phone_number_id`** when multiple numbers exist. Multiple assets without selection → **`409`** with **`candidates`** and pending session (then **`POST …/select-asset`**).
POST/v1/me/whatsapp/meta/select-assetComplete Meta connection after **`409`** on **`exchange-token`** — uses pending long-lived token + chosen WABA and phone number id.
GET/v1/me/outbound-webhook/deliveriesD-7 — List **`outbound_webhook_deliveries`** for the JWT tenant (newest first). Optional **`order_id`** filters to envelopes where **`order.id`** matches (real callbacks). Each item includes derived **`order_id`** when present. Requires **`webhooks:manage`**.
POST/v1/me/outbound-webhook/deliveries/{deliveryId}/retryD-7 / E1-7 — Re-queue a **dead** or **pending** outbound delivery for **this JWT tenant only** (same semantics as admin **`POST /v1/admin/outbound-webhook-deliveries/{deliveryId}/retry`**; **404** if delivery belongs to another merchant). Requires **`webhooks:manage`**. **`audit_logs`**: **`outbound_webhook.delivery_retry`** (**`actor_type`**: **`merchant_jwt`**).
POST/v1/me/wallet/paystack-topup-intentE9-2 — Start **Paystack** checkout on **IDeliver’s** Paystack account (**`PLATFORM_PAYSTACK_SECRET_KEY`**) to credit **`merchant_wallets`** via **`POST /webhooks/paystack/platform`** + **`charge.success`** + **`tryApplyPaystackWalletCredit`** (metadata **`ideliver_purpose`**: **`merchant_wallet_topup`**, **`merchant_id`**). Bearer **merchant access JWT**. Requires oldest password **user** **`email`**. Env **`WALLET_PAYSTACK_TOPUP_MIN_MINOR`** (default **600000** kobo = ₦6000) and **`WALLET_PAYSTACK_TOPUP_MAX_MINOR`**. Optional **`Idempotency-Key`** (≤**255**): same tenant + key replays stored initialize (**`idempotent_replay`**) when **`amount_minor`** & **`callback_url`** match; mismatch → **409**.
GET/v1/public/tracking/{token}Customer tracking (**no auth**): status + pickup/dropoff text from **`unified_json`**. Path **`token`** = **`orders.tracking_token`** (opaque). When **`SOCKET_IO_ENABLED=1`**, body includes **`live_updates`** (**`path`**, **`transport`**, structured **`event_catalog`** — **E7-3**). **404** for unknown/malformed tokens.
GET/v1/tracking/{token}Customer tracking (**alias**, same as **`GET /v1/public/tracking/{token}`**). **`token`** = **`orders.tracking_token`**. **404** **`tracking_expired`** after terminal TTL (**`TRACKING_PUBLIC_TERMINAL_TTL_HOURS`**, default **48** h).
POST/v1/public/contactD-8 — public contact / lead form (**no auth**). Optional **`source`**: **`web_marketing`** (default) or **`signup_request`**. Optional honeypot **`website`**: must be empty or omitted. **201** `{ ok: true }`.
POST/v1/public/merchants/signupSelf-serve merchant signup (**no auth**): creates **`merchants`** row (default **`pay_as_you_go`** billing, no time-gated trial — V4.1), owner **`users`** row with password, **`merchant_wallets`**, default **`integration_api_keys`** (full Phase 1 scopes), and a **`contact_inquiries`** row (**`source`**: **`self_serve_signup`**). Returns **`api_key` once**. When **`MERCHANT_SIGNUP_REQUIRE_EMAIL_VERIFICATION=1`**, **`users.email_verified_at`** stays null until **`POST /v1/public/email-verification/confirm`**; verification email via **Resend** when configured. Disabled when **`MERCHANT_SIGNUP_ENABLED`** is **`0`**/**`false`**/**`off`**. Honeypot **`website`** must be empty. Per-IP rate limit.
POST/v1/public/loginEmail + password → merchant **`access_token`** + **`refresh_token`** (same shape as **`POST /v1/auth/token`**). Uses the **oldest active** integration key for that merchant. When **`MERCHANT_SIGNUP_REQUIRE_EMAIL_VERIFICATION=1`**, **403** **`email_not_verified`** until the address is confirmed. Requires **`MERCHANT_JWT_SECRET`**. Per-IP rate limit.
GET/v1/public/auth/merchant-oidc/status**E1-6** — **`{ "sso_available": boolean }`** — whether **`GET …/merchant-oidc/login`** would **302** (JWT + OIDC env) vs **503**. No auth; per-IP rate limit.
GET/v1/public/auth/merchant-oidc/login**E1-6** — Start merchant SSO (**OIDC**). Redirects to IdP when **`MERCHANT_OIDC_ISSUER`**, **`MERCHANT_OIDC_CLIENT_ID`**, **`MERCHANT_OIDC_CLIENT_SECRET`**, **`MERCHANT_OIDC_REDIRECT_URI`** are set. Per-IP rate limit.
GET/v1/public/auth/merchant-oidc/callback**E1-6** — OIDC redirect handler. Maps IdP **email** → **`users`** + oldest active **`integration_api_keys`**; returns JWT pair (same as **`POST /v1/public/login`**) unless **`MERCHANT_OIDC_WEB_SUCCESS_URL`** → **302** with **`#ideliver_merchant_oidc=1&access_token=…`**.
POST/v1/public/password-reset/requestRequest password reset (**no auth**). Always **202** `{ ok: true }` (no email enumeration). If a **users** row with a password exists, stores a one-hour token and emails a link when **`RESEND_API_KEY`** + **`PASSWORD_RESET_FROM_EMAIL`** are set. Link base: **`PASSWORD_RESET_PUBLIC_BASE_URL`**. **`PASSWORD_RESET_RETURN_TOKEN=1`**: includes **`dev_only_reset_token`** (never in prod). Optional honeypot **`website`** empty.
POST/v1/public/password-reset/confirmComplete reset with **token** from email (or dev response) and new **password** (**≥ 10** chars). Invalid/expired → **400** / **410**.
POST/v1/public/email-verification/confirmConfirm signup email with **token** from **`#verify-email?token=`** link (48h). Sets **`users.email_verified_at`**.
POST/v1/public/email-verification/resendRequest a new verification email (**202** always). Only sends if an **unverified** password user exists for that email. **Resend** + from address required to actually email.
GET/v1/system/capture-configE1-8 OTA capture config. Default **`ota_capture.enabled: false`**. **`OTA_CAPTURE_ENABLED=1`** + optional **`OTA_CAPTURE_ED25519_PUBLIC_KEY_B64`** for Phase 2 listeners. **`package_allowlist`** from Redis when **`CAPTURE_CONFIG_ALLOWLIST_FROM_REDIS=1`**. Per-IP rate limit.
GET/v1/ordersList orders for the merchant (`orders:read` or `orders:write`). Query: **`limit`**, **`offset`**, **`source`**, optional **`external_order_id`** (exact match; max 512 chars). Optional **`is_test`** (**`true`/`1`** or **`false`/`0`**) — filter dual-test rows on **`orders.is_test`**. Optional **`status`** (exact match on **`unified_json.status`**, e.g. **`delivered`**) and **`payment_verified`** (**`true`/`1`** = verified only; **`false`/`0`** = missing or false — **E7-4** / commission escrow queue). When either JSON filter is used, list rows omit PostGIS **`pickup_geo`** / **`dropoff_geo`** (**`null`**). Each **`order`** includes **`isTest`**. Optional **`X-Ideliver-Act-As-Merchant`** — see parameter ref.
POST/v1/ordersCreate or return existing order. Natural idempotency: `(source, external_order_id)`. Optional `Idempotency-Key` header (≤255 chars): replays same response for identical body; **409** if key reused with different body. **`orders:write`**; when **`ORDER_POST_REQUIRES_KEYS_MANAGE=1`**, also requires **`keys:manage`**. When **`BILLING_ENFORCEMENT_ENABLED=1`**, **403** `billing_blocked` if **`past_due`**, **`canceled`**, or **`compliance_hold`**. When pickup/dropoff coordinates are present, **402** **`insufficient_wallet_for_freelancer_order`** if merchant wallet is below the **dynamic** quoted delivery fee (**`required_minor`**, **`balance_minor`**); without coordinates, same **402** when balance is below static **₦500** floor (non-test credentials). **400** **`OUT_OF_COVERAGE_AREA`** when pickup is outside zones or no rate card applies. Optional Act-As header — **`#/components/parameters/ActAsMerchantId`**.
POST/v1/test/simulator/orders**Dual test / web simulator** — create a tenant-scoped order with **`is_test = true`** (no partner webhook). Auth: **`Bearer ilv_…`** must be a **test** integration key (**`integration_api_keys.is_test`**) with **`orders:write`**, **or** a merchant access JWT with **`orders:write`** (dashboard). Disabled when **`SIMULATOR_ENABLED`** is **`0`/`false`/`no`/`off`** (**503**).
POST/v1/test/simulator/rider-action**Dual test / web simulator** — advance a **test** order through accept → pickup → deliver; fires the same merchant outbound webhooks as live. Target order must have **`is_test = true`** (**403** **`test_mode_required`** otherwise). Auth same as **`POST /v1/test/simulator/orders`**.
GET/v1/orders/{id}Get one order by id (merchant-scoped)
PATCH/v1/orders/{id}Update **`unified_json.status`** and/or **`unified_json.payment_verified`** (at least one field required). Use **`payment_verified: true`** in the same request as **`status: delivered`** when **`RIDER_COMMISSION_REQUIRES_PAYMENT_VERIFIED=1`**, or follow up with a **`payment_verified`-only** patch after delivery — otherwise commission stays in escrow hold. **`orders:write`** (and **`keys:manage`** too when **`ORDER_STATUS_PATCH_REQUIRES_KEYS_MANAGE=1`**). **`RIDER_COMMISSION_AUTOMATION_ENABLED`**: transition **into** a trigger status (default **`delivered`**) may credit **`standardized.rider_id`** via **`applyRiderLedgerDelta`** (amount from **`standardized.delivery_commission_minor`**, merchant **`commission_rules_json`**, or **`RIDER_DEFAULT_COMMISSION_MINOR`**). **`ORDER_DELIVERY_COMMISSION_DEBIT_MERCHANT_ENABLED`** (default on): same-TX **`merchant_wallets`** debit — **409** **`insufficient_merchant_wallet`** when balance would go negative. Commission + order update share one transaction. **`RIDER_COMMISSION_TRIGGER_STATUSES`** comma-list. **`BILLING_ENFORCEMENT_ENABLED`** same as **`POST /v1/orders`**. Optional Act-As: **`#/components/parameters/ActAsMerchantId`**.
GET/v1/orders/{id}/dispatch-candidates**E7-1** — riders with live **`current_location`** within **`metres`** of **`lat`/`lng`**, sorted by **geodesic distance** (PostGIS). Optional load-aware ranking: when **`DISPATCH_ACTIVE_ORDER_PENALTY_METRES` > 0**, each non-terminal assigned order adds that many **virtual metres** to the sort key (**`score_m`**). Requires **`orders:read`**. Query **`lat`**, **`lng`** (WGS84); optional **`metres`** (default **5000**, max **100000**), **`limit`** (default **20**, max **50**). **`order_id`** must exist for the tenant (UI anchor).
POST/v1/orders/{id}/rider-assignment**E7-2** — manual dispatch: set or clear **`unified_json.standardized.rider_id`**. Body **`rider_id`**: UUID or **`null`** (unassign). Same auth as **`PATCH /v1/orders/{id}`** (**`orders:write`** + optional **`keys:manage`** when **`ORDER_STATUS_PATCH_REQUIRES_KEYS_MANAGE=1`**). **E1-7** — on change, schedules **`order.rider_assigned`** (**`previous_rider_id`** + order envelope).
POST/v1/orders/{id}/cancelMerchant-scoped cancel — sets **`cancelled_at`**, merges **`unified_json.status`** to **`cancelled`**, emits tracking + **`order.status_updated`** outbound (same rules as **`POST /v1/admin/orders/:orderId/cancel`**: default blocks only terminal **`delivered`** / **`returned_to_merchant`** / **`lost_in_transit`**; **`ADMIN_ORDER_CANCEL_PRE_PICKUP_ONLY=1`** blocks from **`picked_up`** onward). **`orders:write`** (and **`keys:manage`** when **`ORDER_POST_REQUIRES_KEYS_MANAGE=1`**). Optional Act-As: **`#/components/parameters/ActAsMerchantId`**.
GET/v1/shipmentsList shipments for the authenticated merchant. Query: **`limit`** (1–100, default 20), **`offset`** (≥0), optional **`status`** (draft/submitted/dispatching/completed/cancelled). Requires merchant JWT.
POST/v1/shipments/bulk-jsonCreate a bulk shipment from a JSON payload. Body: **`pickup`** (`lat`, `lng`, `address`) + **`orders`** array (up to 5000 items with `recipient_name`, `recipient_phone`, `dropoff_address`, optional `dropoff_lat`/`dropoff_lng`/`package_description`). Requires an active **`MerchantRateCard`**. Returns **`shipment_id`**, **`total_orders`**, **`estimated_fee_minor`**.
POST/v1/shipments/upload-csvUpload a CSV file to create a bulk shipment. Multipart form: **`file`** (CSV with header `recipient_name,recipient_phone,dropoff_address` + optional `dropoff_lat,dropoff_lng,package_description`), optional **`pickup`** (JSON string with `lat`, `lng`, `address`). Max 5000 rows. Returns **`shipment_id`**, **`total_orders`**, **`estimated_fee_minor`**, plus **`rows_parsed`**, **`rows_failed`**, and per-row **`errors`**.
GET/v1/shipments/{id}Get a single shipment by ID (merchant-scoped). Includes batch summary, rate card snippet, and child order count. Requires merchant JWT.
POST/v1/shipments/{id}/submitSubmit a draft shipment for dispatch. Runs the bulk wallet gate (**`validateMerchantWalletForBulkShipment`**) and inserts a bulk escrow hold (**`insertShipmentEscrowHold`**). Returns 402 if wallet balance is insufficient (non-SaaS merchants). Requires merchant JWT.
POST/v1/shipments/{id}/cancelCancel a draft shipment and all its child orders. Sets `cancelled_at` and `cancellation_reason = 'shipment_cancelled'` on every child order via raw SQL. Requires merchant JWT.
GET/v1/riders/me/dispatch-offers**E7-2** — Pending **`order_dispatch_offers`** for the **`RiderAccessJwt`** subject (**`expires_at` > now**, **`status=pending`**). Used with **`AUTO_DISPATCH_OFFER_FLOW_ENABLED`** auto-dispatch ping-and-accept.
POST/v1/riders/me/dispatch-offers/{offerId}/accept**E7-2** — Accept a pending offer → runs **`assignRiderToOrder`** for the offer's **`order_id`** (**same wallet / routing fee rules** as merchant **`POST /v1/orders/:id/rider-assignment`**).
POST/v1/riders/me/dispatch-offers/{offerId}/decline**E7-2** — Decline a pending offer (**`status` → `declined`**); order may receive another offer on a later tick.
GET/v1/riders/me/orders**Track C-2** — assigned orders for the **`RiderAccessJwt`** subject: **`unified_json.standardized.rider_id`** = token **`sub`**, scoped to the token **`mid`**. Query **`limit`** (≤ 200, default 50), **`offset`**, optional **`status`** (exact match on **`unified_json.status`**, max 128 chars). Response same shape as merchant **`GET /v1/orders`** (**`orders`**, **`total`**, **`limit`**, **`offset`**).
GET/v1/riders/me/ledger**Track C-8** — rider **`rider_wallets`** snapshot + **`rider_ledger_transactions`** (same JSON shape as **`GET /v1/admin/riders/{riderId}/ledger`**). Query **`limit`** (≤ 200, default 50), **`offset`**.
GET/v1/riders/me/payout-instrumentsList **`payout_instruments`** for the rider’s **`users`** row (masked **`account_last4`**; no full account number). Bearer **rider JWT**.
POST/v1/riders/me/payout-instrumentsCreate Paystack transfer recipient + **`payout_instruments`** row; triggers **24h withdrawal freeze** + email/SMS alert. Uses **`PLATFORM_PAYSTACK_SECRET_KEY`** (**`paystack_not_configured`** when unset).
PATCH/v1/riders/me/payout-instruments/{id}Set default payout instrument (**`is_default: true`** only). Triggers freeze + alert (same as merchant path).
GET/v1/riders/me/withdrawalsRider **withdrawal** history. Requires **`WITHDRAWAL_REQUESTS_ENABLED=1`**. Bearer **rider JWT**.
POST/v1/riders/me/withdrawals/requestCreate **`pending_review`** rider withdrawal; **`withdrawal_reserve`** ledger line. Optional **`Idempotency-Key`**. **`merchant_id`** on the row is the rider’s fleet tenant when present.
POST/v1/riders/me/uploads/pod-photo-presign**Track C-6** — Presigned **PUT** URL for POD proof photo (**JPEG** / **PNG** / **WebP**). Flutter uploads bytes to **`upload.url`** with **`upload.headers`**, then sends **`storage_key`** from this response in **`POST .../pod-otp/verify`** **`photo`**. Requires **`RIDER_POD_UPLOAD_S3_BUCKET`** (or **`RAW_EML_S3_BUCKET`**) + AWS credentials / compatible endpoint.
POST/v1/riders/me/orders/{orderId}/pod-otp/request**Track C-6** — POD: send **6-digit OTP** SMS to **`unified_json.standardized.customer_phone`** via **`OUTBOUND_SMS_PROVIDER`**. Order must be assigned (**`standardized.rider_id`** = JWT **`sub`**, tenant **`mid`**). **`RIDER_POD_OTP_COOLDOWN_SEC`** (default **60**, **0** = off). **`RIDER_POD_OTP_PEPPER`** (optional; falls back to **`RIDER_PHONE_OTP_PEPPER`** / **`MERCHANT_JWT_SECRET`**). Does **not** gate on **`payment_verified`**.
POST/v1/riders/me/orders/{orderId}/pod-otp/verify**Track C-6** — verify OTP; writes **`unified_json.pod`**. Optional **`RIDER_POD_VERIFY_SETS_STATUS`** (e.g. **`delivered`**); if unset and both **`RIDER_COMMISSION_AUTOMATION_ENABLED`** and **`RIDER_COMMISSION_REQUIRES_PAYMENT_VERIFIED`** are on, defaults to **`delivered`**. **E7-6:** when **`RIDER_COMMISSION_REQUIRES_PAYMENT_VERIFIED=1`**, successful verify sets **`unified_json.payment_verified`** so delivery commission + escrow release can run. Same commission path as **`PATCH /v1/orders/:id`**; **`order.status_updated`** outbound when **`unified_json.status`** changes.
POST/v1/riders/me/sos**Track C-7** — SOS / panic: creates **`rider_sos_alerts`**, optional SMS to **`RIDER_SOS_NOTIFY_E164`** (comma E.164) via **`OUTBOUND_SMS_PROVIDER`**. Throttle **`RIDER_SOS_COOLDOWN_SEC`** (default **120**; **`0`** disables). Body: optional **`lat`**+**`lng`** (both or neither), optional **`note`** (≤500).
POST/v1/riders/{riderId}/locationRider GPS: append **`fleet_location_log`** + **`riders.current_location`**. **(1)** **`ilv_…`** or **merchant access JWT** (Bearer) with **`riders:location`** or **`orders:write`** (and **`keys:manage`** too when **`RIDER_LOCATION_POST_REQUIRES_KEYS_MANAGE=1`**), or **(2)** **`RiderAccessJwt`** (rider tokens never require **`keys:manage`**). **`BILLING_ENFORCEMENT_ENABLED`**: **403** `billing_blocked` for both auth modes.
PATCH/v1/riders/{riderId}/onlineSet **`riders.is_online`**. Same auth + **`RIDER_LOCATION_POST_REQUIRES_KEYS_MANAGE`** as **`POST …/location`** (no **`billing_blocked`** on this route so riders can turn **off** when billing is inactive). When **`RIDER_ONLINE_REQUIRES_KYC_APPROVED=1`**, **`is_online: true`** returns **403** **`kyc_not_approved`** unless **`kycStatus`** is **`approved`**. **`is_online: false`** returns **409** **`active_order`** if the rider still has a non-terminal order assigned (**`unified_json.standardized.rider_id`**).
POST/v1/riders/{riderId}/phone-otp/requestRequest SMS OTP for rider **`users.phone_e164`** (**Termii** or **Ebulksms** via **`OUTBOUND_SMS_PROVIDER`**). Same auth as **`POST …/location`** but **no** **`keys:manage`** / **no** billing. **503** **`outbound_sms_not_configured`**; **429** **`otp_cooldown`**; **409** **`phone_in_use`**.
POST/v1/riders/{riderId}/phone-otp/verifyVerify OTP; sets **`users.phone_e164`** + **`phone_verified_at`**. Same auth as **request**.
GET/v1/me/api-keysList API keys (includes **`enterprise_tier`**, read-only; only admin **PATCH** can change it)
POST/v1/me/api-keysCreate API key (**keys:manage**). If caller key has **`enterprise_tier`**, new key may request any assignable scopes (not only a subset). **Sandbox:** body **`is_test: true`** mints a test key (orders tagged **`is_test`**) and is allowed before KYB / production approval unless billing is **`canceled**`. Omit **`is_test`** or **`false`** for production keys (KYB + business approval required).
POST/v1/me/api-keys/{keyId}/rotate**E1-6** — New random secret for the same key **`id`** (`ilv_<id>_<new>`). Old material invalid immediately; **`api_key.rotate`** audit. Optional body `{ "label"?: string }` updates label. **keys:manage** required. Merchant JWTs for this **`apiKeyId`** still validate until expiry (key not revoked).
DELETE/v1/me/api-keys/{keyId}Revoke an API key
POST/v1/admin/auth/tokenMint admin **`access_token`** (JWT). Requires **`ADMIN_API_TOKEN`** on the request and **`ADMIN_JWT_SECRET`** on the server (min 16 chars each). Use **`Authorization: Bearer <access_token>`** (or keep static token) on other **`/v1/admin/*`** routes.
POST/v1/admin/rbac/tokenMulti-admin RBAC login when **`ADMIN_RBAC_ENABLED=1`**. **`email`** + **`password`** match **`admin_identities`** (scrypt-hashed). Returns JWT for **`Authorization: Bearer`** on **`/v1/admin/*`** (signed with **`ADMIN_JWT_SECRET`**).
GET/v1/admin/rbac/custom-roles**B6** — list **`admin_custom_roles`** with permission keys when **`ADMIN_CUSTOM_ROLES_ENABLED=1`** + **`ADMIN_RBAC_ENABLED=1`**. Requires RBAC **`admin.rbac.manage`**.
POST/v1/admin/rbac/custom-rolesCreate **`admin_custom_role`** + join rows (**`admin_custom_role_permissions`**). **`permission_keys`** must exist in **`admin_permissions`**.
PATCH/v1/admin/rbac/custom-roles/{roleId}Update label and/or replace **`permission_keys`** for a custom role.
DELETE/v1/admin/rbac/custom-roles/{roleId}Delete custom role (identities should be reassigned first).
PATCH/v1/admin/rbac/custom-roles/identities/assignmentSet or clear **`admin_identities.custom_role_id`** by email — user must **re-login** to refresh JWT **`permissions`**.
GET/v1/admin/auth/oidc/loginStart admin OIDC (**`ADMIN_OIDC_*`** + **`ADMIN_RBAC_ENABLED=1`**). Redirects (**302**) to IdP authorization endpoint (PKCE). Requires existing **`admin_identities`** row for IdP email after callback.
GET/v1/admin/auth/oidc/callbackOIDC redirect URI handler — exchanges code, loads **`admin_identities`** by email. Returns JSON like **`POST /v1/admin/rbac/token`** (+ **`auth: oidc`**) unless **`ADMIN_OIDC_WEB_SUCCESS_URL`** is set → **302** to that URL with **`#ideliver_oidc=1&access_token=…`** (SPA reads hash).
GET/v1/admin/audit-logsList audit log entries. Query: **`limit`** (≤ 200, default 50), **`offset`**, optional **`merchant_id`** (UUID), optional **`action`**, optional **`resource_type`**, optional **`actor_type`** (max 64 chars). Response: **`logs`**, **`total`**, **`limit`**, **`offset`**. OIDC logins: filter **`action`** **`admin.auth.oidc_login`** / **`admin.auth.oidc_denied_group`**.
GET/v1/admin/otp-send-logsList platform OTP send attempts (plaintext code + destination) for ops rescue when SMS fails. **RBAC** **`admin.otp_logs.read`**. Query: **`limit`** (≤ 200, default 50), **`offset`**, optional **`phone`** (digits, ≥4 for filter), optional **`order_id`**, optional **`channel`** (**`rider_phone`** | **`delivery_on_order_create`** | **`pod_rider_request`**).
GET/v1/admin/audit-logs/export**E4-4** — download **NDJSON** (**`application/x-ndjson`**) for SIEM / retention. Same filters as **`GET /v1/admin/audit-logs`**; **`limit`** default **2000**, max **5000**.
GET/v1/admin/attachment-ocr-jobs**P2-G G2** — list **`attachment_ocr_queue_jobs`** (async Resend attachment OCR). RBAC **`admin.ingestion.read`**. Optional **`merchant_id`**, **`limit`** (default **50**, max **200**).
POST/v1/public/riders/fleet-invite/redeemRedeem a fleet invite code. Creates or attaches a User + Rider with rider_type=merchant_fleet scoped to the issuing merchant. Returns opaque 200 ok:false for unknown/expired codes (anti-enumeration). No auth required.
POST/v1/public/riders/register/startStart freelancer registration. Creates or returns a Rider row with rider_type=freelancer, no merchantId. Follow up with POST /v1/public/riders/phone-otp/request to verify phone. No auth required.
GET/v1/me/fleet/invite-codesList active (non-revoked, non-expired) fleet invite codes for the authenticated merchant. Bearer merchant access JWT.
POST/v1/me/fleet/invite-codesGenerate a new fleet invite code. Codes are 8-char uppercase alphanumeric. Default expiry: 72 hours. Bearer merchant access JWT.
DELETE/v1/me/fleet/invite-codes/{codeId}Revoke a fleet invite code (soft-delete). Cannot be un-revoked. Bearer merchant access JWT.
GET/v1/me/fleet/ridersList merchant_fleet riders linked to the authenticated merchant. Bearer merchant access JWT.
DELETE/v1/me/fleet/riders/{riderId}Deactivate a fleet rider (unlinks rider from merchant by clearing merchantId and revoking access tokens). Bearer merchant access JWT.
GET/v1/me/billing-usageHybrid billing usage for the current cycle. Returns internal delivery counter vs included allowance, overage fee rate, cycle end date, usage %, and over-allowance flag. Only meaningful when hybridBillingConfig is set; included_allowance is null for non-hybrid merchants. Bearer merchant access JWT.
GET/v1/admin/merchantsE1-6 — list merchants (`limit` ≤ 500, **`offset`**) with **E2-6** billing summary fields. Optional **`q`** — case-insensitive substring match on **`name`** (max 128 chars). **P2-C / C4** — optional **`kyb_status`** exact match (**`merchants.kyb_status`**, max 32 chars).
POST/v1/admin/merchants/whatsapp-engine-bulkSet **`merchants.whatsapp_engine`** for **all** merchants at once (**Termii**, **Meta Cloud**, or **shared global**). Body must include **`confirmation`**: **`UPDATE_ALL_MERCHANTS`**. When target is not **`meta_cloud`**, clears **`meta_waba_id`**, **`meta_phone_number_id`**, **`meta_system_user_token`** (same as per-merchant **`PATCH`**). **Audit** `admin.merchants.whatsapp_engine_bulk`.
GET/v1/admin/merchants/business-approval-queue**P2-C** — merchants awaiting **live / production** approval after KYB **`verified`**, or rejected queue. Static path so **`business-approval-queue`** is not parsed as **`{merchantId}`**.
GET/v1/admin/merchants/{merchantId}Single merchant — **E2-6** billing + **E1-7** outbound callback URL / enabled / **`outboundWebhookSecretConfigured`** (**no** raw inbound or outbound secrets). **D-9 / C4** — includes **`kybStatus`**, **`kybSubmittedAt`**, **`kybNotes`**, **`paystackKybReference`**; **`whatsappEngine`**, **`metaWabaId`**, **`metaPhoneNumberId`**, **`metaSystemUserTokenConfigured`** (no raw token); set **`include_kyb_payload=1`** to add **`kybPayload`** for ops export.
PATCH/v1/admin/merchants/{merchantId}E2-6 / **E1-7** / **E7-2** / **D-9** — break-glass **billing / tier**, optional **`auto_dispatch_enabled`**, **enterprise outbound webhooks**, and optional **KYB** fields (`kyb_status`, `kyb_notes`, `paystack_kyb_reference`). Body: same E2-6 fields; optional **`outbound_webhook_url`** (HTTPS URL or **`null`**), **`outbound_webhook_secret`** (≥16 chars, HMAC **`v1`**, or **`null`**), **`outbound_webhook_enabled`**. Events: **`order.created`**, **`order.status_updated`**, **`order.rider_assigned`** (**`OUTBOUND_WEBHOOKS_ENABLED`** kill switch). **Audit** `merchant.billing_patch`.
GET/v1/admin/merchants/{merchantId}/riders/nearDispatch prototype — rider UUIDs with **`current_location`** within **`metres`** of WGS84 **`lat`**, **`lng`** (**PostGIS `ST_DWithin`**, GiST). Query **`metres`** optional (default **5000**, max **100000**).
GET/v1/admin/merchants/{merchantId}/dispatch-zones**B10** — read **`merchant_dispatch_zones`** (GeoJSON boundaries) for support / **`web-admin`**; same payload shape as **`GET /v1/me/dispatch/zones`** (wrapped with **`merchant_id`**).
GET/v1/admin/merchants/{merchantId}/dispatch-schedule**B10** — read **`auto_dispatch_schedule_timezone`** + **`merchant_dispatch_schedule_windows`** (snake_case window fields).
GET/v1/admin/merchants/{merchantId}/usersTenant **`users`** for support (`limit` ≤ 200, default 50; **`offset`**). Each row may include nested **`rider`** (`id`, **`kycStatus`**, **`isOnline`**) when a rider profile exists.
GET/v1/admin/merchants/{merchantId}/outbound-webhook-deliveriesE1-7 — list persisted **outbound webhook** deliveries (**`order.created`** / **`order.status_updated`**) for admin inspection; newest first.
POST/v1/admin/outbound-webhook-deliveries/{deliveryId}/retryE1-7 — re-queue a **dead** or **pending** row (**resets** attempts). **400** if **`succeeded`**. **Audit** **`outbound_webhook.delivery_retry`**.
GET/v1/admin/contact-inquiriesD-8 — **`contact_inquiries`** from **`POST /v1/public/contact`**, self-serve signup, commission dispute intake, and WhatsApp AI escalation. Query: **`limit`** (≤ 100, default 30), **`offset`**, optional **`email`**, optional **`source`** (**`web_marketing`** | **`signup_request`** | **`self_serve_signup`** | **`commission_dispute`** | **`merchant_dashboard`** | **`whatsapp_ai_escalation`**), optional **`review_status`** (**`open`** | **`reviewed`** | **`archived`**).
PATCH/v1/admin/contact-inquiries/{inquiryId}D-8 — update **`review_status`** on a **`contact_inquiries`** row (**`open`** | **`reviewed`** | **`archived`**). Appends **`audit_logs`** (**`contact_inquiry.review_status_patch`**) when the value changes; no audit row if already that status.
GET/v1/admin/merchants/{merchantId}/ledgerE2-5 — merchant wallet snapshot + recent `ledger_transactions`. Query: **`limit`** (≤ 200, default 50), **`offset`**. Response: **`items`**, **`total`**, **`limit`**, **`offset`**, **`wallet`**, **`merchant_id`** (amounts as decimal strings).
GET/v1/admin/sos-alerts**Track C-7** — list **`rider_sos_alerts`** (newest first). Query **`limit`**, **`offset`**, optional **`merchant_id`**, **`rider_id`**. RBAC **`admin.riders.read`**.
GET/v1/admin/ridersCross-tenant **rider list**. Query: **`sort`** — **`created_desc`** (default), **`created_asc`** (oldest first, KYC backlog), **`updated_desc`**, **`updated_asc`**; **`limit`** (1–200, default 50), **`offset`**, optional **`merchant_id`**, **`kyc_status`** (max 32 chars), **`is_online`**. Each row includes **`user`** (**`email`**, **`phoneE164`**, **`displayName`**) and **`merchant`** (`id`, `name`) when linked.
GET/v1/admin/riders/{riderId}Cross-tenant **rider** by id: **`merchant`** (when linked), flat **`user`** identity, and **`rider`** with KYC metadata + presigned document URLs + nested **`user.payoutInstruments`** (bank / account for review).
PATCH/v1/admin/riders/{riderId}**KYC / online** — set **`kyc_status`** (`pending` | `approved` | `rejected`) and/or **`is_online`**. Non-**`approved`** **`kyc_status`** forces **`is_online`** false. Requires **`admin.riders.write`** when **`ADMIN_RBAC_ENABLED`**. **`audit_logs`**: **`rider.admin_patch`**.
POST/v1/admin/riders/{riderId}/revoke-access-tokensInvalidate all rider access JWTs for this **`riderId`** by incrementing **`access_token_version`** (JWT **`tv`** must match). **`audit_logs`**: **`rider.revoke_access_tokens`**.
GET/v1/admin/riders/{riderId}/ledgerE2-5 — rider wallet snapshot + recent `rider_ledger_transactions`. Query: **`limit`** (≤ 200, default 50), **`offset`**. Response: **`items`**, **`total`**, **`limit`**, **`offset`**, **`wallet`**, **`rider_id`** (amounts as decimal strings).
GET/v1/admin/platform/walletE2 — singleton **platform clearing** wallet (minor units string). Contra lines when **`LEDGER_DOUBLE_ENTRY_ENABLED`**.
GET/v1/admin/platform/ledgerE2 — **`platform_ledger_transactions`** (contra to merchant/rider wallet lines). Query: **`limit`** (≤ 200, default 50), **`offset`**. Response: **`items`**, **`total`**, **`limit`**, **`offset`**, **`wallet_id`**. Lines include **`contra_kind`**, **`contra_ledger_tx_id`**, **`merchant_id`** / **`rider_id`**.
GET/v1/admin/escrow-ledger/summary**E7-4** — Escrow ledger **analytics**: **`by_entry_type`** (**count**, **`sum_amount_minor`**) in a time window; **`open_holds_count`** (**`commission_hold`** rows with no **`commission_hold_void`**). Query: optional **`merchant_id`**, **`from`** / **`to`** (ISO **date-time**; default **30** days). Requires **`admin.orders.read`**.
GET/v1/admin/escrow-ledger/export**E7-4** — **BI / warehouse** export: ledger rows in **`[from, to]`** (default **30** days), optional **`merchant_id`**, **`order_id`**, **`entry_type`**. **`format=json`** (default) or **`format=ndjson`** (**`application/x-ndjson`** attachment). **`limit`** default **5000**, max **10000**. Requires **`admin.orders.read`**.
POST/v1/admin/escrow-ledger/void**E7-4** — Append **`commission_hold_void`** | **`commission_released_void`**. Target must be **`commission_hold`** | **`commission_released`**. When **`ORDER_ESCROW_RELEASE_VOID_WALLET_REVERSAL_ENABLED`** (default on), **`commission_released_void`** debits **`rider_wallet`** by **`amount_minor`** in the same transaction. Requires **`admin.orders.write`**; **`audit_logs`**: **`order_escrow.void`**.
POST/v1/admin/escrow-ledger/adjust**E7-4** — Append **`commission_hold_adjust`** | **`commission_released_adjust`** (signed **`amount_delta_minor`**). Parent must be **`commission_hold`** | **`commission_released`**. Requires **`admin.orders.write`**; **`audit_logs`**: **`order_escrow.adjust`**.
GET/v1/admin/escrow-ledger**E7-4** — Cross-tenant **commission escrow** ledger. Query: **`limit`**, **`offset`**, optional **`merchant_id`**, **`order_id`**, **`entry_type`** (all **`commission_*`** kinds). Requires **`admin.orders.read`**.
GET/v1/admin/ordersCross-tenant **order list** (newest first). Query: **`limit`** (1–200, default 50), **`offset`**, optional **`merchant_id`** (UUID), optional **`source`**, optional **`external_order_id`** (exact match; use with **`merchant_id`** + **`source`** for tenant idempotency lookup), optional **`payment_verified`** (**`true`/`1`** = verified only; **`false`/`0`** = missing or false), optional **`status`** (exact match on **`unified_json.status`**, max 128 chars). Only orders whose **`merchants`** row exists are listed (inner join — skips orphan FK edge cases). Rows are summaries (**no** full **`unified_json`**); include **`status`** and **`payment_verified`** from JSON, **`customer_tracking_url`** when configured.
GET/v1/admin/orders/delivery-commission-gapsSupport — orders in **`RIDER_COMMISSION_TRIGGER_STATUSES`** with **no** rider **`order_delivery_commission`** ledger line (excludes **`internal_fleet`**, test orders). Query **`explain`**: default **`1`** — per-row inferred skip reason; **`0`** faster. **`admin.orders.read**.
GET/v1/admin/orders/{orderId}/delivery-commission-reviewSupport — full delivery-commission review: merchant, rider, amounts, **`standardized`** / **`pod`**, wallet balance, inferred skip reason. **`admin.orders.read**.
POST/v1/admin/orders/{orderId}/delivery-commission-forceSupport — force apply delivery commission after review (**`confirm`**: **`true`**). When payment not verified and escrow requires it, **`acknowledge_payment_unverified`**: **`true`**. **`admin.orders.write`** — audit **`order.delivery_commission_admin_force`**.
GET/v1/admin/dashboard/summaryWar Room **KPI snapshot** — orders in flight, pending KYC, **online** riders, merchants **KYB** **`pending_review`**, platform wallet, dead outbound webhooks (**24 h**), unresolved ingestion DLQ, SOS (**24 h**). Requires **`admin.orders.read`** + **`admin.platform.read`** when RBAC is on.
GET/v1/admin/metrics/orders-ingestion-timeseries**B10** — Hourly **order ingest** counts by **`orders.source`** (UTC buckets) for War Room charts. Query **`hours`**: **1…168**, default **24**. **`admin.orders.read`**.
GET/v1/admin/metrics/ingest-health**A-1** — Per-**`source`** ingest health: **`orders`** created in the window, **`last_order_at`**, and **`ingestion_dlq`** counts split into **mapping** vs **persist** failures (**`error_message`** prefixes). Includes **`observability`** (partner webhook **`source`** names **`bumpa`** / **`catlog`** / **`qshop`** + runbook paths). Query **`hours`**: **1…168**, default **24**. **`admin.orders.read`**.
GET/v1/admin/metrics/operator-dashboard**E7-4** — Operator dashboard (**HTTP** + optional Socket **`operator_dashboard`**) — War Room **`war_room`**, **30 d** **`escrow`**, **`auto_dispatch`**, **24 h** order status mix + rider assignment counts, **`normalization_jobs_pending`**. Optional **`merchant_id`** scopes tenant drill-down (**`filter_merchant_id`** in body). Socket **`operator_dashboard`** stays fleet-wide. Requires **`admin.orders.read`** + **`admin.platform.read`** under RBAC.
GET/v1/admin/metrics/operator-dashboard/war-room**E7-4** — Operator dashboard **War Room** slice only. Same **`?merchant_id=`** tenant drill-down and **`404 unknown_merchant`** as **`GET /v1/admin/metrics/operator-dashboard`**. **`admin.orders.read`** + **`admin.platform.read`**.
GET/v1/admin/metrics/operator-dashboard/escrow**E7-4** — **30 d** escrow rollup only. Same **`?merchant_id=`** as full operator dashboard.
GET/v1/admin/metrics/operator-dashboard/operations**E7-4** — Auto-dispatch Redis counters, **24 h** order mix, **`normalization_jobs_pending`** only.
GET/v1/admin/metrics/operator-dashboard/billing**E7-4** — Paystack billing webhook + invoice-fact counters (**24 h**) only.
GET/v1/admin/metrics/orders-analytics-timeseries**B10** — Order volume by **UTC hour** or **UTC day**; **`group_by`** **`source`** / **`status`** / **`none`**. Query **`granularity`**: **`hour`** (default) or **`day`**; **`hours`**: **1…168** (hour mode, default **24**); **`days`**: **1…90** (day mode, default **7**). **`admin.orders.read`**.
GET/v1/admin/metrics/billing-events-timeseries**B10** — Paystack billing metrics: webhook delivery counts (`paystack_billing_webhook_events`) **plus** durable **invoice paid** series from **`paystack_invoice_facts`** (**`paid_at`** per bucket, **`invoice_paid_total`**). **`granularity`**: **`hour`** | **`day`**; **`group_by`**: **`none`** | **`event`**; **`hours`**, **`days`**; optional **`merchant_id`**. **`admin.orders.read`**.
GET/v1/admin/metrics/dispatch-analytics**B10** — Dispatch analytics: orders vs rider assignment, **`escrow_ledger_in_window`** (commission ledger), optional **`dispatch_cost_estimate`** (**`DISPATCH_FINANCE_COST_MODEL=flat_per_assigned_order`** + **`DISPATCH_FINANCE_FLAT_MINOR_PER_ASSIGNMENT`**), auto-dispatch Redis counters, pending normalization jobs. Query **`hours`** **1…168** (default **24**). **`admin.orders.read`**.
GET/v1/admin/orders/exportExport orders (**CSV** or **JSON**) with the **same query filters** as **GET `/v1/admin/orders`**. **`format`**: **`csv`** (default, UTF‑8 with BOM) or **`json`**. **`limit`** default **5000**, max **10 000**.
POST/v1/admin/orders/bulk-patchBatch break-glass **`status`** / **`payment_verified`** updates (**max 50** orders). **`200`** with per-row **`ok`** / errors — **partial success**. **`admin.orders.write`**.
GET/v1/admin/orders/{orderId}Cross-tenant **order** by id (support / DLQ). Only returned if the **`merchants`** row exists (same inner-join rule as **GET** list). **`order`** includes **`customer_tracking_url`** when **`TRACKING_PUBLIC_BASE_URL`** is set.
PATCH/v1/admin/orders/{orderId}D-4 / E7-5 / E7-6 — Break-glass **`unified_json.status`** and/or **`payment_verified`**. RBAC **`admin.orders.write`**. After merge, body must satisfy **`unified_order_v1`**. Rider commission (**`RIDER_COMMISSION_AUTOMATION_ENABLED`**) runs in the same DB transaction as **`order.update`**; **`ORDER_DELIVERY_COMMISSION_DEBIT_MERCHANT_ENABLED`** pairs **`merchant_wallets`** debit (same **minor**) — **409** **`insufficient_merchant_wallet`** when needed. With **`RIDER_COMMISSION_REQUIRES_PAYMENT_VERIFIED`**, verifying payment while already **`delivered`** can accrue. **`order.status_updated`** outbound when **`status`** changes. **`audit_logs`**: **`order.admin_patch`**. Response includes **`commission`** when **`status`** or **`payment_verified`** changed.
POST/v1/admin/orders/{orderId}/resend-extract-raw-emlA-4 — **`email_inbound`** only. When **`RESEND_ADMIN_RAW_EML_EXTRACT_ENABLED=1`**, fetches Resend **`raw.download_url`** (HTTPS) **in memory**, parses **.eml** via **mailparser**, stores only **`eml_extracted_text` / `eml_extracted_html`** under **`raw_payload.resend_received_body`** (no raw byte persistence). Optional body **`raw_download_url`** if the stored URL expired. Re-queues **`normalization_jobs`** to **pending** when a row exists; else enqueues. Audit **`order.resend_raw_eml_extract`**.
GET/v1/admin/system/normalization-configRead-only snapshot: feature flags (**`NORMALIZATION_*`**, NLP sidecar, **`RESEND_*`**, signup, billing, ledger, commission, dispatch, retention, SMS guards, KYC webhook, PATCH policies, capture, Socket.io, BullMQ, outbound webhooks, dedupe) + non-secret counters (NLP merge stats, normalization job completion totals when Redis metrics on). Includes **`admin_jwt_ttl_sec`**, **`admin_oidc_group_role_sync_enabled`** (P2-A). No raw secrets or URLs.
GET/v1/admin/system/audit-s3-export-status**E4-4 / P2-H H1** — Read-only **audit_logs → S3** worker telemetry: env **enabled/configured** bits (same semantics as **`normalization-config`**), effective **batch / interval / lookback** defaults, and the Redis **cursor** **`ideliver:audit_s3_export:last_created_at_iso`** when **`REDIS_URL`** is reachable. Does **not** return bucket names or credentials.
GET/v1/admin/system/compliance-posture**E2-6**, **E4-4/5/6**, **E5**, **E7-3**, **B10** — Read-only **compliance / release-gate** snapshot: PII + retention flags, audit→S3 bits, KYC webhook config presence, Socket.io + Redis adapter intent, Paystack compliance↔billing automation env, finance dispatch estimate preview (**`DISPATCH_FINANCE_*`**), and BullMQ normalization worker **concurrency** hint (**`NORMALIZATION_BULLMQ_WORKER_CONCURRENCY`**). Path pointers only — no secrets.
GET/v1/admin/withdrawalsFinance — list **`withdrawal_requests`**. RBAC **`admin.finance.withdrawals.read`** when **`ADMIN_RBAC_ENABLED`**. Query: **`status`**, **`kind`**, **`merchant_id`**, **`rider_id`**, **`min_amount_minor`**, **`limit`**, **`offset`**.
GET/v1/admin/withdrawals/{id}Finance — withdrawal detail + last **8** wallet ledger lines (merchant or rider). **`admin.finance.withdrawals.read`**.
PATCH/v1/admin/withdrawals/{id}Reject **`pending_review`** or **`awaiting_otp`** withdrawal (releases **`withdrawal_reserve`** when present). Body **`{ "action": "reject", "rejection_reason" }`**. **`admin.finance.withdrawals.write`**.
POST/v1/admin/withdrawals/{id}/approveApprove: **`payout_mode`** **`paystack`** (default) → **`POST https://api.paystack.co/transfer`**; when Paystack requires OTP, status **`awaiting_otp`** then **`POST .../finalize_transfer`**; **`transfer.success`** marks **`paid`** (no second wallet debit if **`withdrawal_reserve`** exists). **`manual`** → **`paid`** / **`provider: manual`** (no Paystack; reserve from request remains the ledger hold). **`admin.finance.withdrawals.write`**.
POST/v1/admin/withdrawals/{id}/finalize-transferPaystack **`POST /transfer/finalize_transfer`** for row in **`awaiting_otp`**. Body **`{ "otp" }`**. On success status → **`processing`**. **`admin.finance.withdrawals.write`**.
POST/v1/admin/comms/termii-smsE5-4 — send one **SMS** via Termii **`/api/sms/send`** (transactional OTP / alerts). Requires **`TERMII_API_KEY`** + **`TERMII_SMS_SENDER_ID`**; optional **`TERMII_SMS_BASE_URL`**, **`TERMII_SMS_CHANNEL`** (**`dnd`** default). **RBAC** **`admin.comms.termii_sms`** when **`ADMIN_RBAC_ENABLED`**. **`audit_logs`**: **`termii.sms_sent`**.
POST/v1/admin/comms/ebulksms-smsE5-4 — send one **SMS** via Ebulksms **`POST …/sendsms.json`**. Requires **`EBULKSMS_USERNAME`**, **`EBULKSMS_API_KEY`**, **`EBULKSMS_SENDER`**. **RBAC** **`admin.comms.termii_sms`** (shared test-send permission). **`audit_logs`**: **`ebulksms.sms_sent`**.
POST/v1/admin/normalize-previewE6 — preview normalization **without** persisting: **`mergeStandardizedFromRaw`** heuristics, then optional **`NORMALIZATION_NLP_SIDECAR_URL`** **`POST /v1/normalize`** (same merge + Zod validation as worker; **no** Nominatim geocode).
GET/v1/admin/normalization-jobsE6 — list `normalization_jobs`. Query: **`limit`** (≤ 200, default 50), **`offset`**, optional **`merchant_id`** (UUID), **`status`**. Response: **`items`** (includes optional **`metadata`** — E6-4 geocode summary / run flags), **`total`**, **`limit`**, **`offset`**. Worker: optional **text extract** (`NORMALIZATION_TEXT_EXTRACT_ENABLED`) + Nominatim geocode (`NORMALIZATION_GEOCODE_*`).
POST/v1/admin/normalization-jobs/{jobId}/retryE6 — reset a **`normalization_jobs`** row to **`pending`**, clear **`metadata`**, and re-notify the optional BullMQ **`ideliver-normalize`** queue. **403** when admin RBAC lacks **`admin.normalization_jobs.retry`**. **400** when the job **`succeeded`**. Audit **`normalization_job.retry`**.
GET/v1/admin/ingestion-dlqList failed ingest rows (DLQ). Query: **`limit`** (≤ 200, default 50), **`offset`**, optional **`merchant_id`** (UUID), **`include_resolved=1`** for history. Response: **`items`**, **`total`**, **`limit`**, **`offset`**.
POST/v1/admin/ingestion-dlq/{id}/retryRetry DLQ row — **shopify**, **paystack** (billing, wallet **`charge.success`**, **`transfer.*` ledger**, or order map + create), **flutterwave**, **woocommerce**, **email_inbound**, **whatsapp_official**, or **whatsapp_baileys** (body must be a full **UnifiedOrderV1** JSON; marks resolved on success)
GET/v1/admin/merchants/{merchantId}/api-keysList keys for merchant (admin). Query: **`limit`** (≤ 200, default 50), **`offset`**. Response: **`keys`**, **`total`**, **`limit`**, **`offset`**, **`merchant_id`**.
POST/v1/admin/merchants/{merchantId}/api-keysCreate API key for merchant (admin). Body may set **`enterprise_tier`** (IP allowlist ignored; child keys may get full assignable scopes).
POST/v1/admin/merchants/{merchantId}/api-keys/{keyId}/rotateAdmin break-glass: rotate integration key secret (same as merchant **rotate**)
PATCH/v1/admin/api-keys/{keyId}E1-6 — set **`enterprise_tier`** boolean on an active key (`api_key.enterprise_tier` audit)
DELETE/v1/admin/api-keys/{keyId}Revoke any API key (admin)
GET/openapi.jsonThis document

Core Endpoints

Create Order

POST/v1/orders
const response = await fetch("https://api.ideliver.ng/v1/orders", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.IDELIVER_API_KEY}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "schema_version": "string",
    "source": "api",
    "external_order_id": "string",
    "raw_payload": {}
})
});

const data = await response.json();
console.log(data);

List Orders

GET/v1/orders
const response = await fetch("https://api.ideliver.ng/v1/orders", {
  method: "GET",
  headers: {
    "Authorization": `Bearer ${process.env.IDELIVER_API_KEY}`,
    "Content-Type": "application/json"
  }
});

const data = await response.json();
console.log(data);

Get Order

GET/v1/orders/{id}
const response = await fetch("https://api.ideliver.ng/v1/orders/{id}", {
  method: "GET",
  headers: {
    "Authorization": `Bearer ${process.env.IDELIVER_API_KEY}`,
    "Content-Type": "application/json"
  }
});

const data = await response.json();
console.log(data);

Cancel order (merchant)

POST/v1/orders/{id}/cancel
const response = await fetch("https://api.ideliver.ng/v1/orders/{id}/cancel", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.IDELIVER_API_KEY}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "reason": "string",
    "notes": "string"
})
});

const data = await response.json();
console.log(data);

Authentication

Get Current Merchant

GET/v1/auth/me
const response = await fetch("https://api.ideliver.ng/v1/auth/me", {
  method: "GET",
  headers: {
    "Authorization": `Bearer ${process.env.IDELIVER_API_KEY}`,
    "Content-Type": "application/json"
  }
});

const data = await response.json();
console.log(data);

Get API Keys

GET/v1/me/api-keys
const response = await fetch("https://api.ideliver.ng/v1/me/api-keys", {
  method: "GET",
  headers: {
    "Authorization": `Bearer ${process.env.IDELIVER_API_KEY}`,
    "Content-Type": "application/json"
  }
});

const data = await response.json();
console.log(data);

Coverage Check

Before showing IDeliver as a delivery option on your checkout page, verify that both pickup and dropoff coordinates fall inside an active dispatch zone.

curl "https://api.ideliver.ng/v1/coverage/check?pickup_lat=6.5244&pickup_lng=3.3792&dropoff_lat=6.4541&dropoff_lng=3.3947" \ -H "Authorization: Bearer ilv_live_..."

Response:

{ "pickup": { "is_covered": true, "zone_name": "Lagos Mainland", "zone_id": "uuid" }, "dropoff": { "is_covered": false, "zone_name": null, "zone_id": null } }

If is_covered is false for either point, do not create the order — it will be rejected with error code OUT_OF_COVERAGE_AREA.


Parcel Dimensions

Include structured parcel dimensions in standardized to enable capacity-aware dispatch. When provided, the API automatically suggests a vehicle type.

FieldTypeDescription
weight_kgfloatParcel weight in kilograms (max 500)
length_cmfloatLength in centimetres (max 500)
width_cmfloatWidth in centimetres (max 500)
height_cmfloatHeight in centimetres (max 500)
is_fragilebooleanFragile flag — upgrades vehicle suggestion
vehicle_type_hintstringAuto-set if omitted: bicycle, motorcycle, car, van, truck

Vehicle suggestion thresholds:

ConditionSuggested vehicle
weight ≤ 5 kg AND all dims ≤ 40 cmmotorcycle (car if fragile)
weight ≤ 25 kgcar
weight ≤ 100 kgvan
weight > 100 kgtruck

Error Codes

HTTPCodeWhen
400OUT_OF_COVERAGE_AREAPickup or dropoff coordinates fall outside all active dispatch zones
400validation_errorRequest body fails schema validation
400missing_pickup_timescheduled_pickup_at not provided
409idempotency_conflictIdempotency-Key reused with different body
409insufficient_merchant_wallet_for_internal_routingWallet below routing micro-fee

Webhooks

Update Webhook Configuration

PATCH/v1/me/outbound-webhook
const response = await fetch("https://api.ideliver.ng/v1/me/outbound-webhook", {
  method: "PATCH",
  headers: {
    "Authorization": `Bearer ${process.env.IDELIVER_API_KEY}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "outbound_webhook_url": {},
    "outbound_webhook_secret": {},
    "outbound_webhook_enabled": false
})
});

const data = await response.json();
console.log(data);

Webhook Envelope & Deduplication

Every webhook delivery includes an event_id (UUID) that stays constant across all retry attempts. Use it for idempotent processing on your server.

{ "event": "order.status_updated", "event_id": "a1b2c3d4-...", "delivery_id": "e5f6g7h8-...", "occurred_at": "2026-04-15T10:30:00.000Z", "merchant_id": "uuid", "order": { ... }, "rider": { ... } }
FieldDescription
event_idUnique per business event occurrence. Same across retries — use for deduplication.
delivery_idUnique per webhook delivery lifecycle. Same across retries.
X-Ideliver-SignatureHMAC-SHA256 signature header (v1=...). Verify with your webhook secret.

Retry policy: up to 5 attempts with exponential backoff (30s, 60s, 2m, 4m, 8m).


Live API Spec

The machine-readable OpenAPI document is available at:

GET https://api.ideliver.ng/openapi.json

Use this to:

  • Import into Postman or Insomnia
  • Generate typed clients in any language
  • Stay in sync with the latest endpoint changes