# MeshHub Merchant Supplier API — Specification v2.9

**Audience:** engineering teams (or AI coding agents) at merchants who want
their inventory bookable through MeshHub. This document is self-contained:
you can implement the entire integration from this file alone.

**What you build:** a small HTTPS API on your side (6 endpoints, 5 mandatory)
that lets MeshHub search your live availability, book, check status, and
cancel. MeshHub calls you; you never have to call MeshHub.

**What MeshHub does:** registers your API's URLs and the API key you issue,
validates your implementation with an automated conformance suite, and then
routes real customer demand to you. Your system remains the single source of
truth for services, calendars, and prices — MeshHub stores none of your
inventory.

---

## 1. Integration overview

```
MeshHub                                   Your API
   │                                          │
   │  GET  search?date=…&master_id=…&q=…      │   live availability, computed
   │ ─────────────────────────────────────▶   │   from your calendar NOW
   │  ◀─ offers [{offer_id, price, slots[]}] ─│
   │                                          │
   │  POST price {offer_id, appointment_at}   │   re-quote just before booking
   │ ─────────────────────────────────────▶   │   (optional endpoint)
   │  ◀──── {price, valid_until, …} ───────── │
   │                                          │
   │  POST book {offer_id, appointment_at,    │   create the reservation
   │             end_user}                    │
   │ ─────────────────────────────────────▶   │
   │  ◀──── {supplier_order_id, status} ───── │
   │                                          │
   │  GET  booking/{supplier_order_id}        │   MeshHub polls for status
   │ ─────────────────────────────────────▶   │   changes (no webhooks)
   │                                          │
   │  POST booking/{supplier_order_id}/cancel │
   │ ─────────────────────────────────────▶   │
```

Key design points:

- **Opaque offers.** Your `search` returns offers, each carrying an
  `offer_id` — an opaque string only *you* can decode (sign it; see §7.2).
  MeshHub echoes it back at `price`/`book` time. MeshHub never parses it,
  so your internal identifiers and slot model stay private and you can
  change them freely.
- **An offer is a combination, not a time.** One offer = one service, by
  one staff member, at one place — carrying every time it is free in its
  `slots` array. The buyer picks *what and with whom* first, then *when*,
  and sends the chosen time back as `appointment_at`.
- **Live pull, no sync.** Availability is computed by you at the moment of
  each search. There is no inventory push, no cache to invalidate, no
  staleness window.
- **Poll-based status.** After booking, MeshHub polls your status endpoint.
  You do not need to push events to MeshHub (see §11, Tier 3).
- **You issue the API key.** You authenticate MeshHub, not the other way
  around. Generate an API key, configure your API to require it, and hand
  it to MeshHub during onboarding.

## 2. Spec versioning

- This document is **spec v2.9**. The recommended URL prefix remains
  `/api/supplier/v1/` — see the note below.
- **Additive changes** (new optional fields, new optional endpoints) bump
  the minor version (2.x) and will never break you: **MeshHub guarantees it
  ignores unknown fields in your responses**, and you MUST ignore unknown
  fields in MeshHub's requests.
- **Breaking changes** get a new major version and, normally, a new URL
  prefix so both can be served side by side. MeshHub will coordinate
  migrations with you; a version will not be turned off without notice.
- **Exception — the 1.0 → 2.0 change kept the `/v1/` prefix.** 2.0 was
  published while no merchant had yet built against 1.0, so there was
  nothing to run side by side and minting a `/v2/` prefix would have cost
  every future integrator a rename for no benefit. If you are integrating
  now, build the current 2.x at the `/api/supplier/v1/` paths. Later
  breaking changes will bump the prefix as described above.
- Changelog at the bottom of this document.

## 3. Endpoint URLs are yours to choose

The paths used throughout this document are the **standard defaults**:

| Operation | Default path |
|---|---|
| ping | `GET  {base_url}/api/supplier/v1/ping/` |
| search | `GET  {base_url}/api/supplier/v1/search/` |
| price | `POST {base_url}/api/supplier/v1/price/` |
| book | `POST {base_url}/api/supplier/v1/book/` |
| status | `GET  {base_url}/api/supplier/v1/booking/{supplier_order_id}/` |
| cancel | `POST {base_url}/api/supplier/v1/booking/{supplier_order_id}/cancel/` |
| partners | `GET  {base_url}/api/supplier/v1/partners/` |

If you are building from scratch, use the defaults. If you have an existing
API, you may instead register a **custom URL per operation** during
onboarding (URL templates; `{supplier_order_id}` is the only placeholder).
The HTTP method, payloads, envelopes, and semantics in this document are
**not** negotiable — only the URLs are.

## 4. Transport and authentication

- **HTTPS only.** Plain HTTP is rejected at registration.
- **Auth:** every request from MeshHub carries the API key you issued in
  the `X-Api-Key` header. Requests with a missing or wrong key MUST get
  HTTP 401 with the `AUTH_FAILED` error envelope (§5). Store keys hashed
  at rest if you can.
- **Rate limiting:** allow MeshHub at least **120 requests/minute**. If you
  throttle, return HTTP 429 with the `RATE_LIMITED` envelope; a
  `Retry-After` header is respected.
- **Encoding:** JSON request/response bodies, UTF-8.
- **Timestamps:** ISO-8601 **with UTC offset**, e.g.
  `2026-08-01T14:30:00+03:00` or `2026-08-01T11:30:00Z`. Naive local
  timestamps are not compliant.
- **Money:** decimal **strings** (`"25.00"`, never the float `25.0`).
  `currency` is an ISO-4217 code; this spec supports `EUR`, `USD`, `GBP`.
- **Identifiers:** `offer_id` and `supplier_order_id` are strings up to 255
  chars. (If your internal ids are integers, serialize them as strings.)
- **Timeouts:** MeshHub calls you with a 15 s timeout for `search` and 30 s
  for `book`. Answer well within these.

## 5. Error envelope

Every non-2xx response MUST use this body:

```json
{
  "code": "SLOT_TAKEN",
  "message": "That time slot is no longer available.",
  "detail": {}
}
```

`code` is one of the reserved codes below. `message` is human-readable.
`detail` is optional structured context (e.g. per-field validation errors);
`{}` when unused.

| Code | HTTP | When you return it | What MeshHub does on receipt |
|---|---|---|---|
| `AUTH_FAILED` | 401 | Missing/invalid API key | Alerts operators; retries with fixed credentials |
| `VALIDATION` | 400 | Malformed/missing request fields; business rules (e.g. slot in the past, inside minimum booking notice, cancelling a past booking) | Surfaces the problem to the buyer as correctable input |
| `NOT_FOUND` | 404 | Unknown `supplier_order_id`, or the offer's underlying service no longer exists | Treats the order/offer as gone |
| `OFFER_EXPIRED` | 409 | The `offer_id` is stale, tampered, or past its validity | Re-runs search for fresh offers |
| `SLOT_TAKEN` | 409 | The slot was booked by someone else between search and book | Re-runs search for fresh offers |
| `RATE_LIMITED` | 429 | Throttled | Backs off and retries |
| `ERROR` | 5xx | Anything unexpected | Retries with backoff; sustained failures page operators |

Do not invent new codes — map internal errors to the closest reserved
code (fallback: `ERROR`).

## 6. Order status vocabulary

Your API reports order state using exactly these values (MeshHub's
`OrderStatus`):

| Status | Meaning |
|---|---|
| `pending` | Reservation created, awaiting your (or staff) confirmation |
| `confirmed` | Reservation confirmed; customer expected |
| `paid` | Confirmed and paid (only if you track payment) |
| `cancelled` | Cancelled — by MeshHub, by you, or declined/refunded |
| `failed` | Reservation attempt failed terminally |
| `unknown` | State cannot be determined |

