How it works
A customer checks out on your website. Your server posts the delivery details to tadalal. We price it, charge the merchant’s Tadalal wallet when the driver collects, and dispatch. The order appears in the merchant’s Orders page exactly as if it had been typed there by hand.
- 1 Your customer places an order Your site handles the products, the payment and the receipt. Tadalal never sees any of that.
-
2
Your server calls
POST /ordersSend the recipient, their phone and where it is going. That is the whole required payload. -
3
We dispatch and you poll for status
Use the returned
idto show live progress on your own order-tracking page.
There are no webhooks yet — status is read by polling GET /orders/{id}.
Once every 30–60 seconds while an order is active is plenty; anything faster will hit
the rate limit without telling you anything new.
Before you start
Four things must be true, and three of them are set up by the merchant, not by you.
- An API key. The merchant creates it in their Tadalal portal under API keys and sends it to you privately. It is shown once and cannot be recovered.
- The branch has a map location. Orders are collected from a branch, and a branch with no pin cannot be a pickup point. Portal → Branches.
-
The wallet has money in it. An order is refused with
INSUFFICIENT_BALANCEif the balance will not cover the delivery fee. - You call us from a server. Never from browser JavaScript, a mobile app, or anywhere a customer could read the key.
Authentication
Every request carries the key as a bearer token. Keys look like
glk_live_3f9a2b71_… — the part before the last underscore identifies the key,
the rest is the secret.
Authorization: Bearer glk_live_3f9a2b71_Kj8sQ2vX... Content-Type: application/json
The key can create deliveries that spend the merchant’s money. Store it in an environment variable or your platform’s secrets store — never in Git, never in a theme file, never in anything sent to a browser.
If it leaks, the merchant revokes it in the portal and it stops working on the very next request. Then they issue a new one.
Every response has the same shape
{
"success": true,
"data": { /* … */ }
}
{
"success": false,
"error": {
"code": "INSUFFICIENT_BALANCE",
"message": "…"
}
}
Branch on error.code, never on error.message — the wording may be
improved at any time, the codes will not change.
Test your key
Run this first, before writing any order code. It confirms the key, the base URL and the pickup branch in one call — which is the answer to nearly every “it isn’t working” question.
curl https://api.tadalal.app/api/v1/ping \
-H "Authorization: Bearer $TADALAL_API_KEY"
{
"success": true,
"data": {
"authenticated": true,
"merchant": { "id": "…", "name": "Sharq Sweets" },
"api_key": {
"name": "Main website",
"scopes": ["orders:write", "orders:read"],
"pickup_branch_id": "b3f1…"
},
"wallet": { "balance": 412.000, "currency": "KWD" },
"branches": [
{ "id": "b3f1…", "name": "Sharq", "located": true }
],
"server_time": "2026-09-03T09:14:02.881Z"
}
}
If any branch shows "located": false, ask the merchant to set its map pin
before you send orders that collect from it.
Create an order
The one endpoint you genuinely need. Three fields are required.
Fields
| Field | Type | Notes |
|---|---|---|
| recipient_name * | string | Who receives the parcel. |
| recipient_phone * | string | Kuwait mobile, 8 digits, e.g. 50123456. The driver calls this number. |
| delivery_address * | string | Free text. Include block, street and building — see Addresses. |
| delivery_lat delivery_lng |
number |
Strongly recommended. If you have a map picker at checkout, send the pin. Without them we geocode the address text, which can fail on a vague one. |
| external_ref | string |
Your own order number. Echoed back on every read, and you can look the order up by it. Unique per merchant — a second order with the same value is refused. That is your safety net against duplicates. |
| branch_id | uuid | Required only when the merchant has more than one branch and the key is not bound to one. |
| building_number | string | Shown to the driver alongside the address. |
| city | string | Area or governorate, e.g. Salmiya. |
| notes | string | Instructions for the driver — “second floor, ring twice”. |
| scheduled_at | ISO 8601 | Requested delivery time. Advisory, not a guarantee. |
* required. Pickup is never sent — it comes from the branch.
Example
curl -X POST https://api.tadalal.app/api/v1/orders \ -H "Authorization: Bearer $TADALAL_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: web-order-10482" \ -d '{ "recipient_name": "Fatima Al-Sabah", "recipient_phone": "50123456", "delivery_address": "Salmiya, Block 10, Street 1, Building 27", "delivery_lat": 29.3328, "delivery_lng": 48.0672, "city": "Salmiya", "notes": "Second floor, ring twice", "external_ref": "web-order-10482" }'
<?php // Runs on your server. $_ENV['TADALAL_API_KEY'] is never sent to the browser. $payload = [ 'recipient_name' => $order->customer_name, 'recipient_phone' => $order->phone, 'delivery_address' => $order->address, 'delivery_lat' => $order->lat, // from your checkout map 'delivery_lng' => $order->lng, 'external_ref' => 'web-order-' . $order->id, ]; $ch = curl_init('https://api.tadalal.app/api/v1/orders'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $_ENV['TADALAL_API_KEY'], 'Content-Type: application/json', // Same value if you ever retry this same order. 'Idempotency-Key: web-order-' . $order->id, ], CURLOPT_POSTFIELDS => json_encode($payload), ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $result = json_decode($body, true); if ($status === 201) { $order->update(['tadalal_id' => $result['data']['id']]); } elseif ($result['error']['code'] === 'INSUFFICIENT_BALANCE') { notify_shop_owner('Top up your Tadalal wallet'); } else { log_error($result['error']['code'], $result['error']['message']); }
const res = await fetch('https://api.tadalal.app/api/v1/orders', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.TADALAL_API_KEY}`, 'Content-Type': 'application/json', // Reuse this exact value on any retry of this same order. 'Idempotency-Key': `web-order-${order.id}`, }, body: JSON.stringify({ recipient_name: order.customerName, recipient_phone: order.phone, delivery_address: order.address, delivery_lat: order.lat, // from your checkout map delivery_lng: order.lng, external_ref: `web-order-${order.id}`, }), }); const body = await res.json(); if (res.status === 201) { await db.orders.update(order.id, { tadalalId: body.data.id }); } else if (body.error.code === 'INSUFFICIENT_BALANCE') { await notifyShopOwner('Top up your Tadalal wallet'); } else { logger.error(body.error.code, body.error.message); }
{
"success": true,
"data": {
"id": "7c1e4f8a-2b93-4d61-9f0a-55d2c1e77b31",
"order_number": "ORD-260903-4821",
"external_ref": "web-order-10482",
"status": "pending",
"recipient": { "name": "Fatima Al-Sabah", "phone": "50123456" },
"delivery": {
"address": "Salmiya, Block 10, Street 1, Building 27",
"lat": 29.3328, "lng": 48.0672
},
"pickup": { "address": "Sharq, Ahmad Al Jaber St", "lat": 29.3759, "lng": 47.9774 },
"distance_km": 8.42,
"delivery_fee": 1.750,
"currency": "KWD",
"driver": null,
"placed_at": "2026-09-03T09:14:02.881Z"
}
}
Store data.id against your own order. It is what you poll with.
Addresses & coordinates
The delivery fee is calculated from the driving distance between the branch and the drop-off. A wrong coordinate is not a cosmetic problem — it produces a wrong fare and sends a driver to the wrong place.
If your checkout has a map picker, send delivery_lat and
delivery_lng. Nothing we can infer from text beats a pin the customer
dropped themselves.
When you send no coordinates we look the address up. That lookup is deliberately strict:
a vague address is rejected with ADDRESS_NOT_RESOLVED rather than
quietly resolved to the middle of the country — which is what produces a plausible-looking
53 km delivery across the street.
"Salmiya, Block 10, Street 1, Building 27" "Sharq, Ahmad Al Jaber Street, Kuwait City" "Hawally, Block 4, Tunis Street"
"Kuwait" "near the big mosque" "Salmiya" // area alone
Deliveries are Kuwait-only. A coordinate outside the country is refused with
INVALID_DELIVERY_COORDS.
Avoiding duplicates
This is the part worth reading twice. If your request times out you do not know whether the order was created — and retrying blindly is how one customer gets two drivers and the merchant pays two fees.
Send an Idempotency-Key
Put a value that is unique to that order — your own order id works perfectly — in
an Idempotency-Key header. Reuse the same value on every retry of that order.
Idempotency-Key: web-order-10482
The first request does the work. Any repeat with the same key returns the
original response, byte for byte, with an Idempotent-Replay: true header.
No second order, no second charge.
Generate the key per order, not per customer and not per day. Sending the same
key with a different body is refused with IDEMPOTENCY_KEY_REUSED — because
the alternative would be to silently swallow a real second delivery.
Keys are remembered for 24 hours. After that the same value is treated as a new request, which is correct: a retry a day later is not a retry.
The second safety net
Send external_ref as well. It is unique per merchant, so even with no
idempotency key at all, replaying the same website order is refused with
DUPLICATE_EXTERNAL_REF. Use both and duplicates are effectively impossible.
Which branch the order is collected from
Pickup always comes from a branch — you never send a pickup address.
| The merchant has | What happens |
|---|---|
| One branch | Used automatically. Send nothing. |
| Several, key bound to one | The key’s branch is used. Send nothing. Recommended. |
| Several, key not bound | You must send branch_id, or the request is refused with BRANCH_REQUIRED — the response lists the branches to choose from. |
The simplest setup for a multi-branch merchant is one API key per branch, each bound to its own. Then your code never mentions a branch at all.
Order status
Six values, in the order a normal delivery passes through them. Treat any value you do not recognise as “in progress” — new ones may be added.
pendingReceived and waiting for a driver.assignedA driver has it and is heading to the shop.picked_upCollected. The delivery fee is charged at this point.in_transitOn the way to your customer.deliveredHanded over. Final.failedCould not be delivered — nobody home, wrong address.returningComing back to the shop.returnedBack at the shop. Final.cancelledCancelled before collection. Final.These are the only status values the API will ever return. Tadalal uses a more detailed set internally; it is deliberately not exposed, so we can change it without breaking your site.
Read an order
{reference} can be any of three things, so you never have to store an
identifier you did not choose:
| Reference | Example |
|---|---|
| Our order id | 7c1e4f8a-2b93-4d61-9f0a-55d2c1e77b31 |
| Our order number | ORD-260903-4821 |
| Your own reference | web-order-10482 |
curl https://api.tadalal.app/api/v1/orders/web-order-10482 \
-H "Authorization: Bearer $TADALAL_API_KEY"
The response is the same order object as on create. Once a driver is assigned,
driver carries their name and phone so you can show it to your customer.
{
"success": true,
"data": {
"order_number": "ORD-260903-4821",
"status": "in_transit",
"driver": { "name": "Ahmed K.", "phone": "66554433" },
"picked_up_at": "2026-09-03T09:41:20.115Z",
"delivered_at": null
}
}
List orders
Newest first. Useful for reconciliation, not for tracking a single order.
| Query | Default | Notes |
|---|---|---|
| limit | 25 | Maximum 100. Larger values are clamped, not rejected. |
| offset | 0 | For paging. |
| status | — | One of the values in Order status. |
| created_after | — | ISO 8601 timestamp. |
curl "https://api.tadalal.app/api/v1/orders?status=in_transit&limit=50" \ -H "Authorization: Bearer $TADALAL_API_KEY"
Cancel an order
Works only while the order is pending or assigned. Once the driver
has collected the parcel the fee has been charged and someone is carrying it, so
cancellation becomes a support conversation rather than an API call —
you get NOT_CANCELLABLE.
Cancelling an already-cancelled order returns 200, not an error, so a retry is safe.
Errors
Every failure carries a stable error.code. These are the ones worth handling
explicitly; anything else can be logged and retried.
| Code | HTTP | What to do |
|---|---|---|
| INSUFFICIENT_BALANCE | 402 | Tell the shop owner to top up their Tadalal wallet. Do not retry until they have. |
| MISSING_FIELDS | 400 | error.details.missing lists exactly which. A bug in your payload. |
| ADDRESS_NOT_RESOLVED | 422 | Ask the customer for a more precise address, or send coordinates. |
| INVALID_DELIVERY_COORDS | 422 | The point is outside Kuwait. Check you have not swapped lat and lng. |
| DUPLICATE_EXTERNAL_REF | 409 | This order already exists. Safe to treat as success. |
| IDEMPOTENCY_KEY_REUSED | 422 | You sent one key for two different orders. Generate one per order. |
| REQUEST_IN_PROGRESS | 409 | An identical request is still running. Wait a few seconds and retry the same call. |
| BRANCH_REQUIRED | 409 | Send branch_id. The response lists the options. |
| BRANCH_NOT_LOCATED | 409 | The merchant must set the branch’s map pin in the portal. |
| INVALID_API_KEY | 401 | Wrong, revoked, or the merchant’s account is inactive. Do not retry. |
| INSUFFICIENT_SCOPE | 403 | The key is read-only. Ask for one with orders:write. |
| NOT_CANCELLABLE | 409 | Already collected. Contact support. |
| RATE_LIMITED | 429 | Back off and retry after the window resets. |
| GEOCODING_UNAVAILABLE | 503 | Temporary. Retry with the same Idempotency-Key. |
On a timeout or a 5xx, retry the same request with the
same Idempotency-Key. That is the only retry that cannot create a
duplicate. Never retry a 4xx unchanged — it will fail identically.
Rate limits
| Operation | Limit |
|---|---|
| Creating and cancelling orders | 60 / minute per key |
| Reading and listing | 300 / minute per key |
Standard RateLimit-* headers are returned on every response. Polling one
active order every 30 seconds uses about two requests a minute — well inside the limit.
Go-live checklist
-
GET /pingreturns 200 from your production server, not just your laptop. - The key is in an environment variable and is not in your Git history.
-
Every create sends an
Idempotency-Keyand anexternal_ref. -
You store
data.idagainst your own order record. -
INSUFFICIENT_BALANCEalerts the shop owner instead of failing silently. This is the most common real-world failure. - A failed order is visible to someone. A delivery that never reached us should raise an alarm on your side, not sit in a log.
- One real order end to end — placed, collected, delivered — before you switch customers over.