#  ⚠️ THIS FILE IS DOCUMENTATION THAT CAN BE WRONG, AND ONE GATE STOPS THAT.
#
#  `scripts/check-api-docs.mjs` compares the paths below against
#  `src/publicapi/routes.ts` — the table the requests are actually dispatched
#  from — and the `x-scope` on each operation against the scope that table
#  demands. It fails the build in BOTH directions: a documented route the code
#  does not serve, and a served route this file does not document.
#
#  (It used to compare against the `GET /v1` discovery map instead, because the
#  router was an if-chain no gate could parse. The table replaced the chain, so
#  the gate reads the surface itself and the discovery map is DERIVED from it.)
#
#  That gate is the whole reason this file is worth having. A hand-written
#  `docs/public-api.md` already existed and had already drifted: it omitted
#  PATCH's "no writable fields" error, described DELETE /v1/webhooks as
#  returning the `{data}` envelope when it returned `{ok:true}`, and did not
#  mention that DELETE was metered against the READ budget. None of that was
#  anyone's fault; it is what prose about code does when nothing checks it.
#
#  (Both of those last two are now true the other way round — DELETE returns
#  the `{data}` envelope and is metered as a write — which is the point: they
#  were fixed in the code and this file moved with them, in one commit.)
#
#  Rendered at https://venued.eu/docs/api, by venued-web's own components,
#  from a byte-identical copy of this file.

openapi: 3.1.0

info:
  title: Venued Public API
  version: "1.0.0"
  summary: Events, orders and webhooks, for organisers and venues.
  description: |
    The Venued API is a small, deliberately narrow REST surface over data you
    already own on Venued. Every key belongs to one **principal** — an
    organiser account or a venue — and every response is filtered to it.
    `GET /v1/me` tells you which one a key is.

    ## What this API deliberately cannot do

    **It cannot publish an event.** `POST` and `PATCH` accept `visibility` of
    `draft` or `unlisted` only; anything else is refused with `403 forbidden`.
    Publishing is a decision with a public consequence and a phone-verification
    gate behind it, so it stays in the dashboard where the person making it can
    see what they are about to make public.

    ⚠️ Note also that **`draft` → `unlisted` is one-way**. An event that has been
    made unlisted cannot be returned to draft through this API or any other.

    **It cannot touch money.** Orders are read-only. There is no refund, no
    payout and no ticket issuance endpoint.

    **It cannot write server-owned fields.** Anything not on the writable list
    (see `EventPatch`) is silently dropped rather than rejected — including
    `ticketing`, `organizerId`, `priority` and the embedding fields.

    ## Getting a key

    API keys are created from Studio (Business account → API), not through this
    API. A key is shown **once**, at creation, and only its SHA-256 hash is
    stored — Venued cannot recover or re-display it. Creating and revoking keys
    requires the `owner` or `org_manager` role on the organiser account.

    A key looks like `vnd_live_` followed by 48 hex characters.

    ## Who can hold a key

    A key belongs to a **principal** — an organiser, a venue, or (not yet) a
    professional — and `GET /v1/me` tells you which. Authorisation is
    **scope ∧ principal type**, not scope alone, and the second half is not
    decoration:

    * `events:read` means *my events* to an organiser and *the events at my
      address* to a venue. Same scope string, different set.
    * A venue key **cannot be minted** with `events:write` or `orders:read` at
      all. A venue does not own the events booked into it, and the buyer names
      and emails on an order are the organiser's business, not the building's.
    * **A venue can subscribe to webhooks**, for `event.published` only — it
      fires when an event at the venue's address becomes public, alongside the
      organiser's own delivery of the same event.
    * **What a venue sees at its own address:**
      * every `public` event;
      * an `unlisted` event **only when no real account chose to unlist it** —
        that is, when it belongs to an unclaimed placeholder business or to no
        organiser at all. Most unlisted events are catalogue rows collected
        from public sources that nobody has hidden from anyone; an unlisted
        event owned by an active organiser is that organiser's deliberate
        choice, and stays hidden;
      * **never a draft**, whoever owns it. A draft is an organiser's private
        working copy.

      Paging stays exact across that filter: `has_more` is observed rather
      than inferred, so a hidden row is never mistaken for the end of the
      list.
    * Professional keys are not available yet — there is nothing for one to
      read until `/v1/profile` and `/v1/offerings` exist. Minting one is
      refused rather than silently issuing a key that can reach nothing.

    ## Conventions

    * **Success bodies are `{"data": …}`** — all of them.
    * **List bodies add `has_more` and `next_cursor`** beside `data`, on every
      list endpoint, including ones that cannot currently have a second page.
      One shape means one mapping in whatever tool you are wiring this into,
      rather than one per endpoint.
    * Error bodies are
      `{"error": "<code>", "message": "<sentence>", "request_id": "<id>"}`.
      `message` is for a human and may be reworded without notice; **branch on
      `error`**. `request_id` is on every response as the `X-Request-Id`
      header too — quote it when reporting a problem. Send your own and it is
      echoed rather than replaced, provided it is a plausible token of at most
      64 characters.
    * **Paging is by cursor.** Pass `?limit=` (max 100) and follow
      `next_cursor` into `?cursor=`. A cursor is opaque and belongs to the
      query that produced it: it is refused with `invalid_cursor` if the
      ordering changed under it, or if the row it anchors on was deleted.
    * **`?updated_after=` is the poller's filter**, on every list endpoint. ISO
      8601 or epoch milliseconds, and it **reorders the result by `updatedAt`
      ascending** rather than the endpoint's default order — so changes arrive
      in the order they happened and the last `updatedAt` you saw is the only
      state you need to keep.
    * **An unknown query parameter is a `400 unsupported_parameter`**, never
      silently ignored. A filter that looks applied but is not is how a poller
      quietly ships every row it meant to exclude.
    * **Money is in minor units**, as an integer, beside a non-null uppercase
      ISO-4217 `currency`. `totalAmountMinor: 1999` is €19.99.
    * **Concurrency:** `GET /v1/events/{eventId}` returns an `ETag`. Send it
      back as `If-Match` on the `PATCH` and an edit that would overwrite
      somebody else's change fails with `412 precondition_failed` instead.
      Omitting `If-Match` still succeeds.
    * **`HEAD`** is served wherever `GET` is: same status, same headers, no
      body. A `405` always carries an `Allow` header naming what the path does
      accept.
    * Timestamps are ISO 8601 strings in responses. Requests accept either ISO
      8601 or epoch milliseconds.
    * `title` and `description` on an event are **localized maps**, not strings:
      `{"EN": "Sunrise Run"}`. Locale keys are normalised to UPPERCASE. The
      `title` in a *response* is already flattened to a single string.

    ## Changes

    The reference above describes the API as it is. What changed, and when, is
    kept here rather than threaded through it.

    **2026-09-21** — all breaking, all made while production held no API keys
    or webhook subscriptions, so nobody had anything to migrate:

    * **Venue keys.** A key belongs to a principal — an organiser or a venue —
      rather than always an organiser. See *Who can hold a key*.
    * **Paging.** Every list is ordered and pageable by `cursor`, with
      `has_more` and `next_cursor`. `GET /v1/events` was previously unordered,
      so an organiser with more than 100 events had no way to reach the rest.
    * **Money.** `Order.totalAmount`, a float in major units, became
      `totalAmountMinor`, an integer. The float is not kept alongside it.
    * **`DELETE /v1/webhooks/{webhookId}`** returns the `{"data": …}` envelope
      (it returned `{"ok": true}`, the only endpoint with its own shape) and is
      metered as a write (it was metered as a read).
    * **Rate limits** belong to the account, not the key. Minting a second key
      no longer doubles the budget.
    * **Webhook envelopes** name their `principal` instead of `organizerId`.
      A venue receiving `organizerId: "<its venue id>"` would have been wrong.

  contact:
    name: Venued
    url: https://venued.eu
  license:
    name: Proprietary
    url: https://venued.eu/legal

