---
doc: API Conventions
version: 4.11.0
last_updated: 2026-11-10
applies_to: [admin, client, provider, delivery]
---

# API Conventions — Joshen Backend

Everything here is shared by all four apps. Feature docs in
`docs/{admin,client,provider,delivery}/` only describe what is specific to a
feature and assume you have read this file once.

| Surface | App | Base URL |
|---------|-----|----------|
| admin | React dashboard | `{HOST}/api/v1/admin` |
| client | Flutter customer app | `{HOST}/api/v1/client` |
| provider | Flutter provider app | `{HOST}/api/v1/provider` |
| delivery | Flutter driver app | `{HOST}/api/v1/delivery` |
| shared | all apps | `{HOST}/api/v1` |

## Changelog

### 4.11.0 — 2026-11-10

- **Added:** `customer.user_id` beside `customer.id` in admin payloads that name a customer (orders, payments, conversations, cancellations, refunds, reviews, customer filter list).
- **Clarified:** `phone_code` format (§4) — `+966` on users/requests, bare `966` in country catalogs. Not changed, so integrated clients keep working.
- **Changed:** the message of `POST /client/orders/{order}/pay` when the payment is declined (still HTTP 200).
- **Ops:** CORS origin list is `CORS_ALLOWED_ORIGINS` (comma-separated; default `*`); preflights are cached `CORS_MAX_AGE` s (default 3600). Set the dashboard origin on every deployed environment.
- **Breaking:** No. **App action required:** None for the apps; dashboard: use `customer.user_id`.

### 4.9.1 — 2026-11-09

- **Fixed (F5):** POST/PUT/PATCH/DELETE answers that used to carry `message: ""` now carry a translated generic message from the shared `json()` helper (`201` → saved, other writes → updated, `DELETE` → removed). Reads keep their empty message. Endpoints that already name a message are unchanged.
- **Clarified (F10):** date-times are ISO 8601 with an offset, but the offset is not fixed: slots use `+03:00`, stored order fields such as `scheduled_at` come back in UTC (`+00:00`). Both are the same instant; parse them, never compare strings.
- **App action required:** None.

### 4.8.1 — 2026-11-08

- **Fixed (media upload):** `DELETE /media/{id}` for an id that does not exist now answers `404` `{status: fail, message: "Media not found"}`; it used to answer 500 because the not-found response was built with a wrong argument. Upload, list and delete examples are in the Postman collection: `shared.media.upload` › "201 Image uploaded", "422 File and collection missing"; `shared.media.index` › "200 Own uploads"; `shared.media.destroy` › "200 Upload deleted", "404 Upload not found".
- **App action required:** None. Treat a 404 from `DELETE /media/{id}` as "already gone".

### 4.8.0 — 2026-11-08

- **Added (§7.1, TD-174):** event `driver_nearby_demand`: an offline driver is invited to go online when trips wait in her districts (`data`: `count`, `district_id`).
- **App action required:** Driver app: show it as in the inbox design ([delivery/notifications.md](delivery/notifications.md) 1.5.0). Others: none.

### 4.7.0 — 2026-11-08

- **Added (§7, TD-173):** an **admin** token now has an inbox: the shared `/notifications` endpoints return the admin's dashboard alerts (`notifications.app = admin`). Before, it was always empty.
- **Added (§7.1):** event `general`, a notification an admin sent from the dashboard to customers, providers or drivers (`data.broadcast_id`, category `offers`), and the seven `admin_*` dashboard alerts.
- **Added (§7.2):** the dashboard registers its browser with `POST /admin/auth/device` (`device_type = web`); `devices.type` gains `web`.
- **Added (§3.2.1):** `NOTIFICATION_NO_RECIPIENTS`, `NOTIFICATION_RECIPIENTS_UNREACHABLE`.
- **App action required:** Flutter apps: show `general` as a plain inbox item (already the rule for an unknown `type`). React: [admin/notifications.md](admin/notifications.md) 1.1.0.

### 4.9.0 — 2026-11-09

- **Added:** `data.contact` on `GET /config/bootstrap`: `support_phone` (the hotline), `support_email` and `whatsapp`, edited by the admin in settings group `contact`. Public values only; the key is always present and holds whatever the admin has set.
- **App action required:** None (additive). Show the hotline on the support entry screen (SCR-24); keep using support tickets for requests and incident reports (urgent reasons).

### 4.6.0 — 2026-11-08

- **Added (classification fix):** `data.app.logo_url` on `GET /config/bootstrap` is a new response field, so it is a MINOR change; 4.5.1 recorded it as "no contract change". Its note moves from §2.4 (list query parameters) to §4 "Platform logo (bootstrap)", and states it is never null.
- **Clarified (§3.1):** the admin settings 422 carries `data.error_code = VALIDATION_FAILED` besides `data.errors`; its `message` follows the standard first-error rule.
- **App action required:** None (additive). Apps may show `app.logo_url`.

### 4.5.1 — 2026-11-05

- Document the existing shared bootstrap logo using captured responses. App action required: none.

### 4.5.0 — 2026-11-04

- **Added (§3.2.1):** an index of every `error_code`, generated from the code.
- **Updated (§7.1):** the event catalog now lists every event the notification engine sends (TD-167), including the driver as a recipient of `trip_driver_accepted`, and the not-built list is current. `booking_reminder` carries `scheduled_at` as ISO 8601.

### 4.4.0 — 2026-11-04

- **Added (§7.1–§7.3):** the notification event catalog built from the code (audience app, preference category, trigger, `data` keys, push data format, what is not built), the device-token lifecycle as it behaves today (including that logout detaches the token only when `device_token` is sent), and the per-category preference endpoints (TD-166).
- **Fixed (§9, §9.1):** `trip.offer` / `trip.updated` recipients are named (driver, provider, and the customer for `trip_provider_picked_up`); the provider's `order.updated` `cancelled` also follows a provider-initiated cancellation. **App action required:** None for the fixes; preferences are an additive feature (see each app's notifications guide).

### 4.3.2 — 2026-11-03