Map your internal statuses onto this vocabulary. Worked example (a salon
system): `new → pending`, `approved → confirmed`, `paid → paid`,
`canceled / declined / refunded → cancelled`.

Transitions MeshHub tolerates: `pending → confirmed → paid`,
any state → `cancelled`, `pending → failed`. A status may repeat across
polls; it must never move backwards (e.g. `confirmed → pending`).

## 7. Endpoints

### 7.1 `GET ping/` — liveness (Tier 1, mandatory)

Authenticated no-op proving the full auth/throttle/envelope path works.

Response `200`:

```json
{ "status": "ok" }
```

An invalid key here must produce the standard 401 `AUTH_FAILED` envelope —
the conformance suite checks both.

### 7.2 `GET search/` — live availability (Tier 1, mandatory)

Returns bookable **offers**. One offer = **one bookable combination** —
a service, performed by a specific staff member, at a specific place —
carrying every free time it can be booked at in its `slots` array.

> **Changed in 2.0.** In 1.0 an offer was one *slot*, with `time_start` /
> `time_end` at the offer root. It is now the combination, with a `slots`
> array. This makes the buyer's choice explicit — first *what and with
> whom*, then *when* — and matches how MeshHub presents every salon-like
> supplier. See the changelog for the migration.

**Query parameters.** Support for the first group is **mandatory**: a
conforming implementation MUST apply them, not ignore them. The second
group is optional to support. Parameters outside both lists are unknown
and MUST be ignored.

> **Only `category` is mandatory for every family.** The rest of the
> mandatory group below is the **salon-like family's** set (§9.1). Each
> other family declares its own mandatory parameters in its §9.x block:
> car rental in §9.4, hotel in §9.5, travel in §9.6, shipping in §9.7.
> A parameter outside your family's list follows the unknown-parameter
> rule above — ignore it — so implementing one family never obliges you
> to understand another.

Mandatory to support (salon-like family, §9.1):

| Param | Type | Meaning |
|---|---|---|
| `date` | `YYYY-MM-DD` | Only slots on this day |
| `date_start` / `date_end` | `YYYY-MM-DD` | Only slots in this inclusive range; `date_end` before `date_start` → `VALIDATION` |
| `master_id` | string | Only offers for this staff member / resource |
| `partner_id` | string | Only offers at this place. **Mandatory for aggregators** — any merchant fronting more than one salon, branch or location. A single-location merchant has one `partner` and MAY ignore it. |
| `category` | string | A MeshHub category slug (§9), sent on **every** search call. **Mandatory to honour for multi-category merchants** — return only offers belonging to that category; a slug you declared but have no inventory for, or one you never declared, gets an empty `results`, NOT an error. A merchant registered for a single category only ever receives that one value and MAY ignore the param — the same unknown-params rule that made `master_name` additive. A request without the param (manual testing, old tooling) means "no restriction". |

Optional to support:

| Param | Type | Meaning |
|---|---|---|
| `q` | string | Free-text: service, staff, place, or category name |
| `category_id` | string | Restrict to one of your service categories |
| `city` | string | Location filter (contains, case-insensitive) |
| `master_name` | string | Only offers whose staff member's name contains this, case-insensitive. Optional to support: MeshHub also narrows by staff name itself, so ignoring it costs wasted payload, not correctness. |
| `page` | int, default 1 | Pagination |
| `page_size` | int, default 20, max 100 | Page size |

MeshHub sends the mandatory filters whenever the buyer supplied them, and
relies on them being honoured — a merchant that silently ignores `date`
returns tomorrow's availability for a customer asking about next Saturday.
There is no time-of-day or day-of-week parameter: MeshHub narrows `slots`
on those itself, and drops an offer whose every slot falls outside the ask.

With no date parameters, a **salon-like** merchant returns a bounded
near-term default window of its choosing — starting today, spanning at
most 7 days — and documents that choice; MeshHub narrows dates itself
whenever the buyer supplied any. The other families do not have this
rule: car rental and shipping ignore dates at search entirely, while
hotel and travel cannot price anything without them and answer a
dateless search with `VALIDATION` (§9.5, §9.6). Bound your fan-out
whatever your family: paginate over your inventory and compute per
page, so one request can never trigger unbounded computation.

**Response `200`** — pagination envelope:

```json
{
  "count": 42,
  "page": 1,
  "page_size": 20,
  "results": [
    {
      "offer_id": "eyJzIjo0MSwibSI6MTcsInAiOjV9:1r8x…",
      "slots": [
        {
          "time_start": "2026-08-01T14:30:00+03:00",
          "time_end": "2026-08-01T15:15:00+03:00"
        },
        {
          "time_start": "2026-08-01T16:00:00+03:00",
          "time_end": "2026-08-01T16:45:00+03:00"
        }
      ],
      "duration_minutes": 45,
      "price": "25.00",
      "currency": "EUR",
      "expires_at": null,
      "required_fields": ["end_user.first_name", "end_user.last_name", "end_user.phone"],
      "master": {
        "id": "17",
        "name": "Greta Kazlauskienė",
        "photo": "https://your.host/media/masters/greta.jpg",
        "avatar": "https://your.host/media/masters/greta_sm.jpg",
        "rating": 4.9,
        "rating_count": 132
      },
      "service": {
        "id": "41",
        "name": "Women's haircut",
        "description": "Wash, cut and blow-dry.",
        "category_id": "3",
        "category": "Haircut",
        "duration_minutes": 45,
        "price": "25.00"
      },
      "partner": {
        "id": "5",
        "name": "Salon Vilnius",
        "city": "Vilnius",
        "address": "Gedimino pr. 1",
        "description": "Colour specialists in the old town.",
        "image_url": "https://your.host/media/partners/vilnius.jpg"
      }
    }
  ]
}
```

Field rules — **generic core** (category-independent, MeshHub relies on
these):

- `count` (int, required) — total matching offers across all pages. It
  MAY be a cheap upper bound (e.g. the number of window-having
  combinations counted before slot computation) but MUST be >= the
  offers actually returned across all pages; `results` and page
  exhaustion are authoritative for what exists. MeshHub never surfaces
  `count` to buyers.
- `offer_id` (string, required) — opaque token that must survive a
  round-trip to `price`/`book`. **Sign it** (HMAC or equivalent) so a
  tampered token is detectable → `OFFER_EXPIRED`. Encode whatever you need
  to reconstruct the combination (service + master + partner), never trust
  the client copy. It no longer encodes a time — `appointment_at` carries
  that on `price`/`book`.
- `price` (decimal string, required), `currency` (required). The price MUST
  be **greater than zero**. A service you cannot state a price for is not an
  offer — do not return it, exactly as you would not return one with no free
  time left. Beware the common cause: a price column that defaults to `0`
  makes "the merchant never filled this in" indistinguishable on the wire
  from "this is free", and MeshHub cannot sell either. **MeshHub drops
  offers priced at or below zero**, so returning them only costs you the
  search work.
- `slots` (array, required for the **salon-like family** (§9.1) and
  sent by no other) — every free time this combination can be booked at,
  each `{time_start, time_end}` in ISO-8601 **with a UTC offset**. Order
  them ascending. An offer with no free time left is not returned at all
  rather than returned with `"slots": []`, so that every offer MeshHub
  shows is bookable. The other four families are not appointment-based:
  their offers carry no `slots` at all, and what makes them bookable is
  described in their own §9.x block.
