Baladna Express

Partner API

Receive Baladna orders in your POS, tell us what you did with them, and keep your menu in sync. REST over HTTPS, JSON in and out.

Base URL: https://www.baladna-express.com/api/partner/v1

Authentication

Every request carries an API key issued by Baladna. Send it as a bearer token:

curl https://www.baladna-express.com/api/partner/v1/orders \
  -H "Authorization: Bearer bal_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

If your software reserves the Authorization header for its own use, send X-API-Key with the same value instead.

Keys look like bal_live_ or bal_test_ followed by 32 hexadecimal characters. A test key lets you build against the API without touching live orders. Ask for one first.

The key is shown once

Baladna stores only a hash of it and cannot recover the original. If it is lost, a new key is issued and the old one stops working immediately. Treat it as a password: keep it server-side, never in a browser or mobile app.

A revoked key and a key that never existed return exactly the same error. That is deliberate — it stops anyone probing which keys are real.

Reading orders

You only ever see orders for your own store. The merchant is derived from your API key and cannot be overridden by anything in the request.

GET/api/partner/v1/orders
Lists your orders, newest activity last. Query parameters:
  • status — filter to one status
  • since — ISO-8601 timestamp; returns everything changed after it
  • cursor — continue from a previous page
  • limit — default 50, maximum 200
{
  "orders": [ { /* order objects */ } ],
  "has_more": false,
  "next_cursor": "eyJ0cyI6IjIwMjYtMDgtMjVUMTQ6MDI6MTFaIiwiaWQiOiIuLi4ifQ"
}

Results are ordered by when each order last changed, not when it was created, so an order you have already seen reappears when its status moves. Page with cursor rather than an offset: orders are being written while you read, and an offset would skip rows — exactly the ones a catch-up sweep exists to find.

Use this to catch up

Webhooks are the fast path, but they can fail. Poll this endpoint with the last next_cursor you stored — every few minutes is plenty — and you will never permanently miss an order because of a delivery failure.
GET/api/partner/v1/orders/{order_ref}
A single order. Returns 404 if it is not yours.

The order object

{
  "order_ref":  "0a737e06-26ca-4466-9c15-666f9ab2f5dd",
  "short_ref":  "0A737E06",
  "status":     "pending",
  "placed_at":  "2026-08-25T15:02:55.505Z",
  "updated_at": "2026-08-25T15:03:10.117Z",
  "customer":   { "name": "…", "phone": "+2010…" },
  "delivery": {
    "address": "…",
    "notes":   "…",
    "lat": 29.9391,
    "lng": 31.1881
  },
  "items": [
    {
      "sku":        "partner-<integration>-<your-item-id>",
      "name":       "كشري وسط",
      "quantity":   2,
      "unit_price": 45.00,
      "total":      90.00,
      "notes":      "بدون بصل",
      "options": [
        { "group": "الحجم",   "name": "كبير",       "price_delta": 30 },
        { "group": "إضافات", "name": "جبنة زيادة", "price_delta": 5 }
      ]
    }
  ],
  "totals":  { "subtotal": 90.00, "delivery_fee": 20.00, "total": 110.00 },
  "payment": { "method": "cash", "status": "pending" }
}

order_ref is the identifier for every other call. short_ref is what the customer sees in the app and what support will quote on the phone — print it on the ticket.

sku is your own item id, as supplied in your menu feed. It is null for anything Baladna created by hand, in which case match on name.

options is present only when the customer chose extras, and lists them with what each added. Print these on the ticket. unit_price already includes them, so an item listed at 100 arriving at 135 is explained entirely by this array — without it the kitchen has a number and no idea what to make.

total is not subtotal + delivery_fee

total is what the customer pays Baladna overall. It also carries service, express and surcharge fees minus any discount, and on multi-shop orders the delivery fee is charged once across the whole basket rather than on each shop's order. Reconcile on subtotal — that is the value of your items, and the figure Baladna's commission is calculated from.

Updating status

POST/api/partner/v1/orders/{order_ref}/status
Body: { "status": "...", "reason": "...", "eta_minutes": 25 }

Statuses are split by who can actually know the fact.

You may setAllowed fromMeaning
confirmedpendingOrder accepted
preparingpending, confirmedBeing made. Send eta_minutes if you can
cancelledpending, confirmed, preparingreason required

Baladna owns rider_assigned, picked_up, on_the_way and delivered. Those describe what a rider is doing, which your system cannot observe, so sending them returns 400.

