# Integrating a store with Kapitano Logistic — guide for kapitano_web

**Audience:** the engineers building the store side (kapitano_web).
**Source of truth for field shapes:** `storage/api-docs/integration-docs.json`
(live: `/api/documentation/integration`). Where this guide and `docs/store-api-ar.md`
disagree, the spec and the code win — see [§12 Known drift](#12-known-drift-and-gotchas).

---

## 1. How the pieces fit

```
 kapitano_web                                   Kapitano Logistic
 ────────────                                   ─────────────────
 order confirmed ──► outbox row ──► worker ──► POST /api/integration/v1/orders
                                                    │  (token + HMAC + Idempotency-Key)
                                                    ▼
                                           order created, dispatched to a captain
                                                    │
 webhook receiver ◄── signed POST (order.*) ◄───────┘
        │
        └─► update the store's own order (idempotent, ordered by `sequence`)
```

Two directions, one signature format:

| Direction | Carries | Authenticated by |
|---|---|---|
| Store → Logistic (REST) | create / read / edit / cancel / payment | Bearer token **and** `X-Kapitano-Signature` |
| Logistic → Store (webhook) | status events | `X-Kapitano-Signature` using the webhook secret |

---

## 2. Onboarding (one-time, per environment)

Staging and production each have **their own** token and secrets. Never reuse them.

1. Register the store (`POST /api/store-portal/auth/register`) — creates a `store_clients` row in *pending review*.
2. A back-office admin approves it (`POST /api/dashboard/store-clients/{uuid}/approve`).
3. The store owner generates the API token in the portal (**Integration → API token**), or an
   admin issues one (`POST /api/dashboard/store-clients/{uuid}/tokens`, or
   `php artisan integration:issue-token`). Either way the token has abilities `orders:read` and
   `orders:write`, **is shown once**, and generating a new one **revokes the previous token**
   (self-serve: `POST /api/store-portal/settings/token`, owner only, password re-entered).
4. The store owner reveals `signing_secret` and `webhook_secret` (both `whsec_…`) in the portal
   (**Integration → Signing keys**, `POST /api/store-portal/settings/secrets/reveal`, needs the password).
5. Set the webhook URL (`PUT /settings/webhook`), test it (`POST /settings/webhook/test`), and
   optionally lock inbound calls to your server IPs (`PUT /settings/ips`).
6. Configure the **wrapping location** on the store if you will ever send `needs_wrapping: true`.

Store these in the web app's `.env` / secret manager, never in git:

```
KAPITANO_BASE_URL=https://<host>/api/integration/v1
KAPITANO_TOKEN=...
KAPITANO_SIGNING_SECRET=whsec_...
KAPITANO_WEBHOOK_SECRET=whsec_...
```

---

## 3. Request rules every call must follow

Headers on **every** request:

| Header | Value | Notes |
|---|---|---|
| `Authorization` | `Bearer <token>` | |
| `Accept` | `application/json` | Missing → 401 before anything else is checked |
| `Accept-Language` | `ar` or `en` | Missing → 401 |
| `X-Kapitano-Signature` | `t=<unix>,v1=<hex>` | See §4 |
| `Idempotency-Key` | ≤128 chars | **Required on POST and PATCH**, else 422 `idempotency_key_required` |
| `Content-Type` | `application/json` | on bodies |

Limits: **300 requests/min per store** (429 `too_many_requests`).

Every response uses one envelope:

```json
{ "Model": {...}, "Status": 201, "Message": "...", "MessageDebug": {...},
  "Total": 0, "Page": 0, "Records": 0 }
```

**Branch on `MessageDebug` keys, never on `Message` text** (some messages stay English even with `Accept-Language: ar`).
Validation failures arrive as `MessageDebug.validation`, keyed by nested path (e.g. `pickup.lat`).

---

## 4. Signing requests

```
signature = HMAC_SHA256( key = signing_secret, data = "{t}." + rawBody )
header    = "t={t},v1={hex(signature)}"
```

- `t` is the Unix time in seconds; tolerance is **300 s**, so keep the server clock on NTP.
- For a `GET`, the body is empty, so you sign the string `"{t}."`.
- **Sign the exact bytes you send.** Build the JSON string once, sign it, send that same string.
  Do not let the HTTP client re-encode it afterwards.
- A signature is **single-use** (replay nonce). A retry needs a **fresh `t` and fresh signature**
  but the **same `Idempotency-Key`**.

PHP (Laravel) reference client:

```php
final class KapitanoClient
{
    public function __construct(
        private string $baseUrl,
        private string $token,
        private string $secret,
    ) {}

    public function send(string $method, string $path, ?array $body, string $idempotencyKey = null): Response
    {
        $raw = $body === null ? '' : json_encode($body, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
        $t   = time();
        $sig = hash_hmac('sha256', $t.'.'.$raw, $this->secret);

        return Http::withToken($this->token)
            ->withHeaders(array_filter([
                'Accept'              => 'application/json',
                'Accept-Language'     => 'ar',
                'X-Kapitano-Signature'=> "t={$t},v1={$sig}",
                'Idempotency-Key'     => $idempotencyKey,
            ]))
            ->withBody($raw, 'application/json')   // the exact bytes that were signed
            ->timeout(15)
            ->send($method, $this->baseUrl.$path);
    }
}
```

---

## 5. Endpoints

Base: `/api/integration/v1`. Eight operations:

| Method | Path | Purpose | Idempotency-Key |
|---|---|---|---|
| GET | `/health` | Connectivity + auth check | no |
| GET | `/delivery-quote?lat=&lng=&goods_value=` | What a delivery would cost, before the order exists | no |
| POST | `/orders` | Create an order | **yes** |
| GET | `/orders?external_order_id=` | Find by your own id | no |
| GET | `/orders/{uuid}` | Read one | no |
| PATCH | `/orders/{uuid}` | Edit (partial) | **yes** |
| POST | `/orders/{uuid}/cancel` | Cancel | yes (recommended) |
| POST | `/orders/{uuid}/payment` | Report payment | yes (recommended) |

There is **no list endpoint, no since-cursor, no DELETE** (cancel is the delete).
`/orders/{uuid}/rating` appears in the Arabic doc but is **not built** (404).
Reading another store's order returns 404 `item_not_found`.

### 5.1 Create — `POST /orders`

```json
{
  "external_order_id": "5278",
  "external_order_number": "20260927-14412175",
  "customer": {
    "external_id": "6849",
    "name": "Ahmad Ali",
    "phone": "+963969700148",
    "locale": "ar",
    "note": "Call before arriving"
  },
  "pickup": {
    "branch_ref": "shop-77",
    "name": "Shop name",
    "address": "Street, Aleppo",
    "lat": 36.2021, "lng": 37.1343,
    "ready_at": "2026-10-03T12:00:00Z"
  },
  "dropoff": {
    "address": "Afrin, Aleppo",
    "lat": 36.5112, "lng": 36.8667,
    "details": { "building": "4", "floor": "2", "apartment": "7", "landmark": "near the mosque" }
  },
  "payment": {
    "method": "cash_on_delivery",
    "status": "unpaid",
    "amount_to_collect": 1800.00
  },
  "currency": "SYP",
  "delivery_fee": 0,
  "promised_at": "2026-10-03T16:00:00Z",
  "note": "internal note",
  "needs_wrapping": false,
  "items": [
    { "name": "Product", "quantity": 2, "unit_price": 900.00, "sku": "SKU-1",
      "variant": { "size": "M" }, "image_url": "https://cdn.example/p.jpg" }
  ]
}
```

**Required:** `external_order_id`, `external_order_number`, `customer.{external_id,name,phone}`,
`pickup.{branch_ref,name,address,lat,lng}`, `dropoff.{address,lat,lng}`, `payment.{method,status}`,
`items` (1–50, each with `name` and `quantity` 1–999).
`payment.amount_to_collect` is required for `cash_on_delivery` and **dropped** for `prepaid`.

**Enums:** `payment.method` = `cash_on_delivery | prepaid`; `payment.status` = `paid | unpaid | refunded`.

**Optional extras:** `additional_pickups[]` (max 9, same shape as `pickup`; give each item a
`pickup_index`, where `0` is `pickup`), `dropoff.address_ref`, `payment.reference`, `items.*.note`.

**`dropoff.area` / `dropoff.governorate`** — the area the customer chose from your cities list and its
governorate, by name (`"المزة"`, `"دمشق"`). **They price the delivery ahead of the pin**: our areas were
imported from that same list, so the named area's fee is charged even when it is not on our map. Order of
precedence: the named area (inside its governorate; elsewhere only if exactly one area has that name) → the
pin → the governorate alone → the default fee. Spelling variants (ة/ه, أ/ا, ى/ي) match. Send them whenever
you know them; an order without them is priced by the pin as before.

Success: **201**, `Model` = `uuid`, `order_number`, `external_order_id`, `status`, `status_label`, `created_at`.
**Persist the returned `uuid`** next to your order — every later call uses it.

### 5.2 Read

`GET /orders/{uuid}` or `GET /orders?external_order_id=5278`. Treat the response of
`StoreOrderDetailResource` as the shape: `uuid`, `external_order_id`, `order_number`, `status`,
`status_label`, `captain{name,phone}`, `eta_at`, `promised_at`, `timestamps{received_at,picked_up_at,on_the_way_at,delivered_at,failed_at,cancelled_at}`,
`cash{method,status,amount_to_collect,currency}`, `failure_reason`, `cancel_reason`.

### 5.3 Edit — `PATCH /orders/{uuid}`

Partial: absent = unchanged, explicit `null` = clear. `items` is a **full replacement** of the basket.

| You change | Allowed until | Afterwards (409) |
|---|---|---|
| `customer.*`, `note` | order terminal | — |
| `pickup.*` | `assigned` | `pickup_locked` |
| `items`, `payment.amount_to_collect`, `currency`, `delivery_fee` | `picked_up` | `order_already_collected` |
| `dropoff.*` | `on_the_way` | `order_in_transit` |
| `promised_at` | `delivered` | `order_not_editable` |

Never editable (422): `payment.method`, `external_order_id`, `status`, `driver_uuid`.
Send `dropoff.lat` and `dropoff.lng` together. Moving the drop-off without `dropoff.area` forgets the area
sent before (the pin prices it). An empty body → 422 `nothing_to_update`.
You do **not** get an `order.updated` webhook for your own change.

### 5.4 Cancel — `POST /orders/{uuid}/cancel`

`{ "reason": "Customer cancelled" (required, ≤255), "reason_code": "customer_request" (optional, ≤64) }`.
Allowed from any non-terminal status; otherwise 409 `invalid_transition`. Cancelling a wrapped order cancels both legs.

### 5.5 Payment — `POST /orders/{uuid}/payment`

`{ "status": "paid", "reference": "...", "paid_at": "..." }` — use for online payments confirmed
after the order was created (e.g. a Paymera callback arriving later).

### 5.6 Delivery quote — `GET /delivery-quote`

Price your checkout's shipping with **our** fee, so the customer pays exactly what the order will carry.

```
GET /api/integration/v1/delivery-quote?lat=33.5138&lng=36.2765&goods_value=25000
-> { "priced": true, "fee": 200, "customer_pays": 200, "store_pays": 0, "area": "المزة", "source": "area", "round_to": 10, "round_up_to": 10 }
```

- `lat`/`lng` — the drop-off point you will send on the order (the same point, or the price can differ).
- `goods_value` — the basket in SYP; the value bands read it (e.g. free delivery above a basket value).
- `area`, `governorate` (optional) — the same names you will send as `dropoff.area` / `dropoff.governorate`;
  they price ahead of the point, so send them or the quote and the order can differ.
- **`customer_pays`** — charge this as the shipping, as it is (never rounded). `fee` is what your store is billed
  for the trip, exactly as the area is registered; `store_pays` is the part a value band takes off the customer.
- `source` — `area` (an area priced it) or `default` (outside every area, the default fee applies).
- `priced: false` — we price nothing at that point: keep your own shipping.

Read-only (`orders:read`), signed like every GET (`"{t}."`). Nothing is created or reserved. Cache the
answer for a few minutes per point and basket; ask again when the address or the basket changes. Then
the amount collected at the door is **goods − coupon + `customer_pays`, rounded to the nearest `round_to`**
(10 pounds, half up: 1,744 → 1,740, 1,745 → 1,750). Round your order total the same way and the invoice and
the captain's figure always agree. (`round_up_to` is the same step, kept for older readers.)

---

## 6. Order status and the state machine

```
pending ─► assigned ─► accepted ─► picked_up ─► on_the_way ─► delivered
   │          │  ▲         │            │            │ └────► delivery_failed
   │          └──┴─────────┘ (declined/expired → back to pending)
   └──────────── any non-terminal ─────────────────────────► cancelled
```

Terminal: `delivered`, `delivery_failed`, `cancelled`.
`pending` and `accepted` are **never announced** by webhook.

Suggested mapping onto kapitano_web's free-form `delivery_status`:

| Logistic | Store (`orders.delivery_status`) | Store action |
|---|---|---|
| `assigned`, `picked_up` | `picked_up` | notify customer (optional) |
| `on_the_way` | `on_the_way` | |
| `delivered` | `delivered` | set `delivered_date`; if COD, mark `payment_status = paid` |
| `delivery_failed` | keep current; flag for staff | do **not** auto-cancel |
| `cancelled` | `cancelled` | restock **exactly like the store's own cancel flow** |

> The store's cancel/delivered flows do more than set a column (restock, wallet refund,
> commission, notifications). Re-use that logic; never write `delivery_status` with raw SQL.

