{
  "openapi": "3.1.0",
  "info": {
    "title": "Bokko Public API",
    "version": "1.0.0",
    "description": "Bokko appointment booking platform — public REST API v1.\n\n## Auth\n\nAll requests require a Bearer API key issued from the Bokko dashboard.\nKeys are scoped to a single service provider business and carry an explicit capability set.\nFormat: `Authorization: Bearer bk_live_<key>`\n\n## Tenant scope and salonSlug\n\nAPI keys are scoped to a single service provider business. Where `salonSlug` is present\nin a request, it is validated against the key's business scope.\n\n- `salonSlug` is required on endpoints that target a salon directly:\n  `/availability/search`, `POST /bookings`, `/services`, `/staff`.\n- `salonSlug` is not required on booking-resource endpoints\n  (`/bookings/{bookingId}`, `/bookings/{bookingId}/cancel`) — tenant access\n  is enforced via the API key scope and the addressed booking resource.\n- If `salonSlug` does not match the key's business scope → `403 auth.scope_mismatch`\n  (not 404, to avoid leaking the existence of other tenants).\n\n## Idempotency\n\nWrite operations (`POST /bookings`, `POST /bookings/{id}/cancel`, etc.) accept\nan `Idempotency-Key` header (UUID). TTL: 24 hours.\n\nThe idempotency fingerprint is operation-scoped and computed from:\n- business scope (from key)\n- `operationId` (e.g. `createBooking`, `cancelBooking`)\n- path parameters where present (e.g. `bookingId` on cancel)\n- canonical request body (fields sorted, nulls excluded)\n\nReplaying with the same key and matching fingerprint → original response\n(`meta.idempotent: true`). Replaying with the same key but a different\nfingerprint → `409 idempotency.payload_mismatch`.\n\n## Availability disclaimer\n\nResults from `POST /availability/search` are a non-binding point-in-time snapshot.\nSlots are not reserved. A slot may become unavailable between search and booking creation.\nAlways handle `booking.slot_unavailable` at create time.\n\n## Error format\n\nAll errors use a consistent envelope:\n```json\n{\n  \"ok\": false,\n  \"error\": {\n    \"code\": \"{domain}.{reason}\",\n    \"message\": \"Human-readable message.\",\n    \"retryable\": false,\n    \"details\": {}\n  },\n  \"meta\": { \"requestId\": \"req_01HX...\", \"v\": \"1\" }\n}\n```\n\n## Capabilities\n\nAPI keys carry an explicit capability set and a `kind` (owner or guest_partner).\n\n| Capability | Scope | Allowed for `guest_partner`? |\n|---|---|---|\n| `availability.read` | Search available slots | Yes |\n| `booking.create` | Create a booking | Yes |\n| `booking.read` | Read a booking | **No** |\n| `booking.cancel` | Cancel a booking | **No** |\n| `services.read` | List services | Yes |\n| `staff.read` | List staff | Yes |\n| `webhook.manage` | Manage webhook configuration | **No** |\n| `subscription.read` | Read subscription state | **No** |\n\n### Key Kinds\n\n- **owner** (default): Full access to all endpoints granted by the key's capabilities.\n- **guest_partner**: Restricted to guest-facing operations only. Even if granted broader capabilities, a `guest_partner` key is restricted to the following endpoints:\n  - `POST /v1/availability/search`\n  - `POST /v1/bookings`\n  - `GET /v1/services`\n  - `GET /v1/staff`\n\nAdditionally, bookings created by a `guest_partner` key are tagged with `apiSource` metadata containing the partner's API key ID.\n\n## Rate limiting\n\nRequests are rate-limited per API key, per route group, in a rolling 1-minute window.\nWhen the limit is exceeded → `429 rate_limit.exceeded` with `Retry-After` header.\n\n| Route group | Standard tier | Elevated tier | Unit |\n|---|---|---|---|\n| `catalog` | 120 | 600 | requests / minute |\n| `availability` | 30 | 150 | requests / minute |\n| `booking` | 30 | 150 | requests / minute |\n| `webhook` | 10 | 50 | requests / minute |\n| `subscription` | 60 | 300 | requests / minute |\n\nFail-open on internal error (operational abuse protection, not a hard security boundary).\n\n## Versioning\n\nBreaking changes will be released under `/v2/`. Non-breaking additions\n(new optional fields, new endpoints) may be added to v1 without notice.\nDeprecation notice: minimum 90 days before removal.\n",
    "x-rate-limits": {
      "description": "Per-key per-route-group rate limits. Rolling 1-minute window.",
      "groups": {
        "catalog": {
          "standard": 120,
          "elevated": 600
        },
        "availability": {
          "standard": 30,
          "elevated": 150
        },
        "booking": {
          "standard": 30,
          "elevated": 150
        },
        "webhook": {
          "standard": 10,
          "elevated": 50
        },
        "subscription": {
          "standard": 60,
          "elevated": 300
        }
      }
    },
    "x-webhook-events": {
      "description": "Webhook event types dispatched on booking lifecycle changes.",
      "events": [
        {
          "name": "booking.requested",
          "description": "New booking received"
        },
        {
          "name": "booking.confirmed",
          "description": "Booking confirmed"
        },
        {
          "name": "booking.declined",
          "description": "Booking declined"
        },
        {
          "name": "booking.cancelled",
          "description": "Booking cancelled"
        },
        {
          "name": "booking.reschedule_proposed",
          "description": "Appointment modification proposed"
        },
        {
          "name": "booking.reschedule_confirmed",
          "description": "Modified appointment confirmed — both when the guest accepts a proposed new time and when the business moves an already confirmed appointment to a new time"
        },
        {
          "name": "booking.completed",
          "description": "Booking completed"
        },
        {
          "name": "booking.no_show",
          "description": "Guest did not show up"
        }
      ]
    }
  },
  "servers": [
    {
      "url": "https://api.bokko.io/v1",
      "description": "Production"
    }
  ],
  "security": [
    {
      "ApiKeyAuth": []
    }
  ],
  "tags": [
    {
      "name": "Availability",
      "description": "Slot availability search (non-binding snapshot)"
    },
    {
      "name": "Bookings",
      "description": "Booking lifecycle — create, read, cancel"
    },
    {
      "name": "Catalog",
      "description": "Read-only salon service and staff catalog"
    },
    {
      "name": "Subscription",
      "description": "Subscription state — plan, quota, features"
    }
  ],
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "API key obtained from the Bokko dashboard.\nPersonal keys carry delegated user authority (revoked if user loses access).\nIntegration keys carry independent grant authority.\n"
      }
    },
    "parameters": {
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": true,
        "schema": {
          "type": "string",
          "format": "uuid",
          "example": "550e8400-e29b-41d4-a716-446655440000"
        },
        "description": "Client-generated UUID. Idempotency window: 24 hours.\nOperation-scoped fingerprint: tenant + operationId + path params (where present) + canonical body.\nSame key + matching fingerprint → original response (meta.idempotent: true).\nSame key + different fingerprint → 409 idempotency.payload_mismatch.\n"
      },
      "BookingId": {
        "name": "bookingId",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        },
        "description": "Internal opaque booking identifier (Firestore document ID) as returned by\n`createBooking`. Not for guest-facing display — use `publicReference` for that.\n"
      },
      "SalonSlug": {
        "name": "salonSlug",
        "in": "query",
        "required": true,
        "schema": {
          "type": "string",
          "example": "precision-cuts"
        },
        "description": "The URL slug of the target service provider business. Must match the business scope of the API key.\nMismatch → 403 auth.scope_mismatch (not 404).\n"
      },
      "LocationSlug": {
        "name": "locationSlug",
        "in": "query",
        "required": true,
        "schema": {
          "type": "string",
          "example": "downtown"
        },
        "description": "The URL slug of the specific salon location. Required for most operations\nin multi-location salons.\nMismatch or booking disabled → 409 booking.booking_disabled.\n"
      }
    },
    "schemas": {
      "Meta": {
        "type": "object",
        "required": [
          "requestId",
          "v"
        ],
        "properties": {
          "requestId": {
            "type": "string",
            "description": "Unique request correlation ID for support and audit.",
            "example": "req_01HX4K9M2P"
          },
          "v": {
            "type": "string",
            "description": "API version that served this response.",
            "example": "1"
          },
          "idempotent": {
            "type": "boolean",
            "description": "Present and true when this response was replayed from a previous\nidempotent request. Absent otherwise.\n"
          },
          "bookingMode": {
            "type": "string",
            "enum": [
              "groupSession"
            ],
            "description": "Present only on `POST /v1/availability/search` responses when the requested\nservice is a group session; the `data.slots` items are then `GroupSessionSlot`\nobjects. Absent for standard services.\n"
          },
          "cursor": {
            "type": "string",
            "description": "Opaque pagination cursor. Present on list responses when a next page\nexists; absent when the current page is the last one or on non-list\nendpoints. Pass the value as the `cursor` query parameter on the next\nrequest to fetch the subsequent page.\n\nCursors are query-context bound: a cursor from one filter combination\n(e.g. `status=confirmed`) is invalid for a different filter combination\nand will return `request.invalid_cursor`.\n\nCursor format is opaque and may change across API versions.\n"
          },
          "availabilitySuppressed": {
            "type": "boolean",
            "description": "Only on `searchAvailability` responses. Present and `true` when the\nservice exists but `onlineBookable` is `false` — availability was\nintentionally suppressed rather than genuinely empty. Always accompanied\nby an empty `slots` array.\n"
          },
          "truncated": {
            "type": "boolean",
            "description": "Only on `searchAvailability` responses. Present and `true` when the\nresult was truncated to `slotLimit` because more than `slotLimit` slots\nwere available. Narrow the date range to retrieve all slots.\n"
          },
          "returnedSlots": {
            "type": "integer",
            "description": "Number of slots returned after truncation. Only present when `truncated: true`.\n"
          },
          "slotLimit": {
            "type": "integer",
            "description": "Maximum slots returned per request. Only present when `truncated: true`.\n"
          }
        }
      },
      "ErrorBody": {
        "type": "object",
        "required": [
          "code",
          "message",
          "retryable"
        ],
        "properties": {
          "code": {
            "type": "string",
            "pattern": "^[a-z_]+\\.[a-z_]+$",
            "description": "Stable machine-readable error code in `{domain}.{reason}` format.\nClients should branch on `code`, not on `message`.\n",
            "example": "booking.slot_unavailable"
          },
          "message": {
            "type": "string",
            "description": "Human-readable message. May be localised. Do not parse.",
            "example": "A kiválasztott időpont nem elérhető."
          },
          "retryable": {
            "type": "boolean",
            "description": "If true, retrying the same request (with backoff) may succeed.\nIf false, the request must be changed before retrying.\n"
          },
          "details": {
            "type": "object",
            "additionalProperties": true,
            "description": "Optional structured context. Shape is specific to each error code.\nDocumented per endpoint where applicable.\n"
          }
        }
      },
      "ErrorEnvelope": {
        "type": "object",
        "required": [
          "ok",
          "error",
          "meta"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "enum": [
              false
            ]
          },
          "error": {
            "$ref": "#/components/schemas/ErrorBody"
          },
          "meta": {
            "$ref": "#/components/schemas/Meta"
          }
        }
      },
      "LocationSnapshot": {
        "type": "object",
        "required": [
          "id",
          "locationSlug",
          "publicLocationName",
          "address",
          "timezone"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Internal location identifier."
          },
          "locationSlug": {
            "type": "string",
            "description": "URL slug for this location."
          },
          "publicLocationName": {
            "type": "string",
            "description": "Public-facing name of the location."
          },
          "address": {
            "type": "object",
            "required": [
              "city",
              "street",
              "zip"
            ],
            "properties": {
              "city": {
                "type": "string"
              },
              "street": {
                "type": "string"
              },
              "zip": {
                "type": "string"
              }
            }
          },
          "timezone": {
            "type": "string",
            "description": "IANA timezone of this location."
          }
        }
      },
      "SlotTime": {
        "type": "object",
        "required": [
          "date",
          "startTime",
          "endTime",
          "timezone"
        ],
        "description": "A time slot expressed in the salon's local timezone.",
        "properties": {
          "date": {
            "type": "string",
            "format": "date",
            "example": "2026-04-15"
          },
          "startTime": {
            "type": "string",
            "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$",
            "example": "10:00"
          },
          "endTime": {
            "type": "string",
            "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$",
            "example": "11:00"
          },
          "timezone": {
            "type": "string",
            "description": "IANA timezone identifier. All times in this response are in this zone.",
            "example": "Europe/Budapest"
          }
        }
      },
      "GuestInput": {
        "type": "object",
        "required": [
          "name",
          "phone"
        ],
        "description": "Guest identity for a booking. Not a Bokko user account.",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100,
            "example": "Kiss Péter"
          },
          "phone": {
            "type": "string",
            "description": "Phone number in E.164 format preferred. Hungarian default (+36) applied\nif no country code is given.\n",
            "example": "+36301234567"
          },
          "email": {
            "type": "string",
            "format": "email",
            "nullable": true,
            "description": "Optional guest email. Tolerant-input policy: if provided but\ninvalid, the value is silently dropped (stored as null) rather\nthan rejecting the booking. Valid values are lowercased.\n",
            "example": "kiss.peter@example.com"
          }
        }
      },
      "BookingStatus": {
        "type": "string",
        "enum": [
          "requested",
          "confirmed",
          "declined",
          "rescheduleProposed",
          "cancelled",
          "completed",
          "noShow"
        ],
        "description": "Current lifecycle state. Terminal states: completed, noShow, cancelled, declined.\nNo transitions out of terminal states.\n\n**Naming convention:** Status values use camelCase (e.g. `noShow`, `rescheduleProposed`),\nwhile webhook event names use namespaced snake_case (e.g. `booking.no_show`,\n`booking.reschedule_proposed`). See the webhooks documentation for the full mapping table.\n"
      },
      "Booking": {
        "type": "object",
        "required": [
          "bookingId",
          "status",
          "locationId",
          "locationSnapshot",
          "serviceId",
          "serviceName",
          "guestName",
          "requestedSlot",
          "createdAt"
        ],
        "description": "Booking lifecycle: slot timing has two phases.\n- `requestedSlot` is always present — the wall-clock time the guest originally requested.\n  For manually-created bookings (source: 'manual'), date and startTime may be null;\n  use `confirmedSlot` for the actual appointment time.\n- `confirmedSlot` is the latest committed appointment slot. It remains present\n  after cancellation if the booking was previously confirmed, allowing for\n  retrospective analysis.\n\nService, staff, and guest are returned as flat fields (not nested objects) — this\nmatches the on-the-wire shape for v1 list/get/create/cancel responses and webhook payloads.\n",
        "properties": {
          "bookingId": {
            "type": "string",
            "description": "Internal booking identifier (Firestore document ID)."
          },
          "publicReference": {
            "type": "string",
            "description": "Human-readable reference code for the booking (format: `BK-XXXXXXXX`,\n8 uppercase alphanumeric characters, ambiguous chars excluded).\n**Always present (non-null) since 2026-06-26 (v7 B.4 contract)**: A1.4\nbackfill garantálta minden legacy booking-on. Immutable after creation —\nset exclusively by the backend at booking time.\nUse this as the guest-facing identifier (support, confirmation UI, CSV export).\nDo not use `bookingId` (internal Firestore document ID) in guest-facing contexts.\n"
          },
          "status": {
            "$ref": "#/components/schemas/BookingStatus"
          },
          "locationId": {
            "type": "string",
            "description": "Internal identifier of the location where the booking is held."
          },
          "locationSnapshot": {
            "$ref": "#/components/schemas/LocationSnapshot"
          },
          "serviceId": {
            "type": "string",
            "description": "Service identifier.",
            "example": "svc_haircut_01"
          },
          "serviceName": {
            "type": "string",
            "description": "Service display name at the time of booking.",
            "example": "Hajvágás"
          },
          "staffId": {
            "type": [
              "string",
              "null"
            ],
            "description": "Assigned staff identifier. Null if no staff has been assigned yet.",
            "example": "staff_anna_01"
          },
          "staffName": {
            "type": [
              "string",
              "null"
            ],
            "description": "Assigned staff display name. v1 fallback: if no name is stored on the staff\nrecord, `staffId` is returned as the name. Null only when `staffId` is also null.\n",
            "example": "Anna"
          },
          "guestName": {
            "type": "string",
            "description": "Guest display name. Phone and email are intentionally omitted from API\nresponses (PII minimisation).\n",
            "example": "Kiss Péter"
          },
          "requestedSlot": {
            "type": "object",
            "required": [
              "timezone"
            ],
            "description": "For API-created requested bookings, requestedSlot.date and requestedSlot.startTime\nare populated from the guest's requested slot. For manually-created bookings,\nwhich are written directly in the confirmed state, these fields may be null;\nuse confirmedSlot for the actual appointment time.\n",
            "properties": {
              "date": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date",
                "example": "2026-04-15"
              },
              "startTime": {
                "type": [
                  "string",
                  "null"
                ],
                "example": "10:00"
              },
              "timezone": {
                "type": "string",
                "description": "IANA timezone of the salon at time of request.",
                "example": "Europe/Budapest"
              }
            }
          },
          "confirmedSlot": {
            "allOf": [
              {
                "$ref": "#/components/schemas/SlotTime"
              }
            ],
            "nullable": true,
            "description": "The latest committed appointment slot for the booking, when one exists.\nIt may remain present after cancellation if the booking had previously\nbeen confirmed. It is null when the booking has not yet had a confirmed\nappointment slot.\n"
          },
          "note": {
            "type": "string",
            "nullable": true,
            "description": "Optional note from the guest."
          },
          "serviceLocation": {
            "type": "object",
            "required": [
              "address"
            ],
            "description": "Present only for on-site bookings (the service is performed at the guest's\naddress). Absent for in-shop bookings — the key is not emitted at all.\n",
            "properties": {
              "address": {
                "type": "string"
              },
              "note": {
                "type": "string",
                "description": "Optional access note for the address. Omitted when empty."
              }
            }
          },
          "createdAt": {
            "type": "string",
            "format": "date-time",
            "description": "UTC ISO 8601 timestamp."
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time",
            "description": "UTC ISO 8601 timestamp of last status change."
          }
        }
      },
      "WebhookEventName": {
        "type": "string",
        "enum": [
          "booking.requested",
          "booking.confirmed",
          "booking.declined",
          "booking.cancelled",
          "booking.reschedule_proposed",
          "booking.reschedule_confirmed",
          "booking.completed",
          "booking.no_show"
        ],
        "description": "Webhook event type dispatched on booking lifecycle changes."
      },
      "WebhookPayload": {
        "type": "object",
        "required": [
          "event",
          "deliveryId",
          "timestamp",
          "salonSlug",
          "booking"
        ],
        "description": "Payload delivered to the registered webhook URL on each booking lifecycle event.\nAll top-level fields are stable across all event types. The booking object follows\nthe Booking schema — use confirmedSlot for the appointment time when present.\n\n## Delivery Guarantee: at-least-once\nA business event is delivered with **at most 3 attempts**, every attempt carrying the\n**same `deliveryId`** and the **same payload bytes** (including `timestamp`). Your\nendpoint **must process deliveries idempotently, keyed by `deliveryId`**: the same event\ncan arrive more than once (a retry after a timeout whose response was lost, or a\nre-delivered platform event).\n\n- Success: any `2xx` response.\n- Retried: timeout (8 s), network error, `408`, `429` and `5xx`.\n- Not retried: any other status, including `3xx` (redirects are not followed).\n- Schedule: the 2nd attempt about 15 s – 2 minutes after the 1st, the 3rd about 2–6 minutes after\n  the 2nd. A `Retry-After` header (seconds or HTTP date, max 600 s) on `429`/`503`\n  extends the wait, it never shortens it.\n- Retries go to the **currently configured** URL, signed with the **current** secret.\n\n## Delivery Headers\nEvery webhook delivery contains the following custom headers:\n- `X-Bokko-Signature`: `sha256=<hex>` — HMAC-SHA256 signature over the raw JSON body,\n  computed on each attempt with the current signing secret.\n- `X-Bokko-Delivery-Id`: Business event identifier (matching `data.deliveryId`); identical\n  on every attempt.\n- `X-Bokko-Event`: Name of the event (matching `data.event`).\n- `X-Bokko-Timestamp`: ISO 8601 timestamp (matching `data.timestamp`); identical on every\n  attempt.\n- `X-Bokko-Attempt`: Attempt number, `1`–`3`.\n\n## Signature Verification\nTo verify the delivery, compute the HMAC-SHA256 of the raw request body using your\nwebhook signing secret and compare it with the `X-Bokko-Signature` header.\n",
        "properties": {
          "event": {
            "$ref": "#/components/schemas/WebhookEventName"
          },
          "deliveryId": {
            "type": "string",
            "description": "Identity of the business event (event × booking × transition), not of the attempt:\na deterministic 32-character lowercase hex string. Every attempt and every\nre-delivery of the same event carries the same value, so it is the deduplication key\nfor idempotent processing. Two different transitions of the same booking — even along\nthe same edge — get different values.\n",
            "example": "3f2a9c0d8b7e4f1a2c3d4e5f6a7b8c9d"
          },
          "timestamp": {
            "type": "string",
            "format": "date-time",
            "description": "UTC ISO 8601 timestamp of the first dispatch; unchanged on retries.",
            "example": "2026-04-01T09:00:00.000Z"
          },
          "salonSlug": {
            "type": "string",
            "description": "Slug of the salon that owns this booking.",
            "example": "precision-cuts"
          },
          "booking": {
            "$ref": "#/components/schemas/Booking"
          }
        }
      },
      "BookingEnvelope": {
        "type": "object",
        "required": [
          "ok",
          "data",
          "meta"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "enum": [
              true
            ]
          },
          "data": {
            "type": "object",
            "required": [
              "booking"
            ],
            "properties": {
              "booking": {
                "$ref": "#/components/schemas/Booking"
              }
            }
          },
          "meta": {
            "$ref": "#/components/schemas/Meta"
          }
        }
      },
      "AvailabilityEnvelope": {
        "type": "object",
        "required": [
          "ok",
          "data",
          "meta"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "enum": [
              true
            ]
          },
          "data": {
            "type": "object",
            "required": [
              "slots",
              "snapshotAt"
            ],
            "properties": {
              "slots": {
                "type": "array",
                "description": "Available slots. Empty array if no availability or if the service\nis known but has `onlineBookable: false`.\n\nThe item shape depends on the service's booking mode: a standard\nservice returns `AvailabilitySlot` items (wall-clock date/time); a\ngroup-session service (`bookingMode: groupSession`) returns\n`GroupSessionSlot` items — scheduled sessions with remaining seats —\nand the envelope `meta.bookingMode` is set to `groupSession`.\n",
                "items": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/AvailabilitySlot"
                    },
                    {
                      "$ref": "#/components/schemas/GroupSessionSlot"
                    }
                  ]
                }
              },
              "snapshotAt": {
                "type": "string",
                "format": "date-time",
                "description": "UTC timestamp when this snapshot was computed."
              }
            }
          },
          "meta": {
            "$ref": "#/components/schemas/Meta"
          }
        }
      },
      "AvailabilitySlot": {
        "type": "object",
        "description": "Standard (per-appointment) slot, wall-clock time in the salon timezone.",
        "required": [
          "date",
          "startTime",
          "endTime",
          "serviceId",
          "staffId"
        ],
        "properties": {
          "date": {
            "type": "string",
            "format": "date"
          },
          "startTime": {
            "type": "string",
            "example": "10:00"
          },
          "endTime": {
            "type": "string",
            "example": "11:00"
          },
          "serviceId": {
            "type": "string"
          },
          "staffId": {
            "type": "string"
          }
        }
      },
      "GroupSessionSlot": {
        "type": "object",
        "description": "A scheduled group session with free seats. Returned instead of `AvailabilitySlot`\nwhen the requested service has `bookingMode: groupSession`. Times are UTC ISO 8601.\n",
        "required": [
          "kind",
          "sessionId",
          "staffId",
          "staffName",
          "startAtUtc",
          "endAtUtc",
          "capacityMax",
          "remainingSeats",
          "priceMinor",
          "slotToken"
        ],
        "properties": {
          "kind": {
            "type": "string",
            "enum": [
              "group"
            ]
          },
          "sessionId": {
            "type": "string",
            "description": "Session identifier; pass it back when creating the booking."
          },
          "staffId": {
            "type": "string"
          },
          "staffName": {
            "type": [
              "string",
              "null"
            ]
          },
          "startAtUtc": {
            "type": "string",
            "format": "date-time"
          },
          "endAtUtc": {
            "type": "string",
            "format": "date-time"
          },
          "capacityMax": {
            "type": "integer"
          },
          "remainingSeats": {
            "type": "integer",
            "minimum": 1
          },
          "priceMinor": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Price snapshot of the session in minor units (HUF fillér); null when the session carries no price snapshot."
          },
          "slotToken": {
            "type": "string",
            "description": "Signed slot token bound to this session and the remaining-seat count at signing time."
          }
        }
      },
      "ServiceListEnvelope": {
        "type": "object",
        "required": [
          "ok",
          "data",
          "meta"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "enum": [
              true
            ]
          },
          "data": {
            "type": "object",
            "required": [
              "services"
            ],
            "properties": {
              "services": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/Service"
                }
              }
            }
          },
          "meta": {
            "$ref": "#/components/schemas/Meta"
          }
        }
      },
      "StaffListEnvelope": {
        "type": "object",
        "required": [
          "ok",
          "data",
          "meta"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "enum": [
              true
            ]
          },
          "data": {
            "type": "object",
            "required": [
              "staff"
            ],
            "properties": {
              "staff": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/StaffMember"
                }
              }
            }
          },
          "meta": {
            "$ref": "#/components/schemas/Meta"
          }
        }
      },
      "Service": {
        "type": "object",
        "required": [
          "id",
          "name",
          "durationMinutes",
          "priceType",
          "pricingVersion"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "durationMinutes": {
            "type": "integer",
            "minimum": 1
          },
          "priceType": {
            "type": "string",
            "enum": [
              "fixed",
              "from",
              "negotiable"
            ],
            "description": "Pricing model for this service. Determines how `price` must be interpreted:\n- `fixed`: `price` is the exact amount charged. MUST NOT be null.\n- `from`: `price` is the starting minimum; final amount agreed after service delivery. MUST NOT be null.\n- `negotiable`: amount agreed between provider and guest case-by-case. `price` MUST be null.\nThe API never publishes `fixed/from + null` state — such services are excluded as invariant violations.\n",
            "example": "fixed"
          },
          "pricingVersion": {
            "type": "string",
            "description": "12-character hex hash of the pricing-relevant fields (priceType,\nlistedPriceMinor, durationMinutes) at the time of the last service\nupdate. Clients must pass this value as `expectedPricingVersion`\nwhen creating a booking — the backend rejects if the service has\nchanged since the user saw the price.\n",
            "example": "a1b2c3d4e5f6"
          },
          "price": {
            "type": "object",
            "nullable": true,
            "description": "Required `{amount, currency}` object when `priceType` is `fixed` or `from`; MUST be null when\n`priceType` is `negotiable`. Internally stored in minor units (fillér for HUF, cent for EUR);\nthe API converts to major units before sending — clients can use `amount` directly for display.\n",
            "required": [
              "amount",
              "currency"
            ],
            "properties": {
              "amount": {
                "type": "number",
                "description": "Price in **major** currency units (e.g. 5000 means 5000 HUF, 49.90 means 49.90 EUR).\nFor HUF this is always an integer; for fractional currencies (EUR, USD) it may have decimals.\n",
                "example": 5000
              },
              "currency": {
                "type": "string",
                "description": "ISO 4217 currency code. Matches the salon's configured currency.",
                "example": "HUF"
              }
            }
          },
          "description": {
            "type": "string"
          }
        }
      },
      "SubscriptionView": {
        "type": "object",
        "required": [
          "plan",
          "status",
          "cancelAtPeriodEnd",
          "bookingsThisMonth",
          "maxBookingsPerMonth",
          "maxActiveServices",
          "maxStaffMembers",
          "maxLocations",
          "features"
        ],
        "description": "Public subscription state. Does not expose Stripe internals, accessState,\nor internal quota reset timestamps.\n",
        "properties": {
          "plan": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "starter",
              "pro",
              "business",
              null
            ],
            "description": "Active plan identifier. Null while the business has no plan yet (`status: setup`).\nThere is no `free` plan.\n"
          },
          "status": {
            "type": "string",
            "enum": [
              "setup",
              "active",
              "trialing",
              "past_due",
              "canceled",
              "unpaid",
              "paused"
            ],
            "description": "Subscription lifecycle status. `setup` is the Bokko-specific pre-Stripe state\n(no active subscription yet); the remaining values mirror the Stripe subscription\nstatus enum.\n"
          },
          "cancelAtPeriodEnd": {
            "type": "boolean",
            "description": "True if the subscription is set to cancel at the end of the current billing period."
          },
          "currentPeriodEnd": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "UTC ISO 8601 timestamp when the current billing period ends.\nNull for free/setup plans that have no Stripe subscription.\n"
          },
          "bookingsThisMonth": {
            "type": "integer",
            "description": "Bookings created in the current monthly quota window."
          },
          "maxBookingsPerMonth": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Monthly booking quota for the active plan. Null when unlimited\n(e.g. starter and pro plans have no monthly cap).\n"
          },
          "maxActiveServices": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Maximum number of active services allowed. Null when unlimited\n(e.g. pro plan has no service cap).\n"
          },
          "maxStaffMembers": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Maximum number of staff members allowed. Null when unlimited\n(e.g. pro plan has no staff cap).\n"
          },
          "maxLocations": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Maximum number of locations allowed. Null when unlimited."
          },
          "features": {
            "type": "object",
            "required": [
              "smsNotifications",
              "calendarSync",
              "bookingPageBranding",
              "depositCollection",
              "analytics",
              "apiAccess"
            ],
            "description": "Feature flags for the active plan. All boolean — present and explicit for all keys.",
            "properties": {
              "smsNotifications": {
                "type": "boolean"
              },
              "calendarSync": {
                "type": "boolean"
              },
              "bookingPageBranding": {
                "type": "boolean"
              },
              "depositCollection": {
                "type": "boolean"
              },
              "analytics": {
                "type": "boolean"
              },
              "apiAccess": {
                "type": "boolean"
              }
            }
          }
        }
      },
      "SubscriptionEnvelope": {
        "type": "object",
        "required": [
          "ok",
          "data",
          "meta"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "enum": [
              true
            ]
          },
          "data": {
            "type": "object",
            "required": [
              "subscription"
            ],
            "properties": {
              "subscription": {
                "$ref": "#/components/schemas/SubscriptionView"
              }
            }
          },
          "meta": {
            "$ref": "#/components/schemas/Meta"
          }
        }
      },
      "StaffMember": {
        "type": "object",
        "required": [
          "id",
          "name"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "bio": {
            "type": "string"
          },
          "avatarUrl": {
            "type": "string",
            "format": "uri",
            "description": "Profile image URL. Omitted if not set."
          }
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Missing or invalid API key.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "ok": false,
              "error": {
                "code": "auth.invalid_key",
                "message": "Invalid or expired API key.",
                "retryable": false
              },
              "meta": {
                "requestId": "req_01HX...",
                "v": "1"
              }
            }
          }
        }
      },
      "Forbidden": {
        "description": "Key lacks required capability, or `salonSlug` does not match the key's business scope.\nError codes:\n- `auth.insufficient_capability` — key exists but lacks the required permission\n- `auth.scope_mismatch` — salonSlug belongs to a different tenant than the key\n- `auth.plan_insufficient` — the active plan does not include Public API access\n- `auth.kind_endpoint_denied` — a `guest_partner` key called an endpoint outside its\n  allowed route set (`GET /v1/services`, `GET /v1/staff`, `POST /v1/availability/search`,\n  `POST /v1/bookings`).\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "examples": {
              "insufficientCapability": {
                "summary": "Key lacks required capability",
                "value": {
                  "ok": false,
                  "error": {
                    "code": "auth.insufficient_capability",
                    "message": "API key lacks required capability: booking.create",
                    "retryable": false,
                    "details": {
                      "required": "booking.create",
                      "granted": [
                        "availability.read"
                      ]
                    }
                  },
                  "meta": {
                    "requestId": "req_01HX...",
                    "v": "1"
                  }
                }
              },
              "scopeMismatch": {
                "summary": "salonSlug does not match key's business scope",
                "value": {
                  "ok": false,
                  "error": {
                    "code": "auth.scope_mismatch",
                    "message": "The requested salon is not accessible with this API key.",
                    "retryable": false
                  },
                  "meta": {
                    "requestId": "req_01HX...",
                    "v": "1"
                  }
                }
              },
              "kindEndpointDenied": {
                "summary": "guest_partner key called an endpoint outside its allowed route set",
                "value": {
                  "ok": false,
                  "error": {
                    "code": "auth.kind_endpoint_denied",
                    "message": "This key kind cannot access this endpoint.",
                    "retryable": false
                  },
                  "meta": {
                    "requestId": "req_01HX...",
                    "v": "1"
                  }
                }
              },
              "planInsufficient": {
                "summary": "Active plan does not include Public API access (Pro plan required)",
                "value": {
                  "ok": false,
                  "error": {
                    "code": "auth.plan_insufficient",
                    "message": "Public API access requires a Bokko Pro subscription.",
                    "retryable": false
                  },
                  "meta": {
                    "requestId": "req_01HX...",
                    "v": "1"
                  }
                }
              }
            }
          }
        }
      },
      "NotFound": {
        "description": "Resource not found within the key's business scope.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "ok": false,
              "error": {
                "code": "booking.not_found",
                "message": "Booking not found.",
                "retryable": false
              },
              "meta": {
                "requestId": "req_01HX...",
                "v": "1"
              }
            }
          }
        }
      },
      "BadRequest": {
        "description": "Invalid request — missing required field or schema violation.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "ok": false,
              "error": {
                "code": "request.invalid_input",
                "message": "Request validation failed.",
                "retryable": false,
                "details": {
                  "fieldErrors": [
                    {
                      "field": "guest.phone",
                      "reason": "required"
                    }
                  ]
                }
              },
              "meta": {
                "requestId": "req_01HX...",
                "v": "1"
              }
            }
          }
        }
      },
      "BookingDisabled": {
        "description": "The salon is not accepting online bookings right now. Two independent causes map\nto the SAME code, deliberately: the reason is between Bokko and the salon, not\nsomething a public API client should learn.\n- the salon has not published its booking page (`bookingPageEnabled=false`), or\n- the salon's subscription does not currently allow public bookings.\n\nThe response is a stable 409 — NOT an empty list. An empty catalog would claim\n\"this salon has no services\", which is false and would make integrations render\na misleading page.\n\nTreat it as transient at the business level: it resolves without any client\nchange once the salon publishes again or renews.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "ok": false,
              "error": {
                "code": "booking.booking_disabled",
                "message": "Online booking is not available for this salon.",
                "retryable": false
              },
              "meta": {
                "requestId": "req_01HX...",
                "v": "1"
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "Rate limit exceeded. Retry after the indicated interval.",
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "integer"
            },
            "description": "Seconds to wait before retrying."
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "ok": false,
              "error": {
                "code": "rate_limit.exceeded",
                "message": "Too many requests. Retry after 30 seconds.",
                "retryable": true,
                "details": {
                  "retryAfterSeconds": 30
                }
              },
              "meta": {
                "requestId": "req_01HX...",
                "v": "1"
              }
            }
          }
        }
      },
      "InternalServerError": {
        "description": "Generic internal server error fallback. Used when the request reaches the API\nbut an unhandled error occurs. Always retryable; include `requestId` from the\nresponse in any support escalation. Specific error codes (e.g.\n`subscription.snapshot_missing`) are preferred where the failure mode is\nwell-defined.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "ok": false,
              "error": {
                "code": "internal.error",
                "message": "Internal server error. Try again later.",
                "retryable": true
              },
              "meta": {
                "requestId": "req_01HX...",
                "v": "1"
              }
            }
          }
        }
      }
    }
  },
  "paths": {
    "/availability/search": {
      "post": {
        "operationId": "searchAvailability",
        "summary": "Search available booking slots",
        "description": "Returns available slots for a given service within a date range.\n\n**Non-binding:** This is a point-in-time snapshot. Slots are not reserved.\nA slot visible here may be taken by the time `createBooking` is called.\nAlways handle `booking.slot_unavailable` at booking creation time.\n\n**onlineBookable semantics:**\n- Unknown `serviceId` → `404 service.not_found`\n- Known service with `onlineBookable: false` → `200` with empty `slots` array and `meta.availabilitySuppressed: true`\nThese are intentionally different: unknown = client error; non-bookable = valid\nbut no slots available through this channel.\n\n**Truncation:** If more than 500 slots match, the response is truncated and\n`meta.truncated: true`, `meta.returnedSlots`, and `meta.slotLimit` are added.\nNarrow the date range to retrieve all slots.\n\nRequired capability: `availability.read`\n",
        "tags": [
          "Availability"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "salonSlug",
                  "locationSlug",
                  "serviceId",
                  "dateRange"
                ],
                "properties": {
                  "salonSlug": {
                    "type": "string",
                    "example": "precision-cuts"
                  },
                  "locationSlug": {
                    "type": "string",
                    "example": "downtown"
                  },
                  "serviceId": {
                    "type": "string",
                    "description": "Service to search availability for."
                  },
                  "staffId": {
                    "type": "string",
                    "description": "Optional. Restrict results to a specific staff member.\nIf omitted, returns slots across all eligible staff.\n"
                  },
                  "dateRange": {
                    "type": "object",
                    "required": [
                      "from",
                      "to"
                    ],
                    "properties": {
                      "from": {
                        "type": "string",
                        "format": "date",
                        "example": "2026-04-15"
                      },
                      "to": {
                        "type": "string",
                        "format": "date",
                        "example": "2026-04-21",
                        "description": "Inclusive. Maximum 31 days from `from`."
                      }
                    }
                  }
                }
              },
              "example": {
                "salonSlug": "precision-cuts",
                "serviceId": "svc_haircut_01",
                "dateRange": {
                  "from": "2026-04-15",
                  "to": "2026-04-21"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Availability snapshot returned successfully.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AvailabilityEnvelope"
                },
                "example": {
                  "ok": true,
                  "data": {
                    "slots": [
                      {
                        "date": "2026-04-15",
                        "startTime": "10:00",
                        "endTime": "11:00",
                        "serviceId": "svc_haircut_01",
                        "staffId": "staff_anna_01"
                      },
                      {
                        "date": "2026-04-15",
                        "startTime": "14:00",
                        "endTime": "15:00",
                        "serviceId": "svc_haircut_01",
                        "staffId": "staff_anna_01"
                      }
                    ],
                    "snapshotAt": "2026-04-01T09:00:00Z"
                  },
                  "meta": {
                    "requestId": "req_01HX4K9M2P",
                    "v": "1"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid date range or missing fields.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "example": {
                  "ok": false,
                  "error": {
                    "code": "availability.date_range_too_wide",
                    "message": "Date range cannot exceed 31 days.",
                    "retryable": false,
                    "details": {
                      "param": "dateRange",
                      "maxDays": 31
                    }
                  },
                  "meta": {
                    "requestId": "req_01HX...",
                    "v": "1"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Unknown serviceId, or the business behind the key no longer exists.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "serviceNotFound": {
                    "summary": "Unknown serviceId",
                    "value": {
                      "ok": false,
                      "error": {
                        "code": "service.not_found",
                        "message": "Service not found.",
                        "retryable": false
                      },
                      "meta": {
                        "requestId": "req_01HX...",
                        "v": "1"
                      }
                    }
                  },
                  "salonNotFound": {
                    "summary": "The business record is missing",
                    "value": {
                      "ok": false,
                      "error": {
                        "code": "salon.not_found",
                        "message": "Salon not found.",
                        "retryable": false
                      },
                      "meta": {
                        "requestId": "req_01HX...",
                        "v": "1"
                      }
                    }
                  }
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/BookingDisabled"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalServerError"
          }
        }
      }
    },
    "/bookings": {
      "get": {
        "operationId": "listBookings",
        "summary": "List bookings",
        "description": "Returns a paginated list of bookings for the specified salon.\n\n**Filtering:** Results can be filtered by `status` and/or a `from`/`to`\ndate range. Date range parameters must always be provided in pairs.\n\n**Sort order:** The sort order depends on the query shape, not caller\npreference (v1 does not expose a `sort` parameter):\n\n| Query mode | Sort |\n|---|---|\n| No `from`/`to` | `createdAt DESC` — newest bookings first |\n| With `from`/`to` | `requestedDate ASC, createdAt DESC` — chronological within the window |\n\nThe date-range sort is a consequence of Firestore's range-filter constraint\n(the first `orderBy` must match the range-filtered field) and aligns with the\nnatural use case of browsing a calendar window in order.\n\n**Pagination:** Use the `cursor` value from `meta.cursor` as the `cursor`\nquery parameter on the next request. Cursors are query-context bound — a\ncursor from one set of filter parameters is invalid for a different set.\n\n**Consistency:** Results are not snapshot-isolated. The underlying data may\nchange between page fetches (new bookings, status changes, rescheduling),\nwhich can result in items appearing on multiple pages or being skipped. This\nis inherent to cursor-based pagination over a live dataset. The `createdAt`\nfield is immutable and serves as a stable tiebreaker.\n\nRequired capability: `booking.read`\n",
        "tags": [
          "Bookings"
        ],
        "parameters": [
          {
            "name": "salonSlug",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Slug of the salon to list bookings for.",
            "example": "precision-cuts"
          },
          {
            "name": "locationSlug",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Optional. Filter by specific location slug.\n",
            "example": "downtown"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/BookingStatus"
            },
            "description": "Filter by booking status. If omitted, all statuses are returned.\nv1 supports a single status value only.\n"
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date"
            },
            "description": "Start of date range filter (inclusive), in `YYYY-MM-DD` format.\nMust be a valid calendar date. Must be provided together with `to`.\n",
            "example": "2026-04-01"
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date"
            },
            "description": "End of date range filter (inclusive), in `YYYY-MM-DD` format.\nMust be a valid calendar date. Must be provided together with `from`.\nMaximum range: 366 days.\n",
            "example": "2026-04-30"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 20
            },
            "description": "Maximum number of bookings to return per page."
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Pagination cursor from a previous response's `meta.cursor`. Must be\nused with the same filter parameters as the request that produced it.\n"
          }
        ],
        "responses": {
          "200": {
            "description": "List of bookings returned successfully.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "data",
                    "meta"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    },
                    "data": {
                      "type": "object",
                      "required": [
                        "bookings"
                      ],
                      "properties": {
                        "bookings": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Booking"
                          }
                        }
                      }
                    },
                    "meta": {
                      "$ref": "#/components/schemas/Meta"
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "data": {
                    "bookings": [
                      {
                        "bookingId": "XkQ2mN8pRvT4cLwA9dFs",
                        "publicReference": "BK-7F4K2Q9M",
                        "status": "confirmed",
                        "serviceId": "svc_haircut_01",
                        "serviceName": "Hajvágás",
                        "staffId": "staff_anna_01",
                        "staffName": "Anna",
                        "guestName": "Kiss Péter",
                        "requestedSlot": {
                          "date": "2026-04-15",
                          "startTime": "10:00",
                          "timezone": "Europe/Budapest"
                        },
                        "confirmedSlot": {
                          "date": "2026-04-15",
                          "startTime": "10:00",
                          "endTime": "10:30",
                          "timezone": "Europe/Budapest"
                        },
                        "note": null,
                        "createdAt": "2026-04-01T09:12:00.000Z",
                        "updatedAt": "2026-04-01T09:15:00.000Z"
                      }
                    ]
                  },
                  "meta": {
                    "requestId": "req_01HX4K9M2P",
                    "v": "1",
                    "cursor": "eyJ2IjoxLCJjIjoiMjAyNi0wNC0wMVQwOToxMjowMC4wMDBaIiwiaWQiOiJYa1EybU44cFJ2VDRjTHdBOWRGcyJ9"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid request parameters or invalid/mismatched cursor.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "invalid_input": {
                    "summary": "Invalid query parameter",
                    "value": {
                      "ok": false,
                      "error": {
                        "code": "request.invalid_input",
                        "message": "from must be a valid date in YYYY-MM-DD format.",
                        "retryable": false,
                        "details": {
                          "field": "from"
                        }
                      },
                      "meta": {
                        "requestId": "req_01HX...",
                        "v": "1"
                      }
                    }
                  },
                  "invalid_cursor": {
                    "summary": "Cursor invalid or used with mismatched filters",
                    "description": "The cursor token is corrupted, expired, or was produced by\na different filter combination. Start a new listing request\nwithout the `cursor` parameter.\n",
                    "value": {
                      "ok": false,
                      "error": {
                        "code": "request.invalid_cursor",
                        "message": "Cursor is invalid or incompatible with the current query parameters.",
                        "retryable": false
                      },
                      "meta": {
                        "requestId": "req_01HX...",
                        "v": "1"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalServerError"
          }
        }
      },
      "post": {
        "operationId": "createBooking",
        "summary": "Create a booking",
        "description": "Creates a new booking request for a guest. The requested slot is\nvalidated at write time — if unavailable, `booking.slot_unavailable`\nis returned.\n\nThe booking starts in `requested` status and must be confirmed by\nthe service provider. See `BookingStatus` for the full lifecycle.\n\n**Idempotency:** Provide `Idempotency-Key` to safely retry on network\nerrors. Same key + same payload returns the original response.\nSame key + different payload returns `idempotency.payload_mismatch`.\n\nRequired capability: `booking.create`\n",
        "tags": [
          "Bookings"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "salonSlug",
                  "locationSlug",
                  "serviceId",
                  "expectedPricingVersion",
                  "slot",
                  "guest",
                  "consent"
                ],
                "properties": {
                  "salonSlug": {
                    "type": "string"
                  },
                  "locationSlug": {
                    "type": "string",
                    "example": "downtown"
                  },
                  "serviceId": {
                    "type": "string"
                  },
                  "expectedPricingVersion": {
                    "type": "string",
                    "description": "The `pricingVersion` value from the `Service` object the\nguest confirmed. The backend rejects the booking with\n`booking.service_pricing_changed` if the service pricing\nhas been updated since the guest saw the price. Fetch a\nfresh service list and re-confirm with the guest before retrying.\n",
                    "example": "a1b2c3d4e5f6"
                  },
                  "consent": {
                    "type": "object",
                    "required": [
                      "privacyNoticeVersion",
                      "termsVersion",
                      "locale",
                      "acceptedAt",
                      "policyConsentGiven"
                    ],
                    "description": "Evidence of what the guest was shown and accepted, captured by YOU at the\nmoment of booking. Required: a booking created through the API carries the\nsame legal trail as one created on our own surface, otherwise the record\ndepends on which surface the guest happened to arrive through.\n\nSend the versions you ACTUALLY displayed. They are not validated against\nour current documents on purpose — evidence that an older notice was shown\nis accurate data, not an error.\n",
                    "properties": {
                      "privacyNoticeVersion": {
                        "type": "string",
                        "description": "Version identifier of the privacy notice shown to the guest.",
                        "example": "v1-open-beta-r9"
                      },
                      "termsVersion": {
                        "type": "string",
                        "description": "Version identifier of the terms the guest accepted.",
                        "example": "v1-open-beta-r9"
                      },
                      "locale": {
                        "type": "string",
                        "description": "Language the documents were presented in.",
                        "example": "hu"
                      },
                      "acceptedAt": {
                        "type": "string",
                        "format": "date-time",
                        "description": "When the guest accepted, ISO 8601. Must not be in the future — a\nfuture timestamp is a broken clock or an invented value, not evidence.\n",
                        "example": "2026-09-09T10:15:00.000Z"
                      },
                      "policyConsentGiven": {
                        "type": "boolean",
                        "description": "Must be `true`. The guest accepted the provider's booking terms\n(cancellation window, deposit rules and any policy clauses).\n"
                      }
                    }
                  },
                  "staffId": {
                    "type": "string",
                    "nullable": true,
                    "description": "Optional. Request a specific staff member.\nIf omitted, any eligible staff may be assigned.\n"
                  },
                  "slot": {
                    "type": "object",
                    "required": [
                      "date",
                      "startTime"
                    ],
                    "description": "Requested time slot. Expressed as wall-clock time in the\nsalon's timezone — do not send UTC.\n",
                    "properties": {
                      "date": {
                        "type": "string",
                        "format": "date",
                        "example": "2026-04-15"
                      },
                      "startTime": {
                        "type": "string",
                        "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$",
                        "example": "10:00"
                      }
                    }
                  },
                  "guest": {
                    "$ref": "#/components/schemas/GuestInput"
                  },
                  "note": {
                    "type": "string",
                    "maxLength": 500,
                    "nullable": true
                  },
                  "serviceLocation": {
                    "type": "object",
                    "description": "Optional. Send it only for an on-site service request: presence of this\nobject marks the booking as on-site, absence means in-shop. Must be an\nobject (not an array); `address` is validated after the service is resolved.\n",
                    "properties": {
                      "address": {
                        "type": "string"
                      },
                      "note": {
                        "type": "string"
                      }
                    }
                  }
                }
              },
              "example": {
                "salonSlug": "precision-cuts",
                "serviceId": "svc_haircut_01",
                "staffId": "staff_anna_01",
                "slot": {
                  "date": "2026-04-15",
                  "startTime": "10:00"
                },
                "guest": {
                  "name": "Kiss Péter",
                  "phone": "+36301234567",
                  "email": "kiss.peter@example.com"
                },
                "note": "Rövid hajvágás kérném."
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Booking created successfully.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BookingEnvelope"
                },
                "example": {
                  "ok": true,
                  "data": {
                    "booking": {
                      "bookingId": "XkQ2mN8pRvT4cLwA9dFs",
                      "publicReference": "BK-7F4K2Q9M",
                      "status": "requested",
                      "serviceId": "svc_haircut_01",
                      "serviceName": "Hajvágás",
                      "staffId": "staff_anna_01",
                      "staffName": "Anna",
                      "guestName": "Kiss Péter",
                      "requestedSlot": {
                        "date": "2026-04-15",
                        "startTime": "10:00",
                        "timezone": "Europe/Budapest"
                      },
                      "confirmedSlot": null,
                      "note": "Rövid hajvágás kérném.",
                      "createdAt": "2026-04-01T09:12:00Z",
                      "updatedAt": "2026-04-01T09:12:00Z"
                    }
                  },
                  "meta": {
                    "requestId": "req_01HX4K9M2P",
                    "v": "1"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "The business behind the key no longer exists.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "example": {
                  "ok": false,
                  "error": {
                    "code": "salon.not_found",
                    "message": "Salon not found.",
                    "retryable": false
                  },
                  "meta": {
                    "requestId": "req_01HX...",
                    "v": "1"
                  }
                }
              }
            }
          },
          "409": {
            "description": "Slot unavailable or idempotency key conflict.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "slotUnavailable": {
                    "summary": "Requested slot is no longer available",
                    "value": {
                      "ok": false,
                      "error": {
                        "code": "booking.slot_unavailable",
                        "message": "A kiválasztott időpont nem elérhető.",
                        "retryable": false,
                        "details": {
                          "requestedDate": "2026-04-15",
                          "requestedStartTime": "10:00",
                          "nextAvailableStartTime": "11:00"
                        }
                      },
                      "meta": {
                        "requestId": "req_01HX...",
                        "v": "1"
                      }
                    }
                  },
                  "payloadMismatch": {
                    "summary": "Same idempotency key used with different payload",
                    "value": {
                      "ok": false,
                      "error": {
                        "code": "idempotency.payload_mismatch",
                        "message": "Idempotency-Key was already used with a different request payload.",
                        "retryable": false
                      },
                      "meta": {
                        "requestId": "req_01HX...",
                        "v": "1"
                      }
                    }
                  },
                  "bookingDisabled": {
                    "summary": "Online booking is not enabled for this salon",
                    "value": {
                      "ok": false,
                      "error": {
                        "code": "booking.booking_disabled",
                        "message": "Online booking is not available for this salon.",
                        "retryable": false
                      },
                      "meta": {
                        "requestId": "req_01HX...",
                        "v": "1"
                      }
                    }
                  },
                  "bookingRestricted": {
                    "summary": "Booking is restricted for this salon (administrative block)",
                    "value": {
                      "ok": false,
                      "error": {
                        "code": "booking.booking_restricted",
                        "message": "Booking is temporarily restricted for this salon.",
                        "retryable": false
                      },
                      "meta": {
                        "requestId": "req_01HX...",
                        "v": "1"
                      }
                    }
                  },
                  "servicePricingChanged": {
                    "summary": "Service pricing changed since the client last fetched the service list",
                    "value": {
                      "ok": false,
                      "error": {
                        "code": "booking.service_pricing_changed",
                        "message": "A szolgáltatás adatai frissültek.",
                        "retryable": false,
                        "details": {
                          "currentPricingVersion": "b2c3d4e5f6a1"
                        }
                      },
                      "meta": {
                        "requestId": "req_01HX...",
                        "v": "1"
                      }
                    }
                  },
                  "serviceUnavailable": {
                    "summary": "Service is deactivated or archived",
                    "value": {
                      "ok": false,
                      "error": {
                        "code": "service.unavailable",
                        "message": "A szolgáltatás jelenleg nem elérhető.",
                        "retryable": false,
                        "details": {
                          "serviceId": "svc_haircut_01"
                        }
                      },
                      "meta": {
                        "requestId": "req_01HX...",
                        "v": "1"
                      }
                    }
                  },
                  "serviceNotBookable": {
                    "summary": "Service is not configured for online booking",
                    "value": {
                      "ok": false,
                      "error": {
                        "code": "service.not_bookable",
                        "message": "A szolgáltatás nem online foglalható.",
                        "retryable": false,
                        "details": {
                          "serviceId": "svc_haircut_01"
                        }
                      },
                      "meta": {
                        "requestId": "req_01HX...",
                        "v": "1"
                      }
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalServerError"
          },
          "503": {
            "description": "A request with this Idempotency-Key is already being processed.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds to wait before retrying."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "example": {
                  "ok": false,
                  "error": {
                    "code": "idempotency.request_in_progress",
                    "message": "A request with this Idempotency-Key is already being processed. Retry after 2 seconds.",
                    "retryable": true
                  },
                  "meta": {
                    "requestId": "req_01HX...",
                    "v": "1"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/bookings/{bookingId}": {
      "get": {
        "operationId": "getBooking",
        "summary": "Get a booking",
        "description": "Returns the current state of a booking.\nOnly bookings within the key's business scope are accessible.\n\nRequired capability: `booking.read`\n",
        "tags": [
          "Bookings"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/BookingId"
          }
        ],
        "responses": {
          "200": {
            "description": "Booking details.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BookingEnvelope"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalServerError"
          }
        }
      }
    },
    "/bookings/{bookingId}/cancel": {
      "post": {
        "operationId": "cancelBooking",
        "summary": "Cancel a booking",
        "description": "Cancels a booking. Only valid from non-terminal states.\nTerminal states (`completed`, `noShow`, `cancelled`, `declined`) cannot\nbe cancelled — returns `booking.invalid_transition`.\n\n**Idempotency:** Replaying with the same `Idempotency-Key` and matching\nfingerprint (same `bookingId` + same body) returns `200` with the original\ncancelled booking snapshot and `meta.idempotent: true`. Does not re-apply\nthe cancellation.\n\nRequired capability: `booking.cancel`\n",
        "tags": [
          "Bookings"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/BookingId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "reason": {
                    "type": "string",
                    "maxLength": 500,
                    "nullable": true,
                    "description": "Optional cancellation reason visible to the service provider."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Booking cancelled successfully, or idempotent replay of a previous\ncancellation (`meta.idempotent: true`). Both cases return 200.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BookingEnvelope"
                }
              }
            }
          },
          "400": {
            "description": "Missing or invalid Idempotency-Key header.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "example": {
                  "ok": false,
                  "error": {
                    "code": "request.missing_idempotency_key",
                    "message": "Idempotency-Key header is required for this operation.",
                    "retryable": false
                  },
                  "meta": {
                    "requestId": "req_01HX...",
                    "v": "1"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Booking is in a terminal state, or idempotency key conflict.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "invalidTransition": {
                    "summary": "Booking is in a terminal state and cannot be cancelled",
                    "value": {
                      "ok": false,
                      "error": {
                        "code": "booking.invalid_transition",
                        "message": "Booking cannot be cancelled from its current state.",
                        "retryable": false,
                        "details": {
                          "currentStatus": "completed"
                        }
                      },
                      "meta": {
                        "requestId": "req_01HX...",
                        "v": "1"
                      }
                    }
                  },
                  "payloadMismatch": {
                    "summary": "Same idempotency key used with different payload",
                    "value": {
                      "ok": false,
                      "error": {
                        "code": "idempotency.payload_mismatch",
                        "message": "Idempotency-Key was already used with a different request payload.",
                        "retryable": false
                      },
                      "meta": {
                        "requestId": "req_01HX...",
                        "v": "1"
                      }
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalServerError"
          },
          "503": {
            "description": "A request with this Idempotency-Key is already being processed.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds to wait before retrying."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "example": {
                  "ok": false,
                  "error": {
                    "code": "idempotency.request_in_progress",
                    "message": "A request with this Idempotency-Key is already being processed. Retry after 2 seconds.",
                    "retryable": true
                  },
                  "meta": {
                    "requestId": "req_01HX...",
                    "v": "1"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/services": {
      "get": {
        "operationId": "listServices",
        "summary": "List services for a salon",
        "description": "Returns the publicly visible service catalog for a salon.\nUse `serviceId` values from this response when calling\n`searchAvailability` or `createBooking`.\n\nRequired capability: `services.read`\n",
        "tags": [
          "Catalog"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/SalonSlug"
          },
          {
            "$ref": "#/components/parameters/LocationSlug"
          }
        ],
        "responses": {
          "200": {
            "description": "Service list.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ServiceListEnvelope"
                },
                "example": {
                  "ok": true,
                  "data": {
                    "services": [
                      {
                        "id": "svc_haircut_01",
                        "name": "Hajvágás",
                        "durationMinutes": 60,
                        "priceType": "fixed",
                        "pricingVersion": "a1b2c3d4e5f6",
                        "price": {
                          "amount": 5000,
                          "currency": "HUF"
                        }
                      }
                    ]
                  },
                  "meta": {
                    "requestId": "req_01HX...",
                    "v": "1"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/BookingDisabled"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalServerError"
          }
        }
      }
    },
    "/staff": {
      "get": {
        "operationId": "listStaff",
        "summary": "List staff for a salon",
        "description": "Returns publicly visible staff members for a salon.\nUse `staffId` values from this response when filtering availability\nor creating a booking.\n\nRequired capability: `staff.read`\n",
        "tags": [
          "Catalog"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/SalonSlug"
          },
          {
            "$ref": "#/components/parameters/LocationSlug"
          }
        ],
        "responses": {
          "200": {
            "description": "Staff list.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StaffListEnvelope"
                },
                "example": {
                  "ok": true,
                  "data": {
                    "staff": [
                      {
                        "id": "staff_anna_01",
                        "name": "Anna"
                      }
                    ]
                  },
                  "meta": {
                    "requestId": "req_01HX...",
                    "v": "1"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/BookingDisabled"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalServerError"
          }
        }
      }
    },
    "/subscription": {
      "get": {
        "operationId": "getSubscription",
        "summary": "Get subscription state",
        "description": "Returns the current subscription state for the authenticated tenant.\n\nReturns `404 subscription.not_found` if the salon has not yet completed\nsubscription onboarding (legitimate pre-activation state — not a client error).\n\nReturns `500 subscription.snapshot_missing` if the subscription document exists\nbut the internal snapshot is absent (invariant breach — an alert is triggered\nserver-side).\n\nRequired capability: `subscription.read`\n",
        "tags": [
          "Subscription"
        ],
        "responses": {
          "200": {
            "description": "Subscription state returned successfully.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SubscriptionEnvelope"
                },
                "example": {
                  "ok": true,
                  "data": {
                    "subscription": {
                      "plan": "pro",
                      "status": "active",
                      "cancelAtPeriodEnd": false,
                      "currentPeriodEnd": "2026-06-01T00:00:00Z",
                      "bookingsThisMonth": 47,
                      "maxBookingsPerMonth": null,
                      "maxActiveServices": null,
                      "maxStaffMembers": null,
                      "features": {
                        "smsNotifications": true,
                        "calendarSync": true,
                        "bookingPageBranding": true,
                        "depositCollection": true,
                        "analytics": true,
                        "apiAccess": true
                      }
                    }
                  },
                  "meta": {
                    "requestId": "req_01HX4K9M2P",
                    "v": "1"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Subscription record not found (pre-activation state).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "example": {
                  "ok": false,
                  "error": {
                    "code": "subscription.not_found",
                    "message": "Subscription not found for this salon.",
                    "retryable": false
                  },
                  "meta": {
                    "requestId": "req_01HX...",
                    "v": "1"
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "description": "Subscription document exists but internal snapshot is absent (invariant breach).\nAn alert is triggered server-side. This is not a client error.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "example": {
                  "ok": false,
                  "error": {
                    "code": "subscription.snapshot_missing",
                    "message": "Internal error: subscription snapshot missing.",
                    "retryable": false
                  },
                  "meta": {
                    "requestId": "req_01HX...",
                    "v": "1"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/webhooks/config": {
      "get": {
        "operationId": "getWebhookConfig",
        "summary": "Get webhook configuration",
        "description": "Returns the current webhook configuration for the salon.\nReturns `data: null` if no configuration exists.\nInactive configs are returned with `active: false`.\nThe HMAC signing secret is never returned.\n\nRequired capability: `webhook.manage`\n",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/SalonSlug"
          }
        ],
        "responses": {
          "200": {
            "description": "Webhook configuration or null if not configured.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "data",
                    "meta"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    },
                    "data": {
                      "type": [
                        "object",
                        "null"
                      ],
                      "description": "Null if no webhook is configured. Otherwise the current config.",
                      "properties": {
                        "url": {
                          "type": "string",
                          "description": "The registered HTTPS webhook endpoint URL."
                        },
                        "hasSecret": {
                          "type": "boolean",
                          "description": "True if an HMAC signing secret has been provisioned."
                        },
                        "active": {
                          "type": "boolean"
                        },
                        "createdAt": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "updatedAt": {
                          "type": "string",
                          "format": "date-time"
                        }
                      }
                    },
                    "meta": {
                      "$ref": "#/components/schemas/Meta"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalServerError"
          }
        }
      },
      "put": {
        "operationId": "putWebhookConfig",
        "summary": "Create or update webhook configuration",
        "description": "Creates or updates the webhook URL.\nFirst call generates an HMAC secret (shown once).\nSubsequent calls update URL only — no secret rotation.\n\n## Delivery guarantee\nBest effort, at most once. Failed deliveries may be dropped.\nConsumers must treat deliveryId as dedupe key and reconcile\nwith GET /bookings periodically.\n\nRequired capability: `webhook.manage`\n",
        "tags": [
          "Webhooks"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "salonSlug",
                  "url"
                ],
                "properties": {
                  "salonSlug": {
                    "type": "string"
                  },
                  "url": {
                    "type": "string",
                    "description": "HTTPS webhook endpoint URL (max 500 chars)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Config created or updated. `data.secret` is only present in the response\non first creation — store it securely. Subsequent calls update the URL only\nand do not return the secret again.\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "data",
                    "meta"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    },
                    "data": {
                      "type": "object",
                      "required": [
                        "url",
                        "active"
                      ],
                      "properties": {
                        "url": {
                          "type": "string",
                          "description": "The registered HTTPS webhook endpoint URL."
                        },
                        "secret": {
                          "type": "string",
                          "description": "HMAC signing secret (64 hex chars). Only present on initial creation.\nNot returned on subsequent updates. Store it immediately — it cannot\nbe retrieved later without rotating.\n"
                        },
                        "active": {
                          "type": "boolean"
                        },
                        "createdAt": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "updatedAt": {
                          "type": "string",
                          "format": "date-time"
                        }
                      }
                    },
                    "meta": {
                      "$ref": "#/components/schemas/Meta"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalServerError"
          }
        }
      },
      "delete": {
        "operationId": "deleteWebhookConfig",
        "summary": "Deactivate webhook configuration",
        "description": "Soft-deletes the webhook (sets active: false).\nRequired capability: `webhook.manage`\n",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/SalonSlug"
          }
        ],
        "responses": {
          "200": {
            "description": "Configuration deactivated. data is null.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "data",
                    "meta"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    },
                    "data": {
                      "type": "null"
                    },
                    "meta": {
                      "$ref": "#/components/schemas/Meta"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalServerError"
          }
        }
      }
    },
    "/webhooks/config/rotate-secret": {
      "post": {
        "operationId": "rotateWebhookSecret",
        "summary": "Rotate webhook signing secret",
        "description": "Generates a new HMAC secret. Previous secret is immediately invalid.\nNew secret shown once.\nRequired capability: `webhook.manage`\n",
        "tags": [
          "Webhooks"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "salonSlug"
                ],
                "properties": {
                  "salonSlug": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "New secret generated. Shown once — store immediately.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "data",
                    "meta"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    },
                    "data": {
                      "type": "object",
                      "required": [
                        "secret",
                        "active",
                        "updatedAt"
                      ],
                      "properties": {
                        "secret": {
                          "type": "string",
                          "description": "New HMAC-SHA256 secret (64 hex characters). Shown once — store immediately.",
                          "example": "a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1"
                        },
                        "active": {
                          "type": "boolean",
                          "description": "Always true after rotation."
                        },
                        "updatedAt": {
                          "type": "string",
                          "format": "date-time"
                        }
                      }
                    },
                    "meta": {
                      "$ref": "#/components/schemas/Meta"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalServerError"
          }
        }
      }
    },
    "/webhooks/deliveries": {
      "get": {
        "operationId": "listWebhookDeliveries",
        "summary": "List webhook delivery log",
        "description": "Returns webhook delivery audit records for the authenticated tenant — one record per\nbusiness event (`deliveryId`), with every attempt listed in `attempts`.\nSorted by `attemptedAt` (the first attempt) descending (most recent first).\n\nFilters are freely combinable: `event`, `outcome`, and `from`/`to` date range.\nThe `from`/`to` date range uses **UTC-day semantics** — dates are interpreted\nas calendar days in UTC, not in the local timezone.\n\nMax date range per query: 90 days. Records are retained for 12 months; page further\nback with successive date ranges.\n\nRequired capability: `webhook.manage`\n",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/SalonSlug"
          },
          {
            "name": "event",
            "in": "query",
            "description": "Filter by webhook event name (e.g. `booking.confirmed`).\nPattern: `[a-z0-9._-]+`, max 100 characters.\n",
            "schema": {
              "type": "string",
              "maxLength": 100,
              "pattern": "^[a-z0-9._-]+$"
            }
          },
          {
            "name": "outcome",
            "in": "query",
            "description": "Filter by the outcome of the most recent attempt.",
            "schema": {
              "type": "string",
              "enum": [
                "success",
                "timeout",
                "network_error",
                "http_error"
              ]
            }
          },
          {
            "name": "from",
            "in": "query",
            "description": "Start date (inclusive), format `YYYY-MM-DD`. Interpreted in UTC.\nMust be paired with `to`.\n",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "to",
            "in": "query",
            "description": "End date (inclusive), format `YYYY-MM-DD`. Interpreted in UTC.\nMust be paired with `from`. Max range: 90 days.\n",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Maximum number of records to return per page. Default 50, max 100.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "description": "Opaque pagination cursor from a previous response's `meta.cursor`.\nOmit to start from the beginning.\n",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Delivery log page.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "data",
                    "meta"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    },
                    "data": {
                      "type": "object",
                      "required": [
                        "deliveries"
                      ],
                      "properties": {
                        "deliveries": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "required": [
                              "deliveryId",
                              "event",
                              "bookingId",
                              "attemptedAt",
                              "outcome",
                              "statusCode",
                              "status",
                              "attemptCount",
                              "lastAttemptAt",
                              "nextAttemptAt",
                              "attempts"
                            ],
                            "properties": {
                              "deliveryId": {
                                "type": "string",
                                "description": "Business event identifier, identical to the webhook payload `deliveryId`."
                              },
                              "event": {
                                "type": "string",
                                "example": "booking.confirmed"
                              },
                              "bookingId": {
                                "type": "string"
                              },
                              "attemptedAt": {
                                "type": "string",
                                "format": "date-time",
                                "description": "Time of the first attempt."
                              },
                              "outcome": {
                                "type": [
                                  "string",
                                  "null"
                                ],
                                "enum": [
                                  "success",
                                  "timeout",
                                  "network_error",
                                  "http_error",
                                  null
                                ],
                                "description": "Outcome of the most recent attempt; null while the first attempt is in flight."
                              },
                              "statusCode": {
                                "type": [
                                  "integer",
                                  "null"
                                ],
                                "description": "HTTP status code of the most recent attempt; null for timeout and network_error outcomes."
                              },
                              "status": {
                                "type": [
                                  "string",
                                  "null"
                                ],
                                "enum": [
                                  "processing",
                                  "retry_scheduled",
                                  "succeeded",
                                  "failed",
                                  "exhausted",
                                  null
                                ],
                                "description": "Delivery state. `failed` = not retried (non-retryable response or inactive\nwebhook configuration); `exhausted` = retryable failure on the last allowed\nattempt. Null on records written before the retry state machine existed.\n"
                              },
                              "attemptCount": {
                                "type": "integer",
                                "minimum": 0
                              },
                              "lastAttemptAt": {
                                "type": [
                                  "string",
                                  "null"
                                ],
                                "format": "date-time"
                              },
                              "nextAttemptAt": {
                                "type": [
                                  "string",
                                  "null"
                                ],
                                "format": "date-time",
                                "description": "Scheduled time of the next attempt while `status` is `retry_scheduled`."
                              },
                              "attempts": {
                                "type": "array",
                                "items": {
                                  "type": "object",
                                  "required": [
                                    "attemptNo",
                                    "attemptedAt",
                                    "outcome",
                                    "statusCode",
                                    "durationMs",
                                    "retryAfterSeconds"
                                  ],
                                  "properties": {
                                    "attemptNo": {
                                      "type": "integer",
                                      "minimum": 1
                                    },
                                    "attemptedAt": {
                                      "type": "string",
                                      "format": "date-time"
                                    },
                                    "outcome": {
                                      "type": "string",
                                      "enum": [
                                        "success",
                                        "timeout",
                                        "network_error",
                                        "http_error"
                                      ]
                                    },
                                    "statusCode": {
                                      "type": [
                                        "integer",
                                        "null"
                                      ]
                                    },
                                    "durationMs": {
                                      "type": [
                                        "integer",
                                        "null"
                                      ]
                                    },
                                    "retryAfterSeconds": {
                                      "type": [
                                        "integer",
                                        "null"
                                      ],
                                      "description": "Honoured `Retry-After` value, when the response carried one."
                                    }
                                  }
                                }
                              }
                            }
                          }
                        }
                      }
                    },
                    "meta": {
                      "$ref": "#/components/schemas/Meta"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalServerError"
          }
        }
      }
    },
    "/bookings/{bookingId}/events": {
      "get": {
        "operationId": "listBookingEvents",
        "summary": "List booking event timeline",
        "description": "Returns the full lifecycle event history for a booking, sorted by `createdAt`\nascending (oldest event first).\n\nMax 100 events (booking subcollection is typically 5–20 documents).\nNo cursor pagination.\n\nInternal identity fields (`actorUid`, `actorType`) are never included.\nRequired capability: `booking.read`\n",
        "tags": [
          "Bookings"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/BookingId"
          }
        ],
        "responses": {
          "200": {
            "description": "Booking event timeline.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "data",
                    "meta"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    },
                    "data": {
                      "type": "object",
                      "required": [
                        "events"
                      ],
                      "properties": {
                        "events": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "required": [
                              "eventId",
                              "type",
                              "createdAt",
                              "createdVia",
                              "detail"
                            ],
                            "properties": {
                              "eventId": {
                                "type": "string"
                              },
                              "type": {
                                "type": "string",
                                "example": "created"
                              },
                              "createdAt": {
                                "type": "string",
                                "format": "date-time"
                              },
                              "createdVia": {
                                "type": [
                                  "string",
                                  "null"
                                ],
                                "example": "booking_page"
                              },
                              "detail": {
                                "type": [
                                  "object",
                                  "null"
                                ],
                                "description": "Event-type-specific structured detail. Currently null for all v1 event types."
                              }
                            }
                          }
                        }
                      }
                    },
                    "meta": {
                      "$ref": "#/components/schemas/Meta"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalServerError"
          }
        }
      }
    }
  }
}