There is no “ready” status

Baladna goes preparingrider_assigned. If your POS emits a food-is-ready event, send preparing with eta_minutes and Baladna will time the rider from that.

Sending a status the order already has returns 200 with "unchanged": true, so retrying a callback is always safe. Moving out of a finished state returns 409.

Webhooks

Give Baladna an HTTPS endpoint and every new order and status change is posted to it. Events: order.created and order.status_changed.

POST https://your-pos.example/hooks/baladna
Content-Type: application/json
X-Baladna-Event: order.created
X-Baladna-Delivery: 9f2c1e84-...      # idempotency key
X-Baladna-Attempt: 1
X-Baladna-Signature: sha256=<hmac>

{ "event": "order.created", "sent_at": "…", "order": { /* order object */ } }

Deduplicate on X-Baladna-Delivery

The same delivery id is reused on every retry of the same event. If you have already processed that id, return 200 and do nothing. Without this, a retry after a timeout that actually succeeded prints a second ticket in your kitchen.

Verifying the signature

The signature is an HMAC-SHA256 of the raw request body using the shared secret Baladna gives you. Compute it over the bytes you received, before any JSON parsing or re-serialising.

// Node.js
const crypto = require('crypto')

function verify(rawBody, header, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex')
  const a = Buffer.from(expected)
  const b = Buffer.from(header || '')
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}
<?php
// PHP
function verify(string $rawBody, string $header, string $secret): bool {
    $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
    return hash_equals($expected, $header);
}

Retries

Reply 2xx within 10 seconds. Anything else — including a timeout — is retried after 1 minute, then 5, 30, 2 hours and 6 hours. After the last attempt the delivery is parked and a Baladna admin is alerted; it is not retried again until someone requeues it.

Acknowledge first and process afterwards. A slow reply looks like a failure and earns you a duplicate you then have to deduplicate.

Errors

Errors are JSON with a stable code. Match on the code, not the message.

{ "error": "Order not found.", "code": "not_found" }
HTTPcodeWhat to do
401missing_keyNo key sent. Add the Authorization header.
401invalid_keyKey is unknown, malformed or revoked. Do not retry.
403merchant_inactiveThe store is switched off in Baladna. Contact us.
404not_foundNo such order, or it is not yours.
400invalid_statusStatus is not one you can set. See Updating status.
400reason_requiredCancelling needs a reason.
409invalid_transitionThe order has moved on. Re-read it before retrying.
400invalid_cursor / invalid_sinceMalformed paging parameter.
503auth_unavailableTemporary. Retry with backoff.

Going live

  1. Ask Baladna for a bal_test_ key.
  2. Read GET /orders and confirm you can parse the order object.
  3. Give Baladna your webhook URL. Verify the signature and deduplicate on X-Baladna-Delivery before you go further — these are the two things integrations most often get wrong.
  4. Post a status back and confirm it appears in the Baladna app.
  5. Give Baladna your menu URL and any auth it needs. We will dry-run the import and send you the diff before anything goes live.
  6. Swap the test key for a live one.

Sizes are supported — send them as separate items

Baladna models a size as its own sellable item with its own price, and groups them on the storefront. So a small, medium and large go in your feed as three items with three ids and three prices, exactly like any other item, and the app presents them together. Nothing special is needed on your side.

Modifiers and priced extras

Options chosen at checkout that change the price — add cheese +5, without meat −10 — are supported. Nest them on the item and tell us where they live; the mapping works the same way as the item fields.

{
  "id": "K-2", "name_ar": "كشري وسط", "price": "6500",
  "modifier_groups": [
    {
      "id": "g-extras", "name": "إضافات", "min": 0, "max": 2,
      "modifiers": [
        { "id": "m-cheese", "name": "جبنة زيادة", "price": "500" },
        { "id": "m-onion",  "name": "بدون بصل" }
      ]
    }
  ]
}

A modifier with no price is free, which is how no onions is usually expressed. Negative prices work too. Group ids and modifier ids must be as stable as item ids — they are how Baladna recognises an extra across imports.

min and max are clamped to what the group actually offers. A group requiring 4 choices from 2 options would leave a customer unable to finish the order, so it is corrected rather than imported as-is.

What happens when you drop an extra

Same as items: a modifier you stop listing is hidden, not deleted, and keeps its id. If it comes back it is the same extra, and past orders keep showing what was actually charged.

Questions, keys and endpoint changes: talk to your Baladna contact.