One key, one call to book, six states that never change, and a webhook when the parcel arrives. No arrival-time guesses — we tell the recipient how many stops stand before theirs, which is the truth.
Press this and you have a key in five seconds. No sign-up, no email, no form. It works against a business where nothing is real: five parcels are already in it, a rider delivers when you tell him to, and no message is ever sent to anybody. Nothing you do with it reaches a rider, a customer or a telephone.
Your key — this is the only time it is shown.
Five parcels are waiting in it:
Move one along the way a rider would, as many times as you like:
curl -X POST https://osoroghana.com/v1/sandbox/advance \
-H "Authorization: Bearer osk_test_…" \
-H "Content-Type: application/json" \
-d '{ "ref": "…" }'
Each call writes the event a real handset writes — assigned, at the
door, delivered — so your webhook endpoint receives exactly what it
will receive in Accra. Add "fail": true to get the other
ending, which is the one most integrations never handle.
POST /v1/sandbox/reset cancels what is outstanding and
books five fresh ones. It deletes nothing: the event log is
append-only here, which is also true of the real one.
GET /v1/sandbox/messages shows what your customers
would have been sent. None of it was.
When you are ready for real parcels, talk to
us — your live key is a different key, and it begins
osk_ with no test in it.
Each button makes one call with your key and prints exactly what came back.
Nothing called yet.
There is a typed client. One file, no dependencies — not one, and
none underneath it either. It runs on Node 20 and later, Deno, Bun and
Cloudflare Workers, because it uses fetch and Web Crypto and
nothing else. Your key is the kind of secret that should not have a
hundred strangers' packages sitting under it.
npm install @osoro/sdk
import { Osoro } from '@osoro/sdk';
const osoro = new Osoro({ key: process.env.OSORO_KEY });
console.log(await osoro.confirmMode()); // 'test' — print this first
const booking = await osoro.book({
reference: order.id,
recipient: { phone: '0244123456', name: 'Ama' },
destination: { description: 'Blue gate opposite Ebenezer Chapel, Madina' },
cod_amount: 45.5,
}, { idempotencyKey: order.id });
await osoro.sandbox.advance(booking.ref); // test keys only
console.log((await osoro.sandbox.messages()).messages[0].body);
A retried booking without an Idempotency-Key is a second
rider on a second motorbike. The client always sends one, so that
its own retries are safe — it cannot cover yours. If your job
runner or your catch block might call book() twice for the same
order, pass your order number, as above. Then the second call returns the
first booking.
It retries a GET, and a POST that carries a key, and
nothing else. It honours Retry-After. It never logs, because it is
holding your key.
try {
await osoro.book(input, { idempotencyKey: order.id });
} catch (e) {
if (e.code === 'destination_required') return askForDirections(e.field);
if (e.code === 'rate_limited') return retryIn(e.retryAfterS);
if (e.code === 'transport') return retrySameKey(order.id);
throw e;
}
Two codes are the client's own and never come from us:
client means the request was never sent — your key or
your options are wrong, and nothing happened at our end.
transport means no answer arrived, so you do not know
whether that parcel is booked. Ask again with the same idempotency key;
do not book afresh. Everything else is in the table at the bottom of this
page.
import { verifyWebhook } from '@osoro/sdk';
const event = await verifyWebhook({
secret: process.env.OSORO_WEBHOOK_SECRET,
signature: req.header('x-osoro-signature'),
deliveryId: req.header('x-osoro-delivery'),
body: rawBodyString, // the RAW bytes, never a re-stringified object
});
It checks the age, compares in constant time, refuses a malformed header
without throwing something your handler turns into a 500, and parses the
body only after all of that. In Express, mount
express.raw({ type: 'application/json' }) on that route,
before any JSON body parser.
The source is one readable file and you are welcome to copy it instead of installing it. Everything the client does is a plain HTTPS call described on this page, so nothing below needs the package.
Sign in to the merchant portal with your
access code, open Developers and make a key. It begins osk_, it is shown
once, and it goes in the Authorization header of every call. A key is not a
portal login and a login is not a key; each is refused where the other belongs.
curl https://osoroghana.com/v1/me \ -H "Authorization: Bearer osk_…"
{ "merchant": "Road Wholesaler", "key": "shop server", "mode": "live",
"api_version": "2026-09-11", "rate_limit": { "per_minute": 300, "burst": 100 } }
mode is the first field to print, and the only one worth
putting in your start-up log. It is test for a key from the button
above and live for one from the portal. A program that cannot say
which of the two it is holding is one configuration file away from sending a
real rider to a made-up address.
Everything is JSON, UTF-8, over HTTPS. The base is https://osoroghana.com/v1.
Three hundred calls a minute per key; past that, 429 with a Retry-After.
curl -X POST https://osoroghana.com/v1/bookings \
-H "Authorization: Bearer osk_…" \
-H "Idempotency-Key: SHOP-1042" \
-H "Content-Type: application/json" \
-d '{
"reference": "SHOP-1042",
"recipient": { "phone": "0244123456", "name": "Ama Serwaa" },
"destination": { "description": "Blue gate opposite Ebenezer Chapel, Madina",
"landmark": "Ebenezer Chapel" },
"cod_amount": 45.50,
"notes": "Call on arrival",
"lines": [ { "code": "SKU-118", "qty": 2 } ]
}'
| Field | Meaning |
|---|---|
reference | Your order number. Optional. Finds the booking later, and comes back on every event. |
recipient.phone | Required. A Ghanaian mobile in any usual spelling. The rider calls it; the tracking link goes to it. |
destination.description | Where, as the customer would tell a taxi driver. Optional only for a number we have delivered to before — we already know that door better than a sentence says it. For a new number, required (destination_required). |
destination.landmark | Optional. A named place near the door. |
cod_amount | Cedis to collect at the door. 0 or absent means prepaid. |
lines | Optional. Your product codes and quantities, for stock we hold for you in the warehouse. The parcel is picked and packed before it goes out. |
delivery_type, hours, window_hours, notes | Optional. Home, office, shop or pharmacy; when the door is open; how long you give us; anything for the rider. |
Send Idempotency-Key with your own id — the order number will do. If your
server times out and retries, the same key within 24 hours returns the same booking
(200) instead of making a second one (201). The same key with a
different body is refused (idempotency_conflict), because that is the retry
that was not a retry.
{ "ref": "KX7M-4PQD", "reference": "SHOP-1042", "state": "booked",
"recipient": { "phone": "+233244123456", "name": "Ama Serwaa" },
"destination": { "description": "Blue gate opposite Ebenezer Chapel, Madina", "confidence": "medium" },
"cod_amount": 45.5, "currency": "GHS", "lines": 1, "has_proof": false,
"tracking_url": "https://osoroghana.com/t/KX7M-4PQD",
"booked_via": "api", "created_at": "2026-09-11T09:12:44.120Z",
"events": [ { "type": "booking.created", "at": "2026-09-11T09:12:44.120Z" } ] }
ref is ours — letters and digits nobody misreads over a phone, printed on the
label. Give tracking_url to your customer: it shows them how many stops stand
before theirs, lets them leave an instruction for the rider, and never promises a minute.
| State | Meaning |
|---|---|
booked | We have it. Not yet given to a rider. |
with_rider | A rider holds it. booking.collected fires when it is physically on the bike. |
at_the_door | The rider has arrived at the destination. |
delivered | Handed over. has_proof says whether a photograph, signature or code was taken. |
failed | Could not be delivered; failure_reason says why. The desk decides retry or return. |
cancelled | Called off — by you (below) or by the desk. |
These words do not change. When our board learns a new internal state, it maps onto one of these; your integration does not break.
GET /v1/bookings/KX7M-4PQD # by our ref GET /v1/bookings/SHOP-1042 # or by your reference GET /v1/bookings?state=with_rider&since=2026-09-11T00:00:00Z&limit=50 GET /v1/events?since=2026-09-11T08:00:00Z
A booking carries its events: booking.created, booking.with_rider,
booking.collected, booking.at_the_door, booking.delivered,
booking.failed, booking.cancelled. Your parcels’ other news comes the same
way: claim.decided, return.requested, return.decided,
return.collected, booking.instruction when the customer asks for another day
or a call first, and booking.late the moment a promised time passes undelivered —
each with the booking and what happened in note. Those six come only to a
webhook that names them, and from /v1/events only with ?types=; an empty
list still means the seven booking events. /v1/events is the same
stream across all your parcels, oldest first, for a program that polls; pass back
next_since. Webhooks (below) push the same events to you instead.
POST /v1/bookings/SHOP-1042/cancel
{ "reason": "Customer cancelled the order" }
Allowed while the parcel is booked or with_rider. Once the rider is at
the door, or the parcel is delivered or failed, the answer is 409 too_late — ring
the desk.
GET /v1/bookings/SHOP-1042/proof
A booking says has_proof; this is what is behind it. Each entry says what was
recorded, when, the rider’s first name, and how far the evidence carries —
including when it does not. A photograph comes back as
media_url: relative, signed, and valid for link_expires_in_s
seconds. Fetch the picture; do not store the link. It expires, and it is
checked against your account when it is used, so it is no use to anybody else either.
What you will not find in it: the rider’s full name, GPS coordinates, or the distance between the handset and the door. The grade already says what that distance honestly supports, and we grade evidence rather than accuse riders.
Lists are pages, newest first: follow next_cursor until it is null.
Register an HTTPS address in the portal under Developers, or with the API, and every event above is posted to it as it happens. The secret comes back once.
POST /v1/webhooks
{ "url": "https://shop.example.com/osoro/events", "events": ["booking.delivered", "booking.failed"] }
→ 201 { "webhook_id": "…", "secret": "whsec_…", … } # events: [] means all of them
GET /v1/webhooks # yours, with how each has been answering
GET /v1/webhooks/{id}/deliveries # every attempt and its answer
POST /v1/webhooks/{id}/test # a ping, through the same path a real event takes
POST /v1/webhooks/{id}/enable # after it switched itself off
DELETE /v1/webhooks/{id}
POST https://shop.example.com/osoro/events
X-Osoro-Event: booking.delivered
X-Osoro-Delivery: 4d1c… # unique per attempt series; use it to ignore a repeat
X-Osoro-Signature: t=1757580000,v1=8f3a… # see below
{ "id": "…", "type": "booking.delivered", "at": "2026-09-11T15:42:10.000Z",
"booking": { "ref": "KX7M-4PQD", "reference": "SHOP-1042", "state": "delivered",
"failure_reason": null, "has_proof": true, "note": null,
"tracking_url": "https://osoroghana.com/t/KX7M-4PQD",
"url": "https://osoroghana.com/v1/bookings/KX7M-4PQD" } }
The payload names the parcel and its state and nothing else — no phone number, name or
address. You already hold the customer; what you are being told is what happened to the parcel.
Fetch url with your key if you want the rest.
v1 is HMAC-SHA256, hex, over t + "." + rawBody with your
whsec_ secret. Reject anything whose t is more than five minutes from
now. Compare in constant time.
# Node
const [t, v1] = sig.split(',').map(kv => kv.split('=')[1]);
const want = crypto.createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest('hex');
// timingSafeEqual THROWS when the two are different lengths, so check that
// first — otherwise a two-character v1 makes this handler answer 500, and a
// 5xx is retried for a day. Or use verifyWebhook from the SDK, which does it.
ok = Math.abs(Date.now()/1000 - t) < 300
&& want.length === (v1 || '').length
&& crypto.timingSafeEqual(Buffer.from(want,'hex'), Buffer.from(v1,'hex'));
# PHP
[$t, $v1] = array_map(fn($kv) => explode('=', $kv)[1], explode(',', $sig));
$want = hash_hmac('sha256', "$t.$rawBody", $SECRET);
$ok = abs(time() - (int)$t) < 300 && hash_equals($want, $v1);
Anything else — a timeout of ten seconds included — is retried: after 1 minute, 5, 30, 2 hours,
6, 12, 24 and 24 again, then given up. Do your work after you answer, not before. A webhook
that has failed for three days switches itself off; the portal says so, and Enable turns
it back on from now. Deliveries can arrive out of order under retry — trust at and
the booking's state, not the order of arrival.
No code needed. Connect the shop in the portal under Developers › Your shop, paste the address we give you into the shop's webhook settings, and:
| Shopify | WooCommerce | |
|---|---|---|
| An order books when | orders/paid arrives — or orders/create with a cash-on-delivery gateway (the amount is collected at the door) | order.created/order.updated with status processing — or payment method cod |
| Your reference | the order name, #1042 | the order number |
| Where it goes | the shipping address and its phone; the customer's phone if the address has none | the shipping address; the billing phone if shipping has none |
| Cancelled in the shop | calls the parcel off, if the rider has not reached the door | |
| When delivered | a fulfilment with tracking number and link, customer notified (needs an Admin API token with write_fulfillments) | the order is marked completed with the tracking link in its meta and a customer-visible note (needs a REST key with write) |
| Retried | the same order sent twice is one booking; a shop that does not answer is retried five times over a day, and the error is shown beside the order in the portal | |
Optionally, the order's line items (by SKU) become the parcel's lines, picked from the stock we hold for you. Orders without a phone number, or unpaid and not cash-on-delivery, are listed in the portal as skipped with the reason — never silently dropped. The shop's admin credential is sealed at rest and never shown again.
{ "error": "recipient.phone is not a Ghanaian mobile number",
"code": "invalid_request", "field": "recipient.phone", "request_id": "…" }
Branch on code, never on the sentence — the sentence is for a person and we
reword it. The status tells you whose move it is: 400 and 422, fix the request;
401, the key; 404, no such thing or not yours (the same answer on purpose);
409, it is no longer possible; 429, slow down — Retry-After is in
seconds; 5xx, ours, and safe to retry with the same Idempotency-Key.
code | What it means |
|---|---|
unauthorized | the key is missing, wrong, or is a portal login rather than an API key |
disabled | the key was valid and has been turned off |
gone | the thing existed and has been removed |
not_found | no such thing, or not yours |
invalid_request | the body or a field is not something this endpoint accepts |
destination_required | no address, and no delivery to that number before now to infer one from |
unknown_line | a line names a product this business does not have |
insufficient_stock | the product is known and there is not enough of it free |
rejected | refused for a reason with no code of its own — read the sentence |
idempotency_conflict | that Idempotency-Key was used with a different body |
too_late | the parcel has moved past the point where this can be done |
already_cancelled | it was cancelled before you asked |
too_many_keys | the limit on live API keys for this business |
too_many_stores | the limit on connected storefronts |
too_many_webhooks | the limit on webhook endpoints |
not_configured | the server is missing a setting this needs; ours to fix, not yours |
unknown_code | no Osoro Code like that is in use |
rate_limited | too many requests — slow down and retry |
not_the_sandbox | a sandbox-only route was called with a live key |
already_finished | that parcel is delivered, failed or cancelled, and cannot be moved again |
Quote request_id when you write to us; it finds the request in our log.
What this API will not tell you: a rider's position, a rider's name, or an estimated arrival time. Riders are people, and a minute we cannot keep is a promise we do not make.
Newest first. api_version is still 2026-09-11: nothing has been
removed or renamed. Two entries changed an answer you might already depend on — they are
marked changed and fixed, and each says what to do.
added 22 September 2026
Six more things your webhook can be told
You can now be told claim.decided (accepted, partial or rejected, with the amount), return.requested, return.decided (approved or refused), return.collected, booking.instruction (the customer asked for another day, to call first, to leave it with somebody, or said they are not home — the kind and the day, never their own words) and booking.late (the delivery time we promised passed without a delivery; sent at that time, not when it finally arrives). Each carries the same booking object as every other event, with what happened in note. They are sent only to a webhook that lists them in events, and GET /events returns them only when you name them in the new types parameter: a webhook made with events: [] keeps receiving exactly the seven booking events it always did, because version 1.0.0 of our client library refuses a name it does not know. Version 1.1.0 knows all thirteen.
GET /events
fixed 22 September 2026
booking.created is sent
The webhook list has offered booking.created since webhooks began, and it was never sent: nothing wrote it. It is now sent for every booking, however it was made — here, in the portal, from a shop, or at our desk — and appears in /v1/events. A second attempt after a failed delivery is not a new booking and sends nothing. Bookings made before 22 September still show it in their events on GET /v1/bookings, as before.
fixed 21 September 2026
This page now lists every error code, and what changed
The errors table here was written by hand and showed nine of the twenty codes the API can return. It is now written from the same list the server answers from, and so is this change log.
fixed 20 September 2026
The sandbox says booking.at_the_door, never task.arrived
POST /v1/sandbox/advance answered with our internal event name. It now answers with the name your webhook endpoint receives, and adds state — where the parcel is now. If you matched on task. anything, match on booking. instead; no live endpoint ever sent task.. The same batch put six routes that already worked into /v1/openapi.json for the first time — /funds, /webhooks, /stores and the three sandbox routes — and mode on /me. A client generated from the contract before 20 September does not know they exist. The webhook-signature example on this page now checks the header's length before comparing, so a malformed header gets a 400 instead of a 500 that you would retry for a day.
added 19 September 2026
Test keys and a sandbox
A key beginning osk_test_ works in a business where nothing is real and no message is sent. /me says mode: test or mode: live — print it first. Move a parcel along with /sandbox/advance, start again with /sandbox/reset (it cancels and rebooks; it deletes nothing), and read what your customer would have been told with /sandbox/messages. Get a test key from the button at the top of this page.
POST /sandbox/advance POST /sandbox/reset GET /sandbox/messages
added 19 September 2026
Osoro Codes: book to a person, not an address
A customer who has had two deliveries confirmed at their door has a code like OS-4K2P-9TRW. Book with osoro_code and recipient.phone becomes optional — the address and phone are on the job and never come back to you. POST /codes/resolve says, in words, the neighbourhood a code points at, for your checkout. An unknown, paused or revoked code gets the same answer, on purpose.
POST /codes/resolve
changed 14 September 2026
Short stock has its own code
A line for a product you have, but not enough of, used to come back as unknown_line. It is now insufficient_stock. Every code is decided where the refusal happens rather than guessed from the sentence afterwards, and the contract now lists the codes as an enum, so a generated client knows all of them.
added 13 September 2026
The proof from the door
GET /bookings/{ref}/proof returns what was recorded at the door — photograph, code, signature — with how far each piece of evidence carries, and short-lived links to any pictures. Fetch the picture; do not store the link. When there is nothing to show, note says so in words.
GET /bookings/{ref}/proof
added 12 September 2026
Your wallet, before you book
GET /funds returns your prepaid wallet, what you owe, your credit limit if one is set, and whether an unfunded parcel will be held. Read it from your checkout so a shop tops up rather than finding out at the door. (Declared in the contract from 20 September.)
GET /funds
added 12 September 2026
How your deliveries are going
GET /performance: first-attempt rate, on time against the promise, journeys per parcel, cost per drop, claims, and how many parcels went to a door we already knew. A percentage is null until enough parcels stand behind it, and short_by says how many more.
GET /performance
added 12 September 2026
A promise on the booking
GET /service-tiers lists the promises on offer. Book with service_tier and the price and the deadline are fixed then; the booking carries service_tier and promised_by. Missing the promise credits you the stated share automatically — it appears in /claims as an accepted claim.
GET /service-tiers
added 12 September 2026
A code the door says back
A booking carries delivery_code: four digits your customer says to the rider. Put it in your own confirmation email. It is null once it has been said, or when the parcel never needed one.
added 11 September 2026
Claims, and the credit note
POST /bookings/{ref}/claims says a parcel was lost, damaged or short and what it cost you. GET /claims lists yours and what was decided; an accepted claim names the credit note, which is on your next statement.
POST /bookings/{ref}/claims GET /claims
added 11 September 2026
The API opens
Book, list, read and cancel deliveries; follow them by polling /events or by signed webhooks; connect a Shopify or WooCommerce shop. Seven webhook events: booking.created, booking.with_rider, booking.collected, booking.at_the_door, booking.delivered, booking.failed, booking.cancelled. api_version 2026-09-11.
GET /me POST /bookings GET /bookings GET /bookings/{ref} POST /bookings/{ref}/cancel GET /events GET /webhooks POST /webhooks DELETE /webhooks/{webhook_id} GET /webhooks/{webhook_id}/deliveries POST /webhooks/{webhook_id}/test POST /webhooks/{webhook_id}/enable GET /stores POST /stores DELETE /stores/{store_id} GET /stores/{store_id}/orders