# MeshHub Tenant API

> MeshHub is execution infrastructure for AI agents: one HTTP API under
> `/api/v1/` to search, book, pay, and track real-world services
> (travel, shipping, car rental, salons, hotels). Authenticate with an
> API key in the `X-MH-Key` header — no key yet? Mint a free 7-day
> trial key: `POST /api/v1/auth/trial-keys/` (no auth, no body; capped
> per IP, so store and reuse it). State-changing operations are
> asynchronous: they return 202 with a task envelope — poll
> `GET /api/v1/tasks/{task_id}/` or subscribe to signed webhooks. All
> failures use a single flat error envelope; branch on the `error`
> slug, never on the HTTP status.
>
> MeshHub is the system of record for orders and customers: render
> order-history pages from `GET /api/v1/orders/?customer=<your-user-id>`
> and profile pages from `GET /api/v1/end-users/?external_id=<your-user-id>`
> — do not build local orders or customers tables. Keep only login
> credentials and sessions in your own database.

# MeshHub Tenant Integration Protocol

This document is the contract between MeshHub and a tenant integrating against
the MeshHub API. It is the only document you should need to wire a tenant
backend end-to-end (auth, search, book, pay, webhooks). Sections are ordered
roughly in the order you will read them while integrating.

---

## 1. Overview

MeshHub is an execution layer that lets a tenant search, book, pay, track, and
cancel across many supplier APIs through a single uniform HTTP surface. The
gateway hides per-supplier shape differences, runs long-running calls
asynchronously, and pushes terminal state back to the tenant over signed
webhooks.

**MeshHub is the system of record for orders and customers.** Every booking,
payment, and status change is stored on MeshHub and queryable at any time, so
your app does not need — and should not build — its own orders or customers
tables. Render order-history pages from `GET /orders/?customer=<your-user-id>`
(§6.5) and profile pages from the end-user record (§6.16, §9), keyed by the
same user id your app already has. Keep only what MeshHub cannot hold for
you in your own database: login credentials and sessions.

**Base URLs**

| Environment | Base URL                      |
| ----------- | ----------------------------- |
| Production  | `https://merchant.meshhub.ai` |

There is currently a single environment; every example in this guide targets
production. All tenant endpoints live under the `/api/v1/` prefix. Current
API version: `2.0.0`.

**Developer portal (public).** Everything below is also hosted, without
authentication, on the production host:

- Entry point: `GET /docs/` — links the guide, schema, SDKs, and playground
- OpenAPI schema (live, always in sync): `GET /api/schema/?format=json`
  (JSON) or `GET /api/schema/` (YAML)
- Swagger UI: `GET /api/docs/`
- This guide as markdown: `GET /docs/tenant-integration.md`
- API playground (drive search → book → pay in the browser with your API
  key): `GET /docs/playground/`
- Self-serve trial key: `POST /api/v1/auth/trial-keys/`, or the **Get a
  test key** button in the playground (§2, "Self-serve trial keys")
- AI-ingestible indexes: `GET /llms.txt` and `GET /llms-full.txt`

**Official TypeScript SDK (preferred integration path).** JavaScript and
TypeScript apps should integrate through `@meshhub/sdk`
([`sdk/typescript/`](../../sdk/typescript/README.md)) instead of
hand-writing HTTP calls: it covers every endpoint below with types
generated from this contract, and builds in the async task polling (§5),
webhook signature verification (§7), the unified error envelope (§3), and
the payment checkout handoff (§6.12). The curl examples in this guide map
1:1 onto SDK calls.

---

## 2. Authentication

MeshHub uses **API keys** passed in a custom header. There is no OAuth, no JWT,
no session cookie for the tenant API.

**Header**

```
X-MH-Key: mh_<80-hex-chars>
```

- Keys are prefixed `mh_`. The first 8 characters after the prefix appear in
  logs and admin UI; the full secret is shown to you exactly **once**, at
  creation time.
- Keys are scoped to a single tenant. The request's tenant is resolved from the
  key — there is no tenant-id header.
- Keys are stored as SHA-256 hashes at rest. If you lose a key, rotate it; it
  cannot be recovered.

**Scopes**

Each key carries a set of scopes. Each endpoint requires one scope.

| Scope     | Grants                                                        |
| --------- | ------------------------------------------------------------- |
| `search`  | Read & discovery: search, GET order, list methods, transactions, tasks, get extras, tracking |
| `execute` | State-changing: book, cancel, apply extras, pay, revoke method |
| `confirm` | Reserved for payment confirmation flows                       |

A request with a valid key but missing scope returns `403 Forbidden`:

```json
{"detail": "API key missing required scope: 'execute'..."}
```

A request with a missing or invalid key returns `401 Unauthorized`.

**Key management endpoints** (require an existing key with `search` scope):

- `POST   /api/v1/auth/api-keys/` — create
- `GET    /api/v1/auth/api-keys/` — list
- `DELETE /api/v1/auth/api-keys/<id>/` — revoke (sets inactive; not deleted)

Create-request body:

```json
{"name": "production-backend", "scopes": ["search", "execute"]}
```

Create-response (the only response that includes the raw key):

```json
{"id": 12, "key": "mh_abcdef...", "prefix": "abcdef12", "name": "production-backend"}
```

**Self-serve trial keys**

To try the API without becoming a tenant first, mint a trial key — no auth,
no request body, no human involvement:

```
POST /api/v1/auth/trial-keys/
```

```json
{
  "key": "mh_abcdef...",
  "tenant_id": "7f6a...",
  "expires_at": "2026-07-10T12:00:00Z",
  "scopes": ["search", "execute", "confirm"]
}
```

- The key belongs to a fresh, isolated **trial tenant** created for you on
  the spot. It carries all three scopes, so the full search → book → pay
  flow works — calls hit live suppliers.
- The key expires **7 days** after creation; the raw key is shown only in
  this response. **Store it and reuse it for its whole lifetime** — mint a
  new key when the old one expires, not on every app start or session.
