OSORO

Book deliveries from your own software.

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.

A test key, right now

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.

Or skip the curl

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);

The one thing to read before you ship

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.

Errors you can branch on

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.

Webhooks, verified

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.

Start

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.

Book a delivery

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 } ]
  }'
FieldMeaning
referenceYour order number. Optional. Finds the booking later, and comes back on every event.
recipient.phoneRequired. A Ghanaian mobile in any usual spelling. The rider calls it; the tracking link goes to it.
destination.descriptionWhere, 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.landmarkOptional. A named place near the door.
cod_amountCedis to collect at the door. 0 or absent means prepaid.
linesOptional. 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, notesOptional. Home, office, shop or pharmacy; when the door is open; how long you give us; anything for the rider.

Idempotency

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.

What comes back

{ "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.

Six states

StateMeaning
bookedWe have it. Not yet given to a rider.
with_riderA rider holds it. booking.collected fires when it is physically on the bike.
at_the_doorThe rider has arrived at the destination.
deliveredHanded over. has_proof says whether a photograph, signature or code was taken.
failedCould not be delivered; failure_reason says why. The desk decides retry or return.
cancelledCalled 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.

Follow a booking

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.

Cancel

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.

The evidence at the door

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.

Webhooks

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}

What arrives

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.

Verify the signature

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);

Answer 2xx quickly

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.

Shopify and WooCommerce

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:

ShopifyWooCommerce
An order books whenorders/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 referencethe order name, #1042the order number
Where it goesthe shipping address and its phone; the customer's phone if the address has nonethe shipping address; the billing phone if shipping has none
Cancelled in the shopcalls the parcel off, if the rider has not reached the door
When delivereda 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)
Retriedthe 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.

Errors

{ "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.

codeWhat it means
unauthorizedthe key is missing, wrong, or is a portal login rather than an API key
disabledthe key was valid and has been turned off
gonethe thing existed and has been removed
not_foundno such thing, or not yours
invalid_requestthe body or a field is not something this endpoint accepts
destination_requiredno address, and no delivery to that number before now to infer one from
unknown_linea line names a product this business does not have
insufficient_stockthe product is known and there is not enough of it free
rejectedrefused for a reason with no code of its own — read the sentence
idempotency_conflictthat Idempotency-Key was used with a different body
too_latethe parcel has moved past the point where this can be done
already_cancelledit was cancelled before you asked
too_many_keysthe limit on live API keys for this business
too_many_storesthe limit on connected storefronts
too_many_webhooksthe limit on webhook endpoints
not_configuredthe server is missing a setting this needs; ours to fix, not yours
unknown_codeno Osoro Code like that is in use
rate_limitedtoo many requests — slow down and retry
not_the_sandboxa sandbox-only route was called with a live key
already_finishedthat 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.

What changed

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.