---

## 7. Webhooks (Logistic → Store)

**Events:** `order.received`, `order.assigned`, `order.picked_up`, `order.on_the_way`,
`order.delivered`, `order.delivery_failed`, `order.cancelled`, `order.updated`,
`order.address_details`, `integration.ping`.

**Receiver requirements:** public **HTTPS on port 443**, not a private address, answer **2xx within 15 s**.

Headers: `X-Kapitano-Event`, `X-Kapitano-Event-Id`, `X-Kapitano-Delivery-Attempt`, `X-Kapitano-Signature`.

Body:

```json
{ "event": "order.delivered", "event_id": "…", "occurred_at": "…", "sequence": 7,
  "order": { …same shape as GET /orders/{uuid}… } }
```

**Verify before trusting** (same scheme as §4, using `webhook_secret`, over the **raw** body):

```php
$raw    = $request->getContent();
$header = $request->header('X-Kapitano-Signature');          // t=...,v1=...,v1=...
[$t, $sigs] = parse($header);                                 // collect EVERY v1 value
abort_if(abs(time() - $t) > 300, 401);
$expected = hash_hmac('sha256', $t.'.'.$raw, $secret);
abort_unless(collect($sigs)->contains(fn ($s) => hash_equals($expected, $s)), 401);
```

Iterate **all** `v1` entries: during a secret rotation the header carries two (new and old).

