{
  "openapi": "3.1.0",
  "info": {
    "title": "Covered Public API",
    "version": "1.0.0",
    "description": "Public API for Covered — the hospitality OS for restaurants, bars, and venues. Use these endpoints to integrate bookings, customers, menus, payments, and webhooks into partner products.\n\n## Authentication\n\nAll endpoints require an API key in the `Authorization: Bearer covt_<prefix>_<secret>` header. Operators mint keys from `/dashboard/settings/api` and tick the scopes their integration needs. The secret is shown ONCE at creation — store it securely.\n\n## Rate limiting\n\nLimits are counted per API key.\n\n- Bookings and the customer search share one allowance: `GET` and `POST /api/v1/bookings`, `GET`, `PATCH` and `DELETE /api/v1/bookings/{id}` and `GET /api/v1/customers` together get 100 requests a minute (the bookings list and create also count towards 5,000 an hour).\n- Every other route gets 60 requests a minute and 3,000 an hour, counted separately for each resource: availability, tables, menus, payments, audit, webhooks, and `/api/v1/customers/{id}`.\n\nOnce your key and scope are accepted, every response Covered answers deliberately, success or error, carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. An unexpected server error (a 500 Covered did not plan for) may carry none. A request refused before that (401 for a bad key, 403 for a missing scope) may carry no rate-limit headers, or headers reporting a limit of 0. `X-RateLimit-Remaining` is a hint rather than an exact count, so react to the 429.\n\n## Pagination\n\nList endpoints use cursor pagination. Pass `limit` (default 50, max 200 or 500 depending on route) and `cursor` (the `nextCursor` from the previous response).\n\n## Errors\n\nAll error responses share the shape `{ \"error\": \"human-readable string\" }` with the correct HTTP status. See the `Error` schema below.\n\n## Versioning\n\nNo breaking changes inside v1. Breaking changes ship as `/api/v2/*` with a `Sunset` header on the deprecated v1 equivalent (90-day grace period minimum).\n\n### Changes inside v1\n\n- 29 September 2026: `GET`, `PATCH` and `DELETE /api/v1/bookings/{id}` and the `booking.*` webhooks now return the documented `Booking` shape, the same as the list and create. Undocumented columns that used to leak through (payment-provider ids, risk scores, guest feedback, staff ids and similar) are gone. The service times, `tableJoinId`, `allergenTags` and `dietTags` stay and are now documented. Deposit fields in webhooks follow the rule on `WebhookEventEnvelope`.\n- 29 September 2026: webhook deliveries no longer follow redirects. Register the final URL.\n- 29 September 2026: `PATCH /api/v1/bookings/{id}` refuses a party-size increase that would take a deposit, prepayment or card on security, with 422 `PAYMENT_REQUIRED_UNSUPPORTED`, the same rule as create.",
    "contact": {
      "name": "Covered Support",
      "email": "api@covered.technology",
      "url": "https://restaurants.covered.technology/api-docs"
    },
    "license": {
      "name": "Proprietary",
      "url": "https://www.covered.technology/terms"
    }
  },
  "servers": [
    {
      "url": "https://www.covered.technology",
      "description": "Production"
    }
  ],
  "tags": [
    { "name": "bookings", "description": "Create, read, update, and cancel reservations." },
    { "name": "customers", "description": "Read and update customer profiles (PII decrypted server-side)." },
    { "name": "tables", "description": "Read the venue floor-plan layout." },
    { "name": "availability", "description": "Query available time slots for a date + party + booking type." },
    { "name": "menus", "description": "Read booking-type menus and pricing (currency-aware)." },
    { "name": "payments", "description": "Read charges taken against a guest's saved card (no-show, late-cancellation, damage and other fees). Deposits are not in this feed: read them from the booking." },
    { "name": "webhooks", "description": "Register and remove outbound webhook endpoints." },
    { "name": "audit", "description": "Read recent audit log entries for compliance / SIEM." }
  ],
  "security": [
    { "ApiKeyAuth": [] }
  ],
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "covt_<prefix>_<secret>",
        "description": "API key minted at /dashboard/settings/api. Carries the scopes ticked at creation time."
      }
    },
    "parameters": {
      "Limit": {
        "name": "limit",
        "in": "query",
        "schema": { "type": "integer", "minimum": 1, "maximum": 500, "default": 50 },
        "description": "Maximum number of items to return."
      },
      "Cursor": {
        "name": "cursor",
        "in": "query",
        "schema": { "type": "string" },
        "description": "Pagination cursor. Pass the `nextCursor` value from the previous response."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": { "type": "string", "example": "Missing API key" }
        }
      },
      "Pagination": {
        "type": "object",
        "required": ["limit"],
        "properties": {
          "nextCursor": { "type": ["string", "null"] },
          "limit": { "type": "integer" }
        }
      },
      "Money": {
        "type": "object",
        "required": ["amountSmallestUnit", "currency"],
        "description": "Monetary amount in the smallest currency unit (pence for GBP, cents for USD), accompanied by the ISO-4217 currency code.",
        "properties": {
          "amountSmallestUnit": { "type": "integer", "example": 1500 },
          "currency": { "type": "string", "example": "GBP", "description": "ISO 4217 three-letter code." }
        }
      },
      "Booking": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "bookingRef": { "type": "string", "example": "ABCD2345" },
          "bookingDate": { "type": "string", "format": "date-time" },
          "timeSlot": { "type": "string", "example": "19:30" },
          "partySize": { "type": "integer" },
          "bookingType": { "type": "string" },
          "status": {
            "type": "string",
            "enum": ["PENDING", "PENDING_DEPOSIT", "CONFIRMED", "SEATED", "COMPLETED", "CANCELLED", "NO_SHOW"]
          },
          "source": { "type": "string" },
          "specialRequests": { "type": ["string", "null"] },
          "version": { "type": "integer", "description": "Optimistic concurrency token. Pass in If-Match on PATCH." },
          "arrivedAt": { "type": ["string", "null"], "format": "date-time", "description": "When the guest arrived, if the venue recorded it." },
          "seatedAt": { "type": ["string", "null"], "format": "date-time", "description": "When the party was seated." },
          "departedAt": { "type": ["string", "null"], "format": "date-time", "description": "When the party left or the table was cleared." },
          "tableJoinId": { "type": ["string", "null"], "description": "The combined-table join the booking sits on, when it spans more than one table." },
          "allergenTags": { "type": "array", "items": { "type": "string" }, "description": "Allergens flagged for this booking, from the UK FSA's 14 regulated allergens. Tags, not free text." },
          "dietTags": { "type": "array", "items": { "type": "string" }, "description": "Dietary preferences flagged for this booking (for example vegetarian or vegan)." },
          "depositRequiredPence": { "type": ["integer", "null"], "description": "The deposit owed, in the smallest currency unit. null when no deposit applies. Deposits live on the booking, not in /api/v1/payments. The deposit fields are only returned to a key that also holds the payments:read scope." },
          "depositPaidPence": { "type": ["integer", "null"], "description": "The deposit actually taken, in the smallest currency unit." },
          "depositPaidAt": { "type": ["string", "null"], "format": "date-time" },
          "depositRefundedPence": { "type": "integer", "description": "How much of the deposit has been refunded, in the smallest currency unit." },
          "customer": { "$ref": "#/components/schemas/Customer" },
          "table": {
            "type": ["object", "null"],
            "properties": {
              "id": { "type": "string" },
              "tableName": { "type": "string" }
            }
          }
        }
      },
      "CreateBookingInput": {
        "type": "object",
        "required": ["bookingDate", "timeSlot", "partySize", "bookingType", "firstName", "lastName", "email", "phone"],
        "properties": {
          "bookingDate": { "type": "string", "example": "2026-06-01" },
          "timeSlot": { "type": "string", "example": "19:30" },
          "partySize": { "type": "integer", "minimum": 1, "maximum": 50 },
          "bookingType": { "type": "string" },
          "firstName": { "type": "string" },
          "lastName": { "type": "string" },
          "email": { "type": "string", "format": "email" },
          "phone": { "type": "string" },
          "tableTypePreference": { "type": ["string", "null"] },
          "areaPreference": { "type": ["string", "null"], "description": "The area (floor-plan section id) the guest chose. Strict: the booking is placed on a table or set combination in that area, or refused 409 SEAT_TYPE_UNAVAILABLE. Ignored (nothing stored) at a venue with fewer than two areas; an id that is not one of the venue's areas is refused 400 AREA_UNKNOWN. GET /api/v1/availability is not area-aware, so a time it lists can still be refused for an area: omit areaPreference to accept any area." },
          "specialRequests": { "type": ["string", "null"] },
          "whatsappOptIn": { "type": "boolean" },
          "smsOptIn": { "type": "boolean" },
          "emailOptIn": { "type": "boolean" },
          "idempotencyKey": { "type": "string", "description": "Optional. Pass a stable key (e.g. partner-side order id) to make POST safely retryable." }
        }
      },
      "PatchBookingInput": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "status": { "type": "string", "enum": ["PENDING", "CONFIRMED", "SEATED", "COMPLETED", "CANCELLED", "NO_SHOW"] },
          "bookingDate": { "type": "string", "example": "2026-06-01" },
          "timeSlot": { "type": "string", "example": "20:00" },
          "partySize": { "type": "integer", "minimum": 1, "maximum": 50 },
          "specialRequests": { "type": ["string", "null"] }
        }
      },
      "Customer": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "firstName": { "type": "string" },
          "lastName": { "type": "string" },
          "email": { "type": "string", "format": "email" },
          "phone": { "type": ["string", "null"] },
          "whatsappOptIn": { "type": "boolean" },
          "smsOptIn": { "type": "boolean" },
          "emailOptIn": { "type": "boolean" },
          "isVip": { "type": "boolean" },
          "visitCount": { "type": "integer" },
          "noShowCount": { "type": "integer" },
          "createdAt": { "type": "string", "format": "date-time" },
          "updatedAt": { "type": "string", "format": "date-time" }
        }
      },
      "PatchCustomerInput": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "firstName": { "type": "string" },
          "lastName": { "type": "string" },
          "phone": { "type": ["string", "null"] },
          "whatsappOptIn": { "type": "boolean" },
          "smsOptIn": { "type": "boolean" },
          "emailOptIn": { "type": "boolean" },
          "notes": { "type": ["string", "null"] },
          "isVip": { "type": "boolean" }
        }
      },
      "Table": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "name": { "type": "string" },
          "capacity": { "type": "integer" },
          "minCovers": { "type": "integer" },
          "section": { "type": "string" },
          "tableType": { "type": "string" },
          "shape": { "type": "string" },
          "isActive": { "type": "boolean" },
          "status": { "type": "string", "example": "AVAILABLE" },
          "venueId": { "type": ["string", "null"] },
          "revenue": {
            "type": "object",
            "description": "Only present when ?includeRevenue=true. Sum of succeeded charges against bookings assigned to this table.",
            "properties": {
              "amountSmallestUnit": { "type": "integer" },
              "currency": { "type": "string" }
            }
          }
        }
      },
      "AvailabilitySlot": {
        "type": "object",
        "properties": {
          "time": { "type": "string", "example": "19:30" },
          "fittingTables": { "type": "integer", "description": "Informational: tables open to online bookings that seat the party on their own (capacity >= partySize). A larger party can still be bookable across joined tables, so rely on `bookable`, not this count." },
          "bookable": { "type": "boolean", "description": "With `bookingTypeId`: always true, because only times the booking engine would accept are listed. Without it: true when at least one table open to online bookings seats the party on its own (the same as `fittingTables > 0`), because the party's table fit is only checked for a booking type." }
        }
      },
      "MenuItem": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "name": { "type": "string" },
          "description": { "type": ["string", "null"] },
          "category": { "type": "string" },
          "bookingTypeIds": { "type": "array", "items": { "type": "string" } },
          "venueIds": { "type": ["array", "null"], "items": { "type": "string" } },
          "isAvailable": { "type": "boolean" },
          "imageUrl": { "type": ["string", "null"] },
          "allergens": { "type": "array", "items": { "type": "string" } },
          "sortOrder": { "type": "integer" },
          "price": {
            "type": "object",
            "properties": {
              "amountSmallestUnit": { "type": "integer" },
              "amountMajor": { "type": "number" },
              "amountMaxSmallestUnit": { "type": ["integer", "null"] },
              "currency": { "type": "string" }
            }
          }
        }
      },
      "Payment": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "bookingId": {
            "type": ["string", "null"],
            "description": "The booking this charge was taken against. null when that booking has since been deleted: the payment record outlives its booking, and bookingRef still names it."
          },
          "bookingRef": {
            "type": ["string", "null"],
            "description": "The booking's reference, copied onto the charge when it was taken, so it survives the booking being deleted."
          },
          "amount": { "$ref": "#/components/schemas/Money" },
          "chargeType": { "type": "string", "enum": ["DEPOSIT", "CARD_OF_SECURITY"], "description": "CARD_OF_SECURITY for every charge Covered takes today. DEPOSIT is reserved: deposits are recorded on the booking, not as charges." },
          "reason": { "type": "string", "enum": ["NO_SHOW", "LATE_CANCELLATION", "DAMAGE", "OTHER"] },
          "description": { "type": ["string", "null"] },
          "stripePaymentIntentId": { "type": "string" },
          "stripePaymentStatus": { "type": "string", "example": "succeeded" },
          "chargedByEmail": { "type": "string" },
          "createdAt": { "type": "string", "format": "date-time" }
        }
      },
      "WebhookEventType": {
        "type": "string",
        "description": "P2 Rec #19 (2026-05-19) — expanded event catalogue. Grouped: Bookings (4), Customers (4), Payments (4), POS (3), Wallet passes (3), Marketing delivery (3). Pick the subset relevant to your integration; subscribing to a family you don't consume only costs partner-side bandwidth (delivery still runs through the same HMAC-signed + exponential-backoff pipeline as the original 4 booking events).",
        "enum": [
          "booking.created",
          "booking.updated",
          "booking.cancelled",
          "booking.no_show_charged",
          "customer.created",
          "customer.updated",
          "customer.merged",
          "customer.erased",
          "payment.charged",
          "payment.refunded",
          "payment.dispute_opened",
          "payment.dispute_closed",
          "pos.check.opened",
          "pos.check.closed",
          "pos.check.voided",
          "wallet.pass.installed",
          "wallet.pass.uninstalled",
          "wallet.pass.updated",
          "marketing.delivery.sent",
          "marketing.delivery.opened",
          "marketing.delivery.clicked"
        ]
      },
      "WebhookEndpoint": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "url": { "type": "string", "format": "uri" },
          "events": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/WebhookEventType" }
          },
          "lastSuccessAt": { "type": ["string", "null"], "format": "date-time" },
          "lastFailureAt": { "type": ["string", "null"], "format": "date-time" },
          "consecutiveFailures": { "type": "integer" },
          "disabledAt": { "type": ["string", "null"], "format": "date-time" },
          "createdAt": { "type": "string", "format": "date-time" }
        }
      },
      "CreateWebhookInput": {
        "type": "object",
        "required": ["url", "events"],
        "properties": {
          "url": { "type": "string", "format": "uri", "description": "HTTPS only. Register the final URL: redirects are not followed. A 3xx response counts as a failed delivery, is not retried, and counts towards the automatic pause after repeated failures." },
          "events": {
            "type": "array",
            "minItems": 1,
            "items": { "$ref": "#/components/schemas/WebhookEventType" }
          }
        }
      },
      "WebhookEventEnvelope": {
        "type": "object",
        "description": "Wire format of every outbound webhook POST body. The HMAC signature on `X-Covered-Signature` covers the exact JSON bytes of this envelope.",
        "properties": {
          "event": { "$ref": "#/components/schemas/WebhookEventType" },
          "deliveryId": { "type": "string", "description": "Unique id for this delivery attempt — useful for idempotent partner-side processing." },
          "createdAt": { "type": "string", "format": "date-time" },
          "data": {
            "type": "object",
            "description": "The event-specific payload. Shape depends on `event`. All payloads carry `organisationId` so multi-tenant consumers can route safely. Money fields carry `{ amountSmallestUnit, currency }` after the multi-currency rollout.\n\n`booking.created`, `booking.updated` and `booking.cancelled` carry `organisationId` and `booking` (some events add further keys). `booking` is the same `Booking` shape `GET /api/v1/bookings/{id}` returns (including `customer` and `table` when loaded), plus `organisationId`. Card, customer and payment-intent ids at the payment providers, risk scores, guest feedback, cancellation reasons and staff ids are never sent. The deposit fields are included only when the endpoint was registered through the API by a key that holds payments:read and every key that has since re-pointed or re-enabled it holds payments:read too; endpoints added in the dashboard never receive them."
          }
        }
      },
      "WebhookEndpointWithSecret": {
        "allOf": [
          { "$ref": "#/components/schemas/WebhookEndpoint" },
          {
            "type": "object",
            "properties": {
              "secret": { "type": "string", "description": "HMAC signing secret. Returned ONCE on create — store it securely." }
            }
          }
        ]
      },
      "AuditEntry": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "action": { "type": "string" },
          "actorId": { "type": ["string", "null"] },
          "actorEmail": { "type": "string" },
          "actorIp": { "type": ["string", "null"] },
          "resourceType": { "type": ["string", "null"] },
          "resourceId": { "type": ["string", "null"] },
          "metadata": { "type": ["object", "null"] },
          "createdAt": { "type": "string", "format": "date-time" }
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Missing, malformed, or revoked API key.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Forbidden": {
        "description": "API key is valid but is missing the required scope.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "NotFound": {
        "description": "Resource not found (or not owned by the authed tenant — cross-tenant probes return 404).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "BadRequest": {
        "description": "Validation failure on query / body.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "RateLimitExceeded": {
        "description": "Too many requests for this API key in the current window.",
        "headers": {
          "X-RateLimit-Limit": { "schema": { "type": "integer" } },
          "X-RateLimit-Remaining": { "schema": { "type": "integer" } },
          "X-RateLimit-Reset": { "schema": { "type": "integer" } }
        },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    }
  },
  "paths": {
    "/api/v1/bookings": {
      "get": {
        "tags": ["bookings"],
        "operationId": "listBookings",
        "summary": "List bookings",
        "description": "Cursor-paginated list of bookings for the authed tenant. Filter by date or status.",
        "security": [{ "ApiKeyAuth": ["bookings:read"] }],
        "parameters": [
          { "name": "date", "in": "query", "schema": { "type": "string", "example": "2026-06-01" } },
          { "name": "status", "in": "query", "schema": { "type": "string" } },
          { "$ref": "#/components/parameters/Limit" },
          { "$ref": "#/components/parameters/Cursor" }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/Booking" } },
                    "pagination": { "$ref": "#/components/schemas/Pagination" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      },
      "post": {
        "tags": ["bookings"],
        "operationId": "createBooking",
        "summary": "Create a booking",
        "description": "Runs the full business-rule battery. A party at/over the venue's large-party threshold booked inside its lead window is rejected 400 with code LARGE_PARTY_LEAD_TIME.",
        "security": [{ "ApiKeyAuth": ["bookings:write"] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateBookingInput" } } }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "data": { "$ref": "#/components/schemas/Booking" } }
                }
              }
            }
          },
          "200": { "description": "Idempotent replay — body identical to 201." },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/api/v1/bookings/{id}": {
      "parameters": [
        { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
      ],
      "get": {
        "tags": ["bookings"],
        "operationId": "getBooking",
        "summary": "Read a booking",
        "security": [{ "ApiKeyAuth": ["bookings:read"] }],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Booking" } } }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      },
      "patch": {
        "tags": ["bookings"],
        "operationId": "patchBooking",
        "summary": "Update a booking",
        "description": "Honours `If-Match: <version>` for optimistic concurrency. A mismatch returns 412 with the current version.\n\nA party-size increase that would make the booking take a deposit, prepayment or card on security (which the partner API cannot collect) returns 422 with `code: PAYMENT_REQUIRED_UNSUPPORTED` and changes nothing. The guest must use the venue's hosted booking page for that. A booking that already needed payment at its old size, and any decrease, are not affected.",
        "security": [{ "ApiKeyAuth": ["bookings:write"] }],
        "parameters": [
          { "name": "If-Match", "in": "header", "schema": { "type": "string" } }
        ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchBookingInput" } } }
        },
        "responses": {
          "200": { "description": "OK" },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "412": { "description": "Precondition failed — If-Match version did not match the current version." },
          "422": { "description": "Refused by a booking rule. `code: PAYMENT_REQUIRED_UNSUPPORTED` means the new party size would need a deposit, prepayment or card on security; other codes name the rule (for example `TABLE_CAPACITY_TOO_SMALL`)." },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      },
      "delete": {
        "tags": ["bookings"],
        "operationId": "cancelBooking",
        "summary": "Cancel a booking",
        "description": "Idempotent — calling on an already-cancelled booking returns 200.",
        "security": [{ "ApiKeyAuth": ["bookings:write"] }],
        "responses": {
          "200": { "description": "OK" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/api/v1/customers": {
      "get": {
        "tags": ["customers"],
        "operationId": "listCustomers",
        "summary": "List customers",
        "security": [{ "ApiKeyAuth": ["customers:read"] }],
        "parameters": [
          { "name": "email", "in": "query", "schema": { "type": "string", "format": "email" } },
          { "name": "phone", "in": "query", "schema": { "type": "string" } },
          { "$ref": "#/components/parameters/Limit" },
          { "$ref": "#/components/parameters/Cursor" }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/Customer" } },
                    "pagination": { "$ref": "#/components/schemas/Pagination" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/api/v1/customers/{id}": {
      "parameters": [
        { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
      ],
      "get": {
        "tags": ["customers"],
        "operationId": "getCustomer",
        "summary": "Read a customer",
        "security": [{ "ApiKeyAuth": ["customers:read"] }],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Customer" } } }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      },
      "patch": {
        "tags": ["customers"],
        "operationId": "patchCustomer",
        "summary": "Update a customer profile",
        "description": "Strict allowlist of fields. Derived state (visitCount, totalSpend) is owned by the dashboard.",
        "security": [{ "ApiKeyAuth": ["customers:write"] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchCustomerInput" } } }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Customer" } } }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "description": "Customer has been erased (GDPR Article 17) and cannot be modified." },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/api/v1/tables": {
      "get": {
        "tags": ["tables"],
        "operationId": "listTables",
        "summary": "List tables",
        "description": "Floor-plan layout. Pass `includeRevenue=true` to attach a currency-aware revenue summary per table. Revenue also needs the payments:read scope: a key without it gets 403 when it asks for revenue (tables are still listed without `includeRevenue`).",
        "security": [{ "ApiKeyAuth": ["tables:read"] }],
        "parameters": [
          { "name": "venueId", "in": "query", "schema": { "type": "string" } },
          { "name": "section", "in": "query", "schema": { "type": "string" } },
          { "name": "includeRevenue", "in": "query", "description": "Needs payments:read as well as tables:read.", "schema": { "type": "string", "enum": ["true", "false"] } },
          { "$ref": "#/components/parameters/Limit" },
          { "$ref": "#/components/parameters/Cursor" }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/Table" } },
                    "pagination": { "$ref": "#/components/schemas/Pagination" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/api/v1/tables/{id}": {
      "parameters": [
        { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
      ],
      "get": {
        "tags": ["tables"],
        "operationId": "getTable",
        "summary": "Read a table",
        "description": "Single table by id, scoped to the authed tenant. Cross-tenant ids 404 (never 403, so ids can't be enumerated by error shape).",
        "security": [{ "ApiKeyAuth": ["tables:read"] }],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Table" } } }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/api/v1/availability": {
      "get": {
        "tags": ["availability"],
        "operationId": "queryAvailability",
        "summary": "Query available slots",
        "description": "Returns the times a guest could book on this date, from the same slot engine as the Covered booking widget: the booking type's bookable hours (special days, blocked dates, last seating, overnight services included) at the venue's slot interval, with times removed when they are closed out, already passed, inside the large-party lead window, over a pacing limit, or when no table or table combination can seat the party. A date past the venue's booking horizon returns no times (reason `beyond_booking_horizon`). With `bookingTypeId`, every listed time is `bookable: true`.\n\nPass `bookingTypeId` for a definitive answer. Without it, pacing and the party's table fit are not applied (both depend on the booking type), times are dropped only when every table is taken, and `bookable` only says whether a single table seats the party. POST /api/v1/bookings needs a booking type anyway. An unknown `venueId` returns 400.\n\nNot area- or seat-type-aware: the times are for any area and any table type. A POST that names `areaPreference` or `tableTypePreference` is placed strictly in that choice and can be refused 409 SEAT_TYPE_UNAVAILABLE at a time listed here; omit them to accept any table.",
        "security": [{ "ApiKeyAuth": ["availability:read"] }],
        "parameters": [
          { "name": "date", "in": "query", "required": true, "schema": { "type": "string", "example": "2026-06-01" } },
          { "name": "partySize", "in": "query", "required": true, "schema": { "type": "integer", "minimum": 1, "maximum": 200 } },
          { "name": "bookingTypeId", "in": "query", "schema": { "type": "string" } },
          { "name": "venueId", "in": "query", "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/AvailabilitySlot" } },
                    "date": { "type": "string" },
                    "partySize": { "type": "integer" },
                    "bookingTypeId": { "type": ["string", "null"] },
                    "reason": { "type": "string", "description": "Present when data is empty and Covered knows why. One of: booking_type_disabled, party_below_min, party_above_max, beyond_booking_horizon (the date is further ahead than the venue takes bookings), blocked_date, day_not_available, venue_closed, venue_closed_special_day, booking_type_not_available_special_day, outside_venue_hours, no_slots_in_window, all_slots_closed_out, all_slots_past, large_party_lead_time (the party's lead window blocks every time left), pacing_limit, fully_booked (no table or combination can seat the party at any time left)." }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/api/v1/menus": {
      "get": {
        "tags": ["menus"],
        "operationId": "listMenuItems",
        "summary": "List menu items",
        "description": "Read-only menu surface. Authoring lives in the dashboard. Prices are currency-aware.",
        "security": [{ "ApiKeyAuth": ["menus:read"] }],
        "parameters": [
          { "name": "bookingTypeId", "in": "query", "schema": { "type": "string" } },
          { "name": "category", "in": "query", "schema": { "type": "string" } },
          { "name": "includeUnavailable", "in": "query", "schema": { "type": "string", "enum": ["true", "false"] } },
          { "$ref": "#/components/parameters/Limit" },
          { "$ref": "#/components/parameters/Cursor" }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/MenuItem" } },
                    "pagination": { "$ref": "#/components/schemas/Pagination" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/api/v1/menus/{id}": {
      "parameters": [
        { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
      ],
      "get": {
        "tags": ["menus"],
        "operationId": "getMenuItem",
        "summary": "Read a menu item",
        "description": "Single menu item by id. Returns the same currency-aware shape as the list endpoint. Cross-tenant ids 404.",
        "security": [{ "ApiKeyAuth": ["menus:read"] }],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/MenuItem" } } }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/api/v1/payments": {
      "get": {
        "tags": ["payments"],
        "operationId": "listPayments",
        "summary": "List charges",
        "description": "Charges staff took against a guest's saved card: no-show, late-cancellation, damage and other fees (chargeType CARD_OF_SECURITY; the `reason` field says which). Every entry includes the ISO-4217 currency code.\n\nDeposits are NOT in this feed. A deposit is recorded on its booking, so reconcile deposits from `GET /api/v1/bookings`: `depositRequiredPence`, `depositPaidPence`, `depositPaidAt` and `depositRefundedPence` (returned when your key also holds the payments:read scope).",
        "security": [{ "ApiKeyAuth": ["payments:read"] }],
        "parameters": [
          { "name": "bookingId", "in": "query", "schema": { "type": "string" } },
          { "name": "from", "in": "query", "schema": { "type": "string", "example": "2026-01-01" } },
          { "name": "to", "in": "query", "schema": { "type": "string", "example": "2026-12-31" } },
          { "name": "status", "in": "query", "schema": { "type": "string" } },
          { "$ref": "#/components/parameters/Limit" },
          { "$ref": "#/components/parameters/Cursor" }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/Payment" } },
                    "pagination": { "$ref": "#/components/schemas/Pagination" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/api/v1/payments/{id}": {
      "parameters": [
        { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
      ],
      "get": {
        "tags": ["payments"],
        "operationId": "getPayment",
        "summary": "Read a charge",
        "description": "Single charge by id, carrying the Money envelope (smallest-unit + ISO-4217 currency). Cross-tenant ids 404.",
        "security": [{ "ApiKeyAuth": ["payments:read"] }],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Payment" } } }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/api/v1/webhooks": {
      "get": {
        "tags": ["webhooks"],
        "operationId": "listWebhooks",
        "summary": "List webhook endpoints",
        "description": "Signing secret is never returned on read — only at creation.",
        "security": [{ "ApiKeyAuth": ["webhooks:manage"] }],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/WebhookEndpoint" } }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      },
      "post": {
        "tags": ["webhooks"],
        "operationId": "createWebhook",
        "summary": "Register an endpoint",
        "description": "Returns the plaintext signing secret ONCE — store it.\n\nEach event also needs the read scope for the same data: `booking.*` and `wallet.pass.*` need bookings:read; `booking.no_show_charged` needs bookings:read and payments:read; `customer.*` and `marketing.delivery.*` need customers:read; `payment.*` and `pos.check.*` need payments:read. A key missing any of them gets 403 naming the missing scopes, and nothing is saved.\n\nRevoking the API key that registered an endpoint pauses the endpoint (`disabledAt` is set); it is not deleted.\n\nRedirects are not followed. If your URL answers 3xx (for example a trailing-slash or apex-to-www redirect), the delivery counts as failed, is not retried, and counts towards the automatic pause after repeated failures. Register the final URL your server answers 2xx on.",
        "security": [{ "ApiKeyAuth": ["webhooks:manage"] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateWebhookInput" } } }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "data": { "$ref": "#/components/schemas/WebhookEndpointWithSecret" } }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/api/v1/webhooks/{id}": {
      "parameters": [
        { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
      ],
      "delete": {
        "tags": ["webhooks"],
        "operationId": "deleteWebhook",
        "summary": "Delete an endpoint",
        "security": [{ "ApiKeyAuth": ["webhooks:manage"] }],
        "responses": {
          "200": { "description": "OK" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      },
      "patch": {
        "tags": ["webhooks"],
        "operationId": "updateWebhook",
        "summary": "Update an endpoint (URL / events / enable-disable)",
        "description": "Partial update. At least one of url / events / disabled is required. `disabled: false` re-enables a circuit-broken endpoint: it clears disabledAt and resets the consecutive-failure counter so delivery resumes with the SAME signing secret (no delete + recreate, which would rotate the secret). `disabled: true` proactively pauses delivery. A changed url is re-validated against the SSRF host blocklist.\n\nWhen the change adds events, changes the url or re-enables the endpoint (`disabled: false`), the key must hold the read scope for every event the endpoint will receive, including events already on it. Each event also needs the read scope for the same data: `booking.*` and `wallet.pass.*` need bookings:read; `booking.no_show_charged` needs bookings:read and payments:read; `customer.*` and `marketing.delivery.*` need customers:read; `payment.*` and `pos.check.*` need payments:read. A key missing any of them gets 403 naming the missing scopes, and nothing changes. Pausing (`disabled: true`) needs only webhooks:manage.\n\nChanging the url or re-enabling links the endpoint to this key: revoking the key later pauses it.\n\nRedirects are not followed. If your URL answers 3xx (for example a trailing-slash or apex-to-www redirect), the delivery counts as failed, is not retried, and counts towards the automatic pause after repeated failures. Register the final URL your server answers 2xx on.",
        "security": [{ "ApiKeyAuth": ["webhooks:manage"] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "minProperties": 1,
                "properties": {
                  "url": { "type": "string", "format": "uri", "example": "https://example.com/hooks/covered" },
                  "events": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
                  "disabled": { "type": "boolean", "description": "false re-enables (clears the breaker); true pauses delivery" }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "OK" },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    },
    "/api/v1/audit": {
      "get": {
        "tags": ["audit"],
        "operationId": "listAuditEntries",
        "summary": "List audit entries",
        "description": "Recent audit log entries for compliance / SIEM integration.",
        "security": [{ "ApiKeyAuth": ["audit:read"] }],
        "parameters": [
          { "name": "action", "in": "query", "description": "One audit action, for example LOGIN_SUCCESS. An action Covered does not record returns an empty list, not an error.", "schema": { "type": "string" } },
          { "name": "actorEmail", "in": "query", "schema": { "type": "string" } },
          { "name": "resourceType", "in": "query", "schema": { "type": "string" } },
          { "name": "from", "in": "query", "schema": { "type": "string", "example": "2026-01-01" } },
          { "name": "to", "in": "query", "schema": { "type": "string", "example": "2026-12-31" } },
          { "$ref": "#/components/parameters/Limit" },
          { "$ref": "#/components/parameters/Cursor" }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": { "type": "array", "items": { "$ref": "#/components/schemas/AuditEntry" } },
                    "pagination": { "$ref": "#/components/schemas/Pagination" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "429": { "$ref": "#/components/responses/RateLimitExceeded" }
        }
      }
    }
  }
}