- Trial traffic is capped at the `trial` rate tier (§4). Minting itself is
  limited to **3 keys per rolling hour per client IP** (plus a small global
  daily cap across all IPs). On `429`, retry after the `Retry-After` header
  (seconds — can be most of an hour, so don't busy-wait).
- The per-IP cap counts everyone behind a shared egress IP (cloud IDEs,
  corporate NAT, hosted app builders), so a `429` on your *first* mint
  usually means a neighbour used the budget — reuse a stored key, wait out
  `Retry-After`, or contact MeshHub for a production key.
- Under contention `Retry-After` is advisory, not a reservation: the window
  is rolling, so a neighbour on the same IP can take the slot that frees
  while you wait and the next attempt 429s again with a new value. Honor
  each `Retry-After` as it comes (with a little jitter) instead of assuming
  one wait guarantees success.
- Trial access may be revoked by MeshHub at any time. Contact MeshHub for a
  production key when you are ready to integrate.
- In the API playground (`/docs/playground/`), the **Get a test key**
  button does this call for you and stores the key in your browser.

---

## 3. Request & response conventions

- All request and response bodies are `application/json`, UTF-8.
- All timestamps are ISO 8601, UTC, with a trailing `Z`.
- All monetary values are decimal strings (e.g. `"12.50"`) — never floats.
  Currencies are ISO 4217 codes; supported set: `EUR`, `USD`, `GBP`.
- Unknown response fields are added additively (see §13). Clients MUST ignore
  fields they don't recognise.

**Execution mode header (optional)**

```
X-Execution-Mode: async   # default
X-Execution-Mode: sync    # block until task finishes
```

In `async` mode (default), long-running endpoints respond `202 Accepted` with
a `TaskStatus` envelope and the work continues in the background; you poll
the task to get the final result (§5).

In `sync` mode, the gateway runs the work inline and returns:

| Outcome                                    | HTTP |
| ------------------------------------------ | ---- |
| Task completed                             | 200  |
| Caller payload bad / supplier returned 4xx | 400  |
| Supplier returned 5xx                      | 502  |
| Supplier timed out                         | 504  |
| Unhandled server error                     | 500  |

Respect a 90–120 s wall time. `sync` is intended for development and
integration testing; production clients should use `async`.

**Errors**

The gateway returns **every** error in a single, flat envelope — there is one
shape to handle, with no branching on response layout. Together with the HTTP
status, the envelope tells a client (including an LLM-driven agent) whether to
fix the request and retry, back off and retry, or abort.

> **Why no 5xx for supplier failures?** Cloudflare sits in front of both
> production and staging. When the origin returns a 5xx, Cloudflare replaces
> the body with its own HTML interstitial — and your parser sees that HTML, not
> our envelope. To keep error responses parseable, the gateway returns **HTTP
> 400 for supplier failures, supplier timeouts, and most payment errors**. The
> `error` slug in the body is the real discriminator.

**The error envelope**

A validation failure carries a `fields` map; a supplier failure carries the
optional supplier fields instead. Both are the same envelope:

```json
{
  "error": "validation_error",
  "code": "VALIDATION_ERROR",
  "message": "Request validation failed.",
  "fields": {
    "extra.origin": ["This field is required."],
    "end_user.email": ["This field is required."]
  }
}
```

```json
{
  "error": "supplier_error",
  "code": "BOOK_FAILED",
  "message": "Offer expired",
  "supplier_id": "kyte",
  "supplier_error": {                   // optional; parsed upstream response
    "status": 422,
    "code": "INVALID_OFFER",
    "message": "Offer expired",
    "details": {}
  }
}
```

Fields:

- `error` (string, **always present**): family slug — the primary discriminator.
  One of `validation_error`, `supplier_error`, `supplier_timeout`,
  `payment_error`, `authentication_failed`, `permission_denied`, `not_found`,
  `rate_limit_exceeded`, `internal_server_error`.
- `code` (string, **always present**): a stable, more specific machine code (see
  the catalogue at the end of this section). Build retry / UX logic off
  `error` + `code`, never off `message`.
- `message` (string, **always present**): human-readable detail. Safe to log;
  not safe to display verbatim to an end user without review.
- `fields` (object, optional): present only for field-level errors (chiefly
  `validation_error`). A flat map of **dotted JSON path → list of messages**;
  the key is the exact path to correct in your request body (e.g. `extra.origin`,
  `end_user.email`). Top-level, non-field messages appear under the key
  `non_field_errors`. `"This field is required."` means the field is missing.
  When a supplier reports a per-element error without identifying which
  element, the path omits the index — e.g. `extra.passengers[].firstName`
  means "check the firstName of every passenger".
- `supplier_id` (string, optional): present when the failure is attributable to
  a specific supplier (`supplier_error` / `supplier_timeout`).
- `supplier_error` (object, optional): present when MeshHub could parse the
  upstream HTTP response. Treat its contents as supplier-defined; only `status`
  is guaranteed.

HTTP status table:

| HTTP | `error` slug            | When                                                |
| ---- | ----------------------- | --------------------------------------------------- |
| 400  | `validation_error`      | Request payload is bad or missing fields (`fields` set) |
| 400  | `supplier_error`        | Upstream supplier returned an error                 |
| 400  | `supplier_timeout`      | Upstream supplier timed out                         |
| 400  | `payment_error`         | Generic payment failure                             |
| 401  | `authentication_failed` | API key is missing or invalid                       |
| 402  | `payment_error`         | Card declined or payment method inactive            |
| 403  | `permission_denied`     | API key lacks the scope required for this operation |
| 404  | `not_found`             | A referenced resource does not exist for this tenant |
| 424  | `payment_error`         | Upstream payment service (PSP) unavailable          |
| 429  | `rate_limit_exceeded`   | Throttle limit hit. `Retry-After` header is set     |
| 500  | `internal_server_error` | Unhandled error — note Cloudflare may replace the body with its own HTML page; the canonical record is in MeshHub's Sentry |

**Handling errors (one handler)**

Because the shape never changes, a single handler covers the whole API: read
`error` to pick the family, `code` for specifics, and — when present — iterate
the dotted paths in `fields`.

```python
def handle_error(resp):
    body = resp.json()              # always the envelope above
    family = body["error"]          # e.g. "validation_error", "supplier_error"
    code = body["code"]             # stable machine code, e.g. "BOOK_FAILED"

    if family == "validation_error":
        for path, messages in body.get("fields", {}).items():
            fix_field(path, messages)   # path is dotted, e.g. "extra.origin"
        return retry_after_fixing()

    return apply_policy(family, code)   # see the table below
```

**Recommended client behavior**

| Observation                                          | Action                                                  |
| ---------------------------------------------------- | ------------------------------------------------------- |
| 400 with `error=validation_error`                    | Patch each dotted path in `fields`, then re-call        |
| 400 with `error=supplier_error`                      | Surface `supplier_error.body` to the user; re-call only if the supplier message indicates a transient condition |
| 400 with `error=supplier_timeout`                    | Re-call unchanged after a short backoff                 |
| 400 with `error=supplier_error`, `code=EXTRAS_APPLY_UNCONFIRMED` | **Never re-call** — applying extras is additive and not idempotent, so a retry can double-charge. Escalate for manual reconciliation |
| 400 / 402 with `error=payment_error`                 | Do not re-call; escalate to the user                    |
| 424 with `error=payment_error` (`PSP_UNAVAILABLE`)   | Upstream payment service is degraded. Retry after short backoff; if persistent, escalate |
| 401 with `error=authentication_failed`               | Abort; fix the API key                                  |
| 403 with `error=permission_denied`                   | Abort; the key lacks the required scope                 |
| 404 with `error=not_found`                           | Abort; correct the resource id (`supplier_order_id`, `end_user_external_id`, …) |
| 429 with `error=rate_limit_exceeded`                 | Honor `Retry-After`, then re-call unchanged             |
| 500, or a Cloudflare HTML error page                 | Re-call once; if persistent, abort and report incident  |
| Task `status=failed`                                 | Worker will retry automatically; keep polling           |
| Task `status=dead`                                   | Read the `error` envelope and apply the rules above     |

**`code` catalogue**

`error=validation_error` (HTTP 400, code `VALIDATION_ERROR`): a payload field was
rejected. Always carries a `fields` map keyed by dotted JSON path. Patch the
listed paths and re-call. This is emitted in two cases: (a) the request failed
MeshHub's own validation before reaching a supplier; (b) a supplier rejected the
booking for a correctable field (for example a missing passenger `dateOfBirth`),
in which case the envelope also carries `supplier_id`. Both are handled the same
way — fix the paths in `fields` and retry.

`error=authentication_failed` (HTTP 401, code `AUTHENTICATION_FAILED`): the API
key is missing or invalid. `error=permission_denied` (HTTP 403, code
`PERMISSION_DENIED`): the key is valid but lacks the scope required for the
operation. Neither is retryable; fix credentials or scope.

`error=not_found` (HTTP 404): a referenced resource does not exist for the
authenticated tenant. The `code` names the resource — `NOT_FOUND` (generic),
`ORDER_NOT_FOUND`, `END_USER_NOT_FOUND`, `PAYMENT_METHOD_NOT_FOUND`. Not
retryable; correct the id.

`error=supplier_error` / `supplier_timeout`: emitted by supplier connectors via
`ConnectorError`. Codes in use today, grouped by likely root cause:

- `TIMEOUT` — upstream did not respond in time. Transient; safe to retry.
  On `POST /orders/{id}/extras/apply/` this is stronger than a bare timeout:
  MeshHub re-reads the booking from the supplier before reporting it, so
  `TIMEOUT` there means the extras are confirmed **not** applied.
- `SEARCH_FAILED`, `SHOP_FAILED` — upstream rejected a search/offer fetch.
  Often transient; retried automatically by the worker on async tasks.
- `BOOK_FAILED`, `CANCEL_FAILED`, `TRACKING_FAILED`, `GET_EXTRAS_FAILED`,
  `APPLY_EXTRAS_FAILED` — upstream rejected an operation. Usually means the
  supplier did not accept the payload; inspect `supplier_error`.
- `EXTRAS_APPLY_UNCONFIRMED` — the supplier timed out applying extras **and**
  MeshHub could not then establish whether they were applied (the booking was
  unreadable, or only some of the extras landed). The extras may or may not be
  live at the supplier. **Do not retry**: applying extras is additive and not
  idempotent, so a retry risks charging the traveller twice. Escalate and
  reconcile the booking manually.
- `AUTH_FAILED` — MeshHub's stored supplier credentials were rejected. Not
  retryable from the tenant side; escalate.
- `CREDENTIALS_MISSING` — MeshHub has no credentials for this supplier on
  this tenant. Not retryable; escalate.

`error=payment_error`: emitted by the payment service via `PaymentError`.
The `code` field is stable; tenants can build retry / UX logic off it.

- `TENANT_MISMATCH` (HTTP 400) — the order and the end user belong to
  different tenants. Likely a tenant-side bug; do not retry.
- `ENQUIRY_NOT_PAYABLE` (HTTP 400) — the order is an enquiry shell with no
  amount due. Do not retry.
- `ORDER_ALREADY_PAID` (HTTP 400) — a successful charge already exists for
  this order. Idempotent: treat as success.
- `CUSTOMER_CREATION_FAILED` (HTTP 400) — upstream payment gateway refused
  to create the customer record. Escalate.
- `PAY_ORDER_FAILED` (HTTP 400) — upstream payment gateway refused to
  create the order. The cause is in the upstream response; escalate with
  the `supplier_order_id`.
- `CHARGE_DECLINED` (HTTP 402) — the card or saved method declined the
  charge. Surface to the end user; offer to retry with a different method.
- `PAYMENT_METHOD_INACTIVE` (HTTP 402) — the saved method is no longer
  usable. Prompt the end user to add a new method.
- `PSP_UNAVAILABLE` (HTTP 424) — upstream payment service (e.g. Revolut)
  is degraded (5xx). Not a card decline; retry after a short backoff and
  escalate if it persists.
- `ORDER_HAS_NO_END_USER` (HTTP 400) — the order has no end user attached, so it
  cannot be paid. Attach an end user to the order first; do not retry as-is.
- `PAYMENT_GATEWAY_ERROR` (HTTP 400) — generic upstream payment gateway
  error not covered above.

`error=rate_limit_exceeded`: no `code`; the response carries the
`Retry-After` header.

`error=internal_server_error` (code `INTERNAL_SERVER_ERROR`): an unhandled
exception on MeshHub. Report the request id you used (if any); look up the trace
in MeshHub Sentry by `tenant_id` and timestamp.

The async task `error` field uses this same envelope verbatim — see §5.

**Tenant-side observability with `meta`**

`/search`, `/book`, and `/extras/apply` accept an optional top-level `meta`
object — free-form JSON for tenant-side observability (e.g. end-user IP,
client trace id):

```json
{
  "category": "travel",
  "supplier_id": "kyte",
  "offer_id": "...",
  "meta": {"user_ip": "203.0.113.7", "trace_id": "req-abc-123"}
}
```

MeshHub records `meta` on the async task row (`AsyncTask.meta`) and on
service-level logs, so you can correlate a request back to the task that
served it. **`meta` is never forwarded to a supplier.** Put observability
fields here, not inside `extra` — `extra` is the supplier-bound channel and
is strictly validated: any key its schema does not declare returns a
field-level 400.

---

## 4. Rate limiting

- Sliding-window counter, 60-second window, evaluated per `(tenant, API key)`.
- Tiered limits (assigned per tenant by MeshHub):

  | Tier         | Requests / minute |
  | ------------ | ----------------- |
  | `trial`      | 10                |
  | `default` / `starter` | 60        |
  | `growth`     | 300               |
  | `enterprise` | 1000              |

- When throttled you receive `429 Too Many Requests` with a `Retry-After`
  header (seconds) and the error body in §3.
- **Task polling counts against the same budget.** Every
  `GET /api/v1/tasks/<id>/` poll is a request in the window, so on the
  `trial` tier a search (1 POST + a few polls) consumes a meaningful slice
  of the 10 req/min allowance — poll once immediately, then back off (§5),
  and treat a `429` on a poll as "wait it out", not as a failed task.

---

## 5. Async execution model

The endpoints below return a `TaskStatus` envelope:

```
POST /api/v1/search/
POST /api/v1/book/
POST /api/v1/orders/<id>/cancel/
POST /api/v1/orders/<id>/tracking/
POST /api/v1/orders/<id>/extras/
POST /api/v1/orders/<id>/extras/apply/
```

In default `async` mode the response is `202 Accepted` with:

```json
{
  "task_id": "5d6f...e4",
  "operation": "Search",
  "task_name": "meshhub.execution.tasks.execute_search_task",
  "status": "pending",
  "result": null,
  "error": null,
  "attempts": 0,
  "created_at": "2026-05-12T10:00:00Z",
  "started_at": null,
  "completed_at": null
}
```

Poll `GET /api/v1/tasks/<task_id>/` until `status` reaches a terminal value.

| `status`     | Meaning                                             |
| ------------ | --------------------------------------------------- |
| `pending`    | Queued, not started                                 |
| `running`    | Worker is executing                                 |
| `completed`  | Success — `result` is populated                     |
| `failed`     | Retryable failure (will be retried automatically)   |
| `dead`       | Non-retryable or retries exhausted — final failure  |

**Only `completed` and `dead` are final.** Despite the name, `failed` is
not: it means the attempt failed and the worker retries automatically, so
a `failed` task can still become `completed` (or `dead` once retries are
exhausted). Keep polling through `failed` — but bound your patience in
wall-clock time (for a search, giving up after ~30–60 s is reasonable)
rather than waiting for a terminal status forever.

Recommended polling strategy: poll **immediately** after the `202`
(searches usually complete within the first poll), then back off — e.g.
1 s, 2 s, 4 s — rather than waiting a fixed 2 s between polls. Remember
each poll counts against your rate limit (§4). Tasks expire after 30 days.

**Task `error` field shape**

When a task ends in `failed` or `dead`, the `error` field on the task envelope
uses the same error envelope described in §3, verbatim. For example:

```json
{
  "error": "supplier_timeout",
  "code": "TIMEOUT",
  "message": "Upstream timed out",
  "supplier_id": "kyte"
}
```

```json
{
  "error": "supplier_error",
  "code": "BOOK_FAILED",
  "message": "Invalid order payload",
  "supplier_id": "ecoparcel",
  "supplier_error": {
    "status": 400,
    "code": "validation_error",
    "message": "Invalid order payload",
    "details": {}
  }
}
```

```json
{
  "error": "validation_error",
  "code": "VALIDATION_ERROR",
  "message": "Request validation failed.",
  "fields": {
    "end_user.email": ["This field is required."]
  }
}
```

```json
{
  "error": "internal_server_error",
  "code": "INTERNAL_SERVER_ERROR",
  "message": "..."
}
```

Apply the §3 "Recommended client behavior" table here too. New tasks always use
the error envelope above.

---

## 6. Endpoint reference

For each endpoint: HTTP method + path, required scope, request body, response.
All requests carry `X-MH-Key`; only deviations from §3 conventions are called
out below.

### 6.1 Health

```
GET /api/v1/health/
```

No auth. Returns `200 OK` with `{"status": "ok"}`. Use for liveness checks.

### 6.2 Search

```
POST /api/v1/search/         scope: search
```

Request:

```json
{
  "category": "shipping",         // required; supplier category enum
  "supplier_id": "ecoparcel",     // optional; omit to fan out across the category
  "currency": "EUR",              // EUR | USD | GBP, default EUR — a preference, not a guarantee (see currency note below)
  "extra": {                      // per-category schema; see §14
    "origin": "DE",               // shipping: ISO 3166-1 alpha-2 (§14.2)
    "destination": "ES",
    "weight_kg": 2.5
  }
}
```

`category` is the only field required to dispatch a request. Everything
else either has a default (`currency`) or lives inside the `extra`
object, whose schema is owned by the typed serializer for the chosen
category (see §14 for the field tables, or `docs/api/openapi.json` for
the machine-readable `oneOf` schema). Tenants who want to discover the
search-request fields programmatically can call `GET /search/schema/`
(§6.18) — same shape as the response-level `field_schema` below.

`supplier_id` narrows the search to one supplier. Omit it and MeshHub
fans out across every supplier registered in that category and merges
the results (sorted by price, capped at the top 5 — see "Result cap"
below). Today `category: "travel"` resolves to `kyte` and
`category: "shipping"` resolves to `ecoparcel`; as more suppliers come
online they join the fan-out automatically without any caller changes.

**Result cap.** A search returns at most **5 products**, cheapest first.
For flights a *product is a flight*, not an offer: all of a flight's fare
brands come back together and count once against the cap, so a search can
return more than 5 offers (4 flights × 3 brands = 12). This is what lets
you show Light / Inclusive / Inclusive Plus side by side — a flat "5
cheapest offers" cut would return five basic fares and no premium brand,
since the cheapest brand on any flight always undercuts the second brand
on every other flight. Categories whose results do not group (shipping,
hotel, catalogue) return at most 5 offers exactly as before. Group flight
offers with `extras.legs`, which every fare on one flight shares.

Minimal request (recommended unless you have a reason to pin a supplier):

```json
{
  "category": "shipping",
  "extra": {
    "origin": "DE",
    "destination": "ES",
    "weight_kg": 2.5
  }
}
```

Async response (after polling) — `result` is an object with two
keys: `offers`, `field_schema`:

```json
{
  "offers": [
    {
      "supplier_id": "ecoparcel",
      "offer_id": "OFFER-abc-123",
      "category": "shipping",
      "summary": "Express 24h",
      "price": "18.75",
      "currency": "EUR",
      "availability": true,
      "expires_at": "2026-05-12T10:30:00Z",
      "booking_mode": "instant",
      "supported_extras_kinds": ["delivery", "transportation_type", "package_type"],
      "extras": {}
    }
  ],
  "field_schema": {
    "end_user.first_name": {"type": "string", "pattern": "^\\p{L}[\\p{L}' -]*$", "label": "First name", "required": true},
    "end_user.last_name":  {"type": "string", "pattern": "^\\p{L}[\\p{L}' -]*$", "label": "Last name", "required": true},
    "end_user.email":      {"type": "string", "format": "email", "label": "Email", "required": true},
    "end_user.phone":      {"type": "string", "label": "Phone", "required": true},
    "end_user.address_lines":   {"type": "array", "items": {"type": "string"}, "label": "Address lines", "required": false},
    "end_user.address_city":    {"type": "string", "label": "Address city", "required": false},
    "end_user.address_postcode": {"type": "string", "label": "Address postcode", "required": false},
    "end_user.address_country": {"type": "string", "format": "iso-3166-alpha2", "label": "Address country", "required": false},
    "extra.shipper_details.contact_name":  {"type": "string", "pattern": "^\\p{L}[\\p{L}' -]*$", "label": "Contact name", "required": true},
    "extra.shipper_details.phone":         {"type": "string", "format": "phone-e164", "label": "Phone", "required": true},
    "extra.shipper_details.email_address": {"type": "string", "format": "email", "label": "Email address", "required": true},
    "extra.shipper_details.address":       {"type": "string", "label": "Address", "required": true},
    "extra.shipper_details.city":          {"type": "string", "label": "City", "required": true},
    "extra.shipper_details.postcode":      {"type": "string", "label": "Postcode", "required": true},
    "extra.shipper_details.country":       {"type": "string", "format": "iso-3166-alpha2", "label": "Country", "required": true},
    "extra.consignee_details.contact_name":  {"type": "string", "pattern": "^\\p{L}[\\p{L}' -]*$", "label": "Contact name", "required": true},
    "extra.consignee_details.phone":         {"type": "string", "format": "phone-e164", "label": "Phone", "required": true},
    "extra.consignee_details.email_address": {"type": "string", "format": "email", "label": "Email address", "required": true},
    "extra.consignee_details.address":       {"type": "string", "label": "Address", "required": true},
    "extra.consignee_details.city":          {"type": "string", "label": "City", "required": true},
    "extra.consignee_details.postcode":      {"type": "string", "label": "Postcode", "required": true},
    "extra.consignee_details.country":       {"type": "string", "format": "iso-3166-alpha2", "label": "Country", "required": true}
  }
}
```

> **Breaking change.** `result` was previously a bare list of offers.
> If you have an integration written against the old shape, iterate
> `result.offers` instead of `result`, and look up
> `result.field_schema` for the typed metadata that used to live in
> `required_fields` / `optional_fields` on each offer.

See §14 for the per-category `extras` payload shape each connector
emits. `openapi.json` types `result` as free-form JSON because the
contents are category-specific.

The `price` field already includes all markup layers (§10). It is
the only price you should ever show to an end user.

**Offer currency.** The request-level `currency` is a *preference*:
MeshHub forwards it where a supplier supports quoting in a chosen
currency, but performs **no currency conversion**, and some suppliers
always price in their own settlement currency regardless (flights, for
example, are quoted in the departure airport's local currency — a
London departure comes back in GBP even when you asked for EUR).
Always read each offer's `currency` field; it is authoritative for
that offer's `price`, and `quoted_price` at `/book/` must be in the
same currency.

#### Booking-decision metadata on every offer

`booking_mode` is the only booking-policy hint on the offer itself.
Everything else a tenant needs to know — which fields to collect,
what type each one is, which are required — lives in the
response-level `field_schema` block described below.

**`booking_mode`** — either `"instant"` (the supplier confirms the
booking in the same request, so the returned order lands as `paid`
or `confirmed` quickly) or `"enquiry"` (the supplier confirms
asynchronously — the order lands as `pending` and a webhook (§7)
fires once it transitions to `confirmed` or `cancelled`).

**`supported_extras_kinds`** — the kinds of extras this supplier's
catalogue can contain, named after the item `key` families of the
extras endpoints (§6.9), e.g. `["seat", "luggage", "meal",
"sports_equipment", "service", "lounge_access",
"disability_assistance"]` for flights. It is a static per-supplier
capability hint (no extra supplier call is made, so search latency is
unchanged): use it to factor extras availability into offer choice,
then fetch the actual priced catalogue on demand — before booking via
`/offers/details/` with `include_extras` (§6.19, flights), or after
booking via `/orders/{id}/extras/` (§6.9). An empty list means the
supplier has no extras catalogue.

#### Field schema on every `/search/` response

`field_schema` is a flat dictionary keyed by namespaced field names.
Each entry tells you the field's type, optional format, allowed
values (for enums), and whether you need to provide it for the
offers in this response. It's a complete picture of the data you
may want to collect to submit `/book/` against any offer in
`offers` — no separate endpoint, no joining lists.

**Key syntax.** Field names are dotted leaves matching the structure
of the `/book/` request body, so you populate exactly the keys
MeshHub will check:

- `end_user.<field>` — top-level key on the `end_user` block (or on
  a stored `EndUser` record looked up via `end_user_external_id`).
- `extra.<key>` — top-level key inside `extra`.
- `extra.<key>.<subkey>` — nested key inside an `extra` object (for
  example `extra.shipper_details.contact_name`).
- `extra.<key>[].<subkey>` — every element of a list of objects must
  carry the subkey (for example `extra.passengers[].firstName`).

**Entry shape.**

- `type` — one of `"string"`, `"integer"`, `"number"`, `"boolean"`,
  `"enum"`, `"array"`, `"object"`, `"any"`.
- `label` — short, human-friendly display name for the field (e.g.
  `"Pick-up date"`, `"First name"`). Always present. Safe to render
  directly as an input label, so you don't have to map the dotted key
  yourself. The key (not the label) is still what you send back in
  `/book/`.
- `required` — `true` if at least one offer in this response requires
  the field, `false` if it is purely optional. Missing required
  values return `400` from `/book/` with a pointer at the bad field.
  Supplier requirements can exceed MeshHub's own §9 storage minimum
  (first_name, last_name, email) — e.g. a courier may mark
  `end_user.phone` required. When the flags disagree with the §9
  prose, the schema's `required` wins for that response's offers.
- `format` *(strings only, optional)* — `"email"`, `"date"`,
  `"date-time"`, `"uri"`, `"uuid"`, `"iso-3166-alpha2"`,
  `"phone-e164"`.
- `description` *(optional)* — a longer, LLM/agent-oriented hint about
  the field's semantics, expected format, and an example value (e.g.
  *"in E.164 format. Example: +37060000000"*). Show it as help text or
  feed it to your agent; the `label` stays the short display name.
- `choices` *(enums only)* — list of accepted slugs in canonical
  order.
- `items` *(arrays only)* — nested schema entry for each element. In a
  flat HTML form, render an `array` field as a repeatable input (or a
  delimited multi-line textarea split client-side) and send a JSON
  array — a single collected value must still be wrapped in a
  one-element array; an optional `array` field may simply be skipped.
- `minimum` / `maximum` *(numeric)*, `decimal_places` /
  `max_digits` *(decimals)* — bounds when declared.
- `pattern` *(strings only, optional)* — an ECMAScript regular
  expression the whole value must match; compile it with the `u`
  flag (`new RegExp(field.pattern, "u")`). Use it to validate input
  before submitting — the server enforces the same rule at `/book/`
  and rejects violations with `400` `validation_error`. E.g. person-name
  fields declare `^\p{L}[\p{L}' -]*$` (letters, spaces, hyphens and
  apostrophes; must start with a letter) because airlines and other
  suppliers refuse names containing digits or other punctuation.

**The vocabulary is closed.** Only the declared schema keys are
accepted anywhere under `extra`; an unknown key returns a field-level
`400` naming it (e.g. `extra.cabin_class`). Supplier-specific
parameters outside the schema go in the declared `extra.supplier_data`
object, which is forwarded to the supplier verbatim (§14).

**Empty results.** When `offers` is empty, `field_schema` is `{}` too —
the schema describes the offers in *this* response, so you cannot
pre-render a booking form until a search has returned at least one
offer. Use `GET /api/v1/search/schema/?category=...` (§6.18) if you
need the category's field metadata before searching.

**Worked example.**

```js
const { offers, field_schema } = (await search()).result;

for (const offer of offers) {
  for (const [name, field] of Object.entries(field_schema)) {
    renderInput(name, field); // field.label, field.type, field.required, field.format, ...
  }
}
```

### 6.3 Book

```
POST /api/v1/book/           scope: execute
```

Personal data lives on the `EndUser` record (§9). Category-specific
parameters — passengers for a flight, shipper/consignee details for a
parcel — live inside `extra`, validated by the typed serializer for the
chosen category (§14).

Check the offer's `booking_mode` (§6.2) before designing your
confirmation UX: for `instant` offers the order reaches `paid`/
`confirmed` quickly, while for `enquiry` offers a successful `/book/`
leaves the order `pending` until the supplier confirms — show "request
sent", not "booked!", and rely on the `order_status_changed` webhook
(§7) for the final answer.