**Process like this:**

1. **Dedupe on `event_id`** (unique index) — retries re-send the same id.
2. **Order by `sequence`** per order; ignore any event with `sequence <= last_processed`.
3. Treat each event as **"replace my copy"**, not a delta.
4. Return 2xx fast; do the real work on a queue.
5. Unknown future events/fields → ignore, still return 2xx.

**Retries:** 6 attempts at roughly `10s, 60s, 5m, 15m, 1h, 1h` (in practice the first retry is ~60 s), then the delivery is `dropped`.
Inspect/replay in the portal: `GET /api/store-portal/webhooks`, `GET /webhooks/{delivery}`, `POST /webhooks/{delivery}/replay`.
Logistic needs a running `queue:work` to send webhooks.

**Safety net:** webhooks can still be missed. Run a reconciliation job every few minutes that
calls `GET /orders/{uuid}` for orders that have been non-terminal too long, and space those
signed reads **more than 1 s apart** (see §12.1).

---

## 8. Idempotency and retries (what makes it safe)

Three layers protect the store from duplicates:

| Situation | Result |
|---|---|
| Same `Idempotency-Key`, same body | **200**, header `Idempotency-Replayed: true`, the original order |
| Same key, **different** body | **409** `duplicate_external_order` |
| New key, `external_order_id` already exists | **409** `duplicate_external_order` |