- `price_unit` (string, optional; `total` when absent) — what `price`
  counts. **Car-rental offers MUST send `per_day`** (§9.4), because a
  vehicle is quoted at a daily rate and the rental window is agreed at
  `book`. Every other family leaves it absent: their price is the total
  for what was searched. Order bodies are always totals — `price_unit`
  is an offer field only.
- `expires_at` (ISO-8601 or `null`) — moment the offer stops being
  bookable, if you implement holds/TTL; `null` means "re-validated at book
  time" (the normal case).
- `required_fields` (array of strings, required) — what `book` needs about
  the end customer, using the vocabulary `end_user.first_name`,
  `end_user.last_name`, `end_user.email`, `end_user.phone`.

The `master` / `service` / `partner` blocks are the **family block** of the
salon-like family (§9.1) — the per-family portion of the offer: they exist
so AI buyers can present rich, truthful choices. Include what you have;
`null` for missing optional sub-fields. An empty result set is `200` with
`"results": []` — never an error.

Each other family carries its own block in the same position, specified
in its own section: `vehicle` for car rental (§9.4), `hotel` / `room` /
`stay` for hotel (§9.5), `flight` for travel (§9.6), `shipment` for
shipping (§9.7). The generic core above — `offer_id`, `price`,
`currency`, `expires_at`, `required_fields`, the pagination envelope —
is identical for all of them and never forks.

**These fields are what the buyer's card is built from.** MeshHub
publishes every salon offer under one shape, whatever kind of supplier
produced it, and fills that shape from this block:

| buyer sees          | you send                              |
| ------------------- | ------------------------------------- |
| `title`             | `service.name`                        |
| `description`       | `service.description`                 |
| `service_category`  | `service.category`                    |
| `duration_minutes`  | `duration_minutes` or `service.duration_minutes` |
| `city`              | `partner.city`                        |
| `supplier_name`     | `partner.name`                        |
| `supplier_description` | `partner.description` *(optional)* |
| `supplier_image_url`   | `partner.image_url` *(optional)*   |
| `staff.image_url` (specialist photo) | `master.image_url`, else `master.avatar`, else `master.photo` *(optional)* |

Anything you omit is published as empty, never as a missing key — the
offer stays readable, it just shows less. `partner.description` (a short
blurb about the place) and `partner.image_url` (an absolute URL to its
photo) are the two fields with no other source, so a salon that wants a
picture on the card has to send them here.

The specialist's photo works the same way: buyers read one key,
`staff.image_url`, and MeshHub fills it from your `master` block —
`image_url` first, else `avatar`, else `photo`, all absolute URLs.
Prefer sending `avatar` as a listing-sized thumbnail (roughly 250px) in
addition to any full-size `photo`; a URL that 404s renders as a broken
image on the buyer's card, so only send URLs that resolve.

### 7.3 `POST price/` — re-quote (Tier 2, recommended)

Dry-run of `book`: decode the offer, re-validate the requested slot, return
the current price. A stale offer must surface `OFFER_EXPIRED`/`SLOT_TAKEN`
here exactly as `book` would, so MeshHub can re-search *before* attempting
to book.

Request:

```json
{
  "offer_id": "eyJzIjo0MSw…",
  "appointment_at": "2026-08-01T14:30:00+03:00"
}
```

- `appointment_at` (salon-like family only, §9.1) — which of the
  offer's slots to quote, matched against `slots[].time_start`. The offer
  spans several times, so the token alone no longer identifies one. A value
  that is not one of the offer's current slots → `SLOT_TAKEN`. No other
  family sends it.
- `extra` (object, optional) — the family-specific part of the request,
  where the offer alone does not identify one bookable thing. Its shape
  is fixed by the family of the category the offer came from: absent for
  salon-like (which uses `appointment_at`), `{date_from, date_to}` for
  car rental (§9.4) — the rate is per day, so the window changes the
  quote — and absent for hotel, travel and shipping, whose offers were
  already priced for what was searched.

Response `200`:

```json
{
  "offer_id": "eyJzIjo0MSw…",
  "price": "25.00",
  "currency": "EUR",
  "breakdown": [
    { "label": "Women's haircut", "amount": "25.00" }
  ],
  "valid_until": "2026-08-01T12:30:00+03:00"
}
```

- `breakdown` line amounts must sum to `price`. A flat price is one line.
- `valid_until` — latest moment the offer can still be booked (e.g. slot
  start minus your minimum booking notice).

### 7.4 `POST book/` — create the reservation (Tier 1, mandatory)

Request:

```json
{
  "offer_id": "eyJzIjo0MSw…",
  "appointment_at": "2026-08-01T14:30:00+03:00",
  "client_ref": "mh-7f3a2c91",
  "end_user": {
    "first_name": "Jonas",
    "last_name": "Petrauskas",
    "email": "jonas@example.com",
    "phone": "+37060000000"
  }
}
```

- `appointment_at` (salon-like family only, §9.1) — which slot the
  customer chose, matched against the offer's `slots[].time_start`. A value
  that is no longer free → `SLOT_TAKEN`; one that was never offered →
  `VALIDATION`.
- `extra` (object) — the family-specific booking payload, required
  whenever the offer's `required_fields` names an `extra.*` entry. Its
  shape is fixed by the family: absent for salon-like,
  `{date_from, date_to}` for car rental (§9.4), the guest and holder
  block for hotel (§9.5), `{passengers}` for travel (§9.6), and the
  shipper / consignee / parcels block for shipping (§9.7).
- `end_user` carries the fields you declared in the offer's
  `required_fields`; treat the others as optional.
- `client_ref` (string, optional) — MeshHub's idempotency reference. You
  MAY ignore it if your own idempotency key (below) already satisfies the
  replay rule; if you store it, replays with the same `client_ref` MUST
  return the same order.

Behavior:

1. Decode + re-validate the offer (`OFFER_EXPIRED` / `SLOT_TAKEN` /
   `VALIDATION` as appropriate).
2. **Idempotency (MUST):** derive a stable key from the offer's *contents*
   **plus whatever your family uses to select one booking inside the
   offer** — `appointment_at` for salon-like (e.g.
   `meshhub:{service_id}:{master_id}:{appointment_at}`),
   `extra.date_from` + `extra.date_to` for car rental (e.g.
   `meshhub:{vehicle_id}:{date_from}:{date_to}`), and the offer alone for
   hotel, travel and shipping, whose offers already fix the stay, the
   itinerary and the shipment. Never derive it from the signed token,
   which may differ per issuance. Since 2.0 one salon-like offer covers
   several slots, so the token alone is not a unique booking: two
   bookings of the same offer at different times — or of the same vehicle
   for different windows — are two orders, not a replay. If an order with that key already exists, return it with HTTP
   **200** instead of creating a duplicate. First creation returns HTTP
   **201**. Handle the concurrent-duplicate race (unique constraint +
   catch) — the conformance suite books the same offer *and time* twice and
   requires the same `supplier_order_id` back.
3. Create the customer record if needed (e.g. keyed on phone) and the
   reservation, blocking the slot in your calendar.

Response `201` (created) / `200` (idempotent replay):

```json
{
  "supplier_order_id": "1024",
  "status": "confirmed",
  "price": "25.00",
  "currency": "EUR",
  "expires_at": null
}
```

`status` is your initial state in the §6 vocabulary (`pending` if a human
must approve, `confirmed` if auto-approved).

### 7.5 `GET booking/{supplier_order_id}/` — status (Tier 1, mandatory)

MeshHub polls this after booking. Response `200`: same body as §7.4.

**Scope check (MUST):** only orders created through this API may be
readable here. Ids from your other channels (web, app, walk-in) must return
`NOT_FOUND` — MeshHub must never be able to probe them, especially if your
ids are sequential.