Don't confuse two unrelated "confirm" concepts: the **app-level
confirmation gate** (your backend refusing to call `/book/` until the
user explicitly confirms — a pattern we recommend, enforced entirely in
your code) and the **`confirm_and_pay` key** in some categories'
`extra` (a supplier-side fulfilment flag, §14.2). Sending
`confirm_and_pay` does not implement a confirmation step, and your
confirmation gate does not need to touch it.

Request:

```json
{
  "category": "travel",
  "supplier_id": "kyte",
  "offer_id": "OFFER-abc-123",
  "quoted_price": "18.75",                    // optional; see below
  "end_user_external_id": "tenant-user-42",   // optional; see §9
  "end_user": {
    "first_name": "Ada",
    "last_name": "Lovelace",
    "email": "ada@example.com",
    "phone": "+441234567",
    "date_of_birth": "1990-12-10",
    "gender": "female",                        // male|female|other|unspecified
    "title": "ms",                             // mr|mrs|ms|miss|mstr
    "address_lines": ["10 Downing St"],
    "address_city": "London",
    "address_postcode": "SW1A 2AA",
    "address_country": "GB",                   // ISO 3166-1 alpha-2
    "extra_info": {}                           // free-form, supplier-specific
  },
  "extra": {
    "passengers": [
      {"firstName": "Ada", "lastName": "Lovelace", "gender": "female", "title": "ms", "dateOfBirth": "1990-12-10"}
    ]
  }
}
```

Required: `category`, `supplier_id`, `offer_id`. `end_user` is required
unless an `end_user_external_id` already resolves to a stored record
with the minimum fields. The shape of `extra` is owned by the typed
serializer for the chosen category — see §14 for keys per category, or
`docs/api/openapi.json` for the polymorphic schema.

`quoted_price` is the price you displayed to the end user when you decided to
book. If supplied, MeshHub records the delta against the actual booked price
so quote↔book drift surfaces in our audit log; it never changes the price
you are charged.

**Minimum required end-user fields:** `first_name`, `last_name`, `email`.
These can come from the request body, or — if you supplied a previously-known
`end_user_external_id` — from the stored record. Missing fields after the
merge return `400` with a serializer error.

