Shop API
Push delivery orders from your POS, website, or custom backend. Signed HMAC requests, idempotent POSTs, real-time webhooks. Works the same for every Waslni tenant.
1. Overview
The Shop API lets your shop create delivery orders from your own systems — a POS terminal, a website checkout, a WhatsApp bot, anything that can make an HTTPS call. The platform does the rest: assigns a driver, sends push notifications, tracks the delivery, and calls your webhook when the status changes.
What is a “tenant host”?
How to find yours: open the Shop Owner app → Shop API Integration → Docs. The docs page will open with your tenant host already filled into every example. If you don't have the app, ask your platform administrator for the URL directly — we don't publish the list here.
Every request in these docs assumes you're hitting
https://<your-tenant-host>/api/v2/external/....At a glance
| Base URL | https://<your-tenant-host>/api/v2/external |
|---|---|
| Transport | HTTPS only (TLS 1.2+) |
| Body format | JSON (UTF-8) |
| Auth | API key + HMAC-SHA256 request signature |
| Rate limit | 60 requests/minute per key (default) |
Who is this for?
2. Quick Start (5 min)
The fastest way to see it working. Copy-paste, replace 3 values, run.
Step 1 — Get your tenant host
Open the Shop Owner mobile app → tap تكامل الـ API (Shop API Integration). At the top you'll see the URL to use. For example:
https://api.example.comStep 2 — Create a key
In the same screen tap + New Key. Give it a name (e.g. "My Website"). You'll get two values:
publicKey: wsl_a3f91b2c7d4e8f2091de45af
secretKey: 4c8b2e9f1d3a7b6e5c9f2d8a1b4e7c3f6a9d2e5b8c1f4a7dSave the secretKey NOW
Step 3 — Create your first delivery order
Here is a complete working curl command. Replace TENANT_HOST, PUBLIC_KEY, and SECRET_KEY with your values.
# ──────────────── 1. Set your values ────────────────
TENANT_HOST="api.example.com"
PUBLIC_KEY="wsl_a3f91b2c7d4e8f2091de45af"
SECRET_KEY="4c8b2e9f1d3a7b6e5c9f2d8a1b4e7c3f6a9d2e5b8c1f4a7d"
# ──────────────── 2. Build the request ──────────────
BODY='{
"customerName": "Ahmed Ali",
"customerPhone": "0599123456",
"deliveryAddress": {
"lat": 31.9030,
"lng": 35.2050,
"address": "Main St 12, Ramallah"
},
"packageNote": "2 pizzas"
}'
PATH_="/api/v2/external/orders"
TIMESTAMP=$(date +%s)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 | awk '{print $2}')
STRING_TO_SIGN=$(printf '%s\n%s\n%s\n%s' "$TIMESTAMP" "POST" "$PATH_" "$BODY_HASH")
HMAC_KEY=$(printf '%s' "$SECRET_KEY" | openssl dgst -sha256 | awk '{print $2}')
SIGNATURE=$(printf '%s' "$STRING_TO_SIGN" | openssl dgst -sha256 -hmac "$HMAC_KEY" | awk '{print $2}')
# ──────────────── 3. Send it ────────────────────────
curl -X POST "https://${TENANT_HOST}${PATH_}" \
-H "x-api-key: $PUBLIC_KEY" \
-H "x-timestamp: $TIMESTAMP" \
-H "x-signature: $SIGNATURE" \
-H "content-type: application/json" \
-d "$BODY"Step 4 — Expected response
HTTP/1.1 201 Created
{
"ok": true,
"order": {
"id": 4521,
"trackingRef": "TRK-20260418-4521",
"status": "ready_for_pickup",
"deliveryFee": 12.50,
"estimatedDeliveryTime": 18,
"totalPrice": 12.50,
"currency": "ILS",
"estimatedPrepMinutes": null,
"prepReadyAt": null,
"prepSubState": null,
"createdAt": "2026-04-18T13:02:11.000Z"
}
}That's it — you just created a real order. It will appear in the shop dashboard and drivers will be notified. Keep reading to understand why each header matters, how to handle failures, and how to get real-time webhook updates instead of polling.
Prefer Node.js or PHP?
2. Generating API Keys
Only the master shop owner can create keys. Two ways:
A. From the Shop Owner mobile app
- Open the app and go to the shop dashboard.
- Tap "Shop API Integration" (تكامل الـ API).
- Tap + New Key, give it a descriptive name (e.g. "POS System").
- Copy the
secretKeyshown on the confirmation screen. It is displayed once and never again.
B. From the Admin Panel
Staff with the shopApi.manage permission can create keys on behalf of a shop from Operations → Shop API → [shop] → Keys.
Important
Anatomy of a key pair
publicKey— starts withwsl_. Sent in every request asx-api-key. Safe to log.secretKey— 48-hex-char secret. Store encrypted. Only used to compute the HMAC signature.
3. Authentication & Request Signing
Every request must carry three headers:
| Header | Value |
|---|---|
x-api-key | The public key, e.g. wsl_a3f9... |
x-timestamp | Unix epoch in seconds — current time |
x-signature | HMAC-SHA256 hex of the signing string |
Signing string
Concatenate with \n (single newline, no trailing newline):
`${timestamp}\n${method}\n${path}\n${bodyHash}`methodis uppercase (GET,POST,PATCH,DELETE).pathis the full path including query string, e.g./api/v2/external/orders?page=1.bodyHashissha256(rawBody)as hex. For GET / DELETE without a body, usesha256("").
Signature
First derive an HMAC key from your secret (one-time per request, cheap), then sign:
hmacKey = sha256(secretKey) // 64-char hex
signature = HMAC_SHA256(hmacKey, signingString) // hex outputThe extra SHA-256 step exists because the platform never stores your raw secretKey — only its SHA-256 derivation is kept server-side for verification. You must apply the same derivation in your client before HMAC-signing.
Clock drift
Idempotency (optional but recommended)
On any POST that creates a resource, set:
x-idempotency-key: <unique uuid, stable for 24h>Retrying the same request within 24 hours with the same key returns the original response with header x-idempotent-replayed: true — no duplicate order is created.
4. Endpoints
Download a ready-to-run collection — host placeholder, pre-request script auto-signs every call. Just paste your public key + secret into the collection variables.
/ordersCreate a delivery order. Driver assignment is automatic. Set prepaidAmount to the cash the courier should collect from the customer on arrival — it's added to totalPrice: totalPrice = deliveryFee + prepaidAmount.
Request body
{
"customerName": "Ahmed Ali",
"customerPhone": "0599123456",
"deliveryAddress": {
"lat": 31.9030,
"lng": 35.2050,
"address": "Main St 12, Building 4, Ramallah"
},
"packageNote": "2 pizzas, 1 soda",
"prepaidAmount": 0,
"deliveryNotes": "Call on arrival",
"referenceId": "YOUR-ORDER-4521",
// Optional — feature 017
"quoteId": "Y2hkb3duLXdoYXRldmVyMzg", // lock the fee from POST /orders/quote
"estimatedPrepMinutes": 25, // restaurant prep time (see §5b below)
"serviceTypeId": null // null/omit = any courier-eligible driver;
// specific id = restrict to that vehicle class
// (e.g. car-only for bulky packages).
// Discover ids via GET /api/v2/service-types.
}Choosing a vehicle
serviceTypeId unset — every courier-eligible driver (car, moto, etc.) sees the order, so it gets accepted fastest. Set it only when the package needs a specific vehicle class (a fragile cake → car, a small envelope → moto). The tenant's admin controls which service types are courier-eligible; pass an ineligible id and the order is rejected with VALIDATION_ERROR.Response (201)
{
"ok": true,
"order": {
"id": 4521,
"trackingRef": "TRK-20260418-4521",
"status": "preparing", // or "ready_for_pickup" if no prep
"deliveryFee": 12.50,
"estimatedDeliveryTime": 18,
"totalPrice": 12.50,
"currency": "ILS",
"estimatedPrepMinutes": 25,
"prepReadyAt": "2026-04-18T13:27:11.000Z",
"prepSubState": "hidden", // "hidden" | "lead" | null
"serviceTypeId": null, // null = any courier; otherwise the pinned id
"createdAt": "2026-04-18T13:02:11.000Z"
}
}/orders/quoteGet the delivery fee, distance, ETA, and currency for a destination — no order is created. Use this on your checkout page to show the customer a price before they confirm. Rate-limited to 1 request per second per API key.
The response also returns a single-use quoteId with an explicit quoteExpiresAt. Pass that quoteId to POST /orders within the validity window (default 5 min, admin-tunable per tenant) to lock the delivery fee — protects against pricing changes between checkout and order creation.
Request body
{
"deliveryAddress": {
"lat": 31.2001,
"lng": 29.9187
}
}Response (200)
{
"ok": true,
"quote": {
"deliveryFee": 102.00,
"totalPrice": 102.00,
"distanceKm": 8.42,
"estimatedDeliveryTime": 14,
"currency": "ILS",
"quoteId": "Y2hkb3duLXdoYXRldmVyMzg",
"quoteExpiresAt": "2026-05-06T14:35:00.000Z",
"ttlSeconds": 300
}
}Quote-binding rules
quoteId is single-use and bound to the shop and the destination. If POST /orders presents it with a destination more than ~100 m from the quoted lat/lng, the request is rejected with 422 QUOTE_ADDRESS_MISMATCH. Expired or already-consumed handles return 422 QUOTE_NOT_FOUND — fetch a fresh quote./ordersList your shop's orders. Supports filters.
| Param | Notes |
|---|---|
status | pending, accepted, preparing, ready_for_pickup, picked_up, delivered, cancelled |
createdBy | user, shop, admin, api |
from / to | ISO timestamps (default: last 30 days) |
page / limit | default 1 / 20, max limit 100 |
/orders/:idFull order detail including driver info and timeline.
/orders/:id/cancelCancel before pickup. Body: { "reason": "customer changed mind" }. Any pending prep-dispatch jobs are cancelled too — no late driver pings.
/orders/:id/readyFor prep-delay orders only. Releases a preparing order ahead of schedule (e.g. food is actually ready in 12 min, not 25). Cancels both pending prep-dispatch timers, flips status to ready_for_pickup, and dispatches drivers inline if the lead window had not yet opened.
Body: empty. Idempotent — returns 200 with current state if the order is already ready or beyond. Returns 422 ORDER_CANCELLED for cancelled orders.
/orders/:id/addressChange delivery address before pickup. Delivery fee is recalculated.
4b. Restaurant prep delay
For restaurant integrations, you can tell us how long the kitchen needs so drivers don't arrive at an empty counter. Pass estimatedPrepMinutes on POST /orders:
POST /orders
{
"customerName": "...",
"customerPhone": "...",
"deliveryAddress": { "lat": ..., "lng": ..., "address": "..." },
"estimatedPrepMinutes": 25
}What happens server-side
The order is created in preparing status with a prepReadyAt timestamp. The platform schedules two background jobs against that timestamp:
| Window | When | Driver experience |
|---|---|---|
hidden | Create → prepReadyAt − leadMinutes | No driver sees the offer. |
lead | prepReadyAt − leadMinutes → prepReadyAt | Drivers see the offer with a "Ready in ~X min" badge so they can plan their arrival. |
ready_for_pickup | At prepReadyAt | Status flips automatically; any accepted driver's badge clears. |
leadMinutes is admin-tunable per tenant (default 20 min). If estimatedPrepMinutes ≤ leadMinutes, the hidden window is skipped — drivers see the order immediately with the badge. Maximum estimatedPrepMinutes is also admin-tunable (default 180 min); larger values are rejected with 400 VALIDATION_ERROR carrying the cap.
Releasing early
If the kitchen finishes ahead of schedule, call POST /orders/:id/ready. Both timers cancel, status flips to ready_for_pickup, and (if the lead window hadn't opened yet) drivers are notified inline.
Backwards compatible
estimatedPrepMinutes and the order behaves exactly as before — straight to ready_for_pickup, drivers notified at create time. Existing integrations need no changes.5. Webhooks
Instead of polling GET /orders, subscribe to events. Register an HTTPS URL in the Shop Owner app under Shop API Integration → Webhooks, pick which events to subscribe to, and we will POST JSON to your URL when they happen.
Events
| Event | Fired when |
|---|---|
order_driver_assigned | A driver accepts the delivery |
order_picked_up | Driver has picked up the package |
order_delivered | Driver has delivered the package |
order_cancelled | Order is cancelled by any party |
Webhook payload signature
Each webhook POST carries:
x-wsl-timestamp | Unix epoch seconds |
x-wsl-signature | HMAC-SHA256 of `${timestamp}.${rawBody}` using the webhook secret shown at registration |
x-wsl-event | Event name (e.g. order_cancelled) — lets you route without parsing the body |
x-wsl-delivery-id | Unique delivery attempt id — use for idempotency on your side |
Verify every webhook
Retries
If your endpoint returns non-2xx we retry with exponential backoff up to 3 attempts. Consistent failures move the webhook to failing state; after extended failures it is paused.
6. Errors & Rate Limits
HTTP error codes
| Code | Meaning |
|---|---|
| 400 | VALIDATION_ERROR — missing/invalid field (see details[]) |
| 401 | UNAUTHORIZED — bad API key, bad signature, expired timestamp |
| 403 | API_SUSPENDED or SHOP_INACTIVE |
| 404 | NOT_FOUND — order not owned by this shop |
| 409 | Conflict — e.g. too many active API keys, or ALREADY_CANCELLED |
| 422 | INSUFFICIENT_BALANCE, ORDER_NOT_CANCELLABLE, ORDER_NOT_MODIFIABLE, ORDER_CANCELLED, SHOP_INACTIVE, QUOTE_NOT_FOUND (expired / consumed / never issued), QUOTE_ADDRESS_MISMATCH (destination >100 m from quote) |
| 429 | RATE_LIMITED — retry after 60s |
| 5xx | INTERNAL_ERROR — retry with backoff |
Rate limits
Default 60 req/min per key. Responses include the standard RateLimit / RateLimit-Remaining / RateLimit-Reset headers (draft-7). Request a higher limit from your tenant's platform staff if you need it.
7. Code Examples
Node.js
const crypto = require('crypto');
const fetch = require('node-fetch');
const API_BASE = 'https://api.example.com/api/v2/external';
const PUBLIC_KEY = 'wsl_xxxxxxxxxxxx';
const SECRET_KEY = '<secret>';
const HMAC_KEY = crypto.createHash('sha256').update(SECRET_KEY).digest('hex');
function sign(method, path, body) {
const ts = Math.floor(Date.now() / 1000).toString();
const raw = body ? JSON.stringify(body) : '';
const bodyHash = crypto.createHash('sha256').update(raw).digest('hex');
const str = `${ts}\n${method.toUpperCase()}\n${path}\n${bodyHash}`;
const sig = crypto.createHmac('sha256', HMAC_KEY).update(str).digest('hex');
return { ts, sig, raw };
}
async function createOrder(body) {
const path = '/api/v2/external/orders';
const { ts, sig, raw } = sign('POST', path, body);
const res = await fetch(API_BASE + '/orders', {
method: 'POST',
headers: {
'x-api-key': PUBLIC_KEY,
'x-timestamp': ts,
'x-signature': sig,
'x-idempotency-key': crypto.randomUUID(),
'content-type': 'application/json',
},
body: raw,
});
return res.json();
}PHP
<?php
$apiBase = 'https://api.example.com/api/v2/external';
$publicKey = 'wsl_xxxxxxxxxxxx';
$secretKey = '<secret>';
function callApi($method, $path, $body = null) {
global $apiBase, $publicKey, $secretKey;
$ts = (string) time();
$raw = $body === null ? '' : json_encode($body);
$bodyHash = hash('sha256', $raw);
$str = $ts . "\n" . strtoupper($method) . "\n" . $path . "\n" . $bodyHash;
$hmacKey = hash('sha256', $secretKey);
$sig = hash_hmac('sha256', $str, $hmacKey);
$ch = curl_init($apiBase . substr($path, strlen('/api/v2/external')));
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => [
'x-api-key: ' . $publicKey,
'x-timestamp: ' . $ts,
'x-signature: ' . $sig,
'content-type: application/json',
],
CURLOPT_POSTFIELDS => $raw,
CURLOPT_RETURNTRANSFER => true,
]);
return json_decode(curl_exec($ch), true);
}8. Security Best Practices
- Never embed the secret key in mobile or frontend code. It belongs on a server you control.
- Use HTTPS for your own webhook endpoints.
- Rotate keys every 6–12 months, or immediately if a laptop/server is lost.
- Scope per integration — create one key per integration (POS, website, Zapier) so you can revoke just the affected one.
- Verify webhook signatures — do not trust payloads without checking
x-wsl-signature. - Use idempotency keys on all create/cancel operations to survive network retries safely.
If a secret leaks
Your app.
Your brand.
Your customers.
A white-label platform to launch your Uber-style app — rides, delivery, restaurants, and shops. Start a free 14-day sandbox, no credit card and no engineering required.