- **Added (§9, deploy):** the relay listens on port **5071** (`NODE_PORT` in `socket-io/.env`). Staging socket URL: `https://joshen-back.elkady.aait-d.com:5071` (same host as the API, its own port). Each project on a shared server needs its own port and its own `REALTIME_APP_KEY`; `CLIENT_URL` is the React dashboard origin only (Flutter needs no entry). Socket TLS must serve the full certificate chain (domain certificate + Let's Encrypt YR1), otherwise mobile clients fail with `UNABLE_TO_VERIFY_LEAF_SIGNATURE`; staging verified 2026-11-03 (live customer ↔ provider chat over the socket, `Verify return code: 0`). **App action required:** set `REALTIME_URL` to the socket URL above.

### 4.3.1 — 2026-11-02

- **Clarified (§9, §9.2):** `trip.offer` / `trip.updated` carry `status` as the plain trip status string, an exception to the `{value,label}` rule (refetch the trip for the label). The `order.tracking.stage` `transport` block includes `can_confirm_arrival`. A failed `tracking:subscribe` acknowledges `{ok:false, error}` with `invalid_grant`, `expired_grant`, `forbidden` or `too_many_rooms`. Full customer flow: [client/orders §3.4](client/orders.md). **App action required:** None (documents existing behavior).

### 4.3.0 — 2026-11-02

- Added error codes `PAYOUT_NOTHING_AVAILABLE`, `PAYOUT_AMOUNT_NOT_PAYABLE`, `PAYOUT_ACCOUNT_UNUSABLE` (422), `PAYOUT_REQUEST_OPEN` (409) and `INVALID_PAYOUT_SETTINGS` (422) (DEC-11, TD-158). A payout decision on the wrong status is the existing 409 `INVALID_STATE_TRANSITION`. **App action required:** the provider and driver apps handle them in [provider/earnings](provider/earnings.md) and [delivery/earnings](delivery/earnings.md); the dashboard in [admin/payouts](admin/payouts.md).

### 4.2.0 — 2026-11-01

- Added error codes `REFUND_NOT_AVAILABLE` (422) and `CANCELLATION_RULE_IN_USE` (409) (ADMIN-018, TD-154, TD-156). `POST /admin/refunds` also requires an `Idempotency-Key` header (TD-156). **App action required:** none for the mobile apps; the dashboard handles them in [admin/cancellations-refunds](admin/cancellations-refunds.md).

### 4.1.0 — 2026-11-01

- Added error codes `COUPON_INVALID`, `COUPON_EXPIRED`, `COUPON_NOT_APPLICABLE`, `COUPON_FREE_ORDER`, `COUPON_MIN_ORDER_NOT_MET`, `COUPON_USAGE_LIMIT_REACHED` (422) and `COUPON_IN_USE` (409) (TD-131, TD-148). **App action required:** none for existing screens; handle them where a coupon code is entered ([client/booking](client/booking.md) §3.3).

### 4.0.0 — 2026-11-01

- **Breaking for chat payloads:** conversation `status`, message `sender_role` and message `type` are `{value,label}` objects in the HTTP responses and in `chat.message` / `chat.conversation.updated`. App action required: read `.value`; never branch on `label` (§9.3).
- Added: chat sends are throttled (429 `RATE_LIMITED`); `GET …/conversation` never creates a conversation (`conversation.id` is `null` before the first send) (§9.3).
- Added: `order.tracking.stage` is published for every order transition into `confirmed`, `preparing`, `on_the_way`, `arrived`, `in_progress`, `completed` or `cancelled`, and may arrive twice for one change; a position sent on `PATCH /delivery/availability` during a live trip also publishes `order.tracking.location`; `tracking-subscription` answers 503 `INFRASTRUCTURE_FAILURE` when the realtime secret is missing (§9.2). Grant lifetime is configurable. Decision: TD-147. App action required: treat stage events as idempotent.

### 3.0.0 — 2026-10-29

- Order chat lists now return Laravel pagination metadata in `data` with summaries in `data.data`; unread uses the authenticated app role. App action required: update list parsing, page requests and event/HTTP reconciliation. See §9.3.

### 2.15.0 — 2026-10-29

- Added order tracking subscription and private order chat contracts. App action required: subscribe before fetching the order preview, restore chat history/unread after reconnect, and deduplicate messages by UUID.

### 2.14.0 — 2026-10-28

- Added trip.offer and trip.updated realtime payloads for cycle F. App action required: refetch relevant trip or order data after an event.

## Version history

| Version | Date | Summary |
|---------|------|---------|
| 4.11.0 | 2026-11-10 | §4 documents the two `phone_code` formats and the admin customer reference (`customer.id` vs the new `customer.user_id`); a declined payment now has its own message; `CORS_ALLOWED_ORIGINS` / `CORS_MAX_AGE` (QA-03, QA-07, QA-08, QA-10). **App action required:** none for Flutter; the dashboard uses `customer.user_id` for customer links. |
| 4.10.0 | 2026-11-09 | `order.tracking.location` adds `freshness`, `distance_km`, `eta_minutes`, `calculated_at` and `source`; `transport.tracking` carries the same keys (R06–R08, TD-183). `POST /provider/orders/{order}/location` publishes the provider's own position on self travel. Support attachments accept `mp4` / `mov` and return `has_media`. The realtime relay now accepts these shapes (and `can_confirm_arrival` in `transport`). |
| 4.9.1 | 2026-11-09 | A successful write that names no message now answers a generic one (`Data saved successfully` / `Data updated successfully` / `Data removed successfully`); date-time offsets documented. **App action required:** None. |
| 4.9.0 | 2026-11-09 | `data.contact` on `GET /config/bootstrap` publishes the support hotline, e-mail and WhatsApp (TD-181, SCR-24). **App action required:** show the hotline on the support screen. |
| 4.6.0 | 2026-11-08 | `app.logo_url` on bootstrap recorded as an additive field (4.5.1 said no contract change) and moved to §4; §3.1 notes the admin settings `error_code`. **App action required:** None (additive). |
| 4.5.1 | 2026-11-05 | Document captured bootstrap logo responses; no contract change. |
| 4.5.0 | 2026-11-04 | §3.2.1 index of every error code; §7.1 catalog adds the WP-4 events (`order_payment_*`, `order_rating_requested`, reminders, `wallet_refund_credited`, `trip_completed`, `earning_recorded`, `driver_rated`, `account_document_expiring`, `user_registered`) and the driver audience of `trip_driver_accepted`. **App action required:** handle the new event types; unknown types open the inbox. |
| 4.4.0 | 2026-11-04 | §7.1 event catalog, §7.2 device lifecycle, §7.3 preference endpoints (TD-166); §9 recipient fixes. **App action required:** None (additive). |
| 4.3.2 | 2026-11-03 | §9: socket port 5071 and staging `REALTIME_URL`; deploy notes for shared servers and socket TLS (full chain). **App action required:** point `REALTIME_URL` at the socket URL. |
| 4.3.0 | 2026-11-02 | New `PAYOUT_*` and `INVALID_PAYOUT_SETTINGS` error codes in §3.2. **App action required:** provider, driver and dashboard apps. |
| 4.2.0 | 2026-11-01 | New `REFUND_NOT_AVAILABLE`, `CANCELLATION_RULE_IN_USE` error codes in §3.2. **App action required:** dashboard only. |
| 4.1.0 | 2026-11-01 | New `COUPON_*` error codes (422, and 409 `COUPON_IN_USE`) in §3.2. **App action required:** handle them on the coupon field and the admin coupon delete. |
| 4.0.0 | 2026-11-01 | **Breaking for chat payloads:** `status`, `sender_role` and `type` become `{value,label}`; chat sends are rate limited; empty-conversation history has `id: null`. §9.2: stage events for every tracked order transition (repeats possible), availability pings publish location, 503 when the realtime secret is missing, deploy notes. **App action required:** read `.value` of the chat enums, back off on 429, treat stage events as idempotent. |
| 3.0.0 | 2026-10-29 | **Breaking for chat lists:** `GET /conversations` is paginated (15 default, 100 maximum), and §9.3 defines role-specific unread and event-safe recovery. **App action required:** read `data.data` and pagination metadata, then refetch on summary events. |
| 2.15.0 | 2026-10-29 | Tracking room and order chat events, lifecycle and reconnect rules (TD-144/145). **App action required:** subscribe/ack/fetch for preview and recover persisted chat on reconnect. |
| 2.14.0 | 2026-10-28 | Trip realtime events: `trip.offer` carries `trip_id`, `order_id`, `status`; `trip.updated` also carries `reason`. **App action required:** refetch the trip or order. |
| 2.13.0 | 2026-10-27 | §9.1: a completed package purchase sends `order.updated` with `reason: package_purchased` to the customer. **App action required:** refetch the purchase and owned package. |
| 2.12.0 | 2026-10-26 | §1 documents optional `X-Guest-Device-ID` for guest Joshen searches and preserving the search-to-booking handoff (TD-119); CORS accepts the header. **App action required:** keep the same opaque device id across guest search pages and sign-in booking handoff. |
| 2.11.0 | 2026-10-26 | §9.1: the customer receives `order.updated` with `reason: delayed` when the provider's status update is overdue (BR-21); the status in the payload is unchanged. **App action required:** none mandatory (additive); show the delay notice and refetch — details carry `delay_alert` ([client/orders](client/orders.md) 2.4.0). |
| 2.10.2 | 2026-10-26 | 5xx API messages stay generic on staging and production even when debug is enabled; exception details remain in logs (TD-108). **App action required:** none. |
| 2.10.1 | 2026-10-26 | §3.1 states where the correlation id is on a validation 422: the `X-Correlation-ID` response header only, not `data` (TD-107). Documentation only. **App action required:** none; read the header, not the body, to quote a validation error. |
| 2.10.0 | 2026-10-26 | §9.1: customer cancellation sends `order.updated` with `reason: cancelled` to both customer and provider, plus one inbox row each. **App action required:** refetch the order and remove it from the active schedule. |
| 2.9.0 | 2026-10-26 | §9.1: `order.updated` also goes to the customer for every execution stage with `reason: status_changed` (`payload.status` is the stage). **App action required:** none mandatory (additive); refetch the order on this reason. |
| 2.8.0 | 2026-10-25 | New §9 **Realtime events (Socket.IO)**: connect with the REST access token, one private room per app, `order.requested` / `order.updated` events (TD-96). New error code `IDEMPOTENCY_KEY_REUSED` (409) and the per-user order throttles answering 429 `RATE_LIMITED` (TD-95/97). Human order codes are now `JSH-` + six digits. **App action required:** none mandatory (additive); connect the socket to replace order polling, and refetch over HTTP on every event and reconnect. |
| 2.7.0 | 2026-10-25 | 422 responses carry `data.errors`, a map of field → localized messages, beside the unchanged combined `message` (TD-02, §3.1). New error codes `SERVICE_AREA_IN_USE`, `DELIVERY_FEE_RULE_CONFLICT`, `DELIVERY_FEE_UNAVAILABLE`, `PACKAGE_SERVICES_UNAVAILABLE`, `PHONE_IN_USE` (and `SERVICE_AREA_UNAVAILABLE`, already returned, now listed). **App action required:** none mandatory (`data` was `null` on 422 before; code that ignores it keeps working); forms should map `data.errors` onto fields. |
| 2.6.0 | 2026-10-22 | The Postman collection, the API console (`{HOST}/tester`) and the docs MCP `get-endpoint` carry real captured response examples (TD-56) and request chaining: responses save tokens, `verification_token`, `media_hash` and record uuids into variables that related requests read as `{{name}}`; each app has its own token variable (TD-57). §8 updated. **App action required:** none (tooling only); agents may now order calls from `variables`. |
| 2.5.1 | 2026-10-21 | Clarifies that legacy Y-m-d H:i:s timestamps (catalog and admin customers) are UTC, not browser local time (TD-38); application database sessions now enforce UTC for SQL defaults/triggers (TD-40); named unsupported-key validation messages preserve the existing 422 envelope. **App action required:** interpret these offsetless values as UTC before display conversion. |
| 2.5.0 | 2026-10-21 | Shared authenticated POST /api/v1/media, reused upload controller; HasMedia form protocol: upload file + collection, then send data.hash in JSON. **App action required:** optional use of the versioned shared uploader; existing /media endpoint retained. |
| 2.4.0 | 2026-10-21 | Catalog error codes CATALOG_IN_USE and PRICE_RANGE_CONFLICT; safe affected_count is public even with debug disabled (TD-34). **App action required:** services integration handles those 409 codes; other apps unchanged. |
| 2.3.1 | 2026-10-21 | §8 now points to the full agent setup guide [MCP_AGENT_GUIDE.md](MCP_AGENT_GUIDE.md); `get-integration-brief` tool and `integrate-feature` prompt added. **App action required:** none. |
| 2.3.0 | 2026-10-21 | §8: connect your coding agent to the `joshen-docs` MCP server, and the docs viewer moved to `{HOST}/docs` (token required outside local/dev, TD-31). **App action required:** none; optionally add the MCP server to your agent. |
| 2.2.0 | 2026-10-21 | Shared in-app inbox `/api/v1/notifications` on the schema table (§7); admin error codes and `INVALID_CREDENTIALS`/`INTERNAL_ERROR` in the error table; every error message localized (Arabic default); 422 combined message documented in both languages; 401 → refresh before sign-out (TD-29); framework 403/429 carry `error_code`; 500 never exposes internals. **App action required:** none mandatory; refresh on 401 is recommended. |
| 2.1.0 | 2026-10-20 | Added account/OTP/rate-limit error codes, app isolation and coded auth failures. |
| 2.0.0 | 2026-10-20 | **Breaking.** All ids are uuids: the `id` of every object, route parameters, request fields that reference another record (`country_id`, `parent_id`, `permissions[]`, …), list filters (`filters[governorate_id]`), and headers (`X-City`). Integer ids are never accepted or returned (TD-19). **App action required:** store ids as strings, send uuids, never parse an id as an int. |
| 1.2.0 | 2026-10-20 | One translation style for every entity: nested locale objects (`ar` required, `en` optional). The flat `name_ar` / `name_en` style is dropped (TD-12). |
| 1.1.0 | 2026-10-20 | Added the schema translation style (`name_ar` / `name_en`) and human codes (`JSH-…`). |
| 1.0.0 | 2026-10-20 | First version, documents the current backend behavior. |

---

## 1. Request headers

| Header | Required | Value | Why |
|--------|----------|-------|-----|
| `Accept` | Yes | `application/json` | Guarantees JSON errors instead of HTML pages. |
| `Authorization` | Protected routes | `Bearer {token}` | JWT returned by the surface's login/verify endpoint. |
| `Accept-Language` | Recommended | `ar` or `en` | Language of `message` and every `label`. Defaults to `ar`. |
| `X-Correlation-ID` | Optional | UUID | Echoed in logs/errors — send one per request to make bug reports traceable. |
| `Idempotency-Key` | On payment/booking POSTs and `POST /admin/refunds` | UUID per user action | A retried request with the same key returns the first result instead of charging twice. Reuse the key on retry; generate a new one for a new action. |
| `X-Country` / `X-City` / `X-Currency` | Optional | e.g. `SA` / city uuid / `SAR` | Overrides geo detection on routes with geo context. |
| `X-Guest-Device-ID` | Guest Joshen search and booking handoff | Opaque stable device id, maximum 100 characters | Associates guest search pages and allows the same device's signed-in customer to link that search to an order (TD-119). |

## 2. Response envelope

Every response, success or failure, has the same top-level shape:

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {}
}
```

| `status` | HTTP | Meaning | App behavior |
|----------|------|---------|--------------|
| `success` | 200 / 201 | Worked | Read `data`. |
| `fail` | 4xx | The request was wrong or not allowed | Show `message` to the user; branch on `data.error_code` if present. |
| `error` | 5xx (or domain error status) | Server/domain failure | Show a generic error; log `data.correlation_id`. |

`message` is already translated according to `Accept-Language` — show it as-is.

### 2.1 Single object

```json
{ "status": "success", "message": "...", "data": { "id": "0198a1c4-7b52-7c3e-9d0f-3f6a2b1c8e55", "name": "..." } }
```

### 2.2 Paginated list (default for every list endpoint)

The list sits under a key named after the resource (e.g. `faqs`, `addresses`,
`orders`). Each feature doc states the key.

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "faqs": [ { "id": "0198a1c4-7b52-7c3e-9d0f-3f6a2b1c8e55", "question": "..." } ],
    "links": { "first": "...?page=1", "last": "...?page=4", "prev": null, "next": "...?page=2" },
    "meta": { "current_page": 1, "from": 1, "last_page": 4, "path": "...", "per_page": 15, "to": 15, "total": 52 }
  }
}
```