### 7.6 `POST booking/{supplier_order_id}/cancel/` — cancel (Tier 1, mandatory)

No request body. Behavior:

- Cancels the reservation and frees the slot in your calendar.
- **Idempotent (MUST):** cancelling an already-cancelled order returns
  HTTP 200 with its current state — not an error.
- Cancelling a booking whose start time has passed → `VALIDATION`.
- Same scope check as §7.5.

Response `200`: same body as §7.4, with `status` now `cancelled`.

### 7.7 `GET partners/` — your businesses (Tier 3, optional)

Only relevant if you are an **aggregator** — a merchant fronting more than
one salon, branch or location. A single-location merchant can skip this
endpoint entirely.

Today a `partner_id` can only be discovered by running a search, and a
salon-like search must already be anchored on one of `city`,
`staff_name`, `staff_id` or `partner_id` (§9.1) — so there is no way to
enumerate your businesses without already knowing one. That matters
whenever MeshHub has to name one of them up front rather than react to a
search result: configuring a per-business site assistant, for example.

No request parameters. Response `200`:

```json
{
  "partners": [
    {
      "id": "5",
      "name": "Salon Vilnius",
      "city": "Vilnius",
      "address": "Gedimino pr. 1",
      "description": "Colour specialists in the old town.",
      "image_url": "https://your.host/media/partners/vilnius.jpg"
    }
  ]
}
```

- Each entry is exactly the `partner` block already specified inside a
  search offer (§7.2) — `id` and `name` required, the rest optional. If
  you already build that block, you already have this response.
- `id` MUST be the same value you accept as the `partner_id` search
  parameter and return as `partner.id` on offers. A partner listed here
  and not accepted there is worse than not implementing the endpoint.
- List every business you are willing to sell through MeshHub, including
  ones with no current availability. This is a directory, not a search:
  a fully-booked salon still exists.
- **Nothing depends on this endpoint.** It is not part of activation, and
  the conformance suite skips it when absent (a `404` or `405` is a
  legitimate answer). Implement it when you want your businesses to be
  configurable by name rather than transcribed by hand.

## 8. Idempotency summary

| Operation | Rule |
|---|---|
| `search` | Naturally safe; same query may return different offers as availability changes |
| `price` | Read-only dry run; always safe to repeat |
| `book` | Replay of the same offer **at the same booking selector** (or same `client_ref`) → same `supplier_order_id`, HTTP 200. The selector is `appointment_at` for salon-like, `extra.date_from` + `extra.date_to` for car rental, and the offer alone for hotel, travel and shipping. The same offer at a *different* time, or the same vehicle for a *different* window, is a new order. |
| `cancel` | Repeat cancel → HTTP 200 no-op |

MeshHub retries on network failures and 5xx — your `book` WILL occasionally
be called twice for the same offer. The idempotency rules above are what
make that safe.

## 9. Categories and search parameters

MeshHub has a **fixed category taxonomy**, and every supplier — you
included — is registered against it. At onboarding you **declare which
categories you serve** (one or more). Every search MeshHub sends you is
scoped to exactly **one** of your declared categories, carried in the
`category` query parameter (§7.2). MeshHub never merges results across
categories, and it labels every offer with the category it requested it
under.

Every category belongs to exactly one **category family** — a group of
categories that share one integration surface: one buyer-field →
query-parameter mapping and one **family block** on the offer (the
per-family payload of §7.2). The family, not the individual category, is
what you implement: declaring another category from a family you already
serve is configuration, while serving a category from a *new* family
means implementing that family's block and passing its gate (§9.3).
The taxonomy has five families, each with its own section below:
**salon-like** (§9.1), **car rental** (§9.4), **hotel** (§9.5),
**travel** (§9.6) and **shipping** (§9.7). Most merchants implement
exactly one.

The currently declarable categories:

| Slug | Category | Family |
|---|---|---|
| `hair_salon` | Hair salon | salon-like |
| `massage_center` | Massage center | salon-like |
| `health_clinic` | Health clinic | salon-like |
| `beauty_salon` | Beauty salon | salon-like |
| `spa` | Spa | salon-like |
| `fitness` | Fitness | salon-like |
| `dental_clinic` | Dental clinic | salon-like |
| `pet_service` | Pet services | salon-like |
| `nail_salon` | Nail salon | salon-like |
| `tennis_court` | Tennis court | salon-like |
| `child_care` | Child care | salon-like |
| `training` | Training | salon-like |
| `housekeeping` | Housekeeping | salon-like |
| `pet_care` | Pet care | salon-like |
| `nursing` | Nursing | salon-like |
| `refurbishment` | Refurbishment | salon-like |
| `restaurant` | Restaurant | salon-like |
| `car_repair` | Car repair | salon-like |
| `car_rental` | Car rental | car rental |
| `hotel` | Hotel | hotel |
| `travel` | Flights | travel |
| `shipping` | Parcel delivery | shipping |

Four category-shaped concepts appear in this spec — do not conflate
them:

- **MeshHub's `category`** — a slug from the table above; the scope of
  the request (§7.2). This is the only one MeshHub defines.