Rules:

- Use a **deterministic** key, e.g. `create-{orders.id}` / `cancel-{orders.id}` — not a random value per attempt.
- On 409 `duplicate_external_order`, do **not** loop: call `GET /orders?external_order_id=` and adopt the existing `uuid`.
- **Retry only** on network errors, 429, 500, 502, 503, 504. Use exponential backoff with jitter.
- **Never retry** other 4xx (fix the data instead). Both 422 and 409 need a human or a code fix.
- 503 `integration_paused` means logistic's kill switch is on: keep the outbox rows and resume later.

---

## 9. Best-practice architecture for the store

### 9.1 Use an outbox, not an inline HTTP call

Never call logistic inside the customer's checkout request. A slow or down logistic must not slow
or break checkout. Instead:

1. When an order reaches the push-worthy state, write a row to an `integration_outbox` table
   (`order_id`, `operation`, `idempotency_key`, `status`, `attempts`, `next_attempt_at`, `last_error`).
2. A queue worker (or a scheduled command, since the store runs `QUEUE_CONNECTION=sync`) sends pending rows.
3. On success store `logistic_uuid` on a link table (`order_id`, `logistic_uuid`, `last_sequence`, `status`).
4. On a retryable failure, back off and try again; on a permanent failure park the row for review.

### 9.2 Two ways to feed the outbox