### 2.3 Unpaginated list (`?paginate=false`)

```json
{ "status": "success", "message": "...", "data": [ { "id": "0198a1c4-…" }, { "id": "0198a1c5-…" } ] }
```

Capped at 500 items. Use only for small dropdown lists.

### 2.4 List query parameters

| Param | Example | Notes |
|-------|---------|-------|
| `page` | `page=2` | 1-based |
| `per_page` | `per_page=20` | Default 15, max 100 |
| `paginate` | `paginate=false` | Disable pagination |
| `search` | `search=hair` | Searches the fields the feature doc lists |
| `filters[field]` | `filters[is_active]=1` | Only filters listed in the feature doc |
| `sort[field]` | `sort[created_at]=desc` | Default: newest first |

## 3. Errors

### 3.1 Validation — HTTP 422

```json
{
  "status": "fail",
  "message": "حقل رمز الدولة مطلوب. (و 1 خطأ آخر)",
  "data": {
    "errors": {
      "phone_code": ["حقل رمز الدولة مطلوب."],
      "phone": ["حقل رقم الجوال مطلوب."]
    }
  }
}
```

`message` is the first error plus a translated count of the others
(`(and 5 more errors)` / `(و 5 أخطاء أخرى)`); show it as a toast. `data.errors`
(TD-02) maps every failing field to its localized messages, keyed like the
request body (`email`, `items.0.service_id`, `ar.name`), so a form can mark each
field. The maps are in the request language (`Accept-Language`, Arabic by
default) and field names are translated too. Domain failures (`BUSINESS_RULE_VIOLATED`
and the other codes below) are not validation errors: they carry
`data.error_code` instead of `data.errors`. Unsupported object/query keys are
identified by their full path (e.g. `sort.unknown` or `ar.slug`); a scalar where
an array is required still returns an array-type error.

The validation 422 body holds only `data.errors` — except `PUT /admin/settings`, which also
carries `data.error_code = VALIDATION_FAILED` next to the same `data.errors` map and message
([admin settings](admin/settings.md) 0.15.2); its trace id is the
`X-Correlation-ID` response header, which every response carries. Domain and
5xx errors (§3.2) also repeat it as `data.correlation_id` (TD-107).

Catalog object/query validation names unsupported keys by full path, such as
`sort.foo`, `filters.foo`, or `ar.foo`, rather than reporting a false array-type
error. A scalar supplied where an array is required still gets the array-type error.

### 3.2 Business rule / domain error — HTTP 4xx/5xx

```json
{
  "status": "fail",
  "message": "Cancellation is not allowed after the provider has departed",
  "data": { "error_code": "INVALID_STATE_TRANSITION", "correlation_id": "6f1c…" }
}
```

**Branch on `data.error_code`, never on `message`.** Feature docs list the codes
each endpoint can return.

| `error_code` | Typical HTTP | Meaning |
|--------------|--------------|---------|
| `VALIDATION_FAILED` | 422 | Input invalid (domain-level) |
| `BUSINESS_RULE_VIOLATED` | 422 | A BRD rule (BR-xx) blocked the action |
| `INVALID_STATE_TRANSITION` | 409/422 | Action not allowed in the current status |
| `ORDER_TRACKING_UNAVAILABLE` | 422 | Customer tracking grant cannot be issued in this order state |
| `ORDER_CHAT_UNAVAILABLE` | 422 | Order is not paid and confirmed, or conversation is read-only after completion/cancellation |
| `ENTITY_NOT_FOUND` | 404 | Record missing or not yours |
| `ENTITY_ALREADY_EXISTS` | 409 | Duplicate |
| `UNAUTHENTICATED` | 401 | Token missing/expired |
| `PERMISSION_DENIED` | 403 | Not allowed for this user/role |
| `ACCOUNT_INACTIVE` | 403 | Account disabled, banned, or not yet approved |
| `ACCOUNT_NOT_REGISTERED` | 404 | Phone has no profile in this app |
| `INVALID_OTP` | 422 | Incorrect verification code |
| `OTP_EXPIRED` | 422 | Unknown, expired or consumed challenge |
| `OTP_TOO_MANY_ATTEMPTS` | 429 | Attempt allowance exhausted; resend required |
| `RATE_LIMITED` | 429 | Named limiter or OTP resend cooldown |
| `IDEMPOTENCY_KEY_REUSED` | 409 | The same `Idempotency-Key` was sent with a different request body; use a new key for a new intended order (TD-95) |
| `INFRASTRUCTURE_FAILURE` | 503 | External service (payment, SMS, maps) failed or realtime tracking is not configured — retry later |
| `INTERNAL_ERROR` | 500 | Unexpected server failure. `data.correlation_id` is set; no SQL or exception text is returned outside local development, including staging and production |
| `INVALID_CREDENTIALS` | 422 | Admin login: unknown email, wrong password or non-admin account (one generic message) |
| `CATALOG_IN_USE` | 409 | Category/service still referenced; deactivate instead |
| `SERVICE_AREA_IN_USE` | 409 | Country/city/district still referenced by accounts, addresses, orders or fee rules; disable instead (ADMIN-021) |
| `DELIVERY_FEE_RULE_CONFLICT` | 409 | A second active delivery fee rule for the same city or default scope (TD-82) |
| `DELIVERY_FEE_UNAVAILABLE` | 422 | No active delivery fee rule prices the service address city (BR-11) |
| `PACKAGE_SERVICES_UNAVAILABLE` | 422 | A package includes services the provider has not enabled and fully priced; `data.context.services` lists their uuids (PROV-011, BR-07) |
| `COUPON_INVALID` | 422 | Coupon code unknown, switched off or not usable (TD-148) |
| `COUPON_EXPIRED` | 422 | Coupon is before its start or after its end |
| `COUPON_NOT_APPLICABLE` | 422 | Coupon scope does not cover the chosen service, category or provider |
| `COUPON_MIN_ORDER_NOT_MET` | 422 | Services amount is below the coupon minimum |
| `COUPON_USAGE_LIMIT_REACHED` | 422 | The coupon's total or per-customer limit is used up |
| `COUPON_FREE_ORDER` | 422 | The discount would bring the order total to zero or below |
| `REFUND_NOT_AVAILABLE` | 422 | Admin refund of an order with no paid money left (TD-156) |
| `CANCELLATION_RULE_IN_USE` | 409 | Admin deleted a cancellation rule that orders were cancelled under; deactivate it (TD-154) |
| `PAYOUT_NOTHING_AVAILABLE` | 422 | Transfer request with no `available` earning (DEC-11) |
| `PAYOUT_AMOUNT_NOT_PAYABLE` | 422 | The net of her available earnings is ≤ 0 or below `payout_min_amount`; `data.context` has `available` and `minimum` (DEC-11, TD-98) |
| `PAYOUT_ACCOUNT_UNUSABLE` | 422 | No payout account, or it is `rejected` / `inactive` (DEC-11) |
| `PAYOUT_REQUEST_OPEN` | 409 | A transfer request is already `requested` or `processing` (DEC-11) |
| `INVALID_PAYOUT_SETTINGS` | 422 | Admin saved a `payout_min_amount` that is negative or has more than two decimals |
| `COUPON_IN_USE` | 409 | Admin deleted a coupon that was used on an order; deactivate it instead (TD-131) |
| `PHONE_IN_USE` | 409 | The new mobile number belongs to another account (customer/provider mobile change, CUST-023) |
| `SERVICE_AREA_UNAVAILABLE` | 422 | The chosen city or district is not an enabled service area (addresses, discovery, customer profile city) |
| `PRICE_RANGE_CONFLICT` | 409 | New admin range excludes provider prices; `data.affected_count` is a public integer, including inactive rows |
| `ROLE_IN_USE` | 409 | Role still has live administrators or child roles |
| `ROLE_HIERARCHY_CYCLE` | 422 | The chosen parent would put the role inside its own subtree |
| `PRIVILEGE_ESCALATION` | 403 | The admin tried to grant, assign or manage permissions she does not hold |
| `SELF_MODIFICATION_FORBIDDEN` | 403 | The admin tried to change her own account (via supervisors) or the role she holds |
| `LAST_SUPER_ADMIN` | 409 | The change would leave no active super admin |
| `SYSTEM_ROLE_LOCKED` | 403 | The `super_admin` system role cannot be changed, deleted or assigned |

Domain 4xx failures use status=fail; 5xx failures use status=error.

Range-edit conflicts use `PRICE_RANGE_CONFLICT`; immutable service identity uses the existing `INVALID_STATE_TRANSITION`. Their count is safe public data, not debug context.

Feature-specific codes (e.g. `PRICE_OUT_OF_RANGE`) are added to
`App\Core\Exceptions\ErrorCode` and listed in the feature doc.

#### 3.2.1 Every error code (generated from `app/Core/Exceptions/ErrorCode.php`, 2026-11-04)

The table above shows the common ones with their usual HTTP status. This index lists every code
the backend can return; the endpoint's feature doc gives the exact status and when it happens.