- **Your `category_id`** — your internal service taxonomy (the optional
  `category_id` search param and the offer's `service.category_id`).
  Unchanged, still entirely yours; MeshHub never maps its slugs onto it.
- **The buyer's `service_category`** — the buyer's free-text ask
  ("beard trim"), still mapped to `q` or `category_id` exactly as today
  (§9.1).
- **The category's family** — which integration surface governs offers
  for that slug (the Family column above; defined at the top of this
  section). Fixed by MeshHub per category; never chosen per request.

### 9.1 The salon-like family

The `hair_salon` … `car_repair` categories above belong to the
**salon-like** family: an appointment-based business whose offers carry
the `master` / `service` / `partner` **family block** of §7.2 and a
`slots` array, and whose buyer-side search fields map to your query
parameters as:

| MeshHub buyer field | Your query param |
|---|---|
| `date` | `date` |
| `date_from` / `date_to` | `date_start` / `date_end` |
| `staff_id` | `master_id` |
| `partner_id` | `partner_id` |
| `query` (free-text intent) | `q` |
| `city` | `city` |
| `service_category` | `q` or `category_id` (your choice of granularity) |
| `staff_name` | `master_name` (MeshHub post-filters `master.name` regardless; the id form, `staff_id`, is the precise one) |
| `time_from`, `time_to`, `days_of_week`, `max_duration_minutes`, `max_price` | applied by MeshHub on your results — no param needed |

The first four map to the mandatory params of §7.2; the fifth mandatory
param, `category`, has no buyer-side field — MeshHub sets it from the
declared category the request is scoped to. MeshHub narrows the rest
itself: it filters your `slots` by time of day and weekday, and drops an
offer whose every slot falls outside the buyer's ask.

### 9.2 Multi-category merchants (aggregators)

A merchant may declare **several** categories — a marketplace fronting
beauty salons and dog groomers, say. If you do:

- You **MUST honour `category`** (§7.2): every search names one of your
  declared slugs, and you return only offers belonging to it.
- Each of your services belongs to exactly **one** declared category,
  kept stable across requests. For a service that genuinely straddles
  two — a dog wash sold by a grooming salon could arguably be
  `pet_service` or `beauty_salon` — you pick one and stick to it.
- The same offer must never surface under two slugs. MeshHub's
  activation validation probes your search once per declared category
  and checks that the returned offer ids are pairwise disjoint; an
  overlap blocks activation.

Worked example: an aggregator declares `beauty_salon` + `pet_service`.
`GET search/?category=beauty_salon` returns its manicure and facial
offers; `GET search/?category=pet_service` returns its dog-grooming
offers. No offer id appears in both responses — not even the dog wash,
which the merchant filed under `pet_service` once and for all.

These rules do not change when your declared categories span more than
one **family** (§9.3) — a salon platform that also rents cars, say: each
search still names exactly one category, you answer it with that
category's family block, and offer ids stay disjoint across all declared
categories, across families exactly as within one. The only additional
cost of a cross-family catalogue is implementing each family's block.
Note that the conformance suite runs the shape, filter and booking
checks **once per family** you declare, so each has to stand on its own.

### 9.3 New families and how they are gated

Each family has its own §9.x block below — a parameter-mapping table
plus the family block its offers carry, just as §9.1 does for the
salon-like family. The `category` value on each request (§7.2) tells you
which family's block governs that request. The generic core never forks: the error
envelope (§5), the status vocabulary (§6), the request/response
envelopes (§7) and the idempotency rules (§8) apply to every family
unchanged.

**A family is an integration surface, and each family is gated
separately.** You can only declare categories whose family has a
published §9.x block; a category from an unpublished family is not in
the §9 table, so there is nothing to declare. When a new family ships, a
merchant expanding into it — say a salon platform that starts renting
out cars — takes on that family's contract as a **new implementation
surface**: the same six logical endpoints at your same registered URLs,
but honouring the new family's parameter mapping and returning its
family block whenever `category` names one of the new slugs. The
expansion is gated exactly like a first integration: run the conformance
suite with the new slugs added to `--category` until green, then MeshHub
re-runs its validation and books a test reservation per newly declared
category (§10) before activating them. Until that pass succeeds the new
categories stay undeclared — and by §7.2 a search for an undeclared slug
returns empty `results`.

Adopting a second family is **additive**. It changes nothing about the
surface serving your existing categories: their parameter mapping,
family block, and offers are untouched, and requests for them behave
exactly as before while the new family's categories await activation.

### 9.4 The car-rental family

The `car_rental` category. A merchant fronting a rental fleet: an offer
is a **vehicle you will rent**, not a time. There are no `slots` — the
vehicle is quoted at a daily rate, and the rental window is agreed at
`book`.

| MeshHub buyer field | Your query param |
|---|---|
| `city` | `city` |
| `pickup_point` | `pickup_point` |
| `vehicle_class` | `vehicle_class` |
| `transmission` | `transmission` (`manual` \| `automatic`) |
| `min_seats` / `min_doors` | `min_seats` / `min_doors` |
| `min_small_bags` / `min_large_bags` | `min_small_bags` / `min_large_bags` |
| `unlimited_mileage` | `unlimited_mileage` (`true` \| `false`) |
| `insurance_type` | `insurance_type` |
| `currency` | `currency` |
| `cities` (a list) | `cities`, comma-separated |
| `query` (free-text intent) | `q` |
| `min_price`, `max_price` | applied by MeshHub on your results — no param needed |

**Mandatory to honour for this family:** `city`, `pickup_point` and
`category`. Sent together, `city` and `pickup_point` must be satisfied by
**one and the same** location — a vehicle collectable from that point in
that city — not by two different ones. MeshHub re-checks this on your
results, so a vehicle whose only Vilnius branch is the airport will not
be shown to someone asking for the city centre.

The date parameters of §7.2 (`date`, `date_start` / `date_end`) do **not**
apply here and MeshHub does not send them: a rental window is agreed at
booking, not searched. Neither do `master_id` or `partner_id`.

`min_price` / `max_price` are deliberately never forwarded. Your price is
the pre-markup one, so a bound applied at your end would admit vehicles
MeshHub then displays above the buyer's limit.

**Offer shape.** On top of the generic core (§7.2):

- `price_unit` (string, **required for this family**) — MUST be
  `"per_day"`. `price` is the **daily** rate. MeshHub publishes it to
  the buyer as one (the buyer's offer carries `price_unit: per_day`),
  takes the window's total from your `book` response, and **checks that
  total against `price × days`**, where days = `date_to − date_from` in
  calendar days, minimum one (`2026-06-10` to `2026-06-14` is four). A
  total more than 5 % away from that is refused as a changed price and
  the booking undone — so a minimum charge, a weekend rate or a
  length-of-rental band must be priced into the daily rate you quote,
  not applied at `book`.
- `vehicle` (object, **required**) — the family block. `id` and `name`
  are required; every other sub-field is optional.
- `partner` (object, optional) — the rental branch, if you front more
  than one.

```json
{
  "offer_id": "eyJ2IjoxMjN9:9a1c…",
  "price": "42.00",
  "price_unit": "per_day",
  "currency": "EUR",
  "expires_at": null,
  "required_fields": [
    "end_user.first_name", "end_user.last_name", "end_user.email",
    "extra.date_from", "extra.date_to"
  ],
  "vehicle": {
    "id": "123",
    "name": "VW Golf or similar",
    "example_model": "VW Golf",
    "vehicle_class": "Compact",
    "seats": 5, "doors": 5, "small_bags": 2, "large_bags": 1,
    "transmission": "automatic",
    "unlimited_mileage": true,
    "insurance_type": "CDW",
    "included_liability_amount": "1500.00",
    "image_url": "https://your.host/media/vehicles/golf.jpg",
    "locations": [
      {"city": "Vilnius", "pickup_point": "Airport terminal 1"},
      {"city": "Kaunas",  "pickup_point": "City centre"}
    ]
  }
}
```

**These fields are what the buyer's card is built from.** MeshHub
publishes every car-rental offer under one shape, whatever kind of
supplier produced it, and fills that shape from this block:

| buyer sees | you send |
|---|---|
| `name` | `vehicle.name` |
| `example_model` | `vehicle.example_model` |
| `vehicle_class` | `vehicle.vehicle_class` |
| `seats` / `doors` / `small_bags` / `large_bags` | the same keys on `vehicle` |
| `transmission` | `vehicle.transmission` |
| `unlimited_mileage` | `vehicle.unlimited_mileage` |
| `insurance_type` | `vehicle.insurance_type` |
| `included_liability_amount` | `vehicle.included_liability_amount` |
| `image_url` | `vehicle.image_url` |
| `locations` | `vehicle.locations` |
| `cities` | the distinct `vehicle.locations[].city` |

**Booking.** `price` and `book` carry no `appointment_at` — there are no
slots. They carry the rental window in `extra`:

```json
{
  "offer_id": "eyJ2IjoxMjN9:9a1c…",
  "extra": { "date_from": "2026-06-10", "date_to": "2026-06-14" },
  "client_ref": "mh-7f3a2c91",
  "end_user": {
    "first_name": "Jonas", "last_name": "Petrauskas",
    "email": "jonas@example.com"
  }
}
```

- `extra.date_from` / `extra.date_to` (both required) — ISO-8601 calendar
  dates `YYYY-MM-DD`, no time component. `date_to` before `date_from` →
  `VALIDATION`. A window the vehicle is not free for → `SLOT_TAKEN`,
  exactly as a taken appointment is under §9.1.
- **Idempotency (§7.4, step 2)** derives from the offer's contents plus
  **both dates**, e.g. `meshhub:{vehicle_id}:{date_from}:{date_to}`. The
  same vehicle for a different window is a different order.
- The `book` response's `price` is the **total** for the window, not the
  daily rate — the offer's `price × days` as defined above.

**Not part of this family's contract.** Add-ons and upsells — child
seats, additional drivers, insurance upgrades — have no place in the six
endpoints: price them into the daily rate, or express the package in
`insurance_type`. There is no post-booking modification step for any
merchant-hosted supplier.

### 9.5 The hotel family

The `hotel` category. An offer is **one room, on one rate, for the stay
that was searched**. The dates are search parameters rather than a
booking selector, so there is nothing extra to name at `book`.

| MeshHub buyer field | Your query param |
|---|---|
| `destination` | `destination` |
| `check_in` / `check_out` | `check_in` / `check_out` |
| `adults` / `children` / `rooms` | `adults` / `children` / `rooms` |
| `child_ages` (a list) | `child_ages`, comma-separated |
| `hotel_codes` (a list) | `partner_id`, comma-separated |

**Mandatory to honour for this family:** `check_in`, `check_out` and
`category`. A search with no dates cannot be priced, so answer it with
`VALIDATION` rather than guessing a stay.

`check_in` / `check_out` deliberately do **not** reuse the salon-like
`date_start` / `date_end`. Those mean "only slots inside this inclusive
range"; a stay is `[check_in, check_out)` and the departure day is not a
night, so reusing them would be wrong by one night. `hotel_codes` reuses
the existing `partner_id` ("only offers at this place") rather than
inventing a parameter.

**Offer shape.** On top of the generic core (§7.2): `hotel` and `room`
are **required**, `rate` and `stay` optional but strongly recommended —
`stay` is what lets MeshHub show the buyer the nights they asked for.

```json
{
  "offer_id": "eyJoIjoxMDAxfQ:4c2b…",
  "price": "120.00",
  "currency": "EUR",
  "required_fields": [
    "end_user.first_name", "end_user.last_name", "end_user.email"
  ],
  "hotel": {
    "code": 1001, "name": "Hotel Vilnius",
    "category": "4 EST", "star_rating": 4,
    "destination": "Vilnius", "zone": "Old town",
    "latitude": 54.68, "longitude": 25.28
  },
  "room": {"id": "r1", "name": "Double standard", "board": "Bed and breakfast"},
  "rate": {"rate_type": "BOOKABLE", "rate_class": "NOR", "cancellation_policies": []},
  "stay": {"check_in": "2026-09-01", "check_out": "2026-09-03", "adults": 2, "rooms": 1}
}
```

The buyer's card is filled from `hotel.*`, `room.name`, `room.board`,
`rate.rate_type` / `rate.rate_class` / `rate.cancellation_policies`, and
`stay.check_in` / `stay.check_out`.

**Booking.** `extra` carries the guests and the person the room is held
for: `guests` (a list of `{type, name, surname}`), `holder`
(`{name, surname}`), an optional `remark`, and an optional
`client_reference` which MeshHub forwards as `client_ref`. Idempotency
is on the offer alone — the stay is already fixed by it.

**One thing MeshHub cannot do for you here.** There is no offer-details
endpoint among the six, so a rich hotel card has to arrive on the offer
itself. Put a `description` and an `image_url` on the `hotel` block if
you have them.

### 9.6 The travel family

The `travel` category. An offer is **one priced itinerary**, bounded by
`expires_at`. There is no booking selector: the flights are fixed by the
offer, and who is flying rides in `extra`.

| MeshHub buyer field | Your query param |
|---|---|
| `origin` / `destination` | `origin` / `destination` |
| `date_from` / `date_to` | `date_start` / `date_end` |
| `trip_type` | `trip_type` |
| `cabin` | `cabin` |
| `airlines` | `airlines` |
| `flexibility` | `flexibility` |
| `passengers` (a list) | `adults`, `children`, `infants`, plus `passenger_ages` for entries that state an age |

**Mandatory to honour for this family:** `origin`, `destination`,
`date_start` and `category`. A search with no route or no date cannot be
priced — answer it with `VALIDATION`.

**Offer shape.** On top of the generic core (§7.2), a required `flight`
block:

```json
{
  "offer_id": "eyJmIjoiV1oxMjMifQ:8d0a…",
  "price": "180.00",
  "currency": "EUR",
  "expires_at": "2026-08-22T18:00:00+03:00",
  "required_fields": [
    "end_user.first_name", "end_user.last_name", "end_user.email"
  ],
  "flight": {
    "airline": "Wizz Air",
    "origin_airport": "VNO",
    "destination_airport": "LHR",
    "legs": ["VNO 08:00 → LHR 09:20"],
    "departure_time": "2026-09-01T08:00:00+03:00",
    "arrival_time": "2026-09-01T09:20:00+01:00",
    "duration": "3h 0m",
    "price_per_person": "180.00",
    "fare_brand": "WIZZ GO",
    "fare_includes": ["cabin bag"]
  }
}
```

`origin_airport` / `destination_airport` are three-letter IATA codes;
`departure_time` / `arrival_time` are ISO-8601 **with a UTC offset**.
`fare_code`, `fare_brand` and `fare_includes` are optional — leave them
out entirely rather than sending `null`, so a buyer can test presence to
decide whether to render a brand. MeshHub groups offers sharing an
itinerary, so your fare tiers for one route stay together on the card.

**Booking.** `extra.passengers` is a list of
`{firstName, lastName, gender, dateOfBirth}`, with an optional
`extra.contact` address block. `end_user` remains the booker, who may not
be travelling. Idempotency is on the offer alone.

**What this family does not include.** A merchant-hosted travel supplier
sells a **fixed, all-in fare**. The six endpoints have no
`get_extras` / `apply_extras`, no payment step and no offer-details call,
so **seat maps, baggage add-ons, meals, lounge access and any other
ancillary are not on offer** — MeshHub publishes an empty
`supported_extras_kinds` for you and refuses those requests. Price
everything into the fare, or express what is included in `fare_includes`.
If you hold a record locator, put it on the order body; MeshHub surfaces
it, but does not require it.

### 9.7 The shipping family

The `shipping` category. An offer is **a quote for the parcel described
in the search** — a carrier, a service, and a price for that route and
weight. The shipment is fixed by the search, so there is no booking
selector.

| MeshHub buyer field | Your query param |
|---|---|
| `origin` / `destination` | `origin` / `destination` (ISO-3166-1 alpha-2) |
| `weight_kg` | `weight_kg` |
| `dimensions` `{w, h, l}` | `dim_w`, `dim_h`, `dim_l` |
| `package_type` | `package_type` |
| `transportation_type` | `transportation_type` |

**Mandatory to honour for this family:** `origin`, `destination`,
`weight_kg` and `category`. A quote without a route and a weight is not a
quote — answer it with `VALIDATION`.

**Offer shape.** On top of the generic core (§7.2), a required
`shipment` block:

```json
{
  "offer_id": "eyJzIjoiTFQtTFYtMSJ9:1f6e…",
  "price": "6.50",
  "currency": "EUR",
  "required_fields": [
    "end_user.first_name", "end_user.last_name",
    "end_user.email", "end_user.phone"
  ],
  "shipment": {
    "origin": "LT", "destination": "LV",
    "weight_kg": "1.00",
    "transportation_type": "locker_locker",
    "package_type": "parcel",
    "carrier": "Omniva",
    "service_name": "Locker to locker",
    "transit_days": 2,
    "dimensions": {"w": 10, "h": 10, "l": 10}
  }
}
```

Echo the route and weight you actually quoted — MeshHub publishes them
as typed fields on the offer, and a buyer comparing two quotes needs to
know they are for the same parcel.

**Booking.** `extra` carries `shipper_details` and `consignee_details`
(each `{contact_name, phone, email_address, address, city, postcode,
country}`) plus `parcels`. A `package_type` or `transportation_type` that
contradicts the quoted offer → `VALIDATION`. Idempotency is on the offer
alone.

**What this family does not include.** A merchant-hosted courier
**quotes and books; it does not track, and it hands back no label.**
Neither has an endpoint among the six, so a tenant receives an order id
and nothing to print. If you have a tracking number or a label URL, put
them on the order body — MeshHub polls `booking/{id}/` and surfaces
what is there. There is no event push.

One behavioural note: settlement here is deferred, so **`book` is what
engages your courier**. There is no separate payment step that confirms
the shipment later.

## 10. Conformance and go-live

MeshHub provides a standalone conformance script (Python, no framework
dependencies) that exercises everything above against your implementation:

```bash
python merchant_api_conformance.py --base-url https://staging.your.host --api-key <key>
```

It checks: ping (valid + invalid key), search envelope and offer field
types (including a non-empty `slots` array with offset-aware timestamps),
**that the mandatory filters are honoured** — `date=` returns only slots on
that day, `master_id=` only that master's offers — price round-trip, book
idempotency (same offer *and* `appointment_at` twice → same
`supplier_order_id`), status vocabulary, cancel idempotency, and every
error-envelope code. Since 2.1 it also checks that a no-date search stays
within the bounded default window of §7.2 (slots within
[today, today + 7 days] in your own timezone offsets), that `count` is >=
the offers actually returned across all pages, and `master_name=`
narrowing — the latter is optional to support, so a merchant that clearly
ignores it is reported as **INFO**, not failed. Since 2.2 it also takes a
repeatable **`--category <slug>`** flag — pass your full declared set
(§9) — and then probes search once per declared category (each must
return offers from your staging inventory), checks cross-category
isolation (no offer id under two slugs, §9.2) when you declare more than
one, and checks that an undeclared slug returns empty `results`; a
single-category merchant that clearly ignores the param is reported as
**INFO**, not failed. Since 2.3 the offer-shape checks are dispatched by
each declared category's **family** (§9), and since 2.8 the shape,
filter and booking phases run **once per family** you declare, each
scoped to that family's own slugs — so a cross-family merchant has both
contracts checked rather than whichever answered first. Pass every slug
you declare: without `--category` only the generic core runs. Results come in a
PASS/FAIL tier plus an informational INFO/SKIP tier; only FAIL blocks
activation. Iterate until green.

**What MeshHub checks at activation.** Two runs, and both must be clean
before you can be switched on:

1. **Your live API** — an authenticated `ping` and one `search` probe per
   declared category (plus, for multi-category merchants, that no offer
   id appears under two slugs). This proves the production config you
   registered actually answers.
2. **Your staging API** — *this whole suite*, executed by MeshHub from
   the same file you downloaded. It books, re-books, cancels and probes
   error envelopes, which is why it needs a staging host and why we will
   not run it against your production one.

So register a **staging base URL and key** as well as production: without
them the second run cannot happen and you cannot be activated. Passing
locally means activation will pass, because it is the same code.

**Run it against your staging environment** — it creates and cancels real
reservations.

Go-live checklist (what you send MeshHub):

1. ✅ Conformance suite green against your staging.
2. The list of MeshHub categories you serve (§9) — one or more slugs.
3. Staging base URL (+ any custom endpoint URLs, §3) and a staging API key.
   **Required** — MeshHub re-runs the suite here before activating you.
4. Production base URL (+ custom URLs) and a production API key.
5. An OpenAPI schema URL for your implementation, if you have one (nice to
   have, not required).
6. A technical contact for incidents.

MeshHub then registers you, runs its activation checks against your
production config, books an end-to-end test reservation per declared
category, and activates you.

You can also do most of this yourself: sign up at the
[supplier portal](https://merchant.meshhub.ai/supplier-portal/signup/),
tick **"I have my own booking API"**, and the API integration screen
collects your base URL, any custom endpoint URLs and your API key (write
only — it goes straight to our vault and is never shown back), then runs
the activation checks on demand and shows you the report. Approval by
MeshHub is the last step and the only one that is not self-service.

## 11. Capability tiers

| Tier | What | Status |
|---|---|---|
| **Tier 1 — mandatory** | `ping`, `search`, `book`, status, `cancel`; error envelope; idempotency rules; honouring **your family's** mandatory search filters and returning **your family's** offer shape — `slots[]` for salon-like (§9.1), `vehicle` plus `price_unit: per_day` for car rental (§9.4), `hotel` + `room` for hotel (§9.5), `flight` for travel (§9.6), `shipment` for shipping (§9.7). `category` is mandatory for every family. | Required for activation |
| **Tier 2 — recommended** | `price` re-quote; `expires_at` on offers | Strongly recommended; MeshHub books more confidently with them |
| **Tier 3 — optional** | `GET partners/` (§7.7) — aggregators only | Build it when you want your businesses configurable by name; nothing depends on it |
| **Tier 3 — reserved** | Status-change webhooks (you push HMAC-signed events to a MeshHub callback URL), `GET capabilities/` self-description, slot holds with TTL | **Do not build these yet.** They are reserved for a future spec revision; this one is deliberately poll-only. |

## Changelog

- **2.9 (2026-09-03)** — **Clarification of §9.4. Nothing new to
  implement if your car-rental `book` total already is `price × days`.**

  1. **The car-rental window's day count is now defined:** `date_to −
     date_from` in calendar days, minimum one. MeshHub checks a car-rental
     `book` response's `price` against the offer's daily `price × days` and
     refuses a total more than 5 % away, so minimum charges and length
     bands belong in the daily rate. 2.8 said MeshHub "multiplies by the
     rented days itself" without saying what for, or what a day was; this
     is what for.
  2. The conformance suite's `book` flow checks that arithmetic for the
     car-rental family.

- **2.8 (2026-08-22)** — **Additive. Four new families; nothing an
  existing salon-like merchant built changes.**

  1. **Four new declarable families** — car rental (§9.4), hotel (§9.5),
     travel (§9.6) and shipping (§9.7). Each has its parameter-mapping
     table and its offer family block, exactly as §9.1 does for
     salon-like, and each is gated separately as §9.3 always said. The
     §9 category table gains their slugs.
  2. **`slots` and `appointment_at` are now family-conditional in fact,
     not only in wording.** 2.3 scoped them to the salon-like family
     while every declarable category was in it; 2.8 publishes four
     families that carry neither. If you are salon-like, nothing
     changes: `slots` is still required of you.
  3. **New optional offer field `price_unit`** (`total` | `per_day`,
     `total` when absent). Only car-rental offers must set it, to
     `per_day`, because a vehicle is quoted at a daily rate.
  4. **New optional `extra` object on `price` and `book`** — the
     family-specific part of the request, for a family where the offer
     alone does not identify one bookable thing. Car rental carries the
     rental window there; salon-like does not use it at all.
     `required_fields` may now name `extra.*` entries alongside the
     `end_user.*` ones.
  5. **§7.2's mandatory-parameter list is the salon-like family's.**
     Only `category` is mandatory for everyone; each family declares its
     own in its §9.x block. Likewise the bounded no-date default window
     is salon-like's rule — hotel and travel reject a dateless search
     with `VALIDATION`, and car rental and shipping do not take dates at
     search at all.
  6. **§9.3 is retitled** "New families and how they are gated". Same
     rules, no longer written in the future tense. §9.1, §9.2 and §9.3
     keep their numbers; the new blocks were appended as §9.4–§9.7.
  7. **The conformance suite runs the family checks once per declared
     family.** A merchant declaring categories from two families now has
     both offer shapes checked; previously a cross-family run fell back
     to the generic core. Pass every slug you declare to `--category`.

- **2.7 (2026-08-14)** — **Additive. Optional; nothing you already built
  changes.**

  1. **New optional endpoint `GET partners/` (§7.7)** for aggregators —
     the businesses you front, as a directory. Each entry is the
     `partner` block you already return on offers (§7.2), so if you built
     that, the response is the same shape.
  2. **Why it exists:** a `partner_id` was only discoverable by running a
     search, and a salon-like search must already be anchored on a place
     or a person — so there was no way to enumerate your businesses
     without already knowing one. That blocks anything MeshHub has to
     name a business for up front, such as a per-business site assistant.
  3. **Not part of activation.** The conformance suite skips it when
     absent; a `404` or `405` is a legitimate answer.

- **2.6 (2026-08-12)** — **A staging environment is now required to go
  live.** Nothing changes in what you implement.

  1. **MeshHub runs the full conformance suite against your staging**
     before activating you (§10) — the same file you run, executed by us.
     2.5 corrected this section to admit we only probed `ping` and
     `search`; now the original promise is true.
  2. **Register a staging base URL and key** alongside production (§10
     checklist). Without them the run cannot happen and activation is
     blocked. The suite books and cancels real reservations, so it will
     not be pointed at your production host.
  3. Only a **FAIL** blocks you. INFO (optional behavior you chose not to
     implement) and SKIP (a check your staging inventory could not
     exercise) do not.

- **2.5 (2026-08-12)** — **Documentation only. Nothing to implement.**

  1. **This specification is now published** at
     <https://merchant.meshhub.ai/docs/merchant-api-spec.md>, alongside an
     [OpenAPI 3 schema](https://merchant.meshhub.ai/docs/merchant-api-openapi.json)
     you can generate a server skeleton from, the
     [conformance suite](https://merchant.meshhub.ai/docs/merchant_api_conformance.py),
     and an
     [AI-builder prompt](https://merchant.meshhub.ai/docs/merchant-ai-builder-prompt.md).
     Previously all of it was sent by email during onboarding.
  2. **§10 corrected.** The spec claimed MeshHub re-runs this whole suite
     before activating you. It does not — it verifies `ping` and a
     `search` probe per declared category, and trusts your own green run
     for the booking-flow checks. §10 now says what actually happens.
  3. **§10 documents the self-service route** through the supplier
     portal, which existed but was not mentioned here.

- **2.4 (2026-08-06)** — **Restriction.** One rule tightened; check your
  `search` results against it.

  1. **An offer's `price` must be greater than zero** (§7.2) — previously
     the spec asked only for a decimal string, so `"0.00"` was legal and
     the conformance suite passed it. It no longer is: a service with no
     stated price is not a sellable offer, and MeshHub now drops offers
     priced at or below zero rather than publishing them to buyers.
  2. **Conformance suite** (§10) — the offer check now fails a
     non-positive price.

  Migrating: if your price column has a zero default (the usual cause),
  exclude unpriced services from `search`, and from whatever your
  `price`/`book` endpoints resolve an `offer_id` to, so a token issued
  earlier cannot still be booked at zero.

- **2.3 (2026-07-24)** — Editorial/definitional; **no wire change, no
  merchant action required.**

  1. **Category families named** (§9) — every category belongs to
     exactly one *family*, a group sharing one integration surface
     (parameter mapping + family block); the category table gains a
     Family column. Today's single family is **salon-like** (§9.1,
     retitled). What §7.2 called the "category block" is now the
     **family block**.
  2. **New-family gating made explicit** (§9.3, retitled "Future
     families") — a family is a separately gated integration surface:
     categories of an unpublished family cannot be declared, and a
     merchant expanding into a newly published family implements that
     family's block as a new surface and passes conformance plus
     per-category activation validation before those categories go
     live. Adoption is additive; the existing surface is untouched.
  3. **Family-qualified wording** (§7.2, §11) — `slots[]` and the
     Tier-1 offer-shape mandate are scoped to the salon-like family
     instead of stated universally. The generic core (§5–§8) is
     unchanged and family-independent.
  4. **Conformance suite** (§10) — offer-shape checks are dispatched by
     category family internally; identical behaviour while salon-like
     is the only family.

- **2.2 (2026-07-23)** — Additive; no migration required for existing
  single-category merchants.

  1. **New search parameter `category`** (§7.2) — a MeshHub category
     slug, sent on every search call. Mandatory to honour for
     multi-category merchants (return only offers belonging to it; a
     declared-but-empty or undeclared slug → empty `results`, not an
     error). A single-category merchant only ever receives its own slug
     and MAY ignore the param — the same unknown-params rule that made
     `master_name` additive.
  2. **§9 restructured** — the intro now covers declared categories (the
     fixed taxonomy, one-or-more declaration at onboarding, one category
     per search) and disambiguates MeshHub's `category` from your
     `category_id` and the buyer's `service_category`; §9.1 keeps the
     salon-like param-mapping table and category block; §9.2 adds the
     aggregator rules (honour `category`, one stable category per
     service, no offer under two slugs); §9.3 reserves per-category
     §9.x blocks for future families.
  3. **Conformance suite** (§10) — new repeatable `--category` flag,
     per-declared-category search probes, a cross-category isolation
     check, and an undeclared-slug-returns-empty check.

- **2.1 (2026-07-23)** — Additive; no migration required.

  1. **New optional search parameter `master_name`** (§7.2) — narrows to
     offers whose staff member's name contains the value,
     case-insensitive. Optional to support: MeshHub post-filters
     `master.name` itself, so ignoring it costs wasted payload, not
     correctness. The buyer-side `staff_name` field now maps to it (§9).
  2. **Bounded no-date default** (§7.2) — with no date parameters,
     return a near-term default window of your choosing — starting
     today, spanning at most 7 days — and document your choice. This
     replaces 2.0's "default to today"; returning only today remains
     conformant.
  3. **`count` semantics** (§7.2) — `count` MAY be a cheap upper bound
     (e.g. window-having combinations before slot computation) but MUST
     be >= the offers actually returned across all pages; `results` and
     page exhaustion are authoritative. MeshHub never surfaces `count`
     to buyers.
  4. **Conformance suite** (§10) — new checks for the three points
     above, and an INFO/SKIP result tier so optional-to-support behavior
     is reported without failing the run.

- **2.0 (2026-07-22)** — **Breaking.** An offer is now a *combination*
  (service + master + place) carrying its free times, not a single slot,
  and the availability filters became mandatory to support.

  Migrating from 1.0:

  1. **`search` offers** — replace the root `time_start` / `time_end` with
     a `slots: [{time_start, time_end}]` array, and collapse what were N
     per-slot offers for one service+master+place into **one** offer with N
     slots. `offer_id` now identifies the combination, not the time, so it
     no longer needs the slot encoded in it.
  2. **`price` and `book`** — accept `appointment_at` in the request body
     and use it to select the slot. Reject a time that is not currently in
     the offer's `slots` with `SLOT_TAKEN` (gone) or `VALIDATION` (never
     offered).
  3. **Idempotency** — add `appointment_at` to your booking key. Under 1.0
     the offer token identified one booking; now the same offer at two
     times is two orders.
  4. **Filters** — `date`, `date_start`/`date_end` and `master_id` must be
     applied rather than ignored, and aggregators must also support
     `partner_id`. Re-run the conformance script: it now fails a merchant
     that ignores them.

  No change to `ping`, status, `cancel`, the error envelope, the status
  vocabulary, or the endpoint-URL configuration.

- **1.0 (2026-07-22)** — Initial specification: Tier 1 + Tier 2 contract,
  salon-like categories, poll-only status, per-merchant endpoint URLs.
