Developers
Ship from your store with the Obana API
Connect your website, Shopify store or custom app in minutes. Show live delivery prices at checkout, book a shipment for every order, and follow each shipment and customer — your system hears about every status change as it happens.
https://obana-logistics-t6qg.onrender.comEvery path on this page is relative to this address. All requests and responses are JSON over HTTPS.
Get started
Quickstart
Five steps from zero to a working key. Each store you run (website, Shopify shop, mobile app) gets its own key, so you can see and control their shipments separately.
- 1
Sign in to Obana
- 2
Open Stores & API
In your dashboard, go to Stores & API. - 3
Add a store and copy its key
Click Add store. Your key (obk_live_…) is shown once — we only keep a hashed copy. Lost it? Rotate the key to get a new one; the old key stops working straight away. - 4
Add a webhook URL (optional)
Give the store a publichttps://address and press Send test event to check it arrives. See Webhooks. - 5
Test your key
CallGET /stores/me. A200with your store’s name means you’re connected.
On Shopify or another hosted platform?
curl https://obana-logistics-t6qg.onrender.com/stores/me \
-H "Authorization: Bearer $OBANA_API_KEY"{
"status": "success",
"data": {
"id": 12,
"name": "Ade Fashion",
"website_url": "https://adefashion.ng",
"status": "active",
"api_key_hint": "obk_live_…9f3a",
"api_key_created_at": "2026-09-01T10:12:00.000Z",
"last_used_at": "2026-09-11T08:30:00.000Z",
"webhook_url": "https://adefashion.ng/webhooks/obana",
"created_at": "2026-09-01T10:12:00.000Z"
}
}Get started
Authentication
Send your store key as a bearer token in the Authorization header of every call that needs one.
What a store key can do
Store keys are made for integrations and only open these calls:
- Get quotes —
POST /routes/quote - Create shipments —
POST /shipments - List your store’s shipments —
GET /stores/me/shipments - Track and cancel your store’s shipments —
/shipments/track/…,/shipments/cancel/… - Check the key —
GET /stores/me
Any other route answers 403. So does a key whose store is paused — un-pause the store in your dashboard to switch it back on. A missing key gets 401.
Keep keys on your server
Older OBN- account keys
OBN- still work, but they aren’t tied to a store. New integrations should use store keys (obk_live_…).Authorization: Bearer obk_live_your_store_key{
"status": "error",
"code": null,
"message": "Store API keys can only be used for quotes, shipments and tracking"
}Get started
Responses & errors
Successful calls answer { "status": "success", "data": … }. Failed calls answer { "status": "error", "message": "…" } with a plain-English message you can show your team. Shipment calls (/shipments…) use "success": true or false instead of status, so check the HTTP status code first.
200 / 201- It worked. 201 means a shipment was created.
400- Something in the request is missing or wrong. Read
message, anderrorswhen present — it lists every field to fix. 401- No key was sent.
403- The key is invalid, its store is paused, or store keys can’t use this route.
404- Not found — or it belongs to another store.
429- Too many requests. Wait for the number of seconds in the
Retry-Afterheader, then try again. 5xx- Something went wrong on our side. Retry with backoff (for example after 1 s, 2 s, 4 s).
There are no 409 conflicts: sending an order_id you’ve already used returns the existing shipment instead (see Create a shipment).
{
"status": "success",
"data": { … }
}{
"status": "error",
"message": "Weight must be between 0.1 and 1000 kg"
}{
"success": false,
"message": "Invalid payload",
"errors": [
"pickup_address.phone is required",
"delivery_address.city is required"
]
}Shipments
Get a quote
Show buyers real delivery prices before they pay. Every option on the route comes back, with the cheapest and fastest marked for you.
/routes/quoteNo key neededNo key is needed, so it’s fine to proxy it for your checkout. Limit: 20 quotes per 10 minutes per IP address. Identical quotes are cached for 30 minutes.
Body
originobjectRequiredWhere the parcel starts.countrystringRequiredCountry name, e.g. United Kingdom.country_codestringRequiredISO code, e.g. GB.statestringRequiredState, region or county.state_codestringState code when you have it, e.g. LA for Lagos.citystringRequired
destinationobjectRequiredWhere it’s going. Same fields as origin.weight_kgnumberRequiredTotal weight in kg, from 0.1 to 1000.declared_valuenumberValue of the goods in NGN.display_currencystringISO currency to show prices in as well, e.g.GBP.
Response data
options[]arrayOne entry per way to ship.idstringPass it asquote_option_idwhen you create the shipment.providerstringobana(our own network) orpartner(a carrier we book for you).carrier_name, logo_urlstringWho carries it. logo_url can be null.transport_mode, service_levelstringe.g. road + Standard. Can be null for partner options.etastringEstimated delivery time, e.g. 3-5 days.pricenumberWhat you pay, in NGN.display_pricenumber | nullprice converted to display_currency.
cheapest_id, fastest_idstringIds of the cheapest and fastest options. fastest_id is null if no option has an ETA.currency, display_currencystringAlways NGN, and the currency display_price is in.fxobject | nullThe rate used: rate, as_of and source. null when no conversion was needed or available.expires_atstringWhen this price stops being guaranteed. Quote again after it.
Prices are charged in naira
price is what you’re charged, in NGN. display_price is only a conversion at today’s rate to help your buyers — the amount charged stays in NGN.A route we don’t cover yet answers 404 with No routes available for this shipment.
curl -X POST https://obana-logistics-t6qg.onrender.com/routes/quote \
-H "Content-Type: application/json" \
-d '{
"origin": { "country": "United Kingdom", "country_code": "GB", "state": "England", "city": "London" },
"destination": { "country": "Nigeria", "country_code": "NG", "state": "Lagos", "state_code": "LA", "city": "Ikeja" },
"weight_kg": 2.5,
"declared_value": 85000,
"display_currency": "GBP"
}'{
"status": "success",
"data": {
"currency": "NGN",
"display_currency": "GBP",
"fx": { "rate": 0.00047, "as_of": "2026-09-11T06:00:00.000Z", "source": "open.er-api.com" },
"options": [
{
"id": "obana-air-express",
"provider": "obana",
"carrier_name": "Obana Logistics",
"logo_url": null,
"transport_mode": "air",
"service_level": "Express",
"eta": "5-7 days",
"price": 88000,
"display_price": 41.36
},
{
"id": "partner-dhl-0",
"provider": "partner",
"carrier_name": "DHL Express",
"logo_url": "https://…/dhl.png",
"transport_mode": null,
"service_level": null,
"eta": "3-5 days",
"price": 96500,
"display_price": 45.36
}
],
"cheapest_id": "obana-air-express",
"fastest_id": "partner-dhl-0",
"expires_at": "2026-09-11T09:30:00.000Z"
}
}Shipments
Create a shipment
Book a delivery for an order. Call it when the order is paid; we price it, assign the carrier and start tracking.
/shipmentsStore keyRecommended
order_idstringRecommendedYour order number. Sending the sameorder_idagain returns the existing shipment with"duplicate": trueinstead of creating another — so retries after a timeout are safe.customerobjectRecommendedYour customer, so you can list every shipment for them later.idstringYour own customer ID.name, email, phonestring
quote_option_idstringRecommendedAnidfrom Get a quote — the option your buyer chose. If you leave it out, the cheapest option is used.
Required
pickup_addressobjectRequiredWhere we collect the parcel.line1, city, state, country, phonestringRequiredcontact_name, email, line2, zip_codestring
delivery_addressobjectRequiredYour customer’s address.first_name, last_name, line1, city, state, country, phonestringRequiredemail, line2, zipstring
items[]arrayRequiredWhat’s in the parcel.namestringRequiredquantityintegerRequiredweightnumberRequiredWeight of one item, in kg.pricenumberRequiredValue of one item, in NGN.descriptionstring
transport_modestringRequiredroad,airorsea.service_levelstringRequiredExpress,StandardorEconomy.
Obana sets the price
shipping_fee you send is ignored.Save shipment_reference (for tracking) and shipment_id (for cancelling) against your order, and share tracking_url with your customer. The fee charged comes back straight away as shipping_fee (with currency), and also when you list shipments and in webhook events.
When it fails
Missing or invalid fields answer 400 with "message": "Invalid payload" and an errors array naming each field. A route we don’t cover answers 400 with:
{
"success": false,
"message": "We don't deliver on this route yet. Contact us to add it."
}curl -X POST https://obana-logistics-t6qg.onrender.com/shipments \
-H "Authorization: Bearer $OBANA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"order_id": "SO-1042",
"customer": {
"id": "cus_8812",
"name": "Chioma Okafor",
"email": "chioma@example.com",
"phone": "+2348031234567"
},
"quote_option_id": "obana-road-standard",
"pickup_address": {
"contact_name": "Ade Fashion Warehouse",
"line1": "12 Allen Avenue",
"city": "Ikeja",
"state": "Lagos",
"country": "Nigeria",
"phone": "+2348012345678",
"email": "dispatch@adefashion.ng"
},
"delivery_address": {
"first_name": "Chioma",
"last_name": "Okafor",
"line1": "7 Ring Road",
"city": "Ibadan",
"state": "Oyo",
"country": "Nigeria",
"phone": "+2348031234567",
"email": "chioma@example.com"
},
"items": [
{ "name": "Ankara dress", "quantity": 2, "weight": 0.6, "price": 18500 }
],
"transport_mode": "road",
"service_level": "Standard"
}'{
"success": true,
"message": "Shipment created successfully",
"data": {
"shipment_id": 5821,
"shipment_reference": "OBN-7F3K2Q",
"order_id": "SO-1042",
"shipping_fee": 6500,
"currency": "NGN",
"tracking_url": "https://logistics.obana.africa/?track=OBN-7F3K2Q",
"carrier": "Obana Logistics",
"status": "confirmed",
"estimated_delivery": "2-3 days"
}
}{
"success": true,
"duplicate": true,
"message": "A shipment already exists for this order",
"data": {
"id": 5821,
"shipment_reference": "OBN-7F3K2Q",
"status": "pending",
"shipping_fee": 6500,
"currency": "NGN"
}
}Shipments
List your shipments
Everything your store has shipped, newest first. Filter by customer, order number or status to answer the questions your team gets every day.
/stores/me/shipmentsStore keyQuery parameters (all optional)
statusstringOne of the statuses, e.g.in_transit.order_idstringYour order number.customer_idstringThe customer.id you sent when creating shipments.qstringSearch by shipment reference or order number (partial match).pageintegerPage number, starting at 1.limitintegerResults per page, up to 100.
Each shipment
id, referencenumber, stringOur shipment id (for cancelling) and reference (for tracking).order_id, customerstring, objectWhat you sent when creating it.statusstringSee statuses.carrierobjecttypeisobanaorpartner, plusname. Partner shipments may include the carrier’stracking_number.shipping_fee, currencynumber, stringWhat the shipment costs (NGN).tracking_urlstringPublic tracking page to share with your customer.destinationobjectname, city, state and country of the delivery address.created_at, updated_atstringISO 8601 timestamps.
pagination gives total, page, pages and limit.
Recipe: all shipments for one of your customers
curl "https://obana-logistics-t6qg.onrender.com/stores/me/shipments?customer_id=cus_8812" \
-H "Authorization: Bearer $OBANA_API_KEY"Recipe: find a shipment by your order number
curl "https://obana-logistics-t6qg.onrender.com/stores/me/shipments?order_id=SO-1042" \
-H "Authorization: Bearer $OBANA_API_KEY"Recipe: everything in transit right now
curl "https://obana-logistics-t6qg.onrender.com/stores/me/shipments?status=in_transit&limit=100" \
-H "Authorization: Bearer $OBANA_API_KEY"curl "https://obana-logistics-t6qg.onrender.com/stores/me/shipments?page=1&limit=20" \
-H "Authorization: Bearer $OBANA_API_KEY"{
"status": "success",
"data": {
"shipments": [
{
"id": 5821,
"reference": "OBN-7F3K2Q",
"order_id": "SO-1042",
"status": "in_transit",
"customer": {
"id": "cus_8812",
"name": "Chioma Okafor",
"email": "chioma@example.com",
"phone": "+2348031234567"
},
"carrier": { "type": "obana", "name": "Obana Logistics" },
"shipping_fee": 6500,
"currency": "NGN",
"tracking_url": "https://logistics.obana.africa/?track=OBN-7F3K2Q",
"destination": { "name": "Chioma Okafor", "city": "Ibadan", "state": "Oyo", "country": "Nigeria" },
"created_at": "2026-09-10T14:02:11.000Z",
"updated_at": "2026-09-11T09:40:03.000Z"
}
],
"pagination": { "total": 1, "page": 1, "pages": 1, "limit": 20 }
}
}Shipments
Track a shipment
Get one shipment in full: addresses, items, current status and its tracking_events, newest first.
/shipments/track/:shipment_referenceStore keyA store key only sees its own store’s shipments — anything else answers 404.
Tracking page for your customers
https://logistics.obana.africa/?track=<reference> — it’s the tracking_url we return with every shipment.curl https://obana-logistics-t6qg.onrender.com/shipments/track/OBN-7F3K2Q \
-H "Authorization: Bearer $OBANA_API_KEY"{
"success": true,
"data": {
"id": 5821,
"shipment_reference": "OBN-7F3K2Q",
"status": "in_transit",
"shipping_fee": 6500,
"currency": "NGN",
"pickup_address": { "city": "Ikeja", "state": "Lagos", … },
"delivery_address": { "city": "Ibadan", "state": "Oyo", … },
"items": [ { "name": "Ankara dress", "quantity": 2, … } ],
"tracking_events": [
{ "status": "in_transit", "description": "…", "createdAt": "2026-09-11T09:40:03.000Z" },
{ "status": "picked_up", "description": "…", "createdAt": "2026-09-11T07:15:40.000Z" }
]
}
}Shipments
Cancel a shipment
Cancel an order’s delivery before we collect it. Use the numeric shipment_id from the create response (id in lists), not the reference.
/shipments/cancel/:shipment_idStore keyBody
reasonstringWhy it was cancelled. Kept in the shipment history.
Only shipments still pending can be cancelled. Later statuses answer 400, e.g. Shipment cannot be cancelled in picked_up status — contact support instead.
curl -X POST https://obana-logistics-t6qg.onrender.com/shipments/cancel/5821 \
-H "Authorization: Bearer $OBANA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "reason": "Customer cancelled the order" }'{
"success": true,
"message": "Shipment cancelled successfully"
}Shipments
Shipment statuses
A shipment moves through these statuses. Each change triggers a shipment.updated webhook.
pending- Created and waiting to be confirmed. This is the only status you can cancel from.
confirmed- Accepted by Obana — a pickup is being arranged.
picked_up- Collected from your pickup address.
dispatched- Sent out on its journey to the customer.
in_transit- On the way to the delivery address.
delivered- Handed over to the customer. Done.
failed- The delivery couldn’t be completed. Check the tracking events or contact support.
cancelled- Cancelled before pickup. Nothing will be delivered.
returned- Sent back to the pickup address.
Webhooks
Receive events
Instead of polling, let us tell you when something happens. Set a public https:// URL on your store in Stores & API; we POST a JSON event to it.
shipment.created- A shipment was created for your store.
shipment.updated- A shipment’s status changed.
webhook.test- Sent when you press Send test event. Its
datahas amessageand yourstoreinstead of a shipment.
data.shipment has the same fields as a shipment in List your shipments (without destination).
Headers
Obana-Event- The event name, e.g. shipment.updated.
Obana-Delivery- The delivery id. It stays the same when we retry the same event — quote it when you contact support.
Obana-Signaturet=<unix seconds>,v1=<signature>. Always verify it — see Verify signatures.
Replies and retries
Answer with any 2xx within 8 seconds — reply first, then do the slow work. If we get anything else (or no answer), we retry after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then stop.
Expect repeats and out-of-order events
id you’ve handled and skip repeats, and only apply a shipment update if its updated_at is newer than what you have.POST /webhooks/obana HTTP/1.1
Content-Type: application/json
User-Agent: Obana-Webhooks/1.0
Obana-Event: shipment.updated
Obana-Delivery: 90211
Obana-Signature: t=1789119603,v1=5f2c8e0b…{
"id": "evt_4b1f0c2e9a7d31f6c8e2b5a0",
"event": "shipment.updated",
"created_at": "2026-09-11T09:40:03.512Z",
"data": {
"shipment": {
"id": 5821,
"reference": "OBN-7F3K2Q",
"order_id": "SO-1042",
"status": "in_transit",
"customer": {
"id": "cus_8812",
"name": "Chioma Okafor",
"email": "chioma@example.com",
"phone": "+2348031234567"
},
"carrier": { "type": "obana", "name": "Obana Logistics" },
"shipping_fee": 6500,
"currency": "NGN",
"tracking_url": "https://logistics.obana.africa/?track=OBN-7F3K2Q",
"created_at": "2026-09-10T14:02:11.000Z",
"updated_at": "2026-09-11T09:40:03.000Z"
}
}
}Webhooks
Verify signatures
Every event is signed with your store’s webhook secret (starts with whsec_, shown in Stores & API). Checking the signature proves the event came from Obana and wasn’t changed.
- 1
Read the raw body
Use the exact bytes you received. Parsing the JSON and re-encoding it changes the bytes and breaks the check. - 2
Split the header
FromObana-Signaturetaket(unix seconds) andv1(hex signature). - 3
Compute the expected signature
HMAC-SHA256 of<t>.<raw body>with your webhook secret, as hex. - 4
Compare safely
Use a constant-time comparison, and reject events whose t is more than 5 minutes old so an old event can’t be replayed.
// npm install express
const express = require("express");
const crypto = require("crypto");
const app = express();
const WEBHOOK_SECRET = process.env.OBANA_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 5 * 60;
function isValidSignature(rawBody, header) {
const parts = Object.fromEntries(
String(header || "").split(",").map((part) => part.trim().split("="))
);
const t = Number(parts.t);
if (!parts.v1 || !Number.isFinite(t)) return false;
// Too old (or from the future): could be a replay.
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(t + "." + rawBody)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw keeps the exact bytes we signed. Don't use express.json() on this route.
app.post("/webhooks/obana", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!isValidSignature(raw, req.get("Obana-Signature"))) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(raw);
res.sendStatus(200); // reply first, then do the work
if (event.event === "shipment.created" || event.event === "shipment.updated") {
const shipment = event.data.shipment;
// Skip if you've handled event.id before, or already hold a newer shipment.updated_at.
console.log(shipment.reference, shipment.status);
}
});
app.listen(3000);Launch
Going live checklist
Run through this before real orders flow through your integration.
- Your key lives in a server environment variable — not in browser, theme or app code, and not in git.
- Every create call sends your
order_id, so a retry can never book twice. - You send
customer.id, so you can pull up any customer’s shipments. - Checkout shows the price from
/routes/quoteand passes the chosenquote_option_id; you quote again afterexpires_at. - Errors are handled:
400messages reach your team,429waits forRetry-After,5xxretries with backoff. - Your webhook URL is
https://, verifiesObana-Signature, replies2xxwithin 8 seconds and skips repeated event ids. - You sent a test event from the dashboard and saw it arrive.
- Customers get the
tracking_urlin their order confirmation. - You know where to rotate the key or pause the store if a key ever leaks.
Launch
Support
Stuck, or need a route we don’t cover yet? Email us with your store name and, if it’s about a shipment, its reference or the Obana-Delivery id of the webhook.