| `error_code` | Meaning |
|--------------|---------|
| `ENTITY_NOT_FOUND` | Requested entity does not exist |
| `INVOICE_NOT_FOUND` | The requested invoice does not exist or has not been issued yet (CUST-015). |
| `ENTITY_ALREADY_EXISTS` | Entity already exists (uniqueness violation) |
| `BUSINESS_RULE_VIOLATED` | One or more business rules were violated |
| `INSTANT_TERMS_REQUIRED` | Instant booking terms must be acknowledged before enabling (PROV-012, D3). |
| `INVALID_WORKING_HOURS` | Provider weekly periods overlap or have invalid ordering (PROV-013). |
| `INVALID_BOOKING_SETTINGS` | Booking setting is missing, invalid or violates the deadline/lead guard. |
| `INVALID_NOTIFICATION_SETTINGS` | Notification reminder or channel setting is invalid or violates invariants (422, TD-167). |
| `CHANNEL_UNAVAILABLE` | Requested notification channel is unsupported or unavailable on the platform (422, TD-167). |
| `NOTIFICATION_NO_RECIPIENTS` | An admin general notification matches no reachable recipient (422, TD-173). |
| `NOTIFICATION_RECIPIENTS_UNREACHABLE` | Some picked recipients are not approved or not active and cannot be notified (422, TD-173). |
| `INVALID_VAT_SETTINGS` | VAT seller name or registration number setting is invalid (422, TD-165). |
| `INVALID_SERVICE_OPTIONS` | Selected option choices violate an active group rule. |
| `PROVIDER_UNAVAILABLE` | Provider or service is no longer eligible at this address. |
| `BOOKING_TIME_UNAVAILABLE` | Requested start violates lead, working hours, time off or overlap. |
| `DISTANCE_UNAVAILABLE` | Provider or address coordinates needed for the delivery fee are absent. |
| `COMMISSION_RULE_UNAVAILABLE` | No active percentage provider service commission can be snapshotted. |
| `IDEMPOTENCY_KEY_REUSED` | The same Idempotency-Key was sent with a different request body (TD-95). |
| `PROVIDER_RESPONSE_EXPIRED` | A provider answered after the response deadline. |
| `PAYMENT_GATEWAY_UNAVAILABLE` | The simulated payment driver is unavailable outside local and testing. |
| `PAYMENT_METHOD_DISABLED` | The chosen payment method is switched off by an admin (ADMIN-025, TD-168). |
| `WALLET_PAYMENTS_DISABLED` | Wallet payment is disabled by the platform. |
| `WALLET_INSUFFICIENT_BALANCE` | Wallet cannot cover the complete order. |
| `INVALID_STATE_TRANSITION` | Operation not allowed in the current state |
| `ORDER_TRACKING_UNAVAILABLE` | Order tracking is unavailable in this order state. |
| `ORDER_CHAT_UNAVAILABLE` | Order chat is unavailable before paid confirmation or after execution ends. |
| `ORDER_STAGE_MANAGED_BY_DRIVER` | The order uses a Joshen driver, so on_the_way comes from the driver trip, not the provider (422, PROV-019). |
| `TRANSPORT_CHOICE_NOT_ALLOWED` | Transport mode cannot be chosen in the current order state (422, PROV-017). |
| `TRIP_OFFER_NOT_AVAILABLE` | A trip offer is no longer available because another driver accepted or it expired (422, BR-17). |
| `DRIVER_ALREADY_ON_TRIP` | A driver who already has an active trip cannot accept another trip offer (422, DRIV-009). |
| `DRIVER_VEHICLE_REQUIRED` | A driver must have an active registered vehicle to accept trip offers (422, DRIV-008). |
| `ORDER_EXECUTION_TOO_EARLY` | A scheduled order cannot leave confirmed earlier than execution_early_start_minutes before scheduled_at (422, TD-102). |
| `ORDER_NOT_EXECUTABLE` | The order has no visit to execute, e.g. a package purchase (422, TD-101). |
| `ORDER_CANCELLATION_LOCKED` | Cancellation is locked because execution reached on_the_way (422, BR-19). |
| `ORDER_CANCELLATION_NOT_ALLOWED` | No active allowing cancellation rule matches this booking and stage (422, BR-20). |
| `REFUND_NOT_AVAILABLE` | The order has no paid, refundable money left for an admin refund (422, ADMIN-018). |
| `PAYOUT_NOTHING_AVAILABLE` | The beneficiary has no `available` earnings to request (422, PROV-023, DRIV-017). |
| `PAYOUT_AMOUNT_NOT_PAYABLE` | The net of the available earnings is zero, negative or below `payout_min_amount` (422, DEC-11). |
| `PAYOUT_REQUEST_OPEN` | A transfer request is already `requested` or `processing` (409, DEC-11). |
| `PAYOUT_ACCOUNT_UNUSABLE` | No payout account, or it is `rejected` / `inactive` (422, DEC-11, DEC-25). |
| `INVALID_PAYOUT_SETTINGS` | `payout_min_amount` is not a non-negative amount with at most two decimals (422, DEC-11). |
| `REVIEW_ORDER_NOT_RATABLE` | The order cannot be rated: not completed, a package purchase, or not the caller's (422, BR-28, CUST-021, PROV-024). |
| `REVIEW_ALREADY_SUBMITTED` | This direction already has a rating on the order (409, BR-28; one per order per direction). |
| `REVIEW_TAG_INVALID` | A chosen tag is inactive or belongs to the other direction (422, DEC-19). |
| `SUPPORT_REASON_INVALID` | The support reason is inactive or belongs to another app (422, DEC-21). |
| `SUPPORT_TICKET_CLOSED` | The ticket is closed; open a new request instead (409, DEC-21). |
| `SUPPORT_ASSIGNEE_INVALID` | The chosen assignee is not an active admin who can see the support desk (422, ADMIN-026, TD-163). |
| `COMMISSION_RULE_LOCKED` | A commission rule that has taken effect is never edited or deleted; add a newer rule (409, ADMIN-016, TD-159). |
| `COMMISSION_RULE_REQUIRED` | The provider rule in force cannot be switched off without another one in force (409, ADMIN-016, TD-159). |
| `CANCELLATION_RULE_IN_USE` | A cancellation rule that orders reference cannot be deleted; deactivate it instead (409, ADMIN-018). |
| `CATALOG_IN_USE` | A referenced catalog entity cannot be deleted (409). |
| `PRICE_RANGE_CONFLICT` | A range edit would invalidate existing provider prices (409). |
| `SERVICE_AREA_IN_USE` | A country, city or district still referenced by accounts, addresses or orders cannot be deleted (409). |
| `DELIVERY_FEE_RULE_CONFLICT` | A city already has an active delivery fee rule (409, TD-82). |
| `DELIVERY_FEE_UNAVAILABLE` | No active delivery fee rule covers the service address city (422, BR-11). |
| `COUPON_INVALID` | The coupon code does not exist or is switched off (422, TD-148). |
| `COUPON_EXPIRED` | The coupon is outside its start/end dates (422, TD-148). |
| `COUPON_MIN_ORDER_NOT_MET` | The order is below the coupon's minimum amount (422, TD-148). |
| `COUPON_NOT_APPLICABLE` | The coupon does not cover this service, category or provider (422, TD-148). |
| `COUPON_USAGE_LIMIT_REACHED` | The coupon's total or per-customer usage limit is used up (422, TD-148). |
| `COUPON_FREE_ORDER` | The coupon would bring the order total to zero or below (422, TD-148). |
| `COUPON_IN_USE` | A coupon that was used on an order cannot be deleted; deactivate it (409, TD-131). |
| `PACKAGE_SERVICES_UNAVAILABLE` | A package contains services the provider has not enabled and fully priced (422, BR-07). |
| `PACKAGE_NOT_BUYABLE` | Package is not available for purchase (HTTP 422). |
| `PACKAGE_NOT_ACTIVE` | The purchased package is not active or has expired (HTTP 422). |
| `PACKAGE_SESSION_UNAVAILABLE` | The purchased session cannot be booked, with a reason (HTTP 422). |
| `PACKAGE_SESSION_AFTER_EXPIRY` | The requested start is after the package expires (HTTP 422). |
| `PACKAGE_PURCHASE_CANCELLATION_UNAVAILABLE` | Paid package purchase cannot be cancelled (HTTP 422). |
| `PHONE_IN_USE` | The new mobile number already belongs to another account (409, CUST-023). |
| `FEATURED_PLAN_UNAVAILABLE` | The featured plan is disabled by Joshen or unknown to providers (422, PROV-007). |
| `FEATURED_SUBSCRIPTION_ACTIVE` | The provider already holds an active featured subscription (409, DEC-12). |
| `FEATURED_PLAN_IN_USE` | A featured plan with subscription history cannot be deleted, disable it instead (409, ADMIN-005). |
| `JOSHEN_DISABLED` | Joshen finder is disabled (422, ADMIN-012). |
| `JOSHEN_CONFIG_UNAVAILABLE` | Joshen finder configuration is missing or invalid (422, TD-114). |
| `JOSHEN_INPUT_DISABLED` | A supplied finder input is disabled in current settings (422, ADMIN-012). |
| `FINDER_SEARCH_NOT_LINKABLE` | A finder search does not belong to this customer or guest device (422, CUST-009). |
| `INVALID_FINDER_OCCASION_TAGS` | Occasion tags are unsupported for a non-root service or inactive occasion (422, ADMIN-012). |
| `SERVICE_AREA_UNAVAILABLE` | Selected city or district is not an enabled service area (422). |
| `PRICE_OUT_OF_RANGE` | A provider price lies outside its admin node range (422). |
| `PROFILE_CHANGE_REVIEW_REQUIRED` | Changing approved account data needs a reviewed change request, not built yet (409, TD-76). |
| `VALIDATION_FAILED` | Request validation failed |
| `EMPTY_PAYLOAD` | Required data was empty or missing |
| `UNAUTHENTICATED` | User is not authenticated |
| `PERMISSION_DENIED` | User lacks the required permission |
| `INVALID_CREDENTIALS` | Admin login: unknown email, wrong password, or not an admin account (422) |
| `ACCOUNT_INACTIVE` | Account is inactive or banned |
| `ACCOUNT_NOT_REGISTERED` | Phone has no profile in this app. |
| `INVALID_OTP` | Incorrect verification code. |
| `OTP_EXPIRED` | Expired, consumed or unknown challenge. |
| `OTP_TOO_MANY_ATTEMPTS` | Code attempt allowance exhausted. |
| `ROLE_IN_USE` | Role still has administrators or child roles (409) |
| `ROLE_HIERARCHY_CYCLE` | Parent role would create a loop in the hierarchy (422) |
| `PRIVILEGE_ESCALATION` | Actor tried to grant or manage permissions she does not hold (403) |
| `SELF_MODIFICATION_FORBIDDEN` | Actor tried to change her own account or role (403) |
| `LAST_SUPER_ADMIN` | Change would leave no active super admin (409) |
| `SYSTEM_ROLE_LOCKED` | System role cannot be changed or assigned (403) |
| `RBAC_CATALOG_INCOMPLETE` | An admin route has no section in config/rbac.php (500, deploy-time) |
| `INFRASTRUCTURE_FAILURE` | An external service failed or is unavailable |
| `CIRCUIT_OPEN` | Circuit breaker is open for the target service |
| `MISSING_BINDING` | Required contract binding is missing from the container |
| `RATE_LIMITED` | Request rate limit exceeded |
| `IDEMPOTENCY_CONFLICT` | Duplicate request detected (idempotency conflict) |
| `PAYMENT_FAILED` | Payment processing failed |
| `WEBHOOK_INVALID_SIGNATURE` | Webhook signature verification failed |
| `INTERNAL_ERROR` | Unexpected internal error |