**Reusable booking profile (prefill).** When you supply an
`end_user_external_id` that resolves to a stored end-user, any category-specific
booking details that customer saved before — their `booking_profile` for this
`category` (see §9) — are merged **under** the `extra` you send: each top-level
key you omit is filled from the saved profile, and any key you do send wins.
This lets a returning customer skip re-entering details like flight `passengers`
or a parcel's `shipper_details`/`consignee_details`. After a booking succeeds,
the `extra` used is auto-saved back to that customer's `booking_profile[category]`
(last write wins), so the next booking in the same category can omit them.
Transient per-booking keys (e.g. `confirm_and_pay`, a shipment's `parcels`) are never saved or prefilled —
intent must be sent explicitly on every booking.

Async response (after polling) — `result` is an `OrderDetail` (§8). Capture
the returned `supplier_order_id`; that single identifier is the only order
reference used by every downstream endpoint and outbound webhook. For
API-integrated suppliers (Kyte, EcoParcel) it is the id issued by the third
party; for self-serviced suppliers (Salons, Car Rentals) it is a 4-char
MeshHub-generated short code such as `gsM7`.

### 6.4 Get order

```
GET /api/v1/orders/<supplier_order_id>/    scope: search
```

Synchronous. Returns the current `OrderDetail` (§8).

### 6.5 List orders

```
GET /api/v1/orders/    scope: search
```

Synchronous. Returns the requesting tenant's orders, newest first, as a
paginated list of `OrderSummary` rows (a subset of the full `OrderDetail`
in §8). Every result is scoped to the authenticated tenant.

Query parameters (all optional):

| Param          | Type     | Description                                                                        |
|----------------|----------|------------------------------------------------------------------------------------|
| `status`       | string   | Lifecycle state: `pending`, `paid`, `confirmed`, `cancelled`, `failed`, `unknown`. |
| `category`     | string   | Supplier category, e.g. `car_rental`, `hair_salon`, `hotel` (the same slugs as `/search/`, §14). |
| `supplier_id`  | string   | Supplier identifier, e.g. `hotelbeds`.                                             |
| `customer`     | string   | Your own end-user id (the `external_id` you assigned), not MeshHub's UUID.         |
| `created_from` | datetime | Lower bound (inclusive) on creation time, ISO 8601.                                |
| `created_to`   | datetime | Upper bound (inclusive) on creation time, ISO 8601.                                |
| `limit`        | integer  | Page size (default 50, max 200).                                                   |
| `offset`       | integer  | Number of rows to skip before the returned page.                                   |

Response:

```json
{
  "count": 137,
  "next": "https://merchant.meshhub.ai/api/v1/orders/?limit=50&offset=50",
  "previous": null,
  "results": [
    {
      "supplier_id": "kyte",
      "supplier_order_id": "f9e1...",
      "status": "confirmed",
      "category": "travel",
      "summary": "BCN -> LHR, 2026-07-01",
      "price": "118.75",
      "currency": "EUR",
      "created_at": "2026-06-20T10:15:00Z",
      "end_user_external_id": "user-42"
    }
  ]
}
```

To list every booking for one customer, pass `?customer=<external_id>`
— your own customer id, i.e. the same value you send as
`end_user_external_id` when booking (§9); combine it with any other
filter.

### 6.6 Order status timeline

```
GET /api/v1/orders/<supplier_order_id>/timeline/    scope: search
```

Synchronous. Returns the order's immutable status history, oldest first.
Returns `404 not_found` (code `ORDER_NOT_FOUND`) if the order is unknown
or belongs to another tenant.

Response:

```json
{
  "results": [
    {"from_status": "", "to_status": "pending", "detail": {"source": "booking"}, "created_at": "2026-06-20T10:15:00Z"},
    {"from_status": "pending", "to_status": "confirmed", "detail": {}, "created_at": "2026-06-20T10:18:30Z"}
  ]
}
```


### 6.7 Cancel order

```
POST /api/v1/orders/<supplier_order_id>/cancel/    scope: execute
```

Empty body. Async. `result`:

```json
{"cancelled": true}
```

### 6.8 Tracking

```
POST /api/v1/orders/<supplier_order_id>/tracking/    scope: search
```

Empty body. Async. `result`:

```json
{
  "supplier_id": "kyte",
  "order_id": "f9e1...",
  "status": "in_transit",
  "estimated_collection_date": "2026-05-13T09:00:00Z",
  "estimated_delivery_date": "2026-05-14T17:00:00Z",
  "origin": "DE",
  "destination": "ES",
  "parcels": [
    {
      "parcel_id": "P1",
      "status": "in_transit",
      "events": [
        {"timestamp": "2026-05-13T09:14:00Z", "event": "picked_up", "location": "Berlin"}
      ]
    }
  ]
}
```

### 6.9 Get available extras

```
POST /api/v1/orders/<supplier_order_id>/extras/    scope: search
```

Empty body. Async. `result` is a **flat list of atomic purchasable
items** — one uniform shape across every supplier and category:

```json
{
  "supplier_id": "kyte",
  "order_id": "f9e1...",
  "items": [
    {
      "id": "luggage::BBG::FR256-STN-DUB",
      "key": "luggage",
      "label": "20kg Check-in Bag",
      "description": "20kg Check-in Bag",
      "price": "31.49",
      "currency": "GBP",
      "per_passenger": true,
      "segments": ["FR256-STN-DUB"],
      "quantity": null,
      "group": "luggage",
      "purchasable_at": "post_booking",
      "metadata": {"weight": ["20kg"]}
    }
  ]
}
```

To buy an item, send its `id` back via §6.10 — nothing else from the
item needs echoing.

| field            | type             | notes                                                              |
| ---------------- | ---------------- | ------------------------------------------------------------------ |
| `id`             | string           | stable item id — send it verbatim in the §6.10 `items` list to purchase this item. Treat it as opaque; never construct one yourself |
| `key`            | string           | cross-supplier kind (`seat`, `luggage`, `meal`, `delivery`, …)     |
| `label`          | string           | human-readable name; may be empty                                  |
| `description`    | string           | longer explanation; may be empty                                   |
| `price`          | string \| null   | decimal string. `null` means the supplier prices this item **at booking time** (e.g. shipping options re-priced on apply) — distinct from a free item, which is `"0"` |
| `currency`       | string \| null   | ISO 4217; `null` exactly when `price` is `null`                    |
| `per_passenger`  | boolean          | `true` when bought per traveller — send a `passenger` index on apply |
| `segments`       | array of strings | the flight segment id this item covers (at most one — an extra spanning several segments is listed once per segment); empty for non-segmented categories |
| `quantity`       | integer \| null  | the unit count a tier item represents (see below); `null` for non-tiered items |
| `group`          | string           | mutual-exclusion group: pick **at most one** item per group (per passenger for `per_passenger` items). Scoped to this response — don't persist across fetches |
| `purchasable_at` | string           | `post_booking` → apply to a pending order via §6.10; `booking` → discovery-only here, include the choice in the **book** request's `extras` instead (see §14.2) |
| `metadata`       | object           | supplier-specific attributes that don't fit the typed fields (seat characteristics such as row/column, weight limits, ...) |

**Bag quantity tiers (flight orders).** Some airlines price extra checked bags
as a set of mutually-exclusive quantity tiers (1, 2, 3 … bags per passenger).
Each tier is one item carrying its `quantity` (how many units the tier
represents), and all tiers share a `group` — pick the one tier you want.
**Do not** also set the apply-side `quantity` (§6.10) — the count is
already encoded in the item.

**Seat map (flight orders).** Alongside `items`, a flight order's `result`
carries a `seat_map` array describing the physical seating grid per segment,
so you can render a seat map and let a passenger pick an exact seat. The flat
category-level seat products still appear in `items`; `seat_map` is the
spatial layout. It is absent/empty for suppliers that do not expose one.

```json
{
  "supplier_id": "kyte",
  "order_id": "f9e1...",
  "items": [ /* … incl. category-level "seat" items … */ ],
  "seat_map": [
    {
      "segment_id": "FR256-STN-DUB",
      "aircraft": "738",
      "cabins": [
        {
          "columns": ["A", "B", "C", "", "D", "E", "F"],
          "rows": [
            {
              "row": "12",
              "seats": [
                {
                  "value": "12A",
                  "item_id": "seat::12A::FR256-STN-DUB",
                  "column": "A",
                  "position": "window",
                  "available": true,
                  "price": "14.50",
                  "currency": "GBP",
                  "category": "11",
                  "category_name": "EXTRALEG",
                  "emergency_exit": false,
                  "facing_bulkhead": false,
                  "restrictions": ["infant", "child"]
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}
```

- `columns` is the left-to-right column layout; an empty string marks the
  aisle so the gap renders in the right place.
- Each seat's `value` is its designator (e.g. `"12A"`) for display; its
  `item_id` is what you send in the §6.10 `items` list to book that exact
  seat — the same apply rule as flat items.
- `price`/`currency` are the seat's cost, resolved from its fare band
  (`category`/`category_name`); seats in the same band share a price.
- Only seats with `available: true` are bookable; unavailable seats are
  included so the full map can be drawn.

### 6.10 Apply extras

```
POST /api/v1/orders/<supplier_order_id>/extras/apply/    scope: execute
```

Only valid while the order is in `pending` status.

Request — send the chosen catalogue item ids from §6.9 (or a seat's
`item_id` from `seat_map`):

```json
{
  "extra": {
    "items": [
      {"id": "seat::12A::FR256-STN-DUB", "passenger": 0},
      {"id": "luggage::BBG::FR256-STN-DUB", "passenger": 0}
    ]
  }
}
```

Each entry:

| field       | required | type    | notes                                                                 |
| ----------- | -------- | ------- | ---------------------------------------------------------------------- |
| `id`        | yes      | string  | an item `id` from the get-extras catalogue, verbatim                    |
| `passenger` | no       | integer | 0-based index into your `/book` `extra.passengers` list, for items with `per_passenger: true`; defaults to `0` (lead passenger) |
| `quantity`  | no       | integer | units to add; defaults to `1`. Quantity **tiers** are separate items — pick the tier item instead of raising `quantity` |

Only items with `purchasable_at: "post_booking"` can be applied here;
`booking` items go in the **book** request's `extras` (§14.2).

Async. `result`:

```json
{
  "supplier_id": "kyte",
  "order_id": "f9e1...",
  "selected_extras": [
    {"id": "seat::12A::FR256-STN-DUB", "key": "seat", "value": "12A",
     "price": "14.50", "currency": "GBP", "passenger": 0,
     "segments": ["FR256-STN-DUB"], "quantity": 1}
  ],
  "extras_total": "14.50",
  "new_order_total": "35.25",
  "currency": "GBP"
}
```

Each echoed entry carries the applied item's `id` and the price it was
shopped at. The order's `price` and `selected_extras` fields are updated
server-side.

### 6.11 Task status

```
GET /api/v1/tasks/<task_id>/    scope: search
```

Synchronous. Returns the `TaskStatus` envelope (§5). Returns `404 task_not_found`
if the task is unknown, belongs to another tenant/key, or has expired.

### 6.12 Pay order

```
POST /api/v1/payments/pay-order/    scope: execute
```

Synchronous (returns immediately, but actual capture is async — confirmed by
Revolut webhook to MeshHub, which then emits the tenant webhook in §7).

Request:

```json
{"supplier_order_id": "K7mQ"}
```

Response — two shapes:

**CIT (first-time payment for the end user):**

```json
{
  "requires_action": true,
  "order_token": "rev_...",
  "checkout_url": "https://checkout.revolut.com/..."
}
```

The tenant frontend must drive the Revolut JS Checkout widget with the token
(preferred) or redirect to `checkout_url`. After the customer completes the
checkout flow, Revolut calls MeshHub, the order is marked `paid`, and the
end-user's payment method is saved for future MIT charges.

**MIT (saved method on file for this end user):**

```json
{"requires_action": false}
```

MeshHub charges the saved method server-side. The webhook (§7) confirms the
result.

### 6.13 List payment methods

```
GET /api/v1/payments/methods/?end_user_external_id=<id>    scope: search
```

Supports `limit` / `offset` pagination (default 50, max 200) and responds
with the same envelope as `GET /orders/` (§6.5). Response:

```json
{
  "count": 1,
  "next": null,
  "previous": null,
  "results": [
    {
      "id": "uuid",
      "method_type": "card",
      "last4": "4242",
      "brand": "visa",
      "exp_month": 12,
      "exp_year": 2028,
      "is_default": true,
      "status": "active",
      "created_at": "2026-04-10T08:00:00Z"
    }
  ]
}
```

`status` ∈ `pending | active | failed | expired | revoked`.

**Set the default payment method:**

```
POST /api/v1/payments/methods/<method_id>/default/    scope: execute
```

Makes that method the customer's default and clears the previous default
automatically; future server-side charges (§6.12) use the default method.
No request body. Returns `200` with the updated payment-method object
(shape above), or `404` if the id is unknown, revoked, or belongs to
another tenant.

### 6.14 Revoke payment method

```
DELETE /api/v1/payments/methods/<method_id>/    scope: execute
```

Returns `204 No Content`, or `404` if not found.

### 6.15 Transactions

```
GET /api/v1/payments/transactions/?end_user_external_id=<id>&supplier_order_id=<id>
                                                       scope: search
```

Both query params are optional filters. Supports `limit` / `offset`
pagination (default 50, max 200) and responds with the same envelope as
`GET /orders/` (§6.5). Response:

```json
{
  "count": 1,
  "next": null,
  "previous": null,
  "results": [
    {
      "id": "uuid",
      "supplier_order_id": "K7mQ",
      "transaction_type": "charge",     // charge | refund | setup
      "amount": "18.75",
      "currency": "EUR",
      "status": "succeeded",             // pending | succeeded | failed | refunded
      "failure_code": "",
      "failure_message": "",
      "created_at": "2026-05-12T10:05:00Z"
    }
  ]
}
```

### 6.16 End-users

The `EndUser` record is the durable home for an end-customer's personal info
(see §9 for the data model and how `POST /book/` resolves end-users). The
endpoints below let you create, read, update, and delete those records
directly — useful for backfilling profiles before placing the first booking,
or for keeping MeshHub in sync when the tenant-side record changes.

```
GET    /api/v1/end-users/                scope: search
GET    /api/v1/end-users/?external_id=<your-id>   scope: search
POST   /api/v1/end-users/                scope: execute
GET    /api/v1/end-users/<id>/           scope: search
PUT    /api/v1/end-users/<id>/           scope: execute
PATCH  /api/v1/end-users/<id>/           scope: execute
DELETE /api/v1/end-users/<id>/           scope: execute
```

`<id>` is the MeshHub UUID (`EndUser.id`), not the tenant-side
`external_id`. All endpoints are scoped to the request's tenant — listing
returns only your tenant's records, and `<id>`s belonging to other tenants
return `404`.

The list endpoint uses `limit` / `offset` pagination (default 50, max 200)
with the same `{count, next, previous, results}` envelope as `GET /orders/`
(§6.5). The optional `external_id` query param is an exact-match lookup by
**your own** customer id — the same value you send as
`end_user_external_id` when booking. Because `external_id` is unique per
tenant, the filtered list contains at most one result; this is the intended
way to resolve your user id to the MeshHub UUID for the detail endpoints
(order detail's `end_user.id` is another). You choose and own the
`external_id` when you supply one at booking or creation; if you book
without one, MeshHub generates it.

`POST` / `PUT` / `PATCH` body (all fields except `external_id` are optional;
see §9 for enum values and §8 for currency codes):

```json
{
  "external_id": "tenant-side-id-123",
  "first_name": "Ada",
  "last_name": "Lovelace",
  "email": "ada@example.com",
  "phone": "+447700000000",
  "date_of_birth": "1815-12-10",
  "gender": "female",
  "title": "ms",
  "address_lines": ["Crown Buildings"],
  "address_city": "London",
  "address_postcode": "SW1A 1AA",
  "address_country": "GB",
  "extra_info": {},
  "booking_profile": {
    "travel": {"passengers": [{"firstName": "Ada", "lastName": "Lovelace"}]}
  },
  "currency": "GBP",
  "language": "en",
  "saved_payment_method_ref": ""
}
```

`external_id` is unique per tenant; reusing one returns `400` with a
validation error. `DELETE` returns `204 No Content`.

`booking_profile` is a map keyed by supplier `category` whose value is a saved
`extra` block reused at `POST /book/` (§6.3, §9). You can set it directly here,
and MeshHub also keeps it current by capturing the `extra` of each successful
booking; you rarely need to write it by hand.

### 6.17 Discovery queries

Read-only, category-specific helpers for narrowing a search before
calling `/search/`. Useful for populating UI selectors and for AI tool
calls that need to know what's available in MeshHub's inventory.

- Same auth as `/search/`: `X-MH-Key`, scope `search`, same tenant
  rate-limit bucket.
- Synchronous: every endpoint returns `200` with
  `{"results": [...]}` directly — no task polling.
- Filters are passed as URL query parameters; each endpoint validates
  them against its own typed serializer.
- Full per-endpoint request/response schemas live in
  `docs/api/openapi.json` under the
  `Discovery: Car Rentals` and `Discovery: Salons` tags.

**Car rentals**

```
GET /api/v1/car-rentals/cities/                       ?vehicle_class=&transmission=
GET /api/v1/car-rentals/vehicle-classes/              ?city=
GET /api/v1/car-rentals/pickup-locations/             ?city=&supplier_id=
```

Counts (`vehicle_count`) reflect distinct active vehicles, so they
work as a soft popularity signal when picking between cities or
classes.

**Salons**

```
GET /api/v1/salons/cities/
GET /api/v1/salons/service-categories/                ?city=
GET /api/v1/salons/staff/                             ?supplier_id=&service_category=
```

`service_category` is a free-text column owned by the supplier, so the
discovery list is whatever values salons have published.

### 6.18 Search request schema

```
GET /api/v1/search/schema/?category=<category>       scope: none (auth only)
```

Returns the fields a tenant should send in the body of `POST /search/`
for the given `category`. Pair it with the per-offer `field_schema` in
the `/search/` response (§6.2): this endpoint tells you what to send
*into* a search, that one tells you what to collect *for* the book
that follows.

- Same auth as `/search/` (`X-MH-Key`), but **no scope check** — any
  authenticated tenant can introspect the contract.
- Synchronous: returns `200` with the schema directly.
- The schema is generated from the live gateway serializers, so new
  fields are visible to tenants without an SDK release.

Response:

```json
{
  "category": "shipping",
  "fields": {
    "supplier_id":       {"type": "string", "label": "Supplier ID", "required": false},
    "currency":          {"type": "enum", "choices": ["EUR", "USD", "GBP"], "label": "Currency", "required": false},
    "meta":              {"type": "object", "label": "Meta", "required": false},
    "extra.origin":      {"type": "string", "format": "iso-3166-alpha2", "label": "Origin", "required": true},
    "extra.destination": {"type": "string", "format": "iso-3166-alpha2", "label": "Destination", "required": true},
    "extra.weight_kg":   {"type": "number", "minimum": 1e-06, "label": "Weight kg", "required": true},
    "extra.supplier_data": {"type": "object", "label": "Supplier data", "required": false}
  }
}
```

Entry shape mirrors §6.2's response-level `field_schema` (same
`type`, `format`, `choices`, `minimum`/`maximum`, `label`, `required`
keys), so a single client renderer can drive both surfaces. The
listed fields are the complete vocabulary: any `extra` key not in it
returns a field-level `400`. Supplier-specific parameters outside the
schema go in `extra.supplier_data` (§14).

Errors: missing or unknown `category` returns `400`; missing or
invalid `X-MH-Key` returns `401`.

### 6.19 Offer details

```
POST /api/v1/offers/details/                          scope: search
```

Return the full, on-demand detail for **one search offer** — the extra
content a "more details" button reveals. **Synchronous**: the detail comes
back in the same response (no task/polling).

Universal across categories. Offers whose category has no richer detail
return an empty `detail` object; **hotels** return a rich content block.

Request body:

| key           | type   | notes                                                                 |
| ------------- | ------ | --------------------------------------------------------------------- |
| `supplier_id` | string | **required**. From the search offer.                                  |
| `category`    | string | **required**. From the search offer.                                  |
| `offer_id`    | string | **required**. The offer identifier from the search result.            |
| `extra`       | object | optional per-category hints, validated against a typed schema (unknown keys → field-level `400`). **Hotels: `{"hotel_code": <code>}`** from the search offer's `extras`. **Flights: `{"include_extras": true}`** to embed the pre-booking extras catalogue (below). Every category also accepts `supplier_data` (§14). |

Response `200`:

```json
{
  "supplier_id": "hotelbeds",
  "offer_id": "20260810|20260814|W|1|24339|DBL.ST|NOR|...",
  "category": "hotel",
  "detail": {
    "description": "Built around a delightful pool area, this pleasant hotel ...",
    "amenities": ["Car park", "24-hour reception", "Wi-fi", "Garden", "Gym"],
    "images": [
      "https://photos.hotelbeds.com/giata/bigger/02/024339/024339a_hb_a_004.jpg",
      "https://photos.hotelbeds.com/giata/bigger/02/024339/024339a_hb_a_005.jpg"
    ],
    "address": "Avenida Calonge, S/N",
    "city": "CALA D'OR",
    "postal_code": "07660",
    "web": "www.hoteldor.com",
    "email": "comodoro@seramarhotels.com",
    "phone": "+34971681951"
  }
}
```

- **`detail` is category-specific and free-form.** For hotels it carries
  `description`, `amenities`, `images` (up to 10 photo URLs), `address`,
  `city`, `postal_code`, `web`, `email`, `phone` — each key present only
  when the supplier has that value. For a category with no detail view it is
  `{}`.
- **Best-effort**: if the supplier's content service is briefly unavailable,
  `detail` comes back empty with a `200` — the button just shows nothing
  extra rather than erroring.

#### Pre-booking extras catalogue (flights)

Send `extra: {"include_extras": true}` on a **travel** offer and `detail`
carries the full extras catalogue for that one offer — the same content the
post-booking extras endpoint returns (§6.9), before any booking exists:

```json
{
  "supplier_id": "kyte",
  "offer_id": "OFFER-abc-123",
  "category": "travel",
  "detail": {
    "extras": {
      "items": [
        {
          "id": "luggage::checked-in-bag-20|count=1::FR256-STN-DUB",
          "key": "luggage",
          "label": "20kg Check-in Bag",
          "price": "31.49",
          "currency": "GBP",
          "per_passenger": true,
          "segments": ["FR256-STN-DUB"],
          "quantity": 1,
          "group": "luggage:checked-in-bag-20:FR256-STN-DUB",
          "purchasable_at": "post_booking",
          "metadata": {"weight": ["20kg"]}
        }
      ],
      "seat_map": [],
      "prices_indicative": true
    }
  }
}
```

- `items` uses the exact flat item shape of §6.9, and `seat_map` the same
  per-segment seat grid — one renderer serves both endpoints.
- **`prices_indicative: true`**: pre-booking prices are the supplier's
  current quotes and may drift until the offer is booked. The authoritative
  catalogue (whose prices the apply-extras flow honours, §6.10) is the
  post-booking one.
- **Latency**: the supplier assembles seat maps on demand — expect up to
  ~30 s. Request it only for the one offer under consideration, never in a
  loop over search results (use each offer's `supported_extras_kinds` hint,
  §6.2, for zero-cost filtering).
- Best-effort like the rest of `detail`: on a supplier hiccup you get
  `detail: {}` with a `200` — re-request to retry.
- Which offers support it: travel offers whose `supported_extras_kinds` is
  non-empty. Categories without a pre-booking catalogue ignore the flag.

Errors: missing `supplier_id`/`category`/`offer_id` returns `400`; missing
or invalid `X-MH-Key` returns `401`; missing the `search` scope returns
`403`.

---

## 7. Outbound webhooks (MeshHub → tenant)

MeshHub pushes events to a URL you configure with MeshHub (`webhook_url`) and
signs each delivery with a shared secret you configure with MeshHub
(`webhook_secret`). Webhooks let a thin frontend stop polling: subscribe once
and react to order, task, payment, and tracking events as they happen.

**One envelope for every event.** Every delivery has the same shape — switch on
`event_type` and read event-specific fields from `details`:

```json
{"event_type":"order_status_changed","supplier_order_id":"K7mQ","status":"confirmed","details":{},"timestamp":"2026-05-12T10:06:11Z"}
```

- `event_type` — which event (see catalogue below).
- `supplier_order_id` — the order reference, or `""` for events with no order.
- `status` — the event sub-status.
- `details` — event-specific fields (`{}` when none). Treat unknown keys as
  forward-compatible additions.
- `timestamp` — ISO-8601, UTC, trailing `Z`.

The envelope is published as the `WebhookPayload` schema component in
`docs/api/openapi.json`; generate webhook-receiver types from it the same way
you generate request types.

**Request**

- `POST <your webhook_url>`
- `Content-Type: application/json`
- `X-MeshHub-Signature: <hex>`
- Body is compact JSON, no whitespace between tokens (signature stability).

### Event catalogue

| `event_type` | when it fires | `status` | `details` |
|---|---|---|---|
| `order_status_changed` | an order transitions | `confirmed` \| `paid` \| `failed` \| `cancelled` | `{}` |
| `task_completed` | an async task (search, book, cancel, track, extras) finished | `completed` | `task_id`, `task_kind` |
| `task_failed` | an async task gave up after its final retry | `failed` | `task_id`, `task_kind`, `error` |
| `payment_succeeded` | a charge settled | `succeeded` | `transaction_id`, `amount`, `currency` |
| `payment_failed` | a charge failed | `failed` | `transaction_id`, `failure_code`, `failure_message` |
| `payment_refunded` | a refund settled | `refunded` | `transaction_id`, `original_transaction_id`, `amount`, `currency` |
| `tracking_updated` | a shipment's tracking changed | supplier tracking status | `tracking` object |

Examples (compact in transit; pretty-printed here for readability):

```json
{"event_type":"task_completed","supplier_order_id":"K7mQ","status":"completed","details":{"task_id":"4f8e…","task_kind":"Book"},"timestamp":"2026-05-12T10:06:11Z"}
{"event_type":"task_failed","supplier_order_id":"","status":"failed","details":{"task_id":"9a1c…","task_kind":"Search","error":{"error":"supplier_error","code":"SHOP_FAILED","message":"upstream rejected"}},"timestamp":"2026-05-12T10:06:11Z"}
{"event_type":"payment_succeeded","supplier_order_id":"K7mQ","status":"succeeded","details":{"transaction_id":"1b2c…","amount":"42.00","currency":"EUR"},"timestamp":"2026-05-12T10:06:11Z"}
{"event_type":"payment_refunded","supplier_order_id":"K7mQ","status":"refunded","details":{"transaction_id":"7c3d…","original_transaction_id":"1b2c…","amount":"42.00","currency":"EUR"},"timestamp":"2026-05-12T10:06:11Z"}
{"event_type":"tracking_updated","supplier_order_id":"EP-99","status":"in_transit","details":{"tracking":{"status":"in_transit","estimated_delivery_date":"2026-05-15","parcels":[{"parcel_id":"P1","status":"in_transit"}]}},"timestamp":"2026-05-12T10:06:11Z"}
```

Notes:

- Payment events fire **in addition to** `order_status_changed` — a settled
  charge emits both `payment_succeeded` and `order_status_changed`/`paid`,
  because order lifecycle and payment outcome are separate concerns.
- `task_failed` fires once, only after the task's final retry is exhausted.
- `tracking_updated` is delivered by a periodic poll (near-real-time, not
  instantaneous) and only when the tracking actually changed.
- Monetary values are decimal strings.

**Signature**

```
X-MeshHub-Signature = HMAC_SHA256(webhook_secret, raw_request_body).hexdigest()
```

Verify against the raw body bytes — not a re-serialised dict — or signatures
will not match.

**Delivery semantics**

- Up to **12 attempts** total. Retries on any non-2xx response or transport
  error.
- Backoff (seconds, with up to 25 % jitter added per attempt):
  `1, 2, 4, 8, 16, 30, 60, 60, 60, 60, 60`.
- HTTP timeout per attempt: **10 seconds**.
- Success = HTTP status in `[200, 300)`.
- Each attempt is logged immutably on the MeshHub side.

**Tenant requirements**

- Endpoint MUST be idempotent — duplicate deliveries are possible.
- Endpoint SHOULD respond within the 10 s timeout; do the actual work async.
- Endpoint MUST verify the signature before trusting any field.

**Python verification example**

```python
import hmac, hashlib

def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_header)
```

---

## 8. Order schema

`OrderDetail` is returned by `GET /orders/<supplier_order_id>/` and by the book task `result`.

| Field                  | Type            | Notes                                                    |
| ---------------------- | --------------- | -------------------------------------------------------- |
| `supplier_id`          | string          | Connector slug (e.g. `kyte`, `ecoparcel`).               |
| `supplier_order_id`    | string          | The order reference. For API-integrated suppliers it is the supplier's id; for self-serviced suppliers a 4-char MeshHub-generated short code (e.g. `gsM7`). |
| `status`               | enum            | See below.                                               |
| `summary`              | string          | Human-readable description.                              |
| `price`                | decimal string  | Total price including all markups.                       |
| `currency`             | string          | `EUR` / `USD` / `GBP`.                                   |
| `selected_extras`      | array           | Extras applied to this order (§6.10).                     |
| `expires_at`           | timestamp \| null | Offer/booking expiration.                              |
| `created_at`           | timestamp       | When the order was created.                              |
| `end_user_external_id` | string \| null  | Convenience alias for `end_user.external_id`.            |
| `end_user`             | object \| null  | Nested personal-info block — `{id, external_id, first_name, last_name, email, phone}`. |

The rows returned by `GET /orders/` (§6.5) are the lighter
`OrderSummary`: the same fields **minus** `selected_extras`,
`expires_at`, and the nested `end_user` block, **plus** `category`
(the supplier category the order was booked under). Fetch the detail
endpoint when you need the full record.

**Order status enum**

| Value       | Meaning                                                   |
| ----------- | --------------------------------------------------------- |
| `pending`   | Created, awaiting payment. Extras can be applied here.    |
| `paid`      | Customer payment confirmed; awaiting supplier confirmation. |
| `confirmed` | Supplier has confirmed; booking is live.                  |
| `cancelled` | Cancelled.                                                |
| `failed`    | Supplier rejected.                                        |
| `unknown`   | Connector could not determine status.                     |

---

## 9. End-users

The `EndUser` record is the durable home for an end-customer's personal info.
Payment methods, transactions, and saved address data hang off it. Two
identifiers matter:

- `end_user_external_id` — the tenant's own user identifier (what your system
  has for the customer). Unique per tenant.
- `id` — MeshHub's UUID for the same end-user, returned in `OrderDetail.end_user.id`.

**Profile pages.** The end-user record is where a customer's profile lives —
your app should not keep its own customers table. Render the profile page
from `GET /end-users/?external_id=<your-user-id>` (§6.16), save edits back
with `PATCH`, and use `extra_info` (free-form JSON) for any app-specific
fields the schema doesn't name. Your own database only needs the login
credentials and session state; everything else about the customer — profile,
orders (§6.5 `?customer=`), saved cards and payment history (§6.13, §6.15) —
reads straight from MeshHub.

**Resolution rules at `POST /book/`:**

1. **No `end_user_external_id` supplied.** MeshHub creates a fresh EndUser
   with an internally-generated UUID as `external_id`. The fields under
   `end_user` (first_name, last_name, email — required minimum) become the
   stored record. The assigned `external_id` is returned in
   `OrderDetail.end_user_external_id`; save it for follow-up bookings.
2. **Supplied `end_user_external_id`, EndUser exists.** Any fields in the
   request's `end_user` block overwrite the stored values; absent fields are
   left untouched (partial update). Stored fields satisfy the required-fields
   check.
3. **Supplied `end_user_external_id`, EndUser unknown.** MeshHub creates the
   record under that external ID — a customer's first booking needs no prior
   registration call. The request's `end_user` block must carry the required
   minimum (first_name, last_name, email); otherwise the request fails
   upfront with `validation_error` listing the missing fields. Pre-creating
   via `POST /api/v1/end-users/` (§6.16) remains available for storing richer
   profiles ahead of time.

**Enum values**

- Gender: `male`, `female`, `other`, `unspecified`.
- Title: `mr`, `mrs`, `ms`, `miss`, `mstr`.

