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.

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.