| Option | Description | Trade-off |
|---|---|---|
| **A. In-store hook** | A model observer / explicit calls where `delivery_status` changes | Real-time, but kapitano_web has **no events today** — every change is an inline assignment in ~8 controllers, so this means touching them |
| **B. Zero-touch bridge** | A separate reader polls the store DB (read-only user) for orders in the push-worthy state and creates outbox rows | **No change to the web code.** Poll by `id` cursor + status, not `updated_at` (that column has no `ON UPDATE`) |

If the store code must stay untouched, pick **B**.

### 9.3 Choose the push trigger deliberately

Push when the order is **final enough to dispatch** — for the store's data that means
`delivery_status = confirmed` (seller accepted), not `pending`. A web-side cancel *after* the push
must call `POST /orders/{uuid}/cancel`; a cancel *before* it just never gets pushed.

### 9.4 One logistic order per store order

The store creates one `orders` row **per seller** in a checkout (`combined_orders` → `orders`).
Send **one logistic order per store order**: `external_order_id = orders.id`. If you ever merge a
multi-seller checkout into one delivery, use `additional_pickups` + `items.*.pickup_index`.

### 9.5 Observability

Log every call with: `order_id`, `idempotency_key`, HTTP status, `MessageDebug` keys, latency.
**Never log** the token, secrets, or full signature headers. Alert on: outbox rows older than N
minutes, `dropped` webhooks, repeated `signature_*` errors, and 503 `integration_paused`.

---

## 10. Mapping kapitano_web data to the payload

| Payload field | kapitano_web source | Watch out for |
|---|---|---|
| `external_order_id` | `orders.id` | `orders.code` is **not unique** — use it only as `external_order_number` |
| `customer.external_id` | `orders.user_id` | Guest checkout creates a real `users` row, so this always exists |
| `customer.name` / `phone` | `orders.shipping_address` JSON (`name`, `phone`) | Phones are mixed (`+963…`, `09…`, `963…`, NULL). Normalise to **E.164 `+9639…`** — logistic does **not** add a country code |
| `pickup.*` | `shops` row of `orders.seller_id` | **Needs `lat`/`lng`** — required by logistic. `branch_ref` = shop id |
| `dropoff.address` | `shipping_address.address` + `city` + `state` | The JSON holds **names, not ids** |
| `dropoff.area` / `governorate` | `shipping_address.city` / `state` | The area and governorate names: they price the delivery ahead of the pin |
| `dropoff.lat` / `lng` | `shipping_address.lat_lang` (`"lat,lng"`) | Only present sometimes. **Required by logistic** and drives delivery pricing/ETA — geocode or fall back to the city centre |
| `payment.method` | `orders.payment_type` | `cash_on_delivery` → `cash_on_delivery`; `paymera` (online) → `prepaid` |
| `payment.status` | `orders.payment_status` | `paid` / `unpaid` |
| `payment.amount_to_collect` | `orders.grand_total` **+ shipping** | See §11 |
| `items[].name/quantity` | `order_details` → `products.name` | |
| `items[].unit_price` | `order_details.price / quantity` | `price` is the **line total**, not the unit price |
| `items[].sku` / `variant` | `product_stocks` joined on `product_id` + `variation` | Many SKUs are null/demo; fall back to `product_id` |

---

## 11. Money rules (read before the first real order)

- **SYP is the only currency.** Send `"currency": "SYP"` (or omit it).
- Amounts are in the **new pound** (the old amount ÷ 100). Sending old-scale amounts makes every COD 100× too large.
- **kapitano_web stores `grand_total` in its base currency (USD)** and converts at display time using a static rate, so the store **must convert to SYP new-pound itself** and send the number the customer will actually pay.
- `orders.grand_total` in the store **excludes shipping** (it lives on `combined_orders.shipping_cost`). `amount_to_collect` is what the captain collects in cash — compute it as the total the customer was told to pay, including shipping.
- `amount_to_collect` is **never altered** by logistic. If the store offers free delivery, lower it yourself.
- `delivery_fee` you send is only a *quote*. If the drop-off falls inside a configured area, logistic **overrides it** with the area's pricing (your value is kept as `quoted_fee`). Do not reconcile your shipping revenue against `orders.fee`.
- **Charge your customer our fee:** price the checkout's shipping with `GET /delivery-quote` (§5.6) and charge `customer_pays`. Your own shipping table and ours can never drift apart that way.
- Drop-off coordinates must be accurate: a point outside every configured area means no pricing and an order that needs manual fixing.