### 3.3 Framework errors

Messages are localized like every other message (Arabic when `Accept-Language`
is missing).

| HTTP | `message` example (ar / en) | `data` | App behavior |
|------|-----------------------------|--------|--------------|
| 401 | غير مصرح، يجب تسجيل الدخول أولًا / Unauthenticated, you have to login first | `{error_code: UNAUTHENTICATED}` | Call the surface's `auth/refresh` once with the stored token (it accepts an expired token inside the refresh window, TD-29); if that is also 401, clear the token → login screen |
| 403 | غير مصرح لك بتنفيذ هذا الإجراء / You are not authorized to make this action | `{error_code: PERMISSION_DENIED}` | Show message; admin: hide the action |
| 404 | المورد غير موجود / Resource not found | `null` | Show empty/not-found state |
| 405 | طريقة الطلب غير مسموح بها / HTTP method not allowed | `null` | Developer bug |
| 429 | طلبات كثيرة جدًا… / Too many requests… | `{error_code: RATE_LIMITED}` | Back off and retry later (`Retry-After`) |
| 500 | حدث خطأ في الخادم… / Internal server error | `{error_code: INTERNAL_ERROR, correlation_id}` | Generic error; include the correlation id in bug reports |

Debug response headers (`X-Query-Count`, `X-Duration-Ms`, `X-Memory-MB`,
`X-Gzip-MW*`) exist only with `APP_DEBUG=true` outside production;
`X-Powered-By` is never sent. `X-Correlation-ID` is always echoed.

## 4. Data formats

| Kind | Format | Example |
|------|--------|---------|
| Keys | snake_case | `booking_type` |
| IDs | uuid **string** (never an integer) | `"0198a1c4-7b52-7c3e-9d0f-3f6a2b1c8e55"` |
| Money | 2-decimal **string** | `"150.00"` (currency: SAR unless stated) |
| Date-time | ISO 8601 with offset (the offset may be `+03:00` or `+00:00`; same instant) | `"2026-10-20T14:30:00+03:00"` |
| Date | `YYYY-MM-DD` | `"2026-10-20"` |
| Status / enum | object | `{ "value": "confirmed", "label": "مؤكد" }` |
| Boolean | `true`/`false` | |
| Media | absolute URL (placeholder when empty) | `"https://…/image.webp"` |
| Phone code | `phone_code` on users, profiles and every request is the dialling prefix **with `+`**: `"+966"`. The reference catalogs (`/countries`, `/governorates`) return the **bare country calling code** (`"966"`, `"20"`); a client that renders a country prepends `+` itself, and never to a value that already starts with `+` (QA-08). | `"+966"` · `"966"` |
| Customer reference in admin payloads | `customer.id` = customer-profile uuid; `customer.user_id` = user uuid (opens `GET /admin/users/{id}`) (QA-03) | `{ "id": "…", "user_id": "…", "full_name": "…" }` |
| Coordinates | numbers | `"lat": 24.7136, "lng": 46.6753` |
| Allowed actions | `can_*` booleans | `"can_cancel": false` |
| Human codes | string, shown to users instead of ids | `"code": "JSH-48219"` (orders), `TRP-…` (trips), `SUP-…` (tickets), `INV-…` (invoices) |

Legacy endpoints may still return `Y-m-d H:i:s` date-times (catalog and admin
customers). These values are **UTC**, even though the offset is absent (TD-38).
Never pass the raw space-separated string to a browser/device local-time parser.
For example, `2026-10-21 12:34:56` means `2026-10-21T12:34:56Z`; convert from UTC
to the dashboard's display zone after parsing. Date-only values are calendar dates.
ISO endpoints retain their explicit offset (TD-03); legacy label-only enums are
identified in their feature docs (TD-06).

Application MySQL/MariaDB sessions are pinned to `+00:00` (TD-40), including
SQL-generated defaults and trigger dates. Deploy with refreshed configuration
and restarted long-lived workers. Historical naive DATETIME rows written before
this correction need a provenance audit before any data normalization; no
historical dates are rewritten by this change.

```ts
// Only for documented legacy UTC date-time strings, not date-only fields:
export const parseLegacyUtc = (value: string): Date =>
  new Date(value.replace(' ', 'T') + 'Z');
```

### Translatable fields (admin forms)

Every translatable entity (geography, roles, categories, services, options,
FAQs, banners, static pages, …) uses the same nested locale objects. Arabic is
required, English optional (TD-12).

```json
{ "is_active": true, "ar": { "name": "العناية بالشعر" }, "en": { "name": "Hair Care" } }
```

Lists return the translated fields flat in the `Accept-Language` locale,
falling back to Arabic (`"name": "Hair Care"`). Show endpoints also return
`ar` and `en` objects for edit forms (`en` is `null` when not entered). Each
feature doc lists the translated fields of its entities.

Text written by users (package names, provider bios, chat, addresses) is not
translated and is returned as entered.

### File uploads

Every model using `HasMedia` follows an upload-first protocol (TD-14/37):

1. `POST {HOST}/api/v1/media` with an authenticated app/admin Bearer token, `Accept: application/json`, and `multipart/form-data`: required `file` and required `collection` string (max 255), e.g. `image`.
2. Read `data.hash` from the 201 response. Media stay temporary until attached; the response also carries public UUID `id`, display `url`, MIME, size, original name and optional variant URLs.
3. Send that **hash** as the corresponding field in the feature's JSON form: `{"image":"<data.hash>"}`. Multiple-file collections use the array form documented by their feature. Never submit a file path, URL or media UUID in place of the hash.
4. `HasMedia` attaches it to the model/collection; resources expose the accessor's URL for display. Omit unchanged media fields. Send null to remove only where the feature explicitly supports it (catalog forms do).

Use the shared API base `/api/v1` for upload, then the surface base such as
`/api/v1/admin` for the form. The existing root `/media` endpoint is retained.
The upload uses existing Media module config (default max 10,240 KB and allowed
MIME extensions), image processing and per-uploader storage quota; individual
form contracts describe accepted media and any additional restrictions. Missing
credentials → 401; invalid upload/form fields → 422. Collection keys and their
single/multiple behavior come from each model's `$media_keys`.

