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
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.
status— filter to one statussince— ISO-8601 timestamp; returns everything changed after itcursor— continue from a previous pagelimit— 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
next_cursor you stored — every few minutes is plenty — and you will never permanently miss an order because of a delivery failure.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
{ "status": "...", "reason": "...", "eta_minutes": 25 }Statuses are split by who can actually know the fact.
| You may set | Allowed from | Meaning |
|---|---|---|
| confirmed | pending | Order accepted |
| preparing | pending, confirmed | Being made. Send eta_minutes if you can |
| cancelled | pending, confirmed, preparing | reason 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
preparing → rider_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
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" }| HTTP | code | What to do |
|---|---|---|
| 401 | missing_key | No key sent. Add the Authorization header. |
| 401 | invalid_key | Key is unknown, malformed or revoked. Do not retry. |
| 403 | merchant_inactive | The store is switched off in Baladna. Contact us. |
| 404 | not_found | No such order, or it is not yours. |
| 400 | invalid_status | Status is not one you can set. See Updating status. |
| 400 | reason_required | Cancelling needs a reason. |
| 409 | invalid_transition | The order has moved on. Re-read it before retrying. |
| 400 | invalid_cursor / invalid_since | Malformed paging parameter. |
| 503 | auth_unavailable | Temporary. Retry with backoff. |
Going live
- Ask Baladna for a
bal_test_key. - Read
GET /ordersand confirm you can parse the order object. - Give Baladna your webhook URL. Verify the signature and deduplicate on
X-Baladna-Deliverybefore you go further — these are the two things integrations most often get wrong. - Post a status back and confirm it appears in the Baladna app.
- 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.
- Swap the test key for a live one.
Sizes are supported — send them as separate items
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
Questions, keys and endpoint changes: talk to your Baladna contact.