---

## 12. Known drift and gotchas

1. **Same-second reads are rejected.** The signature does not cover method or path and the nonce is keyed on the signature alone, so two signed calls with the **same body in the same second** (for example two `GET`s, both empty-body) get `signature_replayed`. Space signed reads > 1 s apart, or retry after a second.
2. **`docs/store-api-ar.md` is partly stale:** its examples use SAR and Saudi addresses (the code is SYP/new-pound); it shows response fields that are not returned (`tracking_url`, `captain.vehicle_type`, `timestamps.assigned_at`, `cash.amount_collected`, `previous_status`); and it omits the `accepted` status while describing `captain.arrived` / `captain.location` events that the code does not emit. Build against `integration-docs.json`.
3. Always send `Accept` and `Accept-Language`; otherwise you get 401 and it looks like an auth problem.
4. Webhook `sequence` is per order and ascending — never compare sequences across orders.
5. The webhook URL must be `https://…:443` and public. Private/local URLs are refused unless `INTEGRATION_ALLOW_PRIVATE_WEBHOOK_URLS` is set (dev only).
6. Suspending a store revokes its tokens, so the store then sees **401**, not 403 `client_inactive`.
7. Sign the raw bytes; any re-serialisation after signing causes `signature_mismatch`.

### Error reference

| Status | `MessageDebug` key | Meaning / action |
|---|---|---|
| 401 | `accept_header`, `Language not definite` | Missing `Accept` / `Accept-Language` |
| 401 | `Unauthorized` | Bad/revoked token |
| 401 | `signature_missing`, `signature_malformed`, `signature_mismatch`, `signature_expired`, `signature_replayed` | Fix signing / clock / retry with a fresh signature |
| 403 | `ip_not_allowed`, `client_inactive`, `missing_ability` | Allowlist / account / token abilities |
| 404 | `item_not_found` | Unknown uuid (or another store's) |
| 409 | `duplicate_external_order` | Look it up and adopt the uuid |
| 409 | `invalid_transition`, `order_in_transit`, `pickup_locked`, `order_already_collected`, `order_not_editable` | Not allowed in the current state |
| 422 | `validation`, `nothing_to_update`, `idempotency_key_required` | Fix the payload |
| 429 | `too_many_requests` | Back off, retry |
| 503 | `integration_paused`, `replay_check_unavailable` | Retry later; keep outbox rows |

---

## 13. Wrapped orders (optional)

Send `"needs_wrapping": true` (a flag, never an address). The location comes from the store's
configured `wrapping_*` fields; if none is set the request is refused with 422. Logistic then
creates two linked orders — a **collection leg** (suppliers → wrapping point; the store never
hears about it) and a **delivery leg** (the one you get the uuid and webhooks for). The delivery
leg stays `pending` until every collection leg is `delivered`, so expect a longer time to `assigned`.

---

## 14. Testing checklist

Run these against **staging** (or locally with `php artisan integration:simulate-store` and
`php artisan integration:webhook-sink`) before any real order:

- [ ] `GET /health` with a valid signature returns 200.
- [ ] Create a COD order → 201; the same call again with the same key → 200 + `Idempotency-Replayed: true`.
- [ ] Same key, changed body → 409.
- [ ] Create a prepaid order → no `amount_to_collect` in the stored order.
- [ ] A bad phone/coordinates → 422 with the right nested key.
- [ ] Wrong signature, stale timestamp, replayed signature → the right 401 key.
- [ ] PATCH before and after `picked_up` → allowed, then 409 `order_already_collected`.
- [ ] Cancel from `pending`, and from `on_the_way`; cancel again → 409 `invalid_transition`.
- [ ] Receiver: bad signature rejected; duplicate `event_id` ignored; out-of-order `sequence` ignored; rotated-secret header (two `v1`) accepted.
- [ ] Kill the receiver for a while → retries arrive; reconciliation catches anything dropped.
- [ ] Amounts: a known order's `amount_to_collect` equals what the customer was quoted, in new-pound SYP.

A recorded real run (49 calls, 10 webhooks) is in
`C:\Users\DELL\Desktop\Kapitano\store-integration-ar\` (`capture.json`, `webhooks-received.jsonl`).
