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.

Base URL
https://obana-logistics-t6qg.onrender.com

Every 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. 1

    Sign in to Obana

  2. 2

    Open Stores & API

    In your dashboard, go to Stores & API.
  3. 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. 4

    Add a webhook URL (optional)

    Give the store a public https:// address and press Send test event to check it arrives. See Webhooks.
  5. 5

    Test your key

    Call GET /stores/me. A 200 with your store’s name means you’re connected.

On Shopify or another hosted platform?

Make the calls from code you control — your own backend or a serverless function — for example when your platform tells you a new order was paid. The key must never go into theme, storefront or app code.
GET/stores/me
curl https://obana-logistics-t6qg.onrender.com/stores/me \
  -H "Authorization: Bearer $OBANA_API_KEY"
Response · 200
{
  "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

Anyone with your key can create shipments billed to your store. Store it in a server environment variable, never in browser JavaScript, mobile app code or a git repository. If a key leaks, rotate it in Stores & API — the old one stops working immediately.

Older OBN- account keys

Account keys starting with OBN- still work, but they aren’t tied to a store. New integrations should use store keys (obk_live_…).
Request header
Authorization: Bearer obk_live_your_store_key
Response · 403
{
  "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, and errors when 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-After header, 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).

Success
{
  "status": "success",
  "data": { … }
}
Error
{
  "status": "error",
  "message": "Weight must be between 0.1 and 1000 kg"
}
Shipment validation error · 400
{
  "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.

POST/routes/quoteNo key needed

No 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

  • originobjectRequired
    Where the parcel starts.
    • countrystringRequired
      Country name, e.g. United Kingdom.
    • country_codestringRequired
      ISO code, e.g. GB.
    • statestringRequired
      State, region or county.
    • state_codestring
      State code when you have it, e.g. LA for Lagos.
    • citystringRequired
  • destinationobjectRequired
    Where it’s going. Same fields as origin.
  • weight_kgnumberRequired
    Total weight in kg, from 0.1 to 1000.
  • declared_valuenumber
    Value of the goods in NGN.
  • display_currencystring
    ISO currency to show prices in as well, e.g. GBP.

Response data

  • options[]array
    One entry per way to ship.
    • idstring
      Pass it as quote_option_id when you create the shipment.
    • providerstring
      obana (our own network) or partner (a carrier we book for you).
    • carrier_name, logo_urlstring
      Who carries it. logo_url can be null.
    • transport_mode, service_levelstring
      e.g. road + Standard. Can be null for partner options.
    • etastring
      Estimated delivery time, e.g. 3-5 days.
    • pricenumber
      What you pay, in NGN.
    • display_pricenumber | null
      price converted to display_currency.
  • cheapest_id, fastest_idstring
    Ids of the cheapest and fastest options. fastest_id is null if no option has an ETA.
  • currency, display_currencystring
    Always NGN, and the currency display_price is in.
  • fxobject | null
    The rate used: rate, as_of and source. null when no conversion was needed or available.
  • expires_atstring
    When 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.

POST/routes/quote
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"
  }'
Response · 200
{
  "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.

POST/shipmentsStore key

Recommended

  • order_idstringRecommended
    Your order number. Sending the same order_id again returns the existing shipment with "duplicate": true instead of creating another — so retries after a timeout are safe.
  • customerobjectRecommended
    Your customer, so you can list every shipment for them later.
    • idstring
      Your own customer ID.
    • name, email, phonestring
  • quote_option_idstringRecommended
    An id from Get a quote — the option your buyer chose. If you leave it out, the cheapest option is used.

Required

  • pickup_addressobjectRequired
    Where we collect the parcel.
    • line1, city, state, country, phonestringRequired
    • contact_name, email, line2, zip_codestring
  • delivery_addressobjectRequired
    Your customer’s address.
    • first_name, last_name, line1, city, state, country, phonestringRequired
    • email, line2, zipstring
  • items[]arrayRequired
    What’s in the parcel.
    • namestringRequired
    • quantityintegerRequired
    • weightnumberRequired
      Weight of one item, in kg.
    • pricenumberRequired
      Value of one item, in NGN.
    • descriptionstring
  • transport_modestringRequired
    road, air or sea.
  • service_levelstringRequired
    Express, Standard or Economy.

Obana sets the price

The fee is always calculated by Obana for the route and weight. Any 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:

Response · 400
{
  "success": false,
  "message": "We don't deliver on this route yet. Contact us to add it."
}
POST/shipments
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"
  }'
Response · 201
{
  "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"
  }
}
Same order_id again · 200
{
  "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.

GET/stores/me/shipmentsStore key

Query parameters (all optional)

  • statusstring
    One of the statuses, e.g. in_transit.
  • order_idstring
    Your order number.
  • customer_idstring
    The customer.id you sent when creating shipments.
  • qstring
    Search by shipment reference or order number (partial match).
  • pageinteger
    Page number, starting at 1.
  • limitinteger
    Results per page, up to 100.

Each shipment

  • id, referencenumber, string
    Our shipment id (for cancelling) and reference (for tracking).
  • order_id, customerstring, object
    What you sent when creating it.
  • statusstring
  • carrierobject
    type is obana or partner, plus name. Partner shipments may include the carrier’s tracking_number.
  • shipping_fee, currencynumber, string
    What the shipment costs (NGN).
  • tracking_urlstring
    Public tracking page to share with your customer.
  • destinationobject
    name, city, state and country of the delivery address.
  • created_at, updated_atstring
    ISO 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"
GET/stores/me/shipments
curl "https://obana-logistics-t6qg.onrender.com/stores/me/shipments?page=1&limit=20" \
  -H "Authorization: Bearer $OBANA_API_KEY"
Response · 200
{
  "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.

GET/shipments/track/:shipment_referenceStore key

A store key only sees its own store’s shipments — anything else answers 404.

Tracking page for your customers

No need to build your own. Send customers to https://logistics.obana.africa/?track=<reference> — it’s the tracking_url we return with every shipment.
GET/shipments/track/:shipment_reference
curl https://obana-logistics-t6qg.onrender.com/shipments/track/OBN-7F3K2Q \
  -H "Authorization: Bearer $OBANA_API_KEY"
Response · 200 (trimmed)
{
  "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.

POST/shipments/cancel/:shipment_idStore key

Body

  • reasonstring
    Why 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.

POST/shipments/cancel/:shipment_id
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" }'
Response · 200
{
  "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

The dashboard card is shipping ahead of the store-side quoting and booking. If Connect is not in your Thaja settings yet, or saving a key does not change what customers see at checkout, it has not reached your store — ask support to enable it rather than assuming the key is wrong.

Connect your store

  1. 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. 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. 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

Thaja holds the key server-side and shows you only its last four characters. It is never sent to your storefront, because a key that can book shipments has no business in a page a customer can open developer tools on.

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.

At checkout — price the lane
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.

When you ship — book the courier
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

This is delivery only. It does not create invoices, move stock or change how your store accounts for anything — it prices a lane and books a courier.

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

A store API key can book shipments and spend money. Anything in theme code, a script tag or a storefront app is readable by every visitor. The key belongs on a server you control, and nowhere else.

The flow

1. Shopify → you
An orders/paid webhook 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. 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. 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. 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 lane
    curl -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. 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. 5

    Write the tracking number back to Shopify

    Back to Shopify
    await 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

Shopify holds weight in grams and Obana prices in kilograms — divide by 1000. And a shipment is refused without a delivery phone number: Shopify makes it optional at checkout, so make it required, or fall back to the customer's account phone.

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?

Open any item in Zoho Inventory. If its type is 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_statusDropdownRequired
    Package Created, In Transit, Fulfilled, Failed, Cancelled, Returned. This drives the whole flow.
  • cf_create_shipmentDropdown
    One option: Via Obana. An explicit way for ops to send an order, when you would rather not use the status field.
  • cf_shipment_idTextRequired
    Obana writes the shipment reference here.
  • cf_tracking_urlURLRequired
    Obana writes the customer's tracking link here.
  • cf_carrier_nameText
    Obana writes the carrier that took it.
  • cf_exchange_rateNumber
    The rate the order was priced at. Obana converts the shipping charge with this, so the order keeps adding up.
  • cf_currency_codeText
    The currency the customer sees, when it differs from the organisation's.
  • cf_salesperson_phonePhone
    Who to notify besides the customer.

cf_shipment_status is the one that matters

Obana accepts an order flagged either way — 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. 1

    Create the custom fields

    As above. Obana reads and writes them by API name.

  2. 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, on cf_shipment_status.

    Condition: cf_shipment_status is Package Created. Action: a webhook.

  3. 3

    Point the webhook at Obana

    Method POST. URL, with your own secret:

    Webhook URL
    https://logistics.obana.africa/zoho/shipment-trigger?salesorder_id=${SALESORDER.SALESORDER_ID}&secret=YOUR_WEBHOOK_SECRET

    The salesorder_id parameter 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. 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_name and the shipping charge onto the order. Nothing further to configure.

  5. 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 URL
    https://logistics.obana.africa/zoho/shipment-status?salesorder_id=${SALESORDER.SALESORDER_ID}&status=${SALESORDER.CF_SHIPMENT_STATUS}&secret=YOUR_WEBHOOK_SECRET

    One rule for In Transit, one for Fulfilled. Both use the same URL.

Where the shipping charge comes from

Obana prices the lane from the pickup and delivery addresses on the order and the weight of the items, then writes that figure to the order's shipping charge. It converts with 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. 1

    Create the same custom fields

    Identical to the goods route. cf_shipment_status is not optional here — it is the only place the lifecycle can live.

  2. 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. 3

    Set the weight on your items

    Obana prices on weight. Put it in the item's package_details.weight in 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

A rule set to run whenever the record is updated re-sends the same status each time anyone touches the order, and Obana's own write-back is an edit. Trigger on field updated, on 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

Terminal
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

Terminal
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 data has a message and your store instead 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-Signature
t=<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

The same event can arrive more than once, and a later update can arrive before an earlier one. Store each event id you’ve handled and skip repeats, and only apply a shipment update if its updated_at is newer than what you have.
Request we send
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…
Body
{
  "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. 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. 2

    Split the header

    From Obana-Signature take t (unix seconds) and v1 (hex signature).
  3. 3

    Compute the expected signature

    HMAC-SHA256 of <t>.<raw body> with your webhook secret, as hex.
  4. 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.
Webhook endpoint
// 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/quote and passes the chosen quote_option_id; you quote again after expires_at.
  • Errors are handled: 400 messages reach your team, 429 waits for Retry-After, 5xx retries with backoff.
  • Your webhook URL is https://, verifies Obana-Signature, replies 2xx within 8 seconds and skips repeated event ids.
  • You sent a test event from the dashboard and saw it arrive.
  • Customers get the tracking_url in 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.