Developer docs

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”?

Each Waslni deployment (one per country/operator) has its own API address — that's the tenant host. It's the same URL the Shop Owner mobile app uses to talk to its backend.

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 URLhttps://<your-tenant-host>/api/v2/external
TransportHTTPS only (TLS 1.2+)
Body formatJSON (UTF-8)
AuthAPI key + HMAC-SHA256 request signature
Rate limit60 requests/minute per key (default)

Who is this for?

Shop owners who want to push delivery orders programmatically — for example from a POS system, an e-commerce website, or a Zapier/Make integration. Keys are generated per shop and are secret— never embed them in a mobile app or frontend code.

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.com

Step 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: 4c8b2e9f1d3a7b6e5c9f2d8a1b4e7c3f6a9d2e5b8c1f4a7d

Save the secretKey NOW

It is shown only once. If you lose it, you must revoke that key and create a new one.

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?

Jump to §8 Code Examples for ready-to-run Node and PHP snippets that do the signing for you.

2. Generating API Keys

Only the master shop owner can create keys. Two ways:

A. From the Shop Owner mobile app

  1. Open the app and go to the shop dashboard.
  2. Tap "Shop API Integration" (تكامل الـ API).
  3. Tap + New Key, give it a descriptive name (e.g. "POS System").
  4. Copy the secretKey shown 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

Maximum 5 active keys per shop. Rotate regularly: create a new key, migrate your integration, then revoke the old one.

Anatomy of a key pair

  • publicKey — starts with wsl_. Sent in every request as x-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:

HeaderValue
x-api-keyThe public key, e.g. wsl_a3f9...
x-timestampUnix epoch in seconds — current time
x-signatureHMAC-SHA256 hex of the signing string

Signing string

Concatenate with \n (single newline, no trailing newline):

`${timestamp}\n${method}\n${path}\n${bodyHash}`
  • method is uppercase (GET, POST, PATCH, DELETE).
  • path is the full path including query string, e.g. /api/v2/external/orders?page=1.
  • bodyHash is sha256(rawBody) as hex. For GET / DELETE without a body, use sha256("").

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 output

The 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

The server rejects timestamps more than 5 minutes off from its own clock. Keep your servers synced to NTP.

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

Postman collection

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.

Download .postman_collection.json
POST/orders

Create 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

Most integrations should leave 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"
  }
}
POST/orders/quote

Get 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

The 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.
GET/orders

List your shop's orders. Supports filters.

ParamNotes
statuspending, accepted, preparing, ready_for_pickup, picked_up, delivered, cancelled
createdByuser, shop, admin, api
from / toISO timestamps (default: last 30 days)
page / limitdefault 1 / 20, max limit 100
GET/orders/:id

Full order detail including driver info and timeline.

POST/orders/:id/cancel

Cancel before pickup. Body: { "reason": "customer changed mind" }. Any pending prep-dispatch jobs are cancelled too — no late driver pings.

POST/orders/:id/ready

For 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.

PATCH/orders/:id/address

Change 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:

WindowWhenDriver experience
hiddenCreate → prepReadyAt − leadMinutesNo driver sees the offer.
leadprepReadyAt − leadMinutesprepReadyAtDrivers see the offer with a "Ready in ~X min" badge so they can plan their arrival.
ready_for_pickupAt prepReadyAtStatus 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

Omit 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

EventFired when
order_driver_assignedA driver accepts the delivery
order_picked_upDriver has picked up the package
order_deliveredDriver has delivered the package
order_cancelledOrder is cancelled by any party

Webhook payload signature

Each webhook POST carries:

x-wsl-timestampUnix epoch seconds
x-wsl-signatureHMAC-SHA256 of `${timestamp}.${rawBody}` using the webhook secret shown at registration
x-wsl-eventEvent name (e.g. order_cancelled) — lets you route without parsing the body
x-wsl-delivery-idUnique delivery attempt id — use for idempotency on your side

Verify every webhook

Reject requests where the signature does not match or the timestamp is older than 5 minutes.

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

CodeMeaning
400VALIDATION_ERROR — missing/invalid field (see details[])
401UNAUTHORIZED — bad API key, bad signature, expired timestamp
403API_SUSPENDED or SHOP_INACTIVE
404NOT_FOUND — order not owned by this shop
409Conflict — e.g. too many active API keys, or ALREADY_CANCELLED
422INSUFFICIENT_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)
429RATE_LIMITED — retry after 60s
5xxINTERNAL_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

Revoke the key immediately from the app or admin panel. Any in-flight request signed with the revoked key will be rejected once the cache expires (within ~60 seconds).
What's next

Your app.
Your brand.
Your customers.

WASLNI / PLATFORM
14
Try free for 14 days
Launching in 30 days - or less.
-> Palestine · Egypt · you next

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.

WASLNI / PLATFORM
-> Palestine · Egypt · you next