Business API
Post delivery runs straight from your own shop software. One pickup, as many drops as you like, one price. REST over HTTPS, no SDK required.
Base URL
https://combative-goldfinch-616.convex.siteNote this is not the domain you are reading. The API is served from FIDI's backend host, which is also where the payment webhook lives.
Getting a key
Sign in and open Business API in your dashboard. A key is shown once, when you create it, and is not recoverable — we store only a hash of it. If you lose it, revoke it and make another. You can revoke a key at any time and it stops working immediately.
A key can create runs and, once wallets are available to you, spend your balance. Treat it like a password: keep it on your server, never in a browser or a mobile app.
Authentication
Send the key as a bearer token on every request.
Authorization: Bearer fidi_live_<64 hex characters>Anything missing, malformed, unknown or revoked gets a 401. Responses are always JSON, and failures always carry an error string.
Coordinates, not addresses
Every pickup and every drop must carry a latitude and longitude. We do not turn address text into coordinates for you.
That is a real cost to you and we would rather say why than pretend it is a feature. Converting arbitrary address text into coordinates at the volume an API invites needs a paid geocoding service; the free one behind our own address picker permits roughly one lookup per second across all of FIDI and forbids bulk use. Accepting address strings here would mean breaching that on your behalf. Use POST /v1/quotes to check that a pair of coordinates prices the way you expect before you rely on them.
Coordinates are checked: they must be real numbers in range, must not be 0,0, and every drop must be within the delivery radius of the pickup (50 km by default).
Quote a run
/v1/quotesWhat a run would cost. Creates nothing and charges nothing.
{
"pickup": { "latitude": 11.0855, "longitude": 7.7199 },
"dropoffs": [ { "latitude": 11.1556, "longitude": 7.6456 },
{ "latitude": 11.0832, "longitude": 7.6898 } ]
}{
"route_km": 12.7,
"drop_count": 2,
"suggested_price": 3920,
"sent_separately": 4136
}suggested_price is an anchor, not a fare — you decide what to offer, and the rider can counter. sent_separately is what the same parcels would cost as individual deliveries, which is the saving a run earns you.
Create a run
/v1/batchesOne pickup, one or more drops, one rider, one price. An Idempotency-Key header is required: retrying a create without one is a second run and a second charge, and only you know whether two requests are the same order. Send a UUID per order.
A retry with the same key returns the original run, creates nothing, and comes back with Idempotent-Replay: true. The body you send on the retry is ignored.
curl -X POST https://combative-goldfinch-616.convex.site/v1/batches \
-H "Authorization: Bearer $FIDI_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 0f9c2b3a-7d41-4e88-9f2c-1a5b6c7d8e90" \
-d '{
"pickup": {
"address": "12 Ahmadu Bello Way, Zaria City",
"latitude": 11.0855, "longitude": 7.7199,
"contact_name": "Shop counter",
"contact_phone": "08031234567",
"instructions": "Blue gate, call on arrival"
},
"packages": [{
"address": "House 4, Samaru",
"latitude": 11.1556, "longitude": 7.6456,
"contact_name": "Amina",
"contact_phone": "08039876543",
"description": "One carton of shoes",
"size": "medium",
"fragile": false
}],
"proposed_price": 3920,
"max_auto_accept_price": 4500,
"scheduled_for": "2026-09-09T07:00:00Z"
}'| Field | Required | Notes |
|---|---|---|
pickup.address, contact_name, contact_phone | yes | — |
pickup.latitude, pickup.longitude | yes | See Coordinates |
packages[].address, contact_name, contact_phone, description | yes | — |
packages[].latitude, longitude | yes | Within the delivery radius |
packages[].size | no | small (default), medium, large |
packages[].weight, instructions, fragile | no | — |
proposed_price | yes | Whole Naira, at or above the minimum |
max_auto_accept_price | no | Overrides the key's ceiling for this order |
scheduled_for | no | ISO 8601. Hidden from riders until then |
Returns 201 and the run, in the shape below.
Read a run
/v1/batches/{id}Also GET /v1/batches?limit=25 for your most recent runs, newest first.
{
"id": "m572a7898m1pmgfz7sabh1jgps8dyce0",
"status": "paid",
"created_at": "2026-09-07T22:56:41.575Z",
"scheduled_for": null,
"pickup": { "address": "...", "latitude": 11.0855, "longitude": 7.7199,
"contact_name": "...", "contact_phone": "..." },
"price": { "proposed": 3920, "agreed": 4500, "max_auto_accept": 4500,
"route_km": 12.7, "refunded": null },
"packages": [
{ "id": "...", "status": "in_transit",
"address": "House 4, Samaru",
"contact_name": "Amina", "contact_phone": "08039876543",
"description": "One carton of shoes",
"delivery_code": "743899", "delivered_at": null }
]
}delivery_code is yours to pass on. Your customer gives those six digits to the rider at handover, and the drop cannot be completed without them. Codes appear once the run is paid for. Send each one to the right recipient — a code given to the wrong person is a parcel handed to the wrong person.
Price, and what happens when a rider counters
FIDI's prices are agreed between two people rather than assigned by us. You propose a price; the rider can accept it or counter. With nobody sitting in the chat thread, max_auto_accept_price is how you say in advance what you would have accepted.
- A rider takes it at your price, or counters at or under your ceiling → agreed for you, and paid from your balance if you have one.
- A counter above your ceiling → nothing automatic. It waits in your dashboard for a person to decide, and the run stays
accepted. - No ceiling set → nothing is ever agreed automatically.
Paying without a person is not switched on yet. Holding a prepaid balance is a licensed activity in Nigeria and we are not going to do it before we are allowed to. Until then an API order reaches an agreed price and then waits for you to pay it from your dashboard, exactly like a run you posted yourself. Nothing else about the API changes when that arrives.
Statuses
A run moves through:
scheduled -> pending -> accepted -> agreed -> paid
-> pickup_pending -> picked_up -> completed
cancelled (before payment only)And each package within it:
pending -> in_transit -> delivered
-> failed (nobody there, refused, wrong address)
-> cancelledThere are no outbound webhooks yet, so poll GET /v1/batches/{id}. If a drop fails, its share of the price shows up as price.refunded.
Errors
| Status | Means |
|---|---|
400 | The body or headers are malformed — a missing Idempotency-Key, absent coordinates, unparseable JSON |
401 | Missing, malformed, unknown or revoked key |
404 | No such run on your account. An id belonging to someone else is also a 404 |
422 | Rejected: price below the minimum, a drop outside the radius, too many drops, coordinates at 0,0 |
{ "error": "Drop 1 is 696 km from the pickup; the limit is 50 km." }Limits
- One pickup per run. Several pickups in one run is not supported.
- Up to 19 drops per run, so that one rider can actually carry it.
- Every drop within 50 km of the pickup.
- Runs can be scheduled up to 14 days ahead.
- No rate limit yet. Please do not poll in a tight loop. A documented per-key limit arrives before this is opened more widely, and we will tell key holders before it does.
Something unclear, or something broken?
Email [email protected] with the run id and roughly when it happened. If you are integrating and something in these docs does not match what the API does, the API is the one that is right and we want to know.