**Reusable booking profile.** `booking_profile` is the durable home for a
customer's category-specific booking `extra` — the part of a booking that is
*not* personal-identity data (e.g. flight `passengers`, a parcel's
`shipper_details`/`consignee_details`). It is a map keyed by supplier
`category`:

```json
{
  "travel":   {"passengers": [{"firstName": "Ada", "lastName": "Lovelace"}]},
  "shipping": {"shipper_details": {…}, "consignee_details": {…}}
}
```

It is populated two ways: you can write it directly through the end-user CRUD
(§6.16), and every successful `POST /book/` captures the booking's `extra` back
into `booking_profile[category]` (last write wins). At the next `POST /book/`
for the same customer + category, the saved block is merged under the `extra`
you send so omitted keys are prefilled and resent keys win (§6.3). Transient
per-booking keys (e.g. `confirm_and_pay`, a shipment's `parcels`) are excluded from both capture and
prefill — they must be sent explicitly per booking.

Payment methods saved for an EndUser are eligible for MIT charges in §6.12.

---

## 10. Pricing model

Each offer price is computed server-side as:

```
final_price = supplier_base_price
            * (1 + mh_markup_percent / 100)
            * (1 + tenant_markup_percent / 100)
```

Markups are configured per `(supplier, optionally per category/product)` on
the MeshHub side and per `(tenant, supplier, optionally per product)` on the
tenant side. The `price` field on search results and orders is the only
authoritative price; markup breakdowns are not surfaced in tenant-facing
responses.

If you want to detect quote→book drift, pass `quoted_price` in the book
request (§6.3); MeshHub will audit-log the delta.

---

## 11. End-to-end flow

```
┌──────────┐                                        ┌──────────────┐
│  Tenant  │                                        │   MeshHub    │
└────┬─────┘                                        └──────┬───────┘
     │ POST /search/                                       │
     │ ─────────────────────────────────────────────────► │
     │ 202 {task_id, status:pending}                      │
     │ ◄───────────────────────────────────────────────── │
     │ GET /tasks/<id>/ (poll until completed)             │
     │ ─────────────────────────────────────────────────► │
     │ 200 {status:completed, result:[offers...]}         │
     │ ◄───────────────────────────────────────────────── │
     │                                                     │
     │ POST /book/  {category, supplier_id, offer_id, end_user, extra}│
     │ ─────────────────────────────────────────────────► │
     │ 202 {task_id} → poll → result.supplier_order_id = ORDER_ID        │
     │                                                     │
     │ (optional) POST /orders/ID/extras/        → seat map + bag prices
     │ (optional) POST /orders/ID/extras/apply/  → per-passenger seats/luggage
     │                                                     │
     │ POST /payments/pay-order/ {supplier_order_id: ORDER_ID}│
     │ ─────────────────────────────────────────────────► │
     │ 200 {requires_action: true, checkout_url, order_token}
     │ ◄───────────────────────────────────────────────── │
     │ (tenant frontend completes Revolut checkout)        │
     │                                                     │
     │                Revolut webhook ─────────────────►  │
     │                MH marks order paid                  │
     │                MH pays supplier in background       │
     │                MH order → confirmed                 │
     │                                                     │
     │ ◄─── POST <webhook_url>                            │
     │      {event_type:"order_status_changed",            │
     │       status:"confirmed",                           │
     │       supplier_order_id:ORDER_ID}                   │
     │      X-MeshHub-Signature: <hmac>                    │
```

For MIT (returning end-user with a saved method), `pay-order` returns
`{"requires_action": false}` and the customer-side step is skipped.

If the saved method has since been detached on the PSP side (e.g. card
expired, customer removed it on Revolut, sandbox garbage-collection),
`pay-order` will transparently revoke the stale method server-side and
respond as if the end-user had no saved method — i.e. `requires_action:
true` with a fresh `checkout_url`. Clients should treat the response
shape uniformly: present the checkout step whenever `requires_action`
is true, regardless of whether the prior call returned an MIT response.

---

## 12. Quick start (curl)

The full flow — authenticate, search, book, pay, list orders — as
copy-paste commands. Replace `mh_KEY` with your API key throughout.

```bash
# 0. No key yet? Mint a free 7-day trial key (§2) — once; store and
#    reuse it. Minting is capped at 3/hour per IP.
curl -s -X POST https://merchant.meshhub.ai/api/v1/auth/trial-keys/
# → 201 {"key":"mh_...","tenant_id":"...","expires_at":"...","scopes":[...]}
```

```bash
# 1. Liveness
curl -s https://merchant.meshhub.ai/api/v1/health/
```

```bash
# 2. Search (async)
curl -s -X POST https://merchant.meshhub.ai/api/v1/search/ \
  -H "X-MH-Key: mh_KEY" -H "Content-Type: application/json" \
  -d '{
        "category":"shipping",
        "currency":"EUR",
        "extra":{"origin":"LT","destination":"DE","weight_kg":1}
      }'
# → 202 {"task_id":"...","status":"pending",...}
```

```bash
# 3. Poll the task
curl -s https://merchant.meshhub.ai/api/v1/tasks/<task_id>/ \
  -H "X-MH-Key: mh_KEY"
# repeat until "status":"completed"; the offers are in result.offers (§6.2)
```

```bash
# 4. Book (async). end_user requires at least first_name, last_name, email.
curl -s -X POST https://merchant.meshhub.ai/api/v1/book/ \
  -H "X-MH-Key: mh_KEY" -H "Content-Type: application/json" \
  -d '{
        "category":"travel",
        "supplier_id":"kyte",
        "offer_id":"OFFER-FROM-STEP-2",
        "end_user":{
          "first_name":"Ada",
          "last_name":"Lovelace",
          "email":"ada@example.com",
          "phone":"+441234567"
        },
        "extra":{},
        "quoted_price":"18.75"
      }'
# poll the returned task_id; result.supplier_order_id is your ORDER_ID
```

```bash
# 5. Pay (supplier_order_id comes from the book result)
curl -s -X POST https://merchant.meshhub.ai/api/v1/payments/pay-order/ \
  -H "X-MH-Key: mh_KEY" -H "Content-Type: application/json" \
  -d '{"supplier_order_id":"ORDER_ID"}'
# requires_action + checkout_url → finish payment in the browser;
# otherwise a saved method was charged
```

```bash
# 6. List your orders (newest first; see §6.5 for filters)
curl -s "https://merchant.meshhub.ai/api/v1/orders/?limit=20" \
  -H "X-MH-Key: mh_KEY"
```

Then wait for the outbound webhook on your configured `webhook_url`.

For sync-mode dev runs, add `-H "X-Execution-Mode: sync"` to steps 2 and 4 to
skip polling.

**Starter kit** — the same flow, packaged:

- Ready-to-import request collection:
  [`meshhub.postman_collection.json`](meshhub.postman_collection.json)
- Complete minimal shop (browser → your backend → MeshHub, key
  server-side): [thin-frontend reference example](examples/thin-frontend.md)
- Building with an AI app builder? Use the
  [copy-paste builder prompt](ai-builder-prompt.md)

---

## 13. Versioning & change policy

- Breaking changes ship behind a new URL prefix (`/api/v2/...`). `/api/v1/`
  is stable.
- Additive changes — new endpoints, new fields, new enum values, new event
  types — can land on v1 without a version bump.
- Clients MUST ignore unknown response fields and unknown enum values
  (treat unknown enum as the closest documented value or as "unknown" and log).
- Deprecations are announced in advance with a sunset date; the deprecated
  surface keeps working until that date.

**Recent changes**

`3.4.0` (2026-07-17, an extras-apply timeout is now an answer)

- New error code **`EXTRAS_APPLY_UNCONFIRMED`** (HTTP 400,
  `error=supplier_error`) on `POST /orders/{id}/extras/apply/` (§7). It means
  the supplier timed out **and** we could not then establish whether the
  extras were applied. **Never retry it** — applying extras is additive and
  not idempotent, so a retry can charge the traveller twice. Escalate and
  reconcile manually.
- **`TIMEOUT` on that endpoint now means something stronger.** MeshHub
  re-reads the booking from the supplier before reporting a timeout, so
  `TIMEOUT` is only returned once the booking confirms the extras were *not*
  applied — making the documented "safe to retry" actually true. Previously a
  timeout was reported blind: the supplier had often applied (and charged for)
  the extras while the order showed none, and retrying added them a second
  time.
- If the extras *did* land, the call now returns **success** with the applied
  extras and updated total, instead of a spurious timeout.
- Additive; no existing field moves, and no change is needed in clients that
  already treat unknown `code` values as non-retryable.

`3.3.0` (2026-07-16, fares say what they include)

- New offer field **`extras.fare_includes`** (§14.1): what the fare buys,
  in the airline's own words, as a list of lines — easyJet's Inclusive
  Plus lists a 23kg hold bag, Speedy Boarding, a meal and lounge access;
  Light lists one small under-seat bag. Additive; no existing field moves.
- This closes the gap left by `3.2.0`/`3.2.1`, which delivered every fare
  brand and its price but nothing to say why the dearest costs ~2.4× the
  cheapest.
- **Display copy, not data.** The wording is the airline's and changes
  without notice — render it, don't parse it. To act on what a fare
  covers, keep using per-offer `extras` (§6.19).
- **Omitted**, never empty, when the airline describes nothing. Both
  carriers currently describe every fare they name, but do not rely on
  presence.

`3.2.1` (2026-07-16, WizzAir GO and PLUS fares restored)

- **Bugfix, no wire change.** `3.2.0` deduped fares on `extras.fare_code`,
  which is a *booking class*, not a product. Airlines reuse one class
  across brands — WizzAir sells Basic / SMART / GO / PLUS on a flight with
  SMART, GO and PLUS all under `"L"` — so **GO and PLUS were dropped** and
  only Basic and SMART were returned. Fares are now deduped on
  `extras.fare_brand`, and all four reach you.
- easyJet was unaffected (its classes map one-to-one onto Light /
  Inclusive / Inclusive Plus) and its results are unchanged.
- **`extras.fare_code` must not be used to group or dedupe offers** (§14.1)
  — it is not unique per brand and varies flight to flight for the same
  brand. Use `fare_brand`.

`3.2.0` (2026-07-16, every fare brand per flight)

- **Behavioural, additive on the wire.** A travel search now returns
  **every distinct fare the airline sells on each flight it returns**,
  rather than only the cheapest few overall. Two fares were being lost:
  the result cap counted offers, so a price sort filled all 5 slots with
  basic fares (a live easyJet LGW→BCN search returned 4 flights × 3
  brands and the caller saw four Light fares and one Inclusive, with
  every Inclusive Plus cut); and two fares on one flight in the same tier
  were treated as duplicates, discarding the dearer one.
- **The result cap now counts products, not offers** (§6.2). For flights
  a product is a *flight*, so all of its fare brands come back together
  and count once. **Expect more than 5 offers per travel search** — up to
  5 flights × however many brands each sells. Categories that do not
  group (shipping, hotel, catalogue) are unchanged at 5 offers.
- New offer fields **`extras.fare_brand`** and **`extras.fare_code`**
  (§14.1) carrying the airline's own fare name and code (`"Inclusive
  Plus"` / `"W"`). Both are **omitted** when the airline names no fare —
  test for presence, and fall back to `extras.flexibility`.
- **`extras.flexibility` is now a fallback label, not the fare's
  identity.** It reports the tier MeshHub shopped at, which is the
  supplier's filter rather than the airline's brand; one tier can return
  several brands. Existing readers keep working, but prefer `fare_brand`
  for display.
- Offers on one flight share the same `extras.legs` — group on it.

`3.1.0` (2026-07-15, all fare tiers shopped by default)

- **Behavioural, additive on the wire.** §14.1: a travel search that
  omits `flexibility` now shops **all three fare tiers** (`lowest`,
  `low`, `flexible`) and returns the merged offers, instead of shopping
  only `flexible`. The cheapest fare is now offered; previously only the
  priciest, least competitive product was returned (and its bundled
  seats read as free).
- Expect **more offers per search**: the same flight commonly appears
  once per tier, cheapest-per-(flight, tier). Offers remain sorted by
  price and capped at the top 5 (§6.2), so a cheaper tier can now
  displace a pricier one.
- New offer field **`extras.flexibility`** (§14.1) naming the tier each
  offer was shopped at — use it to label the fare and to tell otherwise
  near-identical offers apart.
- **Unchanged for anyone passing `flexibility` explicitly**: a named
  tier still shops only that tier.

`3.0.0` (2026-07-12, uniform strict validation)

- **Breaking, coordinated with all tenants.** §14: every category ×
  operation `extra` schema is now **strict** — an unknown key at any
  nesting level returns a field-level `400` naming it (e.g.
  `extra.cabin_class`, `extra.passengers[0].loyaltyNumber`). Previously
  travel/shipping/hotel search and every book schema silently forwarded
  unknown keys to the supplier. The published field schemas (§6.2,
  §6.18) are now the complete, closed vocabulary.
- New declared field **`extra.supplier_data`** (object, optional) on
  every category's search, book, and offer-details schema — the single
  supplier forward-compat channel, forwarded to the supplier verbatim
  and never saved to a customer's booking profile. Keys previously sent
  as undeclared passthrough move here.
- Travel search: previously-passthrough conventions `date_to`,
  `trip_type`, `cabin`, `airlines` are now declared, validated fields
  (§14.1) and appear in `GET /search/schema/`. Same for shipping's
  `dimensions` `{w,h,l}`, `package_type`, `transportation_type` (search)
  and `package_type`, `transportation_type`, `confirm_and_pay` (book).
- Removed: the `additional_extra_fields_allowed` flag from the `/search`
  task result and `GET /search/schema/` — the answer is now always "no".
- §6.19: the offer-details `extra` is validated against a typed
  per-category schema (previously free-form).
- Order-scoped ops (`track`, `cancel`, `get_extras`) accept only an
  empty `extra` — any key returns a `400` (connectors receive only the
  order id for these calls, so nothing could be forwarded anyway).
- Saved booking profiles (§6.3) are filtered to the strict vocabulary
  on prefill, so profiles captured before 3.0.0 keep working.

`2.1.0` (2026-07-12, extras discovery on offers)

- **Additive.** §6.2: every search offer now carries
  `supported_extras_kinds` — the item `key` families the supplier's extras
  catalogue can contain (e.g. `"seat"`, `"luggage"`), a static capability
  hint costing no extra supplier calls. Empty list = no extras catalogue.
- §6.19: on travel offers, `extra: {"include_extras": true}` embeds the
  full **pre-booking** extras catalogue in `detail.extras` — `items` in the
  §6.9 flat shape plus `seat_map`, with `prices_indicative: true` (prices
  can drift until booked; the post-booking catalogue stays authoritative).
  Expect up to ~30 s while the airline assembles seat maps — request it
  only for the offer under consideration.

`2.0.0` (2026-07-12, flat extras contract)

- **Breaking, coordinated with all tenants.** §6.9: the get-extras `result`
  is now a **flat `items` list** of atomic purchasable items — one uniform
  shape across suppliers and categories — replacing the nested
  `extras[].choices[]` options. Each item carries a stable `id`, a
  cross-supplier `key`, `price`/`currency` (nullable = priced at booking),
  `per_passenger`, `segments` (at most one), `quantity` tier, a
  mutual-exclusion `group`, `purchasable_at`, and `metadata`;
  `option_type`/`required` are gone.
- §6.10: applying extras is now **by item id** — `extra.items:
  [{id, passenger?, quantity?}]` replaces `extra.selected_extras`
  key/value/segments echoing. The result's `selected_extras` entries gain
  the applied `id`.
- §6.9: every `seat_map` seat now carries an `item_id` — book an exact
  seat by sending it in the same `items` list.

`1.9.0` (2026-07-12, typed extras choices)

- §6.9: every get-extras `choices[]` entry now has one **typed shape** across
  all categories — `value`, `label`, `price`, `currency`, `segments`,
  `quantity`, `metadata` — instead of a per-supplier free-form object. All
  keys are always present. `price: null` means the supplier prices the choice
  at booking time (shipping options), distinct from a free item (`"0"`).
  Renamed: seat `characteristics` → `metadata`; dropped: the redundant
  singular `segment` on seat choices (use `segments`).

`1.8.2` (2026-07-11, person-name validation rules + field-attributable supplier errors)

- §6.2: string entries in `field_schema` can now carry a `pattern` — an
  ECMAScript regex (compile with the `u` flag) the value must fully match.
  Person-name fields (`extra.passengers[].firstName`/`lastName`,
  `end_user.first_name`/`last_name`, hotel `extra.guests[].name`/`surname`,
  shipping `contact_name`) publish `^\p{L}[\p{L}' -]*$` — letters, spaces,
  hyphens and apostrophes only. The same rule is now **enforced** at `/book/`
  (400 `validation_error`), so names airlines reject (digits, `&`, `.`) fail
  fast instead of surfacing as a supplier error after the booking attempt.
  Note this makes shipping `contact_name` stricter: it must be a person's
  name, not a company-style name.
- §5: Kyte airline-side field rejections (e.g. "Name.First: Invalid passenger
  first name") are now mapped to the `validation_error` envelope with a
  `fields` map instead of a bare `supplier_error`. When the airline does not
  say which passenger, the path is unindexed (`extra.passengers[].firstName`
  = check every passenger).

`1.8.1` (2026-07-10, bag quantity tiers as one option)

- §6.9: airline checked-bag quantity tiers (previously one `luggage` option per
  quantity) now collapse into a **single** `select` option whose `choices[]`
  each carry a `quantity` integer. Mutual exclusivity is expressed by the
  single-select shape; apply by echoing the chosen tier's `value` without also
  setting the apply-side `quantity`. Additive — the option shape is unchanged
  and non-tiered ancillaries are unaffected.

`1.8.0` (2026-07-09, physical seat-map layout for flight orders)

- §6.9: a flight order's get-extras `result` now carries a `seat_map` array —
  the per-segment seating grid (cabins → rows → seats with column, position,
  availability, price and fare band). A passenger can pick an exact seat and
  book it by sending the seat's `value` (its designator, e.g. `"12A"`) as a
  `seat` extra. Category-level seat products are unchanged and still appear in
  `extras`. Additive and best-effort: suppliers without a seat map return an
  empty `seat_map`.

`1.7.0` (2026-07-08, richer hotel offers + on-demand offer details)

- §6.19: new `POST /offers/details/` endpoint — synchronous, `search` scope,
  universal across categories. Returns the full detail for one search offer
  on demand (the frontend's "more details" button). Hotels return
  `description`, `amenities`, `images`, `address`/`city`/`postal_code` and
  `web`/`email`/`phone`; categories with no detail view return `detail: {}`.
- §14.5: hotel search offers now carry a **light content summary** —
  `description` + one primary `image` alongside the existing basic fields —
  enough to render a result card. The full content moved to §6.19.
- Both are best-effort: if the HotelBeds content service is briefly
  unavailable, search offers and the details response come back without the
  content keys rather than failing.

`1.6.1` (2026-07-07, doc-only: MeshHub as the system of record)

- §1: explicit statement that MeshHub is the system of record for orders
  and customers — apps should render order-history and profile pages from
  MeshHub and keep only credentials/sessions locally.
- §9: "Profile pages" note — the end-user record is the customer profile;
  read via `?external_id=`, write via `PATCH`, extend via `extra_info`.
- `llms.txt` header and the AI-builder prompt carry the same instruction;
  the thin-frontend example gains a working profile page.

`1.6.0` (2026-07-07, account-page completeness: customer lookup, pagination, default card)

- §6.16: `GET /end-users/` accepts an exact-match `?external_id=` filter —
  look up a customer by your own id and read the MeshHub UUID from the
  single result.
- **Response-shape change:** `GET /end-users/`, `GET /payments/methods/`,
  and `GET /payments/transactions/` are now paginated (`limit`/`offset`,
  default 50, max 200) and return the `{count, next, previous, results}`
  envelope that `GET /orders/` already used, instead of a bare array.
  **Migration: read the `results` key.** This intentionally converges every
  list endpoint on one shape; SDK `0.3.0` absorbs it.
- §6.13: new `POST /payments/methods/<method_id>/default/` sets a
  customer's default payment method (previous default cleared
  automatically; server-side charges use it).

`1.5.2` (2026-07-06, doc corrections from the third builder simulation)

- The pay endpoint's request key is `supplier_order_id` everywhere —
  the §11 flow diagram and §12 quick start previously said
  `order_code`, and the §11 webhook sketch said `meshhub_code`; all
  three now match §6.12 and the real payload. The book result's order
  reference is `result.supplier_order_id` (not `result.id`).
- §14.2: `confirm_and_pay` explained — supplier-side fulfilment
  settlement, not the customer payment; the customer always pays via
  `pay-order`. Parcel `value` accepts a decimal string or number.
- §6.2: supplier `required` flags may exceed the §9 storage minimum
  (e.g. `end_user.phone`); array fields — a single value must still be
  wrapped in a one-element array.
- §6.3: note distinguishing the app-level confirmation gate from the
  `confirm_and_pay` extra key.
- §6.5/§8: the `?customer=` filter is explicitly the booking-time
  `end_user_external_id`; §8 documents the `OrderSummary` list-row
  shape next to `OrderDetail`.
- §14.4: callout that the car-rental catalogue books in enquiry mode.
- `docs/sandbox.md`: pruned the stale Revolut test-card table — only
  `4929 4205 7359 5709` is currently accepted.

`1.5.1` (2026-07-06, shipping parcels in the field schema + default)

- Shipping bookings no longer require an explicit `extra.parcels` list:
  when omitted, a single parcel is built from the offer's quoted weight
  (minimal dimensions, nominal declared value). Previously the supplier
  rejected the booking with "At least one parcel is required" even
  though `field_schema` never advertised parcels.
- The shipping `field_schema` now includes `extra.parcels[].*`
  (`description`, `weight`, `length`, `width`, `height`, `value`) as
  optional fields, and §14.2 documents the parcel shape. Supplied
  parcels are now validated at `/book/` (400 with field errors instead
  of a supplier rejection).
- `parcels` joined `confirm_and_pay` as a per-booking key excluded from
  the reusable booking profile (§6.3/§9) — a prior shipment's parcels
  are never silently replayed into the next booking.

`1.5.0` (2026-07-06, end-user auto-create on first booking)

- §9: a `POST /book/` with an unknown `end_user_external_id` now
  auto-creates the customer record under that id (previously the task
  failed with "end user not found"). The `end_user` block must still
  carry the required minimum (first_name, last_name, email) — enforced
  upfront as a `validation_error`. Pre-creating via
  `POST /api/v1/end-users/` keeps working and is no longer required
  before a first booking.

`1.4.2` (2026-07-06, discoverability follow-ups from the second builder simulation)

- `llms.txt` now says how to get a key (trial-keys mint) in its header,
  not only via starter-kit links.
- §2: under mint contention `Retry-After` is advisory — a neighbour on
  the same IP can take the freed slot; honor each header as it comes.
- §6.2: guidance for rendering `array`-typed `field_schema` entries in
  flat HTML forms; the AI-builder prompt now points at the §6.2
  `field_schema` spec explicitly.
- AI-builder prompt + thin-frontend example: the booking confirmation
  guard must be server-side (the example now requires `confirm: true`
  on its book route); a frontend-only dialog can be bypassed.
- SDK README: documentation links are absolute URLs (the previous
  repo-relative links 404'd on npm and the hosted mirror).

`1.4.1` (2026-07-06, doc fixes from builder-simulation findings)

- **Shipping examples corrected**: `origin`/`destination` in the §6.2
  search examples and §6.9 tracking example now use ISO 3166-1 alpha-2
  country codes (`"DE"`, `"ES"`), matching the §14.2 field reference —
  the previous `"Berlin, DE"` city strings were never valid input.
- §14.4 gained a complete car-rental search request example and an
  explicit callout that the category value is `car_rental` (underscore)
  while the discovery URLs use hyphens.
- §14.3 / the salon `field_schema`: `appointment_at` accepts the
  seconds-less form HTML `datetime-local` inputs produce.
- §5 corrected: only `completed` and `dead` are final task statuses —
  `failed` is retried automatically and can still complete. The previous
  text listed `failed` as terminal, contradicting its own status table.
- AI-builder prompt now recommends the official SDK first; the
  browser-like User-Agent advice applies to hand-rolled HTTP clients.

`1.4.0` (2026-07-05, quickstart & starter kit)

- §12 quick start now covers the whole flow copy-paste: mint a trial
  key (step 0), search, poll, book, pay, and list orders (step 6).
- New hosted starter-kit surfaces (no contract change):
  - `GET /docs/meshhub.postman_collection.json` — ready-to-import
    request collection for the full flow.
  - `GET /docs/examples/thin-frontend.md` — complete minimal shop
    demonstrating the browser → own backend → MeshHub pattern with
    `@meshhub/sdk` and the API key server-side only.
  - `GET /docs/ai-builder-prompt.md` — copy-paste prompt for AI app
    builders (Replit-first) with safe key handling baked in.
  - All three linked from `/docs/` and `llms.txt`.

`1.3.3` (2026-07-05, trial-key mint limits)

- Documented the trial-key mint throttle precisely (§2): 3 keys per
  rolling hour per client IP plus a small global daily cap; the per-IP
  cap is shared behind NAT / cloud-IDE egress IPs. Guidance added to
  store and reuse the key for its 7-day lifetime rather than minting
  per run. Docs + OpenAPI description only; the limits themselves are
  unchanged.

`1.3.2` (2026-07-05, offer currency semantics)

- Clarified, in docs and the OpenAPI description: the request-level
  `currency` is a **preference**. MeshHub performs no currency
  conversion, and some suppliers always quote in their own settlement
  currency — flights price in the departure airport's local currency.
  The offer's `currency` field is authoritative (§6.2, §14.1).
  Previously the docs implied offers would be quoted in the requested
  currency. Behavioral contract unchanged — this documents what the
  API has always done.

`1.3.1` (2026-07-05, docs accuracy)

- Docs-only, no contract change — fixes drift found by an automated
  builder-agent validation run:
  - All quick-start examples and the environments table now target
    production (`merchant.meshhub.ai`); the decommissioned staging host
    is gone from the guide.
  - §6.5 orders filter: the `category` value for salons is `hair_salon`
    (previously misdocumented as `salon`).
  - The OpenAPI description of `TaskStatus.result` now says search
    returns `{offers, field_schema, additional_extra_fields_allowed}`
    (previously `{results: [...]}` — the API always returned `offers`).
  - `max_price` / `min_price` filters: documented as decimal-as-string
    (JSON numbers also accepted).
  - Polish: task polls count against the rate limit (§4);
    poll-immediately-then-back-off guidance (§5); `field_schema`
    entries' `description` key and the empty-offers case (§6.2);
    `enquiry` vs `instant` UX note on `/book/` (§6.3).

`1.3.0` (2026-07-03, self-serve trial keys)

- Added: `POST /api/v1/auth/trial-keys/` — mint a trial tenant plus a
  7-day, all-scopes API key with no prior credentials (§2). Surfaced in
  the playground as the **Get a test key** button. Trial tenants run on
  the new `trial` rate tier (10 req/min, §4); minting is rate-limited
  per client IP (`429` + `Retry-After`).
- Changed: API keys belonging to a **deactivated tenant** are now
  rejected with `401 Unauthorized` (previously only the key's own
  active/expiry state was checked).

`1.2.0` (2026-07-03, public developer portal)

- The schema (`/api/schema/`), Swagger UI (`/api/docs/`), and this guide
  are now public — no admin session needed. New hosted surfaces: `/docs/`
  entry page, `/docs/tenant-integration.md` markdown mirror,
  `/docs/playground/` bring-your-own-key API playground, `/llms.txt` and
  `/llms-full.txt` AI-ingestible indexes. No contract change.

`1.2.0` (2026-07-03, schema enrichment)

- The published OpenAPI version now matches the documented contract
  version (`1.2.0`; the generated schema previously said `1.1.0`).
- Docs-only, no contract change: every operation now carries a summary
  and a description naming its required scope and sync/async behaviour;
  every request/response field carries a description with format and
  example; the payments and auth endpoints now publish full
  request/response schemas (previously undocumented in the OpenAPI);
  `TaskStatus.error` now references the `GatewayError` component instead
  of an untyped string.

`1.2.0` (2026-06-29)

- Added: outbound webhook **event catalogue** (§7). Beyond
  `order_status_changed`, MeshHub now pushes `task_completed`/`task_failed`,
  `payment_succeeded`/`payment_failed`/`payment_refunded`, and
  `tracking_updated` — all through the same signed envelope, so a thin
  frontend can stop polling for task, payment, and tracking outcomes.
- Changed: the webhook body component is now the single `WebhookPayload`
  (superseding `OrderStatusWebhookPayload`); `event_type` is the discriminator
  and event-specific fields live in `details`.

`1.1.0` (2026-05-15)

- Added: `/api/v1/end-users/` CRUD (§6.16). The legacy admin-side route
  at `/api/end-users/` is removed in favour of the API-key-authenticated
  v1 endpoints.
- Added: `OrderStatusWebhookPayload` schema component in
  `docs/api/openapi.json` documenting the outbound webhook body (§7).
- Schema hygiene: the published OpenAPI is now scoped to tenant routes
  only (admin / supplier-portal / staff endpoints are filtered out),
  enum component names are stable (`PassengerGender`, `PassengerTitle`,
  named envelope components), and only `ApiKeyAuth` appears in
  `securitySchemes`.

---

## 14. Per-category `extra` schemas

Every gateway endpoint accepts the same envelope: a few top-level fields
the gateway needs to route the call, plus an `extra` object whose
schema is owned by the category. This appendix lists the keys
recognised today, per category.

Each category × op pair has a typed DRF serializer in
`meshhub.gateway.extras`; that serializer is also the source of the
`oneOf` polymorphic schema published in `docs/api/openapi.json`. The
table below mirrors those serializers — if you're tooling against the
schema, prefer the JSON file.

**The schemas are strict (since 3.0.0).** Anything you put in `extra`
that is not listed here — at any nesting level — is rejected with a
field-level `400` naming the key. The single forward-compat channel is
the declared **`supplier_data`** object, accepted on every category's
search, book, and offer-details `extra`: its contents are forwarded to
the supplier verbatim, without MeshHub validation. The supplier may
still reject keys it does not understand — see
`docs/suppliers/<supplier>/` for those gotchas — and `supplier_data`
is never saved to a customer's reusable booking profile.

For **Book `extra`**, the tables below document shape and types only.
Required vs. optional is decided per supplier at offer time — see §6.2
and the `required_fields` / `optional_fields` arrays on every offer.
Search `extra` tables keep a `required` column because search input
has no per-offer override.

### Common result envelope

Every entry in a `/search` task's `result` array shares the same
envelope (the connector `Result` dataclass). The `extras` object is the
only per-category slot — its keys are listed under each category below.
`openapi.json` types `result` as free-form JSON; this section is the
canonical reference for the shape.

| key            | type                  | notes                                                              |
| -------------- | --------------------- | ------------------------------------------------------------------ |
| `supplier_id`  | string                | stable identifier for the supplier that produced this result       |
| `offer_id`     | string                | opaque token; pass back as `supplier_ref` on `/book`               |
| `category`     | string                | one of the categories enumerated in this section                   |
| `summary`      | string                | short human label for the offer                                    |
| `price`        | decimal (string)      | quoted total                                                       |
| `currency`     | string                | ISO 4217                                                           |
| `availability` | bool                  | `true` when the offer can be booked right now                      |
| `expires_at`   | string \| null        | optional ISO 8601 timestamp after which `offer_id` is stale        |
| `booking_mode` | string                | `instant` or `enquiry` — see "Booking-decision metadata" above     |
| `extras`       | object                | category-specific (see per-category sections below; may be empty)  |

These are the only fields returned per offer. Supplier raw payloads,
the per-passenger pricing breakdown, and internal ranking fields are
not exposed; everything tenant-facing for the route lives in `extras`.

### 14.1 `category: "travel"`

**Search `extra`**:

| key            | required | type   | notes                              |
| -------------- | -------- | ------ | ---------------------------------- |
| `origin`       | yes      | string | IATA airport code (e.g. `LHR`)     |
| `destination`  | yes      | string | IATA airport code                  |
| `date_from`    | yes      | string | departure date, `YYYY-MM-DD`; must not be in the past (a past date is rejected with a validation error) |
| `date_to`      | no       | string | return date; omit for one-way      |
| `passengers`   | no       | int \| list | default 1. An int is that many adults. For a family / mixed-age search send a **list** of passenger objects — see below |
| `cabin`        | no       | string | default `economy`                  |
| `trip_type`    | no       | string | `one_way` \| `return` (inferred)   |
| `airlines`     | no       | string | comma-separated IATA airline codes |
| `flexibility`  | no       | string | `lowest` \| `low` \| `flexible`. **Omit to shop all three tiers and receive the merged offers** (recommended — this is how the cheapest fare surfaces). Pass one tier to restrict the search to it. `lowest` returns the cheapest Basic fares (which avoid mandatory-seat bundles on some carriers, e.g. Ryanair); `flexible` returns the most changeable. Each offer reports its tier in `extras.flexibility` |
| `supplier_data` | no      | object | supplier-specific params forwarded verbatim (§14 intro) |

**Offer currency.** Flight offers are always quoted in the **departure
airport's local currency** (LON→DUB prices in GBP, DUB→LON in EUR); the
supplier does not support quoting in a requested currency, and MeshHub
does not convert (§6.2, "Offer currency"). Read `currency` on each
offer.

**Multiple passengers (family / mixed-age search).** This is how you
search for more than one traveller. Send `passengers` as a list with one
object per traveller; search needs only the **count and ages** to price
the trip — passenger identities are supplied later at `/book/`. Each
object carries either an explicit `age` (integer years, `0`–`120`)
**or** a `type` — one of `adult` (→ 30), `child` (→ 8), `infant` (→ 1),
whose representative age is used for pricing. The supplier derives the
fare type (adult/child/infant) from the age. A bare integer is shorthand
for that many adults. Examples:

```json
"passengers": [{"age": 34}, {"age": 8}, {"age": 1}]
"passengers": [{"type": "adult"}, {"type": "child"}, {"type": "infant"}]
```

The list length sets the number of travellers. At `/book/` send the same
number of entries in `extra.passengers` (below), **in the same order** as
the search list, so per-passenger pricing and infant fees line up — a
mismatched count is rejected.

**Book `extra`** (shape only; required/optional per supplier in §6.2):

| key          | type    | notes                                                  |
| ------------ | ------- | ------------------------------------------------------ |
| `passengers` | list    | omit to have MeshHub derive a single passenger from the EndUser |
| `contact`    | object  | Booker's primary postal address. **Required** for Kyte (sent as `address` to the supplier). Must contain `addressLines` (non-empty list of strings), `city`, `postalCode`, `countryCode`. |
| `supplier_data` | object | supplier-specific params merged into the top level of the supplier booking payload (§14 intro) |

Each passenger object:

| key                  | type   | notes                                                              |
| -------------------- | ------ | ------------------------------------------------------------------ |
| `firstName`          | string | Letters, spaces, hyphens and apostrophes only (must start with a letter; Unicode letters like `ü` are fine) — see the `pattern` entry in the field schema (§6.2). Airlines reject names with digits or other punctuation, so MeshHub enforces this synchronously at `/book/`. |
| `lastName`           | string | Same character rules as `firstName`.                               |
| `gender`             | string | `male` \| `female`                                                 |
| `title`              | string | `mr` \| `mrs` \| `ms` \| `miss` \| `mstr`                          |
| `dateOfBirth`        | string | **Required** for every passenger on Kyte. ISO date `YYYY-MM-DD` (e.g. `1990-05-21`). The airline rejects a booking without it, so MeshHub enforces it synchronously at `/book/`. |
| `contactInformation` | object | **Required** for every passenger on Kyte. `{email, phone}` — `email` is an RFC 5322 address; `phone` is a single E.164 string (e.g. `+447911123456`), normalized to a list of `{countryCode, number, type: "Mobile"}` on the server. Airlines (FR, U2, …) reject bookings whose lead passenger has no email or no mobile phone, so MeshHub enforces both fields synchronously at `/book/` rather than letting them fail downstream. |

**Selecting luggage & seats (per passenger, per segment).** Luggage and
seats are *paid ancillaries* chosen **after** a booking is held, not at
search time — the supplier only returns a priced seat map and bag
catalogue once an order exists. The funnel is:

1. `POST /book/` → the order lands `pending` (held, unpaid).
2. `POST /orders/<id>/extras/` (§6.9) → the flat `items` catalogue (plus
   `seat_map` for an exact-seat pick).
3. `POST /orders/<id>/extras/apply/` (§6.10) → send the chosen item ids,
   each with the `passenger` it is for. Selections are added to the
   order total.
4. `POST /payments/pay-order/` → charges the updated total.

**Item kinds for travel** (`items[].key`): `luggage`, `seat`, `meal`,
`sports_equipment`, `service`, `lounge_access`, `disability_assistance`.

Worked example — two passengers, each picks a seat on the outbound leg
`SEG-1` (ids taken from `seat_map[].…[].item_id`), and the lead
passenger adds one checked bag (id from `items`):

```json
{
  "extra": {
    "items": [
      {"id": "seat::12A::SEG-1",     "passenger": 0},
      {"id": "seat::12B::SEG-1",     "passenger": 1},
      {"id": "luggage::BAG-23K::SEG-1", "passenger": 0}
    ]
  }
}
```

The response (§6.10) returns the new order total; the same selections are
stored on the order's `selected_extras`, and the subsequent `pay-order`
charges the total including these ancillaries.

**Result `extras`**:

| key                   | type             | notes                                                                  |
| --------------------- | ---------------- | ---------------------------------------------------------------------- |
| `airline`             | string \| null   | marketing carrier name of the first segment                            |
| `origin_airport`      | string \| null   | IATA code the trip departs from (outbound leg's first segment)         |
| `destination_airport` | string \| null   | IATA code the trip arrives at (outbound leg's last segment). On a connecting itinerary this is the **final** destination, not a layover. |
| `legs`                | array of strings | one entry per flight segment, e.g. `["IndiGo 4 LHR-DEL 2026-06-15T21:00", "IndiGo 1463 DEL-DXB 2026-06-16T19:10"]`; intermediate airports are layovers |
| `departure_time`      | string \| null   | ISO 8601 timestamp of the outbound leg's departure                     |
| `arrival_time`        | string \| null   | ISO 8601 timestamp of arrival at the destination (outbound leg)        |
| `duration`            | string           | total travel time formatted `"<H>h <M>m"`; omitted when times are unparseable |
| `price_per_person`    | decimal (string) | `price / passenger_count`                                              |
| `trip_type`           | string           | `one_way` \| `return`                                                  |
| `flexibility`         | string           | fare tier this offer was shopped at — `lowest` \| `low` \| `flexible`. The supplier's own filter, not the airline's brand: prefer `fare_brand` for display and use this only as a fallback |
| `fare_brand`          | string           | the **airline's** name for this fare, e.g. `"Light"`, `"Inclusive"`, `"Inclusive Plus"` on easyJet. **Omitted** when the airline names no fare. This is the label to show the customer |
| `fare_code`           | string           | the airline's **booking class** for this fare, e.g. `"Y"`, `"B"`, `"L"`. **Omitted** when the airline names no fare. **Not a product identifier — do not group or dedupe on it:** airlines reuse one class across several brands (WizzAir sells SMART, GO and PLUS on one flight all under `"L"`), and the class for a given brand varies flight to flight. Use `fare_brand` to identify the product. A multi-leg trip priced at a different fare per leg joins the classes with `+`, e.g. `"B+Y"` |
| `fare_includes`       | array of strings | what this fare includes, in the **airline's own words** — e.g. `["- 1 x 23kg hold bag", "- Speedy Boarding"]`. Free prose meant for display, **not machine-readable**: wording, order and punctuation are the airline's and change without notice, so render it, don't parse it. **Omitted** (never `[]`) when the airline describes nothing, and omitted rather than merged in the rare case where a multi-leg trip's legs describe their fares differently |

**Fare brands.** Unless the search names a `flexibility`, each flight is
shopped at every tier and you get back **every distinct fare the airline
sells on it** — commonly three, at three prices. Read `fare_brand` to
label them; they are otherwise near-identical, since they fly the same
flight at the same times. All fares for one flight share the same `legs`,
which is how you group them.

Cheaper fares buy less, and `fare_includes` says how — in the airline's
own words, on the search result, so you can present the trade-off without
a second call. On easyJet, `Light` carries only a small under-seat bag,
`Inclusive` adds a 23kg hold bag and free seat selection, and `Inclusive
Plus` adds Speedy Boarding, lounge access and free date changes. That is
also why a premium fare's seats often price at zero — it already bundles
them.

Treat `fare_includes` as prose to show, never as rules to parse: it is
the airline's marketing copy, not a structured entitlement. If you need
to *act* on what a fare covers — priced bags, seats — fetch `extras` per
offer (§6.19), which is machine-readable and authoritative.

Not every airline names its fares. When `fare_brand` is absent, fall back
to `flexibility` for a coarse Basic / Standard / Flex label; expect
`fare_includes` to be absent too.

To show the destination of a connecting flight, use
`destination_airport` rather than parsing `summary`. For round trips,
`origin_airport` / `destination_airport` and the
`departure_time` / `arrival_time` / `duration` fields describe the
**outbound** leg (so the destination is where you're flying to, not
the return arrival back home); `legs` lists every segment of both
directions.

### 14.2 `category: "shipping"`

**Search `extra`**:

| key                  | required | type    | notes                                       |
| -------------------- | -------- | ------- | ------------------------------------------- |
| `origin`             | yes      | string  | ISO 3166-1 alpha-2 country (e.g. `LT`)      |
| `destination`        | yes      | string  | ISO 3166-1 alpha-2 country                  |
| `weight_kg`          | yes      | number  | positive; not boolean                       |
| `dimensions`         | no       | object  | `{w, h, l}` in cm, each positive            |
| `package_type`       | no       | string  | default `parcel`                            |
| `transportation_type`| no       | string  | default `door_to_door`                      |
| `supplier_data`      | no       | object  | supplier-specific params forwarded verbatim (§14 intro) |

**Book `extra`** (shape only; required/optional per supplier in §6.2):

| key                  | type    | notes                                       |
| -------------------- | ------- | ------------------------------------------- |
| `shipper_details`    | object  | name + address of the sender                |
| `consignee_details`  | object  | name + address of the recipient             |
| `parcels`            | list    | one object per physical parcel (below)      |
| `package_type`       | string  | overrides search default                    |
| `transportation_type`| string  | overrides search default                    |
| `confirm_and_pay`    | bool    | default `true`                              |
| `supplier_data`      | object  | supplier-specific params merged into the supplier's order entry (§14 intro) |

`shipper_details` and `consignee_details` share the same shape:
`contact_name`, `phone`, `email_address`, `address`, `city`,
`postcode`, `country`. `contact_name` is a **person's** name —
letters, spaces, hyphens and apostrophes only (see the `pattern`
entry in §6.2); a company-style name with `&`, `.` or digits is
rejected. `phone` is validated as
[E.164](https://en.wikipedia.org/wiki/E.164) (e.g. `+34611223344`);
`country` is validated and normalised as ISO 3166-1 alpha-2
(e.g. `ES`). Tenants that pass local-format phones or full country
names are rejected at `/book` with a 400 before the supplier sees the
request.

Each `parcels` entry has `description` (string, what the parcel
contains), `weight` (kg), `length` / `width` / `height` (cm), and
`value` (declared value as a decimal, used for insurance/customs):

```json
"parcels": [
  {"description": "Books", "weight": 2.5,
   "length": 30, "width": 20, "height": 10, "value": "25.00"}
]
```

`parcels` is optional: when omitted, MeshHub books a single parcel
using the offer's quoted weight, minimal (1 cm) dimensions, and a
nominal declared value of 1 — fine for trying the flow, but supply the
real parcels in production for accurate pricing (suppliers reprice on
volumetric weight), insurance, and customs. `value` accepts a decimal
string or a number (the `field_schema` types it `number`); prefer the
decimal string, matching the §3 money convention.

`confirm_and_pay` (default `true`) controls **supplier-side
fulfilment settlement** — whether MeshHub's own account with the
courier is charged at booking so the shipment proceeds immediately.
It is not your customer's payment: the customer still pays through
`POST /payments/pay-order/` (§6.12) exactly like every other
category. Set it `false` only if you deliberately want the shipment
held as a supplier-side draft.

**Item kinds for shipping** (`items[].key`): `delivery`,
`transportation_type`, `package_type`, `pudo_point`. Note the shipping
catalogue's items are `purchasable_at: "booking"` — discover them via
get-extras, then pass the chosen values in the **book** request's
`extras` (apply-extras on an existing shipping order returns
`EXTRAS_PRE_BOOKING_ONLY`).

**Result top-level fields** (in addition to the common ones):

| key                   | type            | notes                                                   |
| --------------------- | --------------- | ------------------------------------------------------- |
| `origin_country`      | string \| null  | ISO 3166-1 alpha-2; echoes the search `origin`          |
| `destination_country` | string \| null  | ISO 3166-1 alpha-2; echoes the search `destination`     |
| `weight_kg`           | decimal string  | echoes the search `weight_kg`                           |

**Result `extras`**: empty object. The route is echoed via the
top-level `origin_country` / `destination_country` / `weight_kg`
fields above. Quotes without dimensions are estimates: EcoParcel
reprices at book using volumetric weight, which is the main cause of
quote↔book drift on this supplier.

### 14.3 `category: "hair_salon"` and salon-like categories

`hair_salon`, `massage_center`, `beauty_salon`, `nail_salon`, `spa`,
`fitness`, `dental_clinic`, `pet_service`, etc. are all live,
booking-enabled salon-vertical categories that share the same contract.
Search runs per category — you pass one `category` and get offers from
the active suppliers in that category (results are not merged across
categories); each returned offer is labelled with the category you
searched.

**Search `extra` is strictly validated** — unknown keys return a 400.
Recognised filters:

- `query` — free-text end-user intent (e.g. *"balayage for thin hair"*).
  When the catalogue is embedded, results are ranked by semantic
  similarity (cosine, OpenAI `text-embedding-3-small`) and then by
  price. When the catalogue has not been embedded yet, falls back to
  case-insensitive substring matching across service name, description,
  service category, supplier name/description, and staff name. Max 500
  chars.
- `city` — exact match on the supplier city (case-insensitive).
- `service_category` — substring match on the service category
  (case-insensitive).
- `staff_name` — substring match on the stylist name
  (case-insensitive).
- `max_duration_minutes` — only services lasting at most this many
  minutes.
- `max_price` — only services priced at or below this amount. Send a
  decimal-as-string (e.g. `"45.00"`, matching how prices come back in
  responses); a plain JSON number is also accepted.
- `supplier_data` — optional object (§14 intro); catalogue-based
  suppliers ignore it when matching.

**Book `extra`** (strict — unknown keys return a 400):

- `appointment_at` — **required**. Date and time the customer wants the
  service, as an ISO-8601 date-time `YYYY-MM-DDTHH:MM:SS`
  (e.g. `"2026-06-10T14:30:00"`). Seconds are optional — the
  seconds-less form HTML `datetime-local` inputs produce
  (`"2026-06-10T14:30"`) is accepted as-is. Use the salon's local time;
  include a UTC offset only if known.
- `contact` — optional free-form object.
- `supplier_data` — optional object, stored verbatim on the order's
  audit payload (§14 intro).

`apply_extras` is not supported for this category — any call returns
400.

**Result `extras`**:

| key                    | type                   | notes                                              |
| ---------------------- | ---------------------- | -------------------------------------------------- |
| `title`                | string                 | service name                                       |
| `description`          | string                 | service description (may be empty)                 |
| `service_category`     | string                 | salon-defined category label                       |
| `duration_minutes`     | int                    |                                                    |
| `city`                 | string                 | supplier city                                      |
| `supplier_name`        | string                 |                                                    |
| `supplier_description` | string                 | may be empty                                       |
| `supplier_image_url`   | string \| null         | absolute URL to the supplier's photo               |
| `staff`                | list[object]           | each entry `{name: string, image_url: string \| null}` — stylists who can perform this service |

### 14.4 `category: "car_rental"`

> **Booking mode:** the current car-rental catalogue books in
> `enquiry` mode — every `/book/` lands the order `pending` until the
> rental desk confirms, and payment happens only after confirmation.
> Design the UX around "request sent", not instant checkout (§6.3).

> **The category value is `car_rental`** (underscore), even though the
> discovery URLs use hyphens (`/api/v1/car-rentals/cities/`, §6.6).
> `"category": "car-rental"` returns a 400.

Complete search request:

```json
{
  "category": "car_rental",
  "currency": "EUR",
  "extra": {
    "city": "Vilnius",
    "transmission": "automatic",
    "min_seats": 4,
    "max_price": "50.00"
  }
}
```

**Search `extra` is strictly validated** — unknown keys return a 400.
All listed filters are optional and combine with logical AND.

**Search `extra` (filters):**

| key                   | type            | notes                                                                 |
| --------------------- | --------------- | --------------------------------------------------------------------- |
| `query`               | string          | free-text end-user intent (e.g. *"cheap automatic for the airport with 3 kids"*). Ranked by semantic similarity (OpenAI `text-embedding-3-small`) when the catalogue is embedded; otherwise falls back to substring matching. Max 500 chars. |
| `vehicle_class`       | string          | case-insensitive equality against the vehicle class name              |
| `transmission`        | string          | `manual` or `automatic`                                               |
| `city`                | string          | case-insensitive equality against any of the vehicle's pickup cities  |
| `cities`              | string[]        | matches any vehicle that has a location in at least one listed city   |
| `pickup_point`        | string          | case-insensitive substring match against the pickup-point label       |
| `min_seats`           | integer         | minimum seat count                                                    |
| `min_doors`           | integer         | minimum door count                                                    |
| `min_small_bags`      | integer         | minimum small-bag capacity                                             |
| `min_large_bags`      | integer         | minimum large-bag capacity                                             |
| `min_price`           | string          | inclusive lower bound on `price_per_day`; decimal-as-string, e.g. `"20.00"` (a JSON number is also accepted) |
| `max_price`           | string          | inclusive upper bound on `price_per_day`; decimal-as-string, e.g. `"100.00"` (a JSON number is also accepted) |
| `currency`            | string          | ISO 4217; case-insensitive                                            |
| `unlimited_mileage`   | bool            | filters when present; `false` returns mileage-capped vehicles only    |
| `insurance_type`      | string          | case-insensitive equality                                             |
| `supplier_data`       | object          | optional (§14 intro); catalogue-based suppliers ignore it when matching |

**Search result `extras` shape:**

Each result returned by the car-rental search includes the full vehicle
projection in `extras`, so tenants can render a complete listing card
without a follow-up call.

| key                          | type           | notes                                                            |
| ---------------------------- | -------------- | ---------------------------------------------------------------- |
| `name`                       | string         | internal vehicle name                                            |
| `example_model`              | string \| null | tenant-facing example model label (`null` when unset)            |
| `vehicle_class`              | string         | vehicle class name (mirrors top-level `product`)                 |
| `seats`                      | integer        |                                                                  |
| `doors`                      | integer        |                                                                  |
| `small_bags`                 | integer        |                                                                  |
| `large_bags`                 | integer        |                                                                  |
| `transmission`               | string         | `manual` or `automatic`                                          |
| `unlimited_mileage`          | bool           |                                                                  |
| `insurance_type`             | string \| null |                                                                  |
| `included_liability_amount`  | string \| null | decimal-as-string (e.g. `"1500.00"`); `null` when not configured |
| `currency`                   | string         | ISO 4217                                                         |
| `image_url`                  | string \| null | absolute URL to the vehicle image, or `null` when no image       |
| `cities`                     | string[]       | sorted unique cities across all locations                        |
| `locations`                  | object[]       | `[{city, pickup_point}, ...]` for every location                 |

**Book `extra`** (strict — unknown keys return a 400):

- `date_from` — **required**. Date the customer wants to pick up the
  car, as an ISO-8601 calendar date `YYYY-MM-DD` (e.g. `"2026-06-10"`).
  Must be on or before `date_to`.
- `date_to` — **required**. Date the car will be returned, as an
  ISO-8601 calendar date `YYYY-MM-DD` (e.g. `"2026-06-14"`). Must be on
  or after `date_from`.
- `contact` — optional free-form object.
- `supplier_data` — optional object, stored verbatim on the order's
  audit payload (§14 intro).

`apply_extras` is not supported for this category — any call returns
400.

### 14.5 `category: "hotel"`

Served by `HotelBedsConnector` (HotelBeds / HBX Group APItude bedbank).
Bookings settle on B2B credit, so there is **no `pay()` step** —
settlement mode is `deferred` and the booking is confirmed immediately.

**Search `extra`** (strict — unknown keys return a 400):

| key            | type      | notes                                                                                   |
| -------------- | --------- | --------------------------------------------------------------------------------------- |
| `destination`  | string    | HotelBeds destination code (e.g. `"PMI"`). Provide either `destination` or `hotel_codes`. |
| `hotel_codes`  | integer[] | specific HotelBeds numeric hotel codes; alternative to `destination`                    |
| `check_in`     | string    | **required**. ISO-8601 date `YYYY-MM-DD`; must not be in the past                       |
| `check_out`    | string    | **required**. ISO-8601 date `YYYY-MM-DD`; must be after `check_in`                      |
| `adults`       | integer   | adults in the room (default `2`)                                                        |
| `children`     | integer   | children in the room (default `0`)                                                      |
| `rooms`        | integer   | rooms required (default `1`)                                                            |
| `child_ages`   | integer[] | age of each child at check-in (HotelBeds prices children by age), e.g. `[4, 9]`         |
| `supplier_data`| object    | HotelBeds availability params forwarded verbatim into the supplier payload (§14 intro)  |

Each search result's `offer_id` is the HotelBeds opaque `rateKey`; pass it
to `/book/` (the connector re-checks the rate first, which is mandatory for
`RECHECK` rates and yields the bookable rateKey).

**Search result `extras`** — each hotel offer carries booking/rate fields
plus a **light content summary** (a description and one photo) — enough to
render a result card. The full detail (all photos, amenities, address,
contact) is fetched on demand from the **offer-details endpoint** (§6.19):

| key                     | type      | notes                                                                    |
| ----------------------- | --------- | ------------------------------------------------------------------------ |
| `hotel_code`            | integer   | HotelBeds numeric hotel code. **Pass this to §6.19 for full detail.**    |
| `hotel_name`            | string    | hotel name                                                               |
| `category`              | string    | raw category label, e.g. `"4 STARS"`                                     |
| `star_rating`           | integer   | star count parsed from `category` (e.g. `4`); omitted for non-star types |
| `destination`           | string    | destination name                                                         |
| `zone`                  | string    | zone / area name                                                         |
| `latitude`, `longitude` | number    | hotel coordinates                                                        |
| `room_name`             | string    | room description                                                         |
| `board`                 | string    | board basis, e.g. `"BED AND BREAKFAST"`                                  |
| `rate_type`             | string    | `"BOOKABLE"` or `"RECHECK"`                                              |
| `rate_class`            | string    | HotelBeds rate class                                                     |
| `cancellation_policies` | object[]  | cancellation deadlines/amounts                                           |
| `check_in`, `check_out` | string    | the searched stay dates                                                  |
| `description`           | string    | short hotel description (best-effort — see note)                         |
| `image`                 | string    | one primary photo URL (best-effort)                                      |

`description` and `image` are fetched from the HotelBeds content service and
are **best-effort**: if that service is briefly unavailable, the offer is
still returned with its booking/rate fields and those two keys are omitted.
They are attached to the cheapest hotels in the result set.

Example offer:

```json
{
  "supplier_id": "hotelbeds",
  "offer_id": "20260810|20260814|W|1|24339|DBL.ST|NOR|...",
  "category": "hotel",
  "summary": "Hotel D'Or — DOUBLE OR TWIN STANDARD (ROOM ONLY)",
  "price": "210.50",
  "currency": "EUR",
  "availability": true,
  "booking_mode": "instant",
  "extras": {
    "hotel_code": 24339,
    "hotel_name": "Hotel D'Or",
    "category": "3 STARS",
    "star_rating": 3,
    "board": "ROOM ONLY",
    "rate_type": "BOOKABLE",
    "latitude": 39.374794,
    "longitude": 3.227116,
    "description": "Built around a delightful pool area, this pleasant hotel ...",
    "image": "https://photos.hotelbeds.com/giata/bigger/02/024339/024339a_hb_a_004.jpg"
  }
}
```

To show everything a booking site does (all photos, amenities, full address
and contact), call the **offer-details endpoint (§6.19)** with the offer's
`supplier_id`, `category`, `offer_id` and `extra.hotel_code`.

**Book `extra`** (all optional — holder and a single adult guest are
derived from `end_user` when omitted):

| key                | type     | notes                                                          |
| ------------------ | -------- | -------------------------------------------------------------- |
| `guests`           | object[] | `[{roomId, type: "AD"\|"CH", name, surname}, ...]`. `name`/`surname` follow the person-name character rules (letters, spaces, hyphens, apostrophes — see the `pattern` entry in §6.2). |
| `holder`           | object   | `{name, surname}` override for the booking holder             |
| `remark`           | string   | free-text remark forwarded to the hotel                       |
| `client_reference` | string   | short caller reference stored on the HotelBeds booking         |
| `supplier_data`    | object   | HotelBeds booking params merged into the top level of the supplier payload (§14 intro) |

`apply_extras` is not supported for this category — any call returns
400.

---

# TypeScript SDK (@meshhub/sdk)

# @meshhub/sdk

Official TypeScript SDK for the [MeshHub](https://merchant.meshhub.ai) tenant
API — search, book, pay, and track orders across travel, shipping,
car-rental, hotel, and salon suppliers from one typed client.

Zero runtime dependencies. Node ≥ 18 (uses global `fetch`). Ships ESM + CJS
with full type declarations generated from the live OpenAPI contract.

## Install

```bash
npm install @meshhub/sdk
```

## Setup

```ts
import { MeshHubClient } from "@meshhub/sdk";

const meshhub = new MeshHubClient({
  apiKey: process.env.MESHHUB_API_KEY!, // "mh_..." — keep it server-side only
  baseUrl: "https://merchant.meshhub.ai",
});

await meshhub.health(); // { status: "ok" }
```

> **Never expose the API key in browser code.** Call MeshHub from your
> server (API route, backend) and keep the key in an environment secret.

## Search → book → pay

```ts
// 1. Search offers (async task handled for you)
const { offers, field_schema } = await meshhub.searchAndWait({
  category: "shipping",
  extra: { origin: "DE", destination: "ES", weight_kg: 2.5 }, // ISO 3166-1 alpha-2
});
const offer = offers[0];

// 1a. (Optional) Show full offer detail on demand — the "more details"
//     button. Synchronous, no polling. For hotels, pass hotel_code from the
//     offer's extras. Non-hotel offers return { detail: {} }.
const { detail } = await meshhub.offerDetails({
  supplier_id: offer.supplier_id,
  category: offer.category,
  offer_id: offer.offer_id,
  extra: { hotel_code: offer.extras?.hotel_code },
});
// detail.description, detail.amenities, detail.images, detail.address, ...

// 1b. (Optional, flights) Pre-booking extras catalogue for ONE offer.
//     Every offer's `supported_extras_kinds` says which kinds exist
//     (["seat", "luggage", ...]) at zero cost; the full priced catalogue
//     is opt-in and can take up to ~30s (airline-side seat maps):
if (offer.supported_extras_kinds?.length) {
  const { detail } = await meshhub.offerDetails({
    supplier_id: offer.supplier_id,
    category: "travel",
    offer_id: offer.offer_id,
    extra: { include_extras: true },
  });
  const catalogue = detail.extras as OfferExtrasCatalogue | undefined;
  // catalogue.items (flat, same shape as post-booking extras),
  // catalogue.seat_map, catalogue.prices_indicative === true
}

// 2. Book the offer. Pass your own customer id so MeshHub stores the
//    customer, prefills repeat bookings, and can charge saved cards later.
const order = await meshhub.bookAndWait({
  category: "shipping",
  supplier_id: offer.supplier_id,
  offer_id: offer.offer_id,
  end_user_external_id: "user-42",
  end_user: {
    first_name: "Ada",
    last_name: "Lovelace",
    email: "ada@example.com",
    phone: "+441234567",
  },
  extra: { /* per-category fields — collect what field_schema marks required */ },
  quoted_price: offer.price,
});

// 3. Take payment. Branch on requires_action:
const payment = await meshhub.payments.payOrder(order.supplier_order_id);
if (payment.requires_action) {
  // First payment for this customer: open the Revolut checkout with
  // payment.order_token (JS widget) or redirect to payment.checkout_url.
} else {
  // Saved card charged server-side — nothing to render.
}
// The definitive outcome arrives via the payment_succeeded /
// payment_failed webhook (see below).
```

`searchAndWait` / `bookAndWait` dispatch the operation and poll
`GET /tasks/{id}/` until it finishes — immediately, then backing off
1 s → 2 s → 4 s → 8 s (cap) up to a 120 s budget, so fast searches
resolve in a poll or two and long tasks don't burn your rate limit.
Configurable via `polling` (client-wide) or per call; pass `intervalMs`
for a fixed interval. Use `meshhub.search()` / `meshhub.book()` +
`meshhub.waitForTask()` when you want the task id yourself.

## Order history — no local order store needed

```ts
const page = await meshhub.orders.list({ customer: "user-42", limit: 20 });
const order = await meshhub.orders.get("VMT2");
const timeline = await meshhub.orders.timeline("VMT2");
await meshhub.orders.cancelAndWait("VMT2");
```

## Customer profiles — no local customer store needed

MeshHub is the system of record for your customers too. Key every
customer by your own user id (`end_user_external_id` at booking /
`external_id` here) and render profile pages straight from MeshHub —
keep only credentials and sessions in your own database:

```ts
// Look up a customer by YOUR id (exact match — zero or one result).
const { results } = await meshhub.endUsers.list({ external_id: "user-42" });
const profile = results[0] ?? null;

// Create or update the profile (MeshHub UUID from the lookup above).
await meshhub.endUsers.create({ external_id: "user-42", first_name: "Ada" });
await meshhub.endUsers.patch(profile.id, { phone: "+441234567" });

// Saved cards & payment history for the account page.
const cards = await meshhub.payments.listMethods("user-42");
await meshhub.payments.setDefaultMethod(cards.results[0].id);
const txns = await meshhub.payments.listTransactions({
  end_user_external_id: "user-42",
});
```

All list endpoints (orders, end users, payment methods, transactions)
return the same paginated `{count, next, previous, results}` envelope.

Also available: `meshhub.apiKeys`, `meshhub.carRentals` /
`meshhub.salons` (discovery), `meshhub.searchSchema(category)` (live
per-category search contract for dynamic forms).

## Webhooks

MeshHub pushes order, task, payment, and tracking events to your webhook
URL, signed with HMAC-SHA256. **Verify against the raw request bytes** —
a re-serialised JSON body will not match the signature:

```ts
import express from "express";
import { parseWebhook, WEBHOOK_SIGNATURE_HEADER } from "@meshhub/sdk";

const app = express();

app.post(
  "/webhooks/meshhub",
  express.raw({ type: "application/json" }), // raw body — required!
  async (req, res) => {
    try {
      const event = await parseWebhook(
        req.body, // Buffer (a Uint8Array) — exactly what was signed
        req.header(WEBHOOK_SIGNATURE_HEADER) ?? "",
        process.env.MESHHUB_WEBHOOK_SECRET!,
      );
      switch (event.event_type) {
        case "order_status_changed": // confirmed | paid | failed | cancelled
        case "task_completed":
        case "task_failed":
        case "payment_succeeded":
        case "payment_failed":
        case "payment_refunded":
        case "tracking_updated":
          break;
      }
      res.sendStatus(200);
    } catch {
      res.sendStatus(400); // signature mismatch
    }
  },
);
```

(The `/payments/webhooks/revolut/` path in the API schema is inbound from
the payment provider to MeshHub — your app never calls or receives it.)

## Error handling

Every API failure throws a typed subclass of `MeshHubError`. Branch on the
class (or `error.slug`) — **not** on the HTTP status; supplier and payment
failures arrive as HTTP 400.

| Class                   | `slug`                  | Meaning / action                                  |
| ----------------------- | ----------------------- | ------------------------------------------------- |
| `ValidationError`       | `validation_error`      | Fix the request; see `error.fields`               |
| `SupplierError`         | `supplier_error`        | Supplier rejected/failed; see `error.supplierError` |
| `SupplierTimeoutError`  | `supplier_timeout`      | Supplier slow — retryable                         |
| `PaymentError`          | `payment_error`         | Charge declined / not payable                     |
| `AuthenticationError`   | `authentication_failed` | Missing/invalid API key                           |
| `PermissionDeniedError` | `permission_denied`     | Key lacks the required scope                      |
| `NotFoundError`         | `not_found`             | Unknown id (or another tenant's)                  |
| `RateLimitError`        | `rate_limit_exceeded`   | Back off `error.retryAfterSeconds`                |
| `ServerError`           | `internal_server_error` | MeshHub-side — retryable                          |

Async task failures throw `TaskFailedError` (with the same typed envelope
at `.error`) and `TaskTimeoutError` when polling exceeds its budget.
`error.retryable` is `true` where a retry can plausibly succeed.

## Synchronous mode — one request, no polling

`new MeshHubClient({ ..., executionMode: "sync" })` makes async endpoints
block and return the finished task inline — the whole
search-or-book round trip is a single HTTP request. This is the
simplest way to integrate and a fine default for low-throughput
backends; move to the default async mode (plus webhooks) when you need
to fan out requests or respond faster than the slowest supplier.

You can also switch modes per call, e.g. keep the client async but make
one blocking search:

```ts
const result = await meshhub.searchAndWait(body, { executionMode: "sync" });
```

## Reference

- Full protocol documentation: <https://merchant.meshhub.ai/docs/tenant-integration.md>
- Machine-readable schema: <https://merchant.meshhub.ai/api/schema/?format=json>
- Everything else (examples, AI-builder prompt, llms.txt): <https://merchant.meshhub.ai/docs/>

## Developing this SDK

```bash
npm ci                    # install
npm run generate:types    # regenerate src/generated/openapi.ts from openapi.json
npm run typecheck && npm run lint && npm test && npm run build
```

Types are generated from the repo's OpenAPI document; CI fails if they
drift. Releases: bump `version` in package.json, tag `sdk-v<version>`, and
the publish workflow ships it to npm.

---

# Reference example: thin frontend

# Thin-frontend example — salon shop

A complete, minimal MeshHub shop in three files, demonstrating the
recommended integration pattern:

```
browser (public/)  →  your backend (server.js)  →  MeshHub API
                       @meshhub/sdk + API key      merchant.meshhub.ai
```

**The API key and the SDK live only on the backend.** The browser talks
to your own `/api/*` routes and never sees the key — putting an
`X-MH-Key` header in frontend code would publish your key to every
visitor.

## What it shows

- **Search** (`POST /api/search`): `searchAndWait` handles the async
  task polling; the response's `field_schema` drives the booking form —
  the frontend renders inputs from it instead of hardcoding fields.
- **Book** (`POST /api/book`): rebuilds the collected dotted
  `end_user.*` / `extra.*` field keys into the nested `/book/` payload
  objects (`extra.shipper_details.contact_name` →
  `extra: {shipper_details: {contact_name}}`) and passes the offer's
  `price` through as `quoted_price`. An `enquiry`-mode offer books as a
  *request*: the order stays `pending` until the supplier confirms (via
  webhook), so the UI says "request sent" and skips payment. The
  booking confirmation guard is **server-side** (`confirm: true`
  required) — a frontend-only dialog can be bypassed.
- **Pay** (`POST /api/pay`): `payments.payOrder`; when
  `requires_action` is set the browser is redirected to the hosted
  checkout, otherwise the saved card was charged server-side.
- **Order history** (`GET /api/orders`): `orders.list` filtered by your
  own customer id — no local order database needed.
- **Profile** (`GET`/`PUT /api/profile`): the customer record lives on
  MeshHub too — `endUsers.list({external_id})` resolves your own user id
  to the stored profile, `PATCH`/`create` saves edits. No local customer
  database needed; your DB is for logins and sessions only.
- **Errors**: MeshHub failures throw typed `MeshHubError`s; the backend
  forwards the stable `slug` + message so the UI can show something
  useful.

## Run it

```bash
npm install

# Mint a free 7-day trial key (3/hour per IP — store and reuse it!):
curl -s -X POST https://merchant.meshhub.ai/api/v1/auth/trial-keys/

MESHHUB_API_KEY=mh_your_key node server.js
# open http://localhost:3000
```

On Replit or similar, put `MESHHUB_API_KEY` in the Secrets tool — never
in a committed file.

> ⚠️ Trial keys execute against **live suppliers** — a booking is a real
> booking. Search freely; book only what you mean.
