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.
Platforms
Thaja
Thaja stores connect to Obana Logistics from the dashboard — there is nothing to build. You paste your Obana store key once, and Thaja does the quoting and the booking for you.
Rolling out
Connect your store
- 1
Create a key in Obana
Obana dashboard → Stores & API → create a store for this shop → copy the key. One key per shop, so each shop's shipments stay separate and you can revoke one without touching the others.
- 2
Paste it into Thaja
Thaja dashboard → Settings → Integrations → Obana Logistics → Connect. Paste the key and save. Thaja checks it with Obana before saving, so a mistyped key fails there and then rather than at a customer's checkout weeks later.
- 3
Turn on prices at checkout
Connecting does not change your checkout on its own — that is deliberate, so nothing moves under your customers without you asking. Use Turn on at checkout when you are ready.
Your key stays on the server
What your customer sees
- At checkout
- The delivery options Obana runs on that lane, priced from the parcel's weight and where it is going — not a flat rate that is wrong in both directions.
- After they pay
- A tracking link, and a WhatsApp message when the parcel moves.
- While it travels
- Package Created, In Transit, Fulfilled — the same three words you see.
What Thaja does on your behalf
Nothing here is yours to build, but it is worth knowing what your store is doing with your key. These are the same public endpoints any platform uses.
POST /routes/quote
{
"origin": { "city": "Ikeja", "state": "Lagos", "country_code": "NG" },
"destination": { "city": "Ughelli", "state": "Delta", "country_code": "NG" },
"weight_kg": 2,
"declared_value": 50000
}No key needed for a quote, so a price can be shown before anyone commits to anything. It returns every service on the lane, cheapest first.
POST /shipments
Authorization: Bearer <your store key>
{
"order_id": "TJ-1024",
"transport_mode": "road",
"service_level": "Standard",
"pickup_address": { ... your location ... },
"delivery_address": { ... your customer ... },
"items": [{ "name": "Shirt", "quantity": 1, "weight": 0.5, "value": 200000 }]
}Your books are not touched
If something looks wrong
- No prices at checkout
- Check that Turn on at checkout is on. Connecting alone does not change checkout.
- "We don't deliver from … to … yet"
- Obana quotes lanes it runs. The reply carries code: route_unavailable and names the lane, so it can be shown to a shopper as it stands or sent to us to be priced.
- A shipment was refused
- Almost always a missing delivery phone number. A courier cannot collect without one.
- The key stopped working
- Rotating a key in Obana does not update Thaja. Paste the new one and save again.
In short
Paste one key into your Thaja settings. Your customers then see real delivery prices at checkout, and the courier is booked when you ship. No code.
Platforms
Shopify
There is no Obana app in the Shopify app store yet. You connect the two with a small piece of your own code — a serverless function is enough — and it takes about an hour.
Never put the key in your theme
The flow
- 1. Shopify → you
- An
orders/paidwebhook fires when a customer pays. - 2. You → Obana
- Book the shipment with the order's address and line items.
- 3. You → Shopify
- Write the Obana tracking number onto the fulfilment, so the customer sees it in Shopify's own emails.
- 4. Obana → you
- Status webhooks as the parcel moves, which you can mirror onto the order.
- 1
Create a store key
In your Obana dashboard, Stores & API → create a store for the Shopify shop → copy the key. One key per shop, so you can see each shop's shipments separately and revoke one without touching the others.
- 2
Subscribe to orders/paid
In Shopify admin: Settings → Notifications → Webhooks → Create webhook. Event
Order payment, format JSON, pointed at your own endpoint.Verify Shopify's HMAC header before trusting the body — anyone can post to a public URL.
- 3
Show the price before checkout (optional)
Quote the lane from your backend and return it to the cart, so the customer sees the real delivery price rather than a flat rate:
Quote a lanecurl -X POST https://logistics.obana.africa/routes/quote \ -H "Content-Type: application/json" \ -d '{ "origin": { "city": "Ikeja", "state": "Lagos", "country_code": "NG" }, "destination": { "city": "Ughelli", "state": "Delta", "country_code": "NG" }, "weight_kg": 2, "declared_value": 50000 }'This one needs no key, so it is safe to call from a serverless function without secrets. It returns every service on the lane, cheapest first.
- 4
Book the shipment when the order is paid
Your webhook handler// Shopify sends the paid order; you send Obana a shipment. const order = req.body; const shipment = await fetch("https://logistics.obana.africa/shipments", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.OBANA_STORE_KEY}`, }, body: JSON.stringify({ order_id: order.name, // "#1024" — your reference in Obana transport_mode: "road", service_level: "Standard", pickup_address: YOUR_WAREHOUSE, delivery_address: { first_name: order.shipping_address.first_name, last_name: order.shipping_address.last_name, phone: order.shipping_address.phone, // required: a courier must be able to call email: order.email, line1: order.shipping_address.address1, city: order.shipping_address.city, state: order.shipping_address.province, country: order.shipping_address.country, }, items: order.line_items.map((li) => ({ name: li.title, quantity: li.quantity, weight: (li.grams || 500) / 1000, // Obana prices in kilograms value: Number(li.price), })), }), }).then((r) => r.json()); - 5
Write the tracking number back to Shopify
Back to Shopifyawait shopify.fulfillment.create(order.id, { tracking_number: shipment.data.shipment_reference, tracking_url: shipment.data.tracking_url, tracking_company: "Obana Logistics", notify_customer: true, });
Two details that cost people an afternoon
In short
Shopify tells your backend an order was paid. Your backend books the shipment with Obana and writes the tracking number back to Shopify. Two calls.
Zoho
Connect Zoho to Obana Logistics
Obana Logistics connects to Zoho Inventory and Zoho Books directly. Someone in ops flags a sales order, a shipment is booked, the price comes back onto the order, and the customer is notified — without anyone retyping an address.
There are two ways to set it up, and which one you use is decided by your catalogue rather than by preference.
- Your items are goods
- Use Zoho's own shipment records. Zoho creates a package and a shipment order, Obana carries the parcel, and both systems hold the same documents. See Goods: the direct route.
- Your items are services
- Zoho will not put a service item in a package, and a shipment order cannot exist without one. The lifecycle lives on the sales order instead. See Services: the custom-field route.
Not sure which you have?
Service, you need the second route — even if the sales-order line says goods. Zoho checks the item master, not the line, and returns error 36140 when you try to package one.What you get
- Flag an order in Zoho, and the shipment is booked.
- The shipping charge is written back onto the order.
- Status moves in either system and the other follows.
- The customer gets a tracking link by WhatsApp.
Zoho
Custom fields to create
Both routes use the same fields on Sales Orders. Create them once, in Zoho Inventory under Settings → Preferences → Sales Order → Field Customisation. The API names must match exactly — Obana reads them by name.
Sales order fields
cf_shipment_statusDropdownRequiredPackage Created, In Transit, Fulfilled, Failed, Cancelled, Returned. This drives the whole flow.cf_create_shipmentDropdownOne option: Via Obana. An explicit way for ops to send an order, when you would rather not use the status field.cf_shipment_idTextRequiredObana writes the shipment reference here.cf_tracking_urlURLRequiredObana writes the customer's tracking link here.cf_carrier_nameTextObana writes the carrier that took it.cf_exchange_rateNumberThe rate the order was priced at. Obana converts the shipping charge with this, so the order keeps adding up.cf_currency_codeTextThe currency the customer sees, when it differs from the organisation's.cf_salesperson_phonePhoneWho to notify besides the customer.
cf_shipment_status is the one that matters
cf_create_shipment = Via Obana, or cf_shipment_status set to a value meaning Package Created. If you only create one field, create the status one: it also drives the later stages.Zoho
Goods: the direct route
When your items are goods, Zoho can hold the shipment itself. A package is created against the sales order, a shipment order is raised against the package, and Obana carries the parcel. Both systems end up holding the same documents, so a reconciliation is a comparison rather than an investigation.
- 1
Create the custom fields
As above. Obana reads and writes them by API name.
- 2
Add a workflow rule that books the shipment
Zoho Inventory → Settings → Automation → Workflow Rules → New. Module
Sales Orders, executed when a field is updated, oncf_shipment_status.Condition: cf_shipment_status is Package Created. Action: a webhook.
- 3
Point the webhook at Obana
Method
POST. URL, with your own secret:Webhook URLhttps://logistics.obana.africa/zoho/shipment-trigger?salesorder_id=${SALESORDER.SALESORDER_ID}&secret=YOUR_WEBHOOK_SECRETThe
salesorder_idparameter is required. Obana fetches the order from Zoho with it — the webhook body itself is not relied upon, because Zoho sends XML and its placeholders do not always resolve. - 4
Let Obana write back
Obana books the shipment, creates the package and shipment order in Zoho, and writes
cf_shipment_id,cf_tracking_url,cf_carrier_nameand the shipping charge onto the order. Nothing further to configure. - 5
Add rules for the later stages
Two more rules on the same field, pointing at the status endpoint, so a status changed in Zoho reaches Obana:
Webhook URLhttps://logistics.obana.africa/zoho/shipment-status?salesorder_id=${SALESORDER.SALESORDER_ID}&status=${SALESORDER.CF_SHIPMENT_STATUS}&secret=YOUR_WEBHOOK_SECRETOne rule for In Transit, one for Fulfilled. Both use the same URL.
Where the shipping charge comes from
cf_exchange_rate when the order carries one, so the charge is in the same currency as everything else on the order rather than in a rate from somewhere else.Zoho
Services: the custom-field route
Zoho refuses to put a service item in a package — error 36140 — and a shipment order cannot exist without a package. A marketplace whose catalogue is services therefore has no Zoho shipment record to move through statuses.
The sales order's own fields become the lifecycle. It is the same flow as above and the same endpoints; the difference is that Zoho holds no package, and cf_shipment_status is the only record of where the parcel is.
- 1
Create the same custom fields
Identical to the goods route.
cf_shipment_statusis not optional here — it is the only place the lifecycle can live. - 2
One rule per stage, all on cf_shipment_status
- Package Created
- Books the shipment. Points at
/zoho/shipment-trigger. - In Transit
- Moves it. Points at
/zoho/shipment-status. - Fulfilled
- Closes it. Points at
/zoho/shipment-status.
- 3
Set the weight on your items
Obana prices on weight. Put it in the item's
package_details.weightin Zoho. Items with no weight ship at a default, which is how a heavy item quietly moves at a light item's price — the shipment records which lines were defaulted so you can find them.
Do not let a rule fire on every edit
cf_shipment_status only. Obana will not move a parcel backwards, but an unconstrained rule still spends your Zoho API quota.Zoho
When nothing happens
Zoho reports a webhook as delivered as soon as Obana answers, and Obana answers immediately so the rule does not time out. A delivered webhook therefore does not mean a shipment was created. These two endpoints tell you what actually happened.
What Zoho actually sent
curl "https://logistics.obana.africa/zoho/recent?limit=10"The last few requests Zoho made, with the parameters it sent and the answer it got. This is where an unresolved placeholder shows up — a rule that sends the literal text ${SALESORDER.SALESORDER_ID} looks fine in Zoho and does nothing here.
Run it and see the error
curl -X POST "https://logistics.obana.africa/zoho/shipment-trigger?salesorder_id=SALESORDER_ID&wait=1&secret=YOUR_WEBHOOK_SECRET"wait=1 runs the booking inline and returns the reason it failed instead of the usual immediate acknowledgement. Use it on an order that did not come through: it names the cause — a missing phone, an address with no city, an item Zoho will not package — in one call.
The usual causes
- No phone number
- A courier cannot collect without one. Obana looks at the shipping address, then the order's contact person, then the customer record.
- No address on the order
- Obana falls back to the customer record, so the address may be on the customer rather than missing.
- No route for that lane
- Obana quotes lanes it runs. The error names the lane so it can be priced.
- Cannot package a service
- Error 36140. Zoho checks the item master, not the sales-order line. Use the services route.
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.