See the complete real upload/hash/form examples in
[services §5.3](admin/services.md#53-upload-first-images-categories-and-services).

### Platform logo (bootstrap)

`GET /api/v1/config/bootstrap?platform=web` returns `data.app.logo_url` (shipped 2026-11-05, recorded as an addition in 4.6.0; see
[admin settings](admin/settings.md) 0.15.0, TD-170): an absolute URL of the logo the admin
uploaded as `app_logo`, or an absolute placeholder URL when none is set. It is never null.
`data.contact` (4.9.0) carries the public support contacts. The other bootstrap keys are unchanged. Real responses: Postman `shared.config.bootstrap` › "200 Bootstrap with the support contacts",
"200 Bootstrap with platform logo" and "200 Bootstrap with placeholder logo".

---

## 5. Flutter integration (client, provider, delivery)

### 5.1 Dio client

```dart
final dio = Dio(BaseOptions(
  baseUrl: '${Env.host}/api/v1/client', // provider: /api/v1/provider · driver: /api/v1/delivery
  headers: {'Accept': 'application/json'},
  connectTimeout: const Duration(seconds: 15),
  receiveTimeout: const Duration(seconds: 30),
));

dio.interceptors.add(InterceptorsWrapper(
  onRequest: (options, handler) async {
    final token = await tokenStorage.read();
    if (token != null) options.headers['Authorization'] = 'Bearer $token';
    options.headers['Accept-Language'] = appLocale.languageCode; // 'ar' | 'en'
    options.headers['X-Correlation-ID'] = const Uuid().v4();
    handler.next(options);
  },
  onError: (e, handler) async {
    // 401: try POST auth/refresh once with the stored token (works after expiry, TD-29)
    if (e.response?.statusCode == 401 && !await authController.tryRefresh()) await authController.logout();
    handler.next(e);
  },
));
```

### 5.2 Envelope parsing

```dart
class ApiResponse<T> {
  final String status;          // success | fail | error
  final String message;
  final T? data;
  final String? errorCode;      // from data.error_code on failures

  bool get isSuccess => status == 'success';

  factory ApiResponse.fromJson(Map<String, dynamic> json, T Function(dynamic)? parse) {
    final raw = json['data'];
    final isMap = raw is Map<String, dynamic>;
    return ApiResponse(
      status: json['status'] as String,
      message: (json['message'] ?? '') as String,
      data: json['status'] == 'success' && parse != null && raw != null ? parse(raw) : null,
      errorCode: isMap ? raw['error_code'] as String? : null,
    );
  }
  // constructor omitted
}

class Paginated<T> {
  final List<T> items;
  final int currentPage, lastPage, total;
  bool get hasMore => currentPage < lastPage;

  factory Paginated.fromJson(Map<String, dynamic> data, String key, T Function(Map<String, dynamic>) item) {
    final meta = data['meta'] as Map<String, dynamic>;
    return Paginated(
      items: (data[key] as List).map((e) => item(e as Map<String, dynamic>)).toList(),
      currentPage: meta['current_page'] as int,
      lastPage: meta['last_page'] as int,
      total: meta['total'] as int,
    );
  }
  // constructor omitted
}
```

### 5.3 Common value types

```dart
class EnumValue {
  final String value;   // branch on this
  final String label;   // display this
  EnumValue.fromJson(Map<String, dynamic> j) : value = j['value'] as String, label = j['label'] as String;
}

// Money arrives as "150.00" — never parse as int.
double parseMoney(dynamic v) => v == null ? 0 : double.parse(v.toString());

// For ISO strings with an explicit offset only; legacy UTC strings use the helper below.
DateTime? parseDate(dynamic v) => v == null ? null : DateTime.parse(v as String).toLocal();

// For documented Y-m-d H:i:s UTC strings, never for date-only values:
DateTime? parseLegacyUtc(dynamic v) => v == null ? null
  : DateTime.parse('${(v as String).replaceFirst(" ", "T")}Z').toLocal();
```

### 5.4 Rules for the Flutter apps

- Drive buttons from the `can_*` flags in responses, not from local status logic.
- Show `message` from failures directly; switch behavior on `errorCode`.
- RTL: `ar` is the default locale — build layouts RTL-first.
- Send `Idempotency-Key` on every payment and booking-creation request; reuse it on retry.
- Register the FCM device token with the surface's device endpoint after login
  and handle the push `type` keys listed in each feature doc.

---

## 6. React integration (admin)

### 6.1 Axios client

```ts
import axios from 'axios';

export const api = axios.create({
  baseURL: `${import.meta.env.VITE_API_HOST}/api/v1/admin`,
  headers: { Accept: 'application/json' },
});

api.interceptors.request.use((config) => {
  const token = localStorage.getItem('admin_token');
  if (token) config.headers.Authorization = `Bearer ${token}`;
  config.headers['Accept-Language'] = i18n.language; // 'ar' | 'en'
  config.headers['X-Correlation-ID'] = crypto.randomUUID();
  return config;
});

api.interceptors.response.use(
  (res) => res,
  (err) => {
    // 401: try POST auth/refresh once with the stored token (works after expiry, TD-29)
    if (err.response?.status === 401) return authStore.refreshOrLogout(err);
    return Promise.reject(err);
  },
);
```

### 6.2 Shared types

```ts
export type ApiStatus = 'success' | 'fail' | 'error';

export interface ApiResponse<T> {
  status: ApiStatus;
  message: string;
  data: T;
}

export interface ApiErrorData {
  error_code?: string;
  correlation_id?: string;
}

export interface PaginationMeta {
  current_page: number; from: number | null; last_page: number;
  path: string; per_page: number; to: number | null; total: number;
}

/** The list key varies per resource, e.g. Paginated<'faqs', Faq> */
export type Paginated<K extends string, T> = { [P in K]: T[] } & {
  links: { first: string; last: string; prev: string | null; next: string | null };
  meta: PaginationMeta;
};

export interface EnumValue<V extends string = string> { value: V; label: string; }

/** POST /api/v1/media: submit hash to a HasMedia form; url is display-only. */
export interface UploadedMedia {
  id: string; hash: string; url: string; mime_type: string;
  size: number; original_name: string; variants?: Record<string, string>;
}

export type Money = string; // "150.00"
export type Translations<T> = { en: T; ar: T };
```

### 6.3 Table query helper

```ts
export interface ListQuery {
  page?: number; per_page?: number; search?: string;
  filters?: Record<string, string | number | boolean>;
  sort?: Record<string, 'asc' | 'desc'>;
}

export function toParams(q: ListQuery) {
  const p: Record<string, unknown> = { page: q.page, per_page: q.per_page, search: q.search };
  Object.entries(q.filters ?? {}).forEach(([k, v]) => (p[`filters[${k}]`] = v));
  Object.entries(q.sort ?? {}).forEach(([k, v]) => (p[`sort[${k}]`] = v));
  return p;
}
```

### 6.4 Rules for the dashboard

- Hide menu items and buttons using the RBAC permission each admin feature doc
  lists (route names such as `admin.orders.index`). The backend still enforces them.
- Edit forms load from **show** (all locales); tables load from **index**.
- Mirror the validation rules from the feature doc in form schemas (e.g. zod/yup).
- The dashboard is bilingual: support RTL when `ar` is active.

---

## 7. Shared in-app notifications (all apps)

Base URL `{HOST}/api/v1/notifications`. Auth: any app's bearer token
(`auth:api` + `active`). The inbox is the signed-in user's rows of the schema
`notifications` table **for the token's `app` claim** (TD-25): a person who
is both customer and provider sees each inbox only in its own app. An admin
token (claim `app = admin`) reads the dashboard alerts of that admin (TD-173,
[admin/notifications.md §10](admin/notifications.md)). Ids are uuids (TD-19).

| Method | Path | Route name | Result |
|--------|------|------------|--------|
| GET | `/notifications?page=&per_page=` | `shared.notifications.index` | Paginated list (newest first, `per_page` ≤ 50, default 15) + `unread_count` |
| GET | `/notifications/{uuid}` | `shared.notifications.show` | The notification; marks it read |
| DELETE | `/notifications/{uuid}` | `shared.notifications.destroy` | Deletes it; `data` null |
| POST | `/notifications/mark-all-read` | `shared.notifications.mark-all-read` | Marks this app's unread rows read; `data` null |
| DELETE | `/notifications/clear-all` | `shared.notifications.clear-all` | Deletes this app's rows; `data` null |

A uuid from another user or another app, or an integer id, is 404. Real
response of the list (from `NotificationInboxTest`; `meta.links` omitted here):

```json
{
    "status": "success",
    "message": "Notifications retrieved successfully",
    "data": {
        "unread_count": 1,
        "notifications": [
            {
                "id": "01a15d87-6d2f-7159-9a9c-6bb5f29101b1",
                "type": "order.confirmed",
                "title": "Demo notification",
                "body": "Sample in-app notification; no delivery is dispatched.",
                "data": null,
                "order_id": null,
                "is_read": true,
                "read_at": "2026-10-21T08:15:18+00:00",
                "created_at": "2026-10-21T08:15:18+00:00"
            },
            {
                "id": "01a15d87-6d2d-71f8-a574-2973f2daf2bd",
                "type": "order.confirmed",
                "title": "Demo notification",
                "body": "Sample in-app notification; no delivery is dispatched.",
                "data": null,
                "order_id": null,
                "is_read": false,
                "read_at": null,
                "created_at": "2026-10-21T07:15:18+00:00"
            }
        ],
        "links": {
            "first": "http://localhost/api/v1/notifications?page=1",
            "last": "http://localhost/api/v1/notifications?page=1",
            "prev": null,
            "next": null
        },
        "meta": {
            "current_page": 1,
            "from": 1,
            "last_page": 1,
            "path": "http://localhost/api/v1/notifications",
            "per_page": 15,
            "to": 2,
            "total": 2
        }
    }
}
```

| Field | Type | Notes |
|-------|------|-------|
| `id` | uuid | |
| `type` | string | Event key (`order.confirmed`, …); route on this |
| `title`, `body` | string | Stored already rendered |
| `data` | object/null | Deep-link payload, e.g. `{"order_id": …, "deeplink": …}` |
| `order_id` | uuid/null | Related order |
| `is_read` / `read_at` | bool / ISO 8601 or null | |
| `created_at` | ISO 8601 | |

Push tokens are registered per app with `POST /{client,provider,delivery}/notifications/device`
(see each app's auth doc). The event catalog, device lifecycle and preferences follow.

### 7.1 Event catalog (built from the code, updated 2026-11-04)

**Vocabulary.** The same app has three names. URL surface `client` = token / inbox / push app
`customer`; `delivery` = `driver`; `provider` = `provider`. The inbox `type` is the event key
below. Every event writes an inbox row first; push, then the realtime event, follow it.
`data` keys are uuids unless noted. **Category** is the preference switch that can mute the
push (§7.3); the inbox row and realtime events are never muted.

| Event key (`type`) | Audience (app) | Category | Trigger | `data` keys |
|---|---|---|---|---|
| `order_request_received` | provider | orders | A scheduled request is sent, or an instant order is paid (response window opens) | `order_id`, `status` |
| `order_accepted_awaiting_payment` | customer | orders | Provider accepted a scheduled request; pay before `payment_due_at` | `order_id`, `status` |
| `order_rejected` | customer | orders | Provider rejected the request | `order_id`, `status` |
| `order_expired` | customer | orders | Response or payment window lapsed | `order_id`, `status` |
| `order_confirmed` | customer, provider | orders | Order confirmed (paid scheduled order, or accepted instant order) | `order_id`, `status` |
| `order_status_changed` | customer | orders | Execution stage reached (`preparing` … `completed`) | `order_id`, `status` |
| `order_cancelled` | customer, provider | orders | Cancelled by the customer or by the provider before execution | `order_id`, `status` |
| `order_delayed` | customer | orders | The provider's status update is overdue (status unchanged, BR-21) | `order_id`, `status` |
| `package_purchased` | customer | orders | Package purchase paid and activated | `order_id`, `status` |
| `trip_offer_received` | driver | orders | A transport offer is sent to her | `trip_id`, `order_id`, `status` |
| `trip_no_driver` | provider | orders | The search window ended without a driver | `trip_id`, `order_id`, `status` |
| `trip_driver_accepted` | provider, driver | orders | A driver accepted the trip (the driver gets the linkage confirmation) | `trip_id`, `order_id`, `status` |
| `trip_driver_approaching` | provider | orders | Driver is on the way to the provider | `trip_id`, `order_id`, `status` |
| `trip_driver_arrived_provider` | provider | orders | Driver reached the provider | `trip_id`, `order_id`, `status` |
| `trip_provider_picked_up` | provider, customer | orders | Provider picked up by the driver | `trip_id`, `order_id`, `status` |
| `trip_driver_arrived_customer` | provider | orders | Driver reached the customer | `trip_id`, `order_id`, `status` |
| `trip_cancelled` | driver | orders | The trip was cancelled after a driver had accepted | `trip_id`, `order_id`, `status` |
| `trip_completed` | driver, provider | orders | The trip reached `completed` (BR-23) | `trip_id`, `order_id`, `status` |
| `order_payment_succeeded` | customer | payments | A service-order payment succeeded (card, mada or wallet; a package purchase sends `package_purchased` instead) | `order_id`, `method`, `amount` |
| `order_payment_failed` | customer | payments | A payment attempt failed (service order or package purchase) | `order_id`, `reason` |
| `order_rating_requested` | customer | orders | The order reached `completed`; once per order (BR-28) | `order_id` |
| `booking_reminder` | customer, provider | orders | `booking_reminder_hours` before a confirmed scheduled order; once per scheduled time | `order_id`, `status`, `scheduled_at` (ISO 8601) |
| `order_status_update_reminder` | provider | orders | `status_update_reminder_lead_minutes` before the customer's delay alert would fire | `order_id`, `status` |
| `wallet_refund_credited` | customer | payments | A refund was credited to her wallet | `order_id`, `order_code`, `amount` |
| `earning_recorded` | provider (service), driver (trip) | payments | Her amount due was calculated (BR-26) | `order_id` or `trip_id`, `net_amount` |
| `driver_rated` | driver | account | A provider rated her after a trip (SCR-25) | `review_id`, `stars`, `trip_id`, `order_id` |
| `account_document_expiring` | provider, driver | account | `document_expiry_reminder_days` before an approved document expires (SCR-25) | `document_id`, `doc_type`, `expires_at` (date), `days` |
| `user_registered` | customer, provider, driver | account | Her profile in that app was created (first registration) | `app` |
| `chat_message` | customer, provider (the recipient) | chat | A participant message on a paid, confirmed order; no message text | `order_id`, `conversation_id`, `message_id` |
| `featured_subscription_activated` | provider | payments | Featured payment succeeded | `subscription_id`, `status` |
| `featured_payment_failed` | provider | payments | Featured payment attempt failed | `subscription_id`, `status` |
| `featured_subscription_expiring` | provider | payments | `featured_expiry_reminder_days` before the plan ends | `subscription_id`, `status`, `days` |
| `featured_subscription_expired` | provider | payments | Subscription ended (`featured:expire`) | `subscription_id`, `status` |
| `payout_paid`, `payout_cancelled`, `payout_failed` | provider or driver (the beneficiary) | payments | Admin decision on her transfer request | `payout_id`, `status`, `amount` (2-decimal SAR string) |
| `account_review_status_changed` | provider, driver | account | Admin review of her account (approve, reject, request changes, suspend, reactivate) | `profile_id`, `account_status`, `action`, `reason` (nullable) |
| `provider_category_reviewed` | provider | account | Admin decision on a category request | `category_request_id`, `category_id`, `status`, `reason` (nullable) |
| `package_reviewed` | provider | account | Admin approved, rejected, hid or unhid her package | `package_id`, `status`, `action`, `hidden_by_admin`, `reason` (nullable) |
| `support_ticket_replied`, `support_ticket_status_changed` | customer, provider or driver (the requester's app) | account | Admin replied to, or changed the status of, her ticket | `ticket_id`, `code`, `status` |
| `driver_nearby_demand` | driver | orders | While offline: at least `driver_nearby_alert_min_trips` searching trips would be offered to her if she were online; at most once per `driver_nearby_alert_cooldown_minutes` (TD-174) | `count`, `district_id` (nullable) |
| `general` | customer, provider, driver (the chosen apps) | offers | An admin sent a general notification from the dashboard (TD-173); title and body are the admin's text in her language | `broadcast_id` |
| `admin_account_submitted`, `admin_category_requested`, `admin_package_submitted`, `admin_support_ticket_opened`, `admin_support_ticket_replied`, `admin_contact_message_received`, `admin_payout_requested` | admin (active super admins, and supervisors allowed to open the page, BR-29) | — (never muted) | Something waits in the dashboard (TD-173) | `event`, `front_route_name`, `id`, plus per event ([admin/notifications.md §10](admin/notifications.md)) |

**Implemented vs not built.** Everything above is implemented and emitted by the code today
(TD-166, TD-167). Each event × audience has an admin-editable template (title and text per
locale, channel switches, on/off — [admin/notifications.md](admin/notifications.md)); an event an
admin switched off sends nothing. `general` has no template: its text is the admin's own. Not built: promotions (`offers` holds only `general`), SMS
delivery (DEC-15; only logged) and WhatsApp (not integrated). OTP is sent by the Verification
module and is not a template. The legacy `withdraw_request_*` scenarios are never sent (DEC-10);
the apps must not rely on them. **Treat an unknown `type` as a plain inbox item**, because events
are added over time.

**Push message.** FCM `notification` = the inbox `title` and `body` (already localized to the
user's language). FCM `data` = the row's `data` keys with every value turned into a **string**
(arrays are JSON-encoded, booleans become `"1"` / `"0"`, null becomes `""`), plus
`notification_id` (the inbox row uuid) and `app` (`customer`, `provider` or `driver`).
**The event key is not in the push data** (open question for the owner): route a tapped push
by the `data` keys above, or read the row by `notification_id`. Reading
`GET /notifications/{id}` marks it read.

### 7.2 Device token lifecycle (as the code behaves)

- **Register** with `POST /{client,provider,delivery}/notifications/device`
  (`device_token`, `device_type` = `ios` | `android` | `huawei`, optional `app_version`).
  The admin dashboard uses `POST /admin/auth/device` with `device_type = web` and its
  Firebase web token (TD-173); `POST /admin/auth/logout` with `device_token` detaches it.
  It is an upsert on `(user, token)` for the token's app: the same token again only refreshes
  `type`, `app_version` and `last_seen_at`; a rotated token creates a new row, and the old
  token stays until FCM reports it invalid. Send it after login and whenever FCM rotates it.
- **Several devices.** A user may hold any number of tokens; a push goes to every token
  registered for the notification's app.
- **Two apps on one phone.** A device row belongs to one app. Registering the same token from
  the provider app after the customer app creates a second row, so each app is targeted
  separately. Pruning an invalid token removes every row of that token for the user.
- **Logout.** `POST /{app}/auth/logout` revokes the session. It also detaches this app's
  registration **only if the body carries `device_token`**. Without it the token stays
  registered and the signed-out device keeps receiving that user's pushes. A different user
  who registers the same token on the same phone does not remove the first user's row. The
  apps should send `device_token` on logout (open question DEC-29).
- **Invalid tokens** reported by FCM (unknown or unregistered) are deleted after the send.
- **Retries.** The job tries three times (10 s, 60 s, 300 s) for whole-request failures. A
  token that fails inside a successful multicast is not retried (TD-143).
- **When nothing is sent.** `FIREBASE_ENABLED=false` or missing credentials, the account-wide
  `allow_notifications` is false, no device is registered for the app, or the category is
  switched off (§7.3). The inbox row exists in every case.

### 7.3 Notification preferences (DEC-13, TD-166)

`GET` and `PUT` `/api/v1/{client,provider,delivery}/notifications/preferences/categories`
(route names `{surface}.notifications.preferences.categories.show|update`). The read returns
`allow_notifications` (the account-wide master switch), `sms_delivery_available` (always
`false` until DEC-15) and the five categories (`orders`, `payments`, `chat`, `offers`,
`account`) of the token's app, each `{category:{value,label}, push_enabled, sms_enabled}`,
defaulting to `true` when nothing was saved; a read creates nothing. The write sends
`{"categories": {"orders": {"push_enabled": false}}}`: only the listed categories and fields
change, and the response is the full state. Settings are per `(user, app)`: the same phone's
customer, provider and driver settings never mix. `PUT /client/notifications/preferences`
(`allow_notifications`) is unchanged and acts as the master switch for every app of the
account: off means no push at all. The OS permission to show notifications is a device
setting the server cannot see. `sms_enabled` is stored for later; nothing sends SMS yet.

---

## 8. Docs for your coding agent (MCP)

The backend docs are served two ways from the same `docs/` folder (TD-31):

| For | Where |
|-----|-------|
| People | `{HOST}/docs` (viewer) and `{HOST}/tester` (API console) |
| Coding agents | `{HOST}/mcp/docs`, the read-only `joshen-docs` MCP server |

Outside local/dev both need the docs token from the backend team
(`Authorization: Bearer <token>`; in the browser open `{HOST}/docs?token=<token>` once).

**Setup, per-agent config, the rules block for your app repo, and the tool
reference: [MCP_AGENT_GUIDE.md](MCP_AGENT_GUIDE.md).** Start every feature with
the `get-integration-brief` tool: it returns the BRD sections, the registry row
and the decisions of the feature, and the app README.

### 8.1 Collection variables and request chaining (TD-57)

`docs/postman-collection.json`, the API console and `get-endpoint` share one
variable model. Running a request saves values from its 2xx response; related
requests read them as `{{name}}`:

| Variable | Saved by | Read by |
|----------|----------|---------|
| `admin_token`, `client_token`, `provider_token`, `delivery_token` | admin login / refresh; app `auth/verify` / `auth/refresh` | every authenticated route of that app (`Authorization: Bearer {{client_token}}`) |
| `token` | every sign-in (last app) | shared routes: `/notifications`, `/media` |
| `verification_token` | app `auth/register`, `auth/login`, `auth/resend-code` | `auth/verify`, `auth/resend-code` |
| `otp_code` | — (type the SMS code) | `auth/verify` |
| `media_hash` | `POST /api/v1/media` | image/file fields of forms |
| `{resource}_id` (`provider_id`, `category_id`, …) | the list (first row), create, details and update of that resource | its details/action routes, and body/query fields that reference it |

In the API console the values persist in the browser (Variables button) and a
toast lists what each response saved; **Quick Auth** fills both `token` and the
app's own token. In Postman they are collection variables set by each request's
test script; a pre-request script warns in the console when a needed value is
still empty. Each request's description has a **Variables** section.

---

## 9. Realtime events (Socket.IO)

Cycle F publishes `trip.offer` (to the **driver** app room) with `trip_id`, `order_id`, `status` and `trip.updated` with those keys plus `reason` (to the **driver**, **provider** or, for `trip_provider_picked_up`, **customer** app room), after commit. In these two events `status` is the **plain trip status string** (`"accepted"`), not a `{value,label}` object; refetch the trip for its label and flags. Trip inbox notifications store `trip_id`, `order_id`, `status`. The customer can receive `trip_provider_picked_up`; refetch relevant trip/order data when notified.

Customer, provider and driver apps hold one Socket.IO connection to the realtime relay
(`socket-io/`, TD-96) to learn about changes the moment they happen. **The socket only
tells the app that something changed; the HTTP API stays the source of truth.** Always
refetch the order over HTTP after an event, and again whenever the socket reconnects —
delivery is best effort and a client that was offline never receives missed events.

**Connect.** URL = the socket host (`REALTIME_URL`, separate from the API URL). The relay
listens on port **5071**:

| Environment | `REALTIME_URL` |
|-------------|----------------|
| Staging | `https://joshen-back.elkady.aait-d.com:5071` |
| Local | `http://localhost:5071` (`cd socket-io && npm start`) |

Send the
same access token as the REST API: `io(url, { auth: { token: accessToken }, transports: ['websocket'] })`.
The relay verifies the JWT; a missing, expired or forged token is refused (`unauthorized`).
The token's `app` claim (`customer` / `provider` / `driver`) puts the socket in that app's
private room, so a person using two apps on one phone only receives each app's own events.
After a token refresh, reconnect with the new token. Rooms cannot be joined by name: a
`join_room` for anyone else's room is refused.

**Envelope.** Each event is `socket.on('<event>', (payload) => …)`. Payloads use the same
rules as the REST API: uuid ids, `{value, label}` enum objects (label localized to the
user's profile language), ISO 8601 times with offset, and every documented key present
(`null` when empty). Payloads never carry addresses, phone numbers or money.

### 9.1 Order events (customer and provider apps)

| Event | Sent to | When | `payload.reason` |
|-------|---------|------|------------------|
| `order.requested` | provider | A request needs her answer: a scheduled request was sent, or an instant order was paid (D3 window starts) | `requested` |
| `order.updated` | customer | Provider accepted a scheduled request (pay now), rejected, the deadline expired, the order was confirmed, it reached an execution stage, the order was cancelled before execution (by the customer or by the provider), the provider's status update is overdue (the status is **unchanged**; the alert never opens or closes cancellation), or a package purchase succeeds | `accepted`, `rejected`, `expired`, `confirmed`, `status_changed`, `cancelled`, `delayed`, `package_purchased` |
| `order.updated` | provider | The customer paid an accepted scheduled request, or the order was cancelled before execution (by the customer, or by the provider herself — PROV-016) | `confirmed`, `cancelled` |

| Payload key | Type | Notes |
|-------------|------|-------|
| `order_id` | uuid | Open `GET …/orders/{order_id}` |
| `code` | string | Human code, e.g. `JSH-482193` |
| `reason` | string | Stable key the app branches on (table above) |
| `status`, `payment_status`, `booking_type` | `{value, label}` | Order state after the change |
| `respond_before` | ISO 8601 / null | Provider deadline while `pending_provider` |
| `payment_due_at` | ISO 8601 / null | Customer payment deadline while `awaiting_payment` |
| `occurred_at` | ISO 8601 | When the backend raised the event |

The provider is **not** sent an `expired` event: when her own response window ends she learns it from her countdown (`respond_before`) and refetches, and an accepted scheduled order the customer never pays was never on her schedule. Events are published after the database transaction commits, so an event never announces a
booking that was rolled back. Deadlines fire on time: each deadline has its own delayed job,
so an unanswered 20-second instant request expires and refunds at 20 s, not at the next minute.

### 9.2 Order tracking subscription

The customer obtains a grant with `POST /api/v1/client/orders/{order}/tracking-subscription` (200 `{grant,expires_at}`; 404 foreign order, 422 `ORDER_TRACKING_UNAVAILABLE`, 429 `RATE_LIMITED`, 503 `INFRASTRUCTURE_FAILURE` when the server is missing its realtime secret). The grant is short lived and authorizes joining only `order:{order_uuid}:tracking`. It lives `REALTIME_GRANT_TTL_SECONDS` (default 120) and only controls new joins: it does not remove an already joined membership (TD-147). The socket accepts `tracking:subscribe` `{grant}` and acknowledges `{ok:true,order_id}`, or `{ok:false,error}` with `error` = `invalid_grant` (malformed, tampered, or relay secret missing), `expired_grant`, `forbidden` (grant of another user/app) or `too_many_rooms`; `tracking:unsubscribe` `{order_id}` leaves that socket's room. At most three tracking rooms may be joined per socket. `REALTIME_GRANT_SECRET` must match in Laravel and Node; it is distinct from the JWT secret.

Register `order.tracking.stage` and `order.tracking.location` listeners **before** subscribing. Obtain the grant, subscribe, await its acknowledgment, then fetch `GET /client/orders/{order}`. The stage event contains `{order_id,order_status:{value,label},transport:{mode,stage,tracking,can_confirm_arrival},occurred_at}` — the same `transport` block as the order details ([client/orders §3.4](client/orders.md) lists the `stage` values). The location event contains `{order_id,lat,lng,recorded_at,freshness,distance_km,eta_minutes,calculated_at,source}`. `transport.tracking` contains `available`, nullable `location` (`{lat,lng,updated_at}` — the HTTP name is `updated_at`, the event's is `recorded_at`), `freshness` (`fresh`/`stale`/`unavailable`), nullable `distance_km`/`eta_minutes`, `calculated_at` and `source`. `order.tracking.stage` is published after commit for every order transition into `confirmed`, `preparing`, `on_the_way`, `arrived`, `in_progress`, `completed` or `cancelled` (never for `pending_provider`, `awaiting_payment`, `rejected` or `expired`) and for every trip change that alters the transport block. One change can emit **two identical stage events**: for example a driver's `to_provider` moves the trip and the order. Stage events are idempotent projections of the committed order, so apply the newest and ignore repeats. Events use the current order projection, even when a retired trip's callback runs after a replacement search. `occurred_at` is when the post-commit projection was emitted, not the retired trip's timestamp.

While the initial HTTP request is in flight, retain received events; apply the HTTP response and then reconcile or refetch if any event arrived during the request. Never let an older HTTP response overwrite newer event state. Ignore location records older than the current displayed `recorded_at`. When stage tracking becomes unavailable, clear location and do not restore it from delayed location events. On preview close or background, unsubscribe, increment a local request generation and ignore older responses. On reconnect, request a fresh grant, subscribe, await acknowledgment and fetch details again. Existing `order.updated` remains in the user's private app room and is not gated by preview presence.

Driver location writes use `POST /api/v1/delivery/trips/{trip}/location` with required numeric `lat` (-90..90) and `lng` (-180..180); the response contains `recorded_at`. A position sent with `PATCH /api/v1/delivery/availability` while the driver has a live trip publishes the same `order.tracking.location` event. The driver's position is visible to the customer from `accepted_at`, including the `to_provider` leg. The route shares the availability/location rate-limit budget (`dispatch-availability`): on 429 `RATE_LIMITED`, honor `Retry-After` and slow both types of updates. A provider travelling herself sends her position with `POST /api/v1/provider/orders/{order}/location` (`lat`, `lng`; allowed only on her own self-travel order that is `on_the_way`; same rate budget), published as the same `order.tracking.location` event (R08, TD-183). Staleness (`freshness`), distance and ETA are calculated by the server (R06, R07, TD-183); the driver's identity stays hidden from the customer. Still open: DEC-07 and the final live-map design (TD-147).

**Deploy notes.** Set the same `REALTIME_GRANT_SECRET` in Laravel `.env` and `socket-io/.env`; it must differ from the JWT secret, and Laravel answers 503 on `tracking-subscription` (and logs an error) while it is empty. Optional: `REALTIME_GRANT_TTL_SECONDS` (default 120) and `ORDER_CHAT_PER_MINUTE` (default 30). Run `php artisan config:clear` after changing them.

**Socket server (PM2 `Joshen Socket`).** `socket-io/.env` sets `NODE_PORT=5071` (a `PORT`
variable, when present, wins over it). On a server shared with other projects, each relay
needs its own port (`EADDRINUSE` in `pm2 logs "Joshen Socket"` means the port is taken) and
its own `REALTIME_APP_KEY`, because the key is part of the Redis channel name. `JWT_SECRET`
and `REALTIME_APP_KEY` must equal Laravel's `.env`; `REDIS_URL` carries the Redis password
(`redis://:<password>@127.0.0.1:6379`). `CLIENT_URL` is the one browser origin allowed
(the React dashboard); the Flutter apps send no origin and need no entry. Open the port in
the firewall. Check with `curl -sk https://127.0.0.1:5071/health` →
`{"status":"ok","connections":N}`.

**Socket TLS.** The relay terminates TLS itself (`SSL_KEY_PATH`, `SSL_CERT_PATH`) and sends
exactly what `SSL_CERT_PATH` contains, so that file must be the **full chain**: the domain
certificate first, then the Let's Encrypt intermediate (YR1). The cPanel AutoSSL `.crt` in
`~/ssl/certs` holds the domain certificate alone; browsers and Postman may still connect,
but Flutter, Node and many Android clients refuse it with `UNABLE_TO_VERIFY_LEAF_SIGNATURE`.
Staging builds `~/ssl/joshen-socket/fullchain.pem` from that `.crt` plus the cPanel CA bundle
(or the chain the API host serves on 443); `SSL_KEY_PATH` stays the matching
`~/ssl/keys/<id>.key` (same `<id>` prefix as the `.crt`). Every `-----BEGIN/END CERTIFICATE-----`
line must stand alone — joining files without a trailing newline glues two markers together
and the relay crashes on start with `ERR_OSSL_PEM_BAD_END_LINE`. The relay reads the files
only at start: after an AutoSSL renewal (new file names; the staging certificate expires
2027-01-19) rebuild the chain and `pm2 restart "Joshen Socket"`. Verify:
`openssl s_client -connect <host>:5071 -servername <host> </dev/null` lists the domain
certificate and YR1 and ends with `Verify return code: 0 (ok)`.

### 9.3 Order chat

See [client chat](client/chat.md) and [provider chat](provider/chat.md). Conversation `status` (`open`, `read_only`), message `sender_role` (`customer`, `provider`) and message `type` (`text`) are `{value,label}` objects everywhere, including socket payloads, where the label uses the recipient's profile language. `POST …/conversation/messages` is limited to `ORDER_CHAT_PER_MINUTE` (default 30) per user and answers 429 `RATE_LIMITED`. `GET …/conversation` never writes; before the first message it returns an empty history with `conversation.id: null`. After a successful HTTP send, both participants receive private `chat.message` with the persisted message UUID, sender role, text body, order/conversation UUIDs and creation time. Both receive `chat.conversation.updated` with role-specific `{id,order_id,status,latest_message,unread_count}`. Incoming means the opposite sender role for this token app, even if both participants share an account ID. A mark-read action sends only that summary to the reader's own app sessions; the peer receives no read receipt.

`GET /conversations` accepts optional `page` (integer ≥ 1, default 1) and `per_page` (integer 1–100, default 15). It returns the normal `{status,message,data}` envelope with `data.data` holding summaries and Laravel `current_page`, `per_page`, `total`, `last_page`, `next_page_url` and related page metadata. Rows order by newest persisted message ID descending, then conversation ID descending; empty conversations sort last. Message history stays at 50 per page, newest page first, with `before` set to the first UUID of the preceding page to walk backward.

For consumer recovery: register both listeners **before** connecting. Key messages by UUID and merge duplicates. On reconnect, fetch the visible list pages and the active conversation's newest history page; if more than one page was missed, follow `before` through older pages until the last locally known message appears or `has_more` is false. Track a local generation for each list/history fetch. If any chat event arrives while a response is in flight, retain the event's message, invalidate that response and refetch; apply only the current generation. Treat `chat.conversation.updated` as a refetch signal, even if its summary is shown optimistically, so a delayed summary cannot overwrite newer HTTP state. An event during that refetch starts another refresh. When the screen closes, invalidate outstanding screen-specific responses. Mark read only through a message UUID actually displayed; a history fetch never changes unread. This is an app integration procedure, not mobile code delivered by this repository.

### 9.4 App rules

- Keep polling off while the socket is connected; poll (every ~10 s) only while it is disconnected, and always refetch on reconnect.
- Handle the same event twice: compare `status.value` with what the screen already shows.
- Never trust a client-side countdown as state: at zero, refetch; the server decides.
- Chat notifications are generic in-app and preference-aware FCM push; they contain no message body. Other notification scenarios follow their feature guides.

## 10. Health probe

`GET /api/v1/health` is an infrastructure probe, **not** an app endpoint, and deliberately does not use the `{status, message, data}` envelope: it answers `{status: healthy|degraded, checks: {database, cache, queue, storage}, version, timestamp}`, HTTP 200 when healthy and 503 when degraded, so load balancers and monitors can read it without unwrapping. Apps must not call it. (2026-11-11)