servers:
  - url: https://europe-west3-venued.cloudfunctions.net/publicApi
    description: |
      Production. ⚠️ This is the real base URL today. `api.venued.eu` appears in
      some source comments and in the integration manifests as an intended
      future home; there is **no hosting rewrite for it yet**, so it does not
      resolve. Routing tolerates a prefix (it locates the `v1` segment rather
      than assuming its position), so an eventual rewrite will not break paths.

security:
  - bearerAuth: []

tags:
  - name: Discovery
    description: What this key can do.
  - name: Events
  - name: Orders
  - name: Venues
    description: For a venue key — the place, and what happens at it.
  - name: Webhooks

paths:
  /v1:
    get:
      tags: [Discovery]
      operationId: getDiscovery
      summary: What this key can reach
      description: |
        Returns the scopes on the calling key, the endpoint paths, the
        available webhook event types, the paging and filtering conventions and
        the current rate limits. Useful as a connectivity and permission
        check — it requires a valid key but **no particular scope**.

        The `endpoints` map is **derived from the route table the server
        dispatches from**, not maintained beside it, so it cannot describe a
        surface the server does not serve.
      x-scope: null
      x-principals: ["organiser", "venue", "professional"]
      responses:
        "200":
          description: Discovery document
          content:
            application/json:
              schema:
                type: object
                properties:
                  api: { type: string, const: venued }
                  version: { type: string, const: v1 }
                  organizerId: { type: string }
                  scopes:
                    type: array
                    items: { $ref: "#/components/schemas/Scope" }
                  endpoints: { type: object, additionalProperties: { type: string } }
                  events_available:
                    description: ⚠️ snake_case, unlike every other key here.
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEventType" }
                  auth: { type: string }
                  pagination:
                    type: object
                    description: |
                      The cursor conventions, so an integration can discover
                      them without reading this document.
                    properties:
                      style: { type: string, const: cursor }
                      parameters: { type: array, items: { type: string } }
                      maxLimit: { type: integer }
                      defaultLimit: { type: integer }
                      envelope: { type: array, items: { type: string } }
                  filters:
                    type: object
                    description: Supported filter parameters, keyed by name.
                    additionalProperties: { type: string }
                  rateLimits:
                    type: object
                    properties:
                      reads: { type: string }
                      writes: { type: string }
                      writeMethods:
                        type: array
                        items: { type: string }
                        description: "⚠️ Includes `DELETE`."
                      headers: { type: array, items: { type: string } }
                      scope:
                        type: string
                        description: What the budget belongs to — the organiser account, not the key.
                  requestId:
                    type: string
                    description: This request's id, the same value as the `X-Request-Id` header.
                  docs: { type: string, format: uri }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/me:
    get:
      tags: [Discovery]
      operationId: getMe
      summary: Who this key is
      description: |
        The principal this key acts as, and the scopes it was minted with.

        ## Why this is separate from `GET /v1`

        `GET /v1` answers *what can I reach* — the endpoint map, the paging
        conventions, the rate limits — and is the same for everybody. This
        answers *who am I*, which is per key and is the thing an integration
        branches on: a no-code step that must decide whether to offer event
        actions or venue actions needs the principal, not the endpoint table.

        Requires a valid key but **no particular scope**. A key that cannot tell
        you what it is cannot be debugged by the person holding it, and "which
        account is this key for" is the first question anybody asks when an
        integration has written to the wrong place.
      x-scope: null
      x-principals: ["organiser", "venue", "professional"]
      responses:
        "200":
          description: The calling principal
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      principal: { $ref: "#/components/schemas/Principal" }
                      organizerId:
                        type: [string, "null"]
                        description: |
                          Echoed for an organiser key and `null` for any other
                          principal. Present because every integration written
                          before principals existed reads it from `GET /v1`.
                      scopes:
                        type: array
                        items: { $ref: "#/components/schemas/Scope" }
                      apiVersion: { type: string, const: v1 }
                      keyPrefix:
                        type: string
                        description: The non-secret display fragment, e.g. `vnd_live_ab12`.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/events:
    get:
      tags: [Events]
      operationId: listEvents
      summary: List events
      description: |
        Newest first, by `startTime` descending, and pageable with `cursor`.

        **Which events** depends on the key: an organiser key lists the
        organiser's own events; a venue key lists the events at its address
        (see *What a venue sees* above).

        ⚠️ **An event with no stored `startTime` is not returned.** Nothing
        reachable is hidden by that — an event without a start is not on the
        site, in the app or in search either, and this API will not create
        one.

        With `updated_after` the order changes to `updatedAt` ascending; see
        the conventions above.
      x-scope: "events:read"
      x-principals: ["organiser", "venue"]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/UpdatedAfter"
      responses:
        "200":
          description: Events owned by this organiser, newest first
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventPage" }
        "400": { $ref: "#/components/responses/BadListRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Events]
      operationId: createEvent
      summary: Create a draft or unlisted event
      description: |
        Creates an event owned by the calling organiser.

        **`Idempotency-Key` is required.** A retried request carrying a key that
        already succeeded replays the stored response verbatim rather than
        creating a second event. A key whose first attempt FAILED is treated as
        fresh and re-run — only successful results are recorded — so a failed
        create is safe to retry with the same key. Keys are scoped per organiser
        and retained 7 days.
      x-scope: "events:write"
      x-principals: ["organiser"]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventPatch" }
            examples:
              minimal:
                summary: The smallest accepted body
                value:
                  title: { EN: "Sunrise Run & Rave" }
                  startTime: "2026-11-21T18:00:00Z"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Event" }
        "400":
          description: |
            `idempotency_key_required` — no `Idempotency-Key` header.
            `invalid_argument` — `startTime` missing or unparseable, or
            `endTime` unparseable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            `insufficient_scope`, or `forbidden` when `visibility` is anything
            other than `draft` or `unlisted`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/events/{eventId}:
    parameters:
      - $ref: "#/components/parameters/EventId"
    get:
      tags: [Events]
      operationId: getEvent
      summary: Fetch one event
      description: |
        ⚠️ An event belonging to a different organiser returns `404 not_found`,
        not `403`. That is deliberate: a `403` would confirm the id exists.
      x-scope: "events:read"
      x-principals: ["organiser", "venue"]
      responses:
        "200":
          description: The event
          headers:
            ETag:
              schema: { type: string }
              description: |
                A strong validator over **this response body**, not over the
                stored document — so it changes only when something you can
                see changes. Send it back as `If-Match` on the `PATCH`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Event" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [Events]
      operationId: updateEvent
      summary: Update an event
      description: |
        Merges the writable fields of the body into the event. Fields outside
        the writable list are dropped silently, not rejected — so a body of
        nothing but server-owned keys is a `400 invalid_argument` ("No writable
        fields in the request body"), which is usually the sign of a typo'd
        field name.

        No `Idempotency-Key` is required: a PATCH of the same body is naturally
        idempotent.

        **`If-Match` makes the edit conditional.** Send the `ETag` you got from
        `GET /v1/events/{eventId}` and the write applies only if nothing has
        changed since; otherwise it is refused with `412 precondition_failed`
        and nothing is written. Without the header the write applies
        unconditionally, exactly as before — this is opt-in protection against
        two editors overwriting each other, not a new requirement.
      x-scope: "events:write"
      x-principals: ["organiser"]
      parameters:
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventPatch" }
      responses:
        "200":
          description: The updated event
          headers:
            ETag:
              schema: { type: string }
              description: The new validator, ready for the next conditional write.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Event" }
        "400":
          description: |
            `invalid_argument` — no writable fields in the body, or `startTime`
            / `endTime` unparseable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            `insufficient_scope`, or `forbidden` when `visibility` is anything
            other than `draft` or `unlisted`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/events/{eventId}/orders:
    parameters:
      - $ref: "#/components/parameters/EventId"
    get:
      tags: [Orders]
      operationId: listEventOrders
      summary: Confirmed orders for one event
      description: |
        Only orders with `status: "confirmed"` are returned — pending, failed and
        abandoned checkouts are not visible through this API.

        Newest first, by `createdAt` descending, and pageable with `cursor`.
      x-scope: "orders:read"
      x-principals: ["organiser"]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/UpdatedAfter"
      responses:
        "200":
          description: Confirmed orders for this event, newest first
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderPage" }
        "400": { $ref: "#/components/responses/BadListRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/orders:
    get:
      tags: [Orders]
      operationId: listOrders
      summary: Confirmed orders across all of this organiser's events
      description: |
        Newest first (`createdAt` descending). Confirmed orders only — pending,
        failed and abandoned checkouts are never visible here.

        This is the poll source a "new order" trigger in Zapier or Make needs;
        the `order.confirmed` webhook is the push counterpart. A robust
        integration usually wants both: the webhook for latency, and
        `?updated_after=` to backfill whatever the receiver was down for.
      x-scope: "orders:read"
      x-principals: ["organiser"]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/UpdatedAfter"
      responses:
        "200":
          description: Confirmed orders, newest first
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderPage" }
        "400": { $ref: "#/components/responses/BadListRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/venues:
    get:
      tags: [Venues]
      operationId: listVenues
      summary: The venue this key is, and the places it is
      description: |
        A venue key's own record, with its locations resolved.

        ⚠️ **Always exactly one entry today.** A venue key's principal IS a
        venue, so "list" is one row. It carries the page envelope anyway, for
        the same reason every other list does: one mapping in a no-code tool
        beats one per endpoint.

        `url` is built from the venue **id**, not its slug. Venue slug
        uniqueness is not enforced — the Studio editor writes the field
        directly — so a slug URL can resolve to a different venue. The slug is
        published as a field for display; it is not the link.
      x-scope: "events:read"
      x-principals: ["venue"]
      responses:
        "200":
          description: The venue, with its locations
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/Page" }
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Venue" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/webhooks:
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: List webhook subscriptions
      description: |
        ⚠️ The `secret` is returned here in plaintext. Treat this response as a
        credential.
      x-scope: "webhooks:read"
      x-principals: ["organiser", "venue"]
      responses:
        "200":
          description: Subscriptions
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookPage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Webhooks]
      operationId: createWebhook
      summary: Subscribe to events
      description: |
        Creates a subscription and returns its signing `secret`. **Store the
        secret now** — it is also readable from `GET /v1/webhooks`, but it is
        what verifies every delivery and it should be treated as a credential
        from the moment it exists.

        A maximum of **10 subscriptions per account** is enforced.

        **A venue key may subscribe to `event.published` only** — an event at
        its address became public. `order.confirmed` is refused with
        `403 forbidden` rather than silently dropped: it carries a buyer's name
        and email, which is the organiser's record and not the building's, and
        a subscription quietly narrower than the one you asked for would only
        announce itself by never firing.
      x-scope: "webhooks:write"
      x-principals: ["organiser", "venue"]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url:
                  type: string
                  format: uri
                  description: |
                    Must be **https** and a public host. Loopback, link-local,
                    private-range and cloud-metadata addresses are refused —
                    and the check is repeated by DNS resolution at delivery
                    time, so a host that later resolves to a private address
                    has its deliveries dropped rather than sent.
                events:
                  type: array
                  minItems: 1
                  items: { $ref: "#/components/schemas/WebhookEventType" }
                  description: |
                    Unknown types are filtered out silently. If nothing known
                    remains, the request is a `400 invalid_argument`.
                description:
                  type: string
                  maxLength: 200
                  description: Trimmed, and truncated at 200 characters.
                enabled:
                  type: boolean
                  default: true
                  description: |
                    ⚠️ Only the literal `false` disables. Any other value —
                    including `0`, `"false"` and `null` — leaves it enabled.
            examples:
              orders:
                value:
                  url: "https://example.com/hooks/venued"
                  events: ["order.confirmed"]
                  description: "Push confirmed orders into the CRM"
      responses:
        "201":
          description: Created. Carries the signing secret.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Webhook" }
        "400":
          description: |
            `invalid_argument` — `url` missing, unparseable, not https, or not a
            public host; or `events` contains no known type.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            `insufficient_scope`, or `forbidden` when a venue key asks for an
            event type a venue may not receive (anything but `event.published`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: "`limit_reached` — already 10 subscriptions."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/webhooks/{webhookId}:
    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      summary: Delete a webhook subscription
      description: |
        Removes the subscription and returns
        `{"data": {"id": "…", "deleted": true}}`. Deliveries already in flight
        are not recalled.

        Metered as a **write** (120/min), like every other request that
        changes something.
      x-scope: "webhooks:write"
      x-principals: ["organiser", "venue"]
      parameters:
        - name: webhookId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string }
                      deleted: { type: boolean, const: true }
        "400":
          description: "`invalid_argument` — no `webhookId` in the path."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

webhooks:
  order.confirmed:
    post:
      operationId: onOrderConfirmed
      summary: A ticket order or RSVP was confirmed
      description: |
        Sent once, on the `pending → confirmed` transition of an order. It does
        not re-fire if the order is written again while already confirmed.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEnvelope"
                - type: object
                  properties:
                    type: { const: "order.confirmed" }
                    data:
                      type: object
                      properties:
                        orderId: { type: string }
                        eventId: { type: [string, "null"] }
                        totalAmount: { type: number }
                        currency: { type: [string, "null"] }
                        isRsvp: { type: boolean }
                        quantity: { type: integer }
                        billingName: { type: [string, "null"] }
                        billingEmail: { type: [string, "null"] }
                        paymentProvider: { type: [string, "null"] }
      responses:
        "200":
          description: |
            Return any 2xx to acknowledge. Anything else — or no response within
            **10 seconds** — is a failure and will be retried.

  event.published:
    post:
      operationId: onEventPublished
      summary: An event became public
      description: |
        Sent once, the first time an event transitions to `public`. Making an
        already-public event public again does not re-fire it.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEnvelope"
                - type: object
                  properties:
                    type: { const: "event.published" }
                    data:
                      type: object
                      properties:
                        eventId: { type: string }
                        title: { type: [string, "null"] }
                        startTime: { type: [string, "null"], format: date-time }
                        endTime: { type: [string, "null"], format: date-time }
                        url: { type: string, format: uri }
      responses:
        "200":
          description: Acknowledged.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer vnd_live_…`

        Scopes are attached to the key, not requested per call. A key with a
        missing scope gets `403 insufficient_scope`. A key created without an
        explicit scope list falls back to read-only
        (`events:read`, `orders:read`).

  parameters:
    Limit:
      name: limit
      in: query
      required: false
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      description: |
        Clamped to 100. A missing, non-numeric, zero or negative value becomes
        the default of 50 rather than an error.
    Cursor:
      name: cursor
      in: query
      required: false
      schema: { type: string }
      description: |
        The `next_cursor` from the previous page. **Opaque** — do not construct,
        parse or store one as though it were an offset.

        It is anchored to a specific row of a specific ordering, and it is
        rejected with `400 invalid_cursor` rather than quietly obeyed when
        that no longer holds: if you add or remove `updated_after` (which
        changes the sort), or if the row it points at has since been deleted.
        Restart the pagination without a cursor in either case.
    UpdatedAfter:
      name: updated_after
      in: query
      required: false
      schema:
        oneOf: [{ type: string, format: date-time }, { type: integer }]
      description: |
        Only rows whose `updatedAt` is at or after this instant. ISO 8601 or
        epoch milliseconds.

        ⚠️ **This changes the ordering** to `updatedAt` ascending, on every
        endpoint that accepts it. That is what a poller wants — changes in the
        order they happened, so the last `updatedAt` processed is the whole of
        the state it has to keep — and it is also what lets the query be
        served by an index.
    IfMatch:
      name: If-Match
      in: header
      required: false
      schema: { type: string }
      description: |
        An `ETag` previously returned for this resource. The write applies only
        if the resource still matches; otherwise `412 precondition_failed` and
        nothing is written. `*` means "it must exist". Omit it and the write is
        unconditional.
    EventId:
      name: eventId
      in: path
      required: true
      schema: { type: string }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string }
      description: |
        Any opaque string you generate per logical operation — a UUID is the
        usual choice. Scoped to your organiser account and retained 7 days.

  responses:
    Unauthorized:
      description: "`unauthorized` — missing, malformed, unknown or revoked key."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    InsufficientScope:
      description: "`insufficient_scope` — the key is valid but lacks the scope."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    BadListRequest:
      description: |
        `unsupported_parameter` — a query parameter this endpoint does not
        accept. `invalid_cursor` — a malformed cursor, one belonging to a
        different ordering, or one anchored to a row that no longer exists.
        `invalid_argument` — `updated_after` was not ISO 8601 or epoch ms.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    PreconditionFailed:
      description: |
        `precondition_failed` — the `If-Match` you sent does not match the
        resource's current `ETag`, so somebody else changed it after you read
        it. **Nothing was written.** Re-read the resource, reapply your change
        to the new version, and retry.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: |
        `not_found`. Also returned for a resource that exists but belongs to a
        different organiser — see the note on `GET /v1/events/{eventId}`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: |
        `rate_limited`. **600 reads/min and 120 writes/min** in a fixed
        60-second window. `POST`, `PATCH` and `DELETE` are writes; `GET` and
        `HEAD` are reads.

        ⚠️ **The budget belongs to the organiser account, not to the key.** It
        was metered per key, which meant minting a second key doubled the
        allowance — so there was no ceiling, only a formality. Two changes,
        both breaking, both made while the API has no keys in existence: this
        one, and `DELETE` moving from the read budget to the write budget.

        ⚠️ The limit is a soft ceiling under burst. The counter is a single
        Firestore document per key per window, and contention means a hard
        burst can push somewhat past the nominal figure before 429s begin. It
        also **fails open**: if the counter cannot be read, the request is
        allowed rather than refused.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds until the window resets.
        X-RateLimit-Limit:
          schema: { type: integer }
        X-RateLimit-Remaining:
          schema: { type: integer }
        X-RateLimit-Reset:
          schema: { type: integer }
          description: Unix timestamp in SECONDS at which the window ends.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      required: [error]
      description: |
        `error` is a stable machine code; `message` is a human sentence that may
        be reworded without notice. Branch on `error`.
      properties:
        error:
          type: string
          enum:
            - method_not_allowed
            - unauthorized
            - insufficient_scope
            - rate_limited
            - idempotency_key_required
            - invalid_argument
            - forbidden
            - not_found
            - limit_reached
            - unsupported_parameter
            - invalid_cursor
            - precondition_failed
            - internal
        message: { type: string }
        request_id:
          type: string
          description: |
            The id of this request, also on the `X-Request-Id` response header.
            Quote it when reporting a problem — it is what the server logs
            under, and without it a report of "a 500 yesterday" is not
            traceable to anything.

    Principal:
      type: object
      description: |
        Who a key acts as. The API is scoped to ONE of these, and every response
        is filtered to it.

        ⚠️ **One key format for all three.** The `vnd_live_` prefix is display
        only — the SHA-256 hash is the key's whole authority — so there is no
        `vnd_venue_` variant. A per-type prefix would buy nothing and leak the
        type into a string people paste into Zapier and screenshot into support
        threads.
      required: [type, id]
      properties:
        type:
          type: string
          enum: [organiser, venue, professional]
        id: { type: string }

    Scope:
      type: string
      enum: ["events:read", "orders:read", "events:write", "webhooks:read", "webhooks:write"]

    WebhookEventType:
      type: string
      enum: ["order.confirmed", "event.published"]

    Page:
      type: object
      description: |
        The envelope every list endpoint returns. `data` is the page;
        `has_more` says whether another exists; `next_cursor` is what to pass
        as `?cursor=` to get it, and is `null` exactly when `has_more` is
        `false`.

        ⚠️ `has_more` is **observed, not inferred**: the server reads one row
        more than you asked for and reports whether it was there. Inferring it
        from a full page is wrong precisely when the last page is full, which
        is the case that makes a poller loop forever or stop one page early.
      required: [data, has_more, next_cursor]
      properties:
        data: { type: array, items: {} }
        has_more: { type: boolean }
        next_cursor: { type: [string, "null"] }

    EventPage:
      allOf:
        - { $ref: "#/components/schemas/Page" }
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/Event" }

    OrderPage:
      allOf:
        - { $ref: "#/components/schemas/Page" }
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/Order" }

    WebhookPage:
      allOf:
        - { $ref: "#/components/schemas/Page" }
        - type: object
          description: |
            Bounded at ten subscriptions per organiser, so `has_more` is always
            `false` here. It carries the same envelope as every other list
            anyway: one mapping in a no-code tool beats saving two constant
            fields.
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/Webhook" }

    Visibility:
      type: string
      enum: [draft, unlisted, cancelled, public]
      description: |
        An event with no stored visibility resolves to `draft`. Only `draft` and
        `unlisted` are writable through this API.

    LocalizedText:
      type: object
      description: |
        Locale code to text. ⚠️ Keys are normalised to UPPERCASE on write, so
        `{"en": "…"}` is stored as `{"EN": "…"}`.
      additionalProperties: { type: string }
      examples:
        - { EN: "Sunrise Run & Rave", DE: "Sunrise Run & Rave" }

    Event:
      type: object
      description: The response shape. Narrower than the stored document.
      properties:
        id: { type: string }
        title:
          type: [string, "null"]
          description: Already flattened to one language — not a map, unlike on write.
        visibility: { $ref: "#/components/schemas/Visibility" }
        startTime: { type: [string, "null"], format: date-time }
        endTime: { type: [string, "null"], format: date-time }
        updatedAt:
          type: [string, "null"]
          format: date-time
          description: |
            Last write of any kind, including ones this API does not surface.
            This is what `?updated_after=` compares against.
        url: { type: string, format: uri }

    EventPatch:
      type: object
      description: |
        The writable surface. **Every other key is dropped silently**, which is
        why a body of only server-owned fields fails as "no writable fields"
        rather than as an unknown-field error.

        Never writable: `id`, `createdAt`, `updatedAt`, `organizerId`,
        `firstPublishedAt`, `editorialTier`, `groupSizeMin`, `groupSizeIdeal`,
        `groupSizeMax`, `groupSizeUncapped`, `accessPasswordHash`,
        `accessPasswordSalt`, `priority`, `embedding`, `hasEmbedding`,
        `ticketing`, `templateId`, `venueId`, `refundOffer`.
      properties:
        title: { $ref: "#/components/schemas/LocalizedText" }
        description: { $ref: "#/components/schemas/LocalizedText" }
        startTime:
          oneOf: [{ type: string, format: date-time }, { type: integer }]
          description: ISO 8601 or epoch ms. **Required on create.**
        endTime:
          oneOf: [{ type: string, format: date-time }, { type: integer }]
        dateOnly:
          type: boolean
          description: No time of day. The stored instant is noon in the venue's zone.
        locationId:
          type: string
          description: The ONE operative venue — ticketing, scanner and map all read this.
        additionalLocationIds:
          type: array
          items: { type: string }
          description: |
            Extra places, in order. **Display only**: no times, no capacity, no
            booking. Deduplicated and capped at 8 server-side, and the primary
            is dropped if echoed here.
        planning:
          type: object
          nullable: true
          description: |
            What is not decided yet. `date.mode` is `fixed` (default: `startTime`
            is the date), `options` (`options[]` of candidate dates) or `window`
            (`from`/`to`); `place.mode` is `venue` (default), `area` (`city`) or
            `open`. While the date is not `fixed`, `startTime` is a placeholder
            and the event **cannot be published**. `null` clears it.
          properties:
            date:
              type: object
              properties:
                mode: { type: string, enum: [fixed, options, window] }
                options:
                  type: array
                  items:
                    type: object
                    properties:
                      id: { type: string }
                      start: { type: string }
                      end: { type: string, nullable: true }
                      allDay: { type: boolean }
                      note: { type: string, nullable: true }
                from: { type: string }
                to: { type: string }
                note: { type: string, nullable: true }
            place:
              type: object
              properties:
                mode: { type: string, enum: [venue, area, open] }
                city: { type: string }
        imageBlobName: { type: string }
        imageBlobNames: { type: array, items: { type: string } }
        tags: { type: array, items: { type: string } }
        subtags: { type: array, items: { type: string } }
        languages: { type: array, items: { type: string } }
        metadata: { type: object }
        visibility: { $ref: "#/components/schemas/Visibility" }
        themeColor: { type: string }
        secondaryThemeColor: { type: string }
        backgroundStyle: { type: string }
        colorMode: { type: string }
        backgroundColor: { type: string }
        headingFont: { type: string }
        titleFontScale: { type: number }
        backgroundPrimaryColor: { type: string }
        backgroundParams: { type: object }
        plasmaPreset: { type: object }
        hideLocation: { type: boolean }
        hideRelatedEvents: { type: boolean }
        groupSizeOverride: { type: object }
        seriesId: { type: string }
        links: { type: array, items: { type: object } }
        refundOfferOnChange:
          type: boolean
          description: |
            ⚠️ Only ever meaningful as `true`, on a save that carries a date or
            venue move. The server consumes and deletes it.

    Venue:
      type: object
      description: |
        A venue and the places it is. Read-only, and returned only to a venue
        key — an organiser key has no venue endpoint because an organiser is
        not a venue.
      properties:
        id: { type: string }
        name: { type: [string, "null"] }
        slug:
          type: [string, "null"]
          description: |
            ⚠️ For display only. Venue slug uniqueness is NOT enforced, so a
            slug may resolve to a different venue. Use `url`, which is built
            from the id.
        description:
          type: [string, "null"]
          description: Already flattened to one language — not a map, unlike on write.
        websiteUrl: { type: [string, "null"] }
        category: { type: [string, "null"] }
        claimStatus: { type: [string, "null"] }
        url: { type: string, format: uri }
        locations:
          type: array
          description: Capped at 30. A location deleted out from under the venue is omitted rather than returned empty.
          items:
            type: object
            properties:
              id: { type: string }
              displayName: { type: [string, "null"] }
              address: { type: [string, "null"] }
              city: { type: [string, "null"] }
              country: { type: [string, "null"] }

    Order:
      type: object
      description: Confirmed orders only. Read-only.
      properties:
        id: { type: string }
        eventId: { type: [string, "null"] }
        status: { type: [string, "null"] }
        isRsvp: { type: boolean }
        quantity:
          type: integer
          description: Summed across the order's line items.
        totalAmountMinor:
          type: integer
          description: |
            The order total in **minor units** — `1999` is €19.99. An integer,
            so you never do currency arithmetic in binary floating point, which
            cannot represent `19.99` exactly.
        currency:
          type: string
          description: |
            Uppercase ISO 4217, never null. Normalised on the way out: the
            Stripe paths store it lower-cased and the free, deposit and door
            paths store `"EUR"`, so the raw value is genuinely inconsistent and
            an integrator would otherwise have to normalise it themselves.
            Defaults to `EUR`, the platform's only settlement currency.
        billingName: { type: [string, "null"] }
        billingEmail: { type: [string, "null"] }
        attributionSource: { type: [string, "null"] }
        createdAt: { type: [string, "null"], format: date-time }
        updatedAt:
          type: [string, "null"]
          format: date-time
          description: What `?updated_after=` compares against.

    Webhook:
      type: object
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        events:
          type: array
          items: { $ref: "#/components/schemas/WebhookEventType" }
        secret:
          type: string
          description: "`whsec_` + 64 hex characters. Treat as a credential."
        enabled: { type: boolean }
        description: { type: [string, "null"] }
        lastStatus:
          type: [integer, string, "null"]
          description: |
            The last delivery outcome: an HTTP status number, or one of the
            strings `timeout`, `network_error`, `blocked_host`.
        consecutiveFailures:
          type: integer
          description: |
            Reaching **20** disables the subscription automatically. Re-enabling
            it from Studio resets this to 0.

    WebhookEnvelope:
      type: object
      description: |
        ## Verifying a delivery

        Every request carries `Venued-Signature: t=<unixSeconds>,v1=<hex>`,
        where `v1` is
        `HMAC-SHA256(key = your subscription secret, message = "<t>.<rawBody>")`.

        ⚠️ **Sign the RAW body**, byte for byte as received. Re-serialising the
        parsed JSON will produce a different string and a signature that never
        matches.

        Compare with a constant-time equality function, and reject a `t` that is
        far from your own clock to prevent replay.

        Also sent: `Venued-Event` (the type), `Venued-Delivery` (the delivery id,
        same as `id` below) and `User-Agent: Venued-Webhooks/1.0`.

        ## Delivery guarantees

        **At least once.** Retries mean the same `id` can arrive more than once —
        deduplicate on `Venued-Delivery` / `id`.

        Return any 2xx within **10 seconds** to acknowledge. Anything else is a
        failure and is retried by Cloud Tasks with exponential backoff.

        ⚠️ The exact attempt count and backoff window are **queue configuration,
        not code**, and are not asserted here: the values in the source are a
        provisioning comment that was not verified against the live queue when
        this was written. Build for at-least-once delivery over an unspecified
        retry window rather than against a specific number.

        What is in code, and is therefore reliable: **20 consecutive failures
        auto-disables the subscription.**
      properties:
        id:
          type: string
          format: uuid
          description: The delivery id. Use it to deduplicate.
        type: { $ref: "#/components/schemas/WebhookEventType" }
        createdAt: { type: string, format: date-time }
        principal:
          $ref: "#/components/schemas/Principal"
          description: |
            Who this delivery is for — an organiser, or the venue an event is
            happening at.

            ⚠️ A delivery **resent** from before 2026-09-21 reproduces its
            original body, which carried `organizerId` instead. See *Changes*.
        data: { type: object }
