openapi: 3.1.0
info:
  title: SeatMapOS API
  version: "1.0.0"
  summary: Seat maps, live availability, holds and confirmed sales for your own site or app.
  description: |
    The versioned integrator API (docs/sdk-plan.md §4). **Additive-only**: new
    fields and endpoints may appear in v1; nothing is removed or renamed.

    ## Authentication
    Send an API key as `Authorization: Bearer <key>`. Create keys under
    **Developers** (`/settings/developers`).

    | Key | Where | Can call |
    | --- | --- | --- |
    | `pk_test_…` / `pk_live_…` (publishable) | browser or server | endpoints marked **public**. From a browser, the page's `Origin` must be on your org's allowed origins. Only sees events that are on sale. |
    | `sk_test_…` / `sk_live_…` (secret) | **your server only** | everything. A secret key sent with an `Origin` header (a browser) is refused. |

    **Test vs live**: a test key sees and creates only *test* events, a live
    key only live ones — a sandbox inside the same deployment.

    **Buyer sessions** (embeds): a publishable key mints one per page view
    (`POST /buyer-sessions`); the `/session/*` endpoints take it as
    `Authorization: Bearer bs_…`. It can hold and release seats for one event —
    never complete a live sale.

    **Who takes the money** — every event has `checkout`:
    `external` (default for live events created here) means ONLY your
    secret-key confirm completes a sale; `simulated` is SeatMapOS's demo
    checkout (test mode / demos).

    ## Selling a seat
    1. `GET /events/{eventId}` — manifest (selection mode, pricing, fees, seat-map URL)
    2. `GET /events/{eventId}/seatmap` + `/availability` — draw it
    3. `POST /events/{eventId}/holds` — all-or-nothing hold for your buyer (`owner`)
    4. take payment in **your** system
    5. `POST /events/{eventId}/holds/{holdId}/confirm` — the sale; or `DELETE` the hold

    SeatMapOS never touches payment. `externalRef` links an order to yours.

    ## Errors
    Every error is `{ "error": { "code": "…", "message": "…" } }` with a stable `code`.
servers:
  - url: https://os.ofpixel.com/api/v1
security:
  - apiKey: []
paths:
  /venues:
    get:
      summary: List your published venues
      description: Secret key.
      operationId: listVenues
      responses:
        "200":
          description: Venues
          content:
            application/json:
              schema:
                type: object
                properties:
                  venues: { type: array, items: { $ref: "#/components/schemas/Venue" } }
        default: { $ref: "#/components/responses/Error" }
  /events:
    get:
      summary: List your events (in the key's mode)
      description: Public. A publishable key lists only events that are on sale.
      operationId: listEvents
      parameters:
        - { name: venueId, in: query, schema: { type: string } }
      responses:
        "200":
          description: Events
          content:
            application/json:
              schema:
                type: object
                properties:
                  events: { type: array, items: { $ref: "#/components/schemas/Event" } }
        default: { $ref: "#/components/responses/Error" }
    post:
      summary: Create an event on one of your venues
      description: Secret key. Created as a `draft` in the key's mode, with the venue's published pricing copied in. PATCH `status` to `onsale` to sell it.
      operationId: createEvent
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [venueId, name]
              properties:
                venueId: { type: string }
                name: { type: string }
                startsAt: { type: [string, "null"], format: date-time }
                checkout: { type: string, enum: [simulated, external], description: "default: external for live keys, simulated for test keys" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { type: object, properties: { event: { $ref: "#/components/schemas/Event" } } }
        default: { $ref: "#/components/responses/Error" }
  /events/{eventId}:
    parameters:
      - $ref: "#/components/parameters/eventId"
    get:
      summary: Event manifest
      description: Public. Everything needed to draw and sell the event in one call.
      operationId: getEvent
      responses:
        "200":
          description: Manifest
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Manifest" }
        default: { $ref: "#/components/responses/Error" }
    patch:
      summary: Update an event
      description: Secret key.
      operationId: updateEvent
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                startsAt: { type: [string, "null"], format: date-time }
                status: { type: string, enum: [draft, onsale, closed] }
                checkout: { type: string, enum: [simulated, external] }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema: { type: object, properties: { event: { $ref: "#/components/schemas/Event" } } }
        default: { $ref: "#/components/responses/Error" }
  /events/{eventId}/seatmap:
    parameters:
      - $ref: "#/components/parameters/eventId"
    get:
      summary: Venue geometry (protobuf)
      description: |
        Public. A protobuf `VenueLayout` (sections, seats, art). Send
        `If-None-Match` with the last `ETag` — an unchanged chart is a 304.
      operationId: getSeatmap
      responses:
        "200":
          description: The seat map
          content:
            application/x-protobuf: {}
        "304": { description: Not modified }
        default: { $ref: "#/components/responses/Error" }
  /events/{eventId}/views:
    parameters:
      - $ref: "#/components/parameters/eventId"
    get:
      summary: Section decor (tags + view-from-seat images)
      description: Public. Keyed by section NAME; `{}` when none. ETag-revalidated.
      operationId: getViews
      responses:
        "200":
          description: Decor
          content:
            application/json:
              schema:
                type: object
                additionalProperties:
                  type: object
                  properties:
                    tags: { type: array, items: { type: string } }
                    view: { type: string }
        "304": { description: Not modified }
        default: { $ref: "#/components/responses/Error" }
  /events/{eventId}/availability:
    parameters:
      - $ref: "#/components/parameters/eventId"
    get:
      summary: Live seat states
      description: Public. A seat absent from both `seats` and `blocked` is available.
      operationId: getAvailability
      responses:
        "200":
          description: Availability
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Availability" }
        default: { $ref: "#/components/responses/Error" }
  /events/{eventId}/best:
    parameters:
      - $ref: "#/components/parameters/eventId"
    get:
      summary: Best contiguous seats
      description: Public. The best run of `count` seats together in one row.
      operationId: getBestSeats
      parameters:
        - { name: count, in: query, schema: { type: integer, minimum: 1, maximum: 10, default: 2 } }
      responses:
        "200":
          description: A run
          content:
            application/json:
              schema:
                type: object
                properties:
                  seatIds: { type: array, items: { type: string } }
                  priceCents: { type: integer }
        default: { $ref: "#/components/responses/Error" }
  /events/{eventId}/holds:
    parameters:
      - $ref: "#/components/parameters/eventId"
    post:
      summary: Hold seats (all-or-nothing)
      description: |
        Secret key. `owner` is your id for the buyer; the same owner must
        confirm or release. The `holdId` is a credential — keep it server-side.
        Retry safely by sending your own `holdId`. TTL is clamped to 1–15 min
        (default 5).

        Send `best: N` instead of `seatIds` and WE pick and hold the best N
        contiguous seats in one step: the same order as `GET …/best`, but
        atomic. Concurrent buyers are handed different seats instead of racing
        for the same pair, so prefer it over `best` → hold during an on-sale.
        No `holdId` with `best` (we mint it). The response adds an advisory
        `priceCents`; charge what `GET …/holds/{holdId}` returns.

        Errors: `409 seat_conflict` (`conflicts`), `409 blocked` (`seatIds`),
        `409 not_on_sale`, `422 orphan_seat` (`seatIds`; resend with
        `allowOrphans: true` to accept). With `best`: `404 none_available`,
        `409 seats_moving` (retry), `422 orphan_seat` (`seatIds` stranded,
        `pick` = the run; resend with `allowOrphans: true`).
      operationId: createHold
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [owner]
              properties:
                owner: { type: string, maxLength: 120 }
                seatIds: { type: array, minItems: 1, maxItems: 50, items: { type: string } }
                best: { type: integer, minimum: 1, maximum: 10, description: "Instead of seatIds: hold the best N together." }
                ttlMs: { type: integer, minimum: 60000, maximum: 900000 }
                allowOrphans: { type: boolean }
                holdId: { type: string, pattern: "^[A-Za-z0-9_-]{8,80}$" }
      responses:
        "201":
          description: Held
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Hold"
                  - type: object
                    properties: { priceCents: { type: integer, description: "Advisory; with best only." } }
        default: { $ref: "#/components/responses/Error" }
  /events/{eventId}/holds/{holdId}:
    parameters:
      - $ref: "#/components/parameters/eventId"
      - $ref: "#/components/parameters/holdId"
    get:
      summary: What a hold covers and costs
      description: |
        Secret key. Charge the buyer THIS amount — from our records, never a
        total a browser reported. `owner` is the one you held with (for an
        embed hold, the `buyerRef` it handed you).
      operationId: getHold
      parameters:
        - { name: owner, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: The hold
          content:
            application/json:
              schema: { type: object, properties: { hold: { $ref: "#/components/schemas/HoldQuote" } } }
        default: { $ref: "#/components/responses/Error" }
    delete:
      summary: Release a hold
      description: Secret key. Idempotent.
      operationId: releaseHold
      parameters:
        - { name: owner, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: Released
          content:
            application/json:
              schema: { type: object, properties: { released: { type: array, items: { type: string } } } }
        default: { $ref: "#/components/responses/Error" }
  /events/{eventId}/holds/{holdId}/confirm:
    parameters:
      - $ref: "#/components/parameters/eventId"
      - $ref: "#/components/parameters/holdId"
    post:
      summary: Confirm the sale
      description: |
        Secret key. Call after your payment succeeded. Idempotent: a replay is
        `409 already_completed` carrying the order. `410 hold_expired` means
        the hold lapsed, was released, or belongs to another owner.
      operationId: confirmHold
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [owner, buyer]
              properties:
                owner: { type: string }
                buyer:
                  type: object
                  required: [name, email]
                  properties:
                    name: { type: string }
                    email: { type: string, format: email }
                externalRef: { type: string, maxLength: 200, description: Your order id }
                promoCode: { type: string }
      responses:
        "201":
          description: Sold — with one signed ticket per seat
          content:
            application/json:
              schema:
                type: object
                properties:
                  order: { $ref: "#/components/schemas/Order" }
                  tickets: { type: array, items: { $ref: "#/components/schemas/Ticket" } }
        default: { $ref: "#/components/responses/Error" }
  /buyer-sessions:
    post:
      summary: Mint a buyer session (embeds)
      description: |
        Publishable key, from an allowed origin. One per page view; valid 2 h,
        bound to one event. Rate-limited per IP.
      operationId: createBuyerSession
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [eventId], properties: { eventId: { type: string } } }
      responses:
        "201":
          description: Session
          content:
            application/json:
              schema:
                type: object
                properties:
                  session: { type: string, description: "bs_… — send as Bearer on /session/*" }
                  buyerRef: { type: string, description: Confirm embed holds with owner = buyerRef }
                  eventId: { type: string }
                  expiresAt: { type: string, format: date-time }
                  live: { type: object, properties: { url: { type: string, description: WebSocket seat-status feed } } }
        default: { $ref: "#/components/responses/Error" }
  /session/holds:
    post:
      summary: Hold seats with a buyer session
      description: |
        Buyer session. Same rules as a secret-key hold, plus at most 10 seats
        held per session and a hold-attempt rate limit (`429 hold_limit` /
        `429 rate_limited`). Hand `{ holdId, buyerRef }` to your server.
        Send `best: N` instead of `seatIds` to have us pick and hold the best
        N together in one step (see `createHold`); the embed's "Best
        available" does this.
      operationId: createSessionHold
      security: [{ buyerSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                seatIds: { type: array, minItems: 1, maxItems: 10, items: { type: string } }
                best: { type: integer, minimum: 1, maximum: 10, description: "Instead of seatIds: hold the best N together." }
                allowOrphans: { type: boolean }
      responses:
        "201":
          description: Held
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Hold"
                  - type: object
                    properties:
                      buyerRef: { type: string }
                      priceCents: { type: integer, description: "Advisory; with best only." }
        default: { $ref: "#/components/responses/Error" }
  /session/holds/{holdId}:
    parameters:
      - $ref: "#/components/parameters/holdId"
    delete:
      summary: Release this session's hold
      operationId: releaseSessionHold
      security: [{ buyerSession: [] }]
      responses:
        "200":
          description: Released
          content:
            application/json:
              schema: { type: object, properties: { released: { type: array, items: { type: string } } } }
        default: { $ref: "#/components/responses/Error" }
  /session/holds/{holdId}/checkout:
    parameters:
      - $ref: "#/components/parameters/holdId"
    post:
      summary: Hosted checkout (TEST MODE ONLY)
      description: |
        Buyer session. SeatMapOS completes the sale with SIMULATED payment —
        refused for live sessions (`403 hosted_checkout_test_only`).
      operationId: sessionCheckout
      security: [{ buyerSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [buyer]
              properties:
                buyer:
                  type: object
                  required: [name, email]
                  properties: { name: { type: string }, email: { type: string, format: email } }
                promoCode: { type: string }
      responses:
        "201":
          description: Sold (test), with its tickets
          content:
            application/json:
              schema:
                type: object
                properties:
                  order: { $ref: "#/components/schemas/Order" }
                  tickets: { type: array, items: { $ref: "#/components/schemas/Ticket" } }
        default: { $ref: "#/components/responses/Error" }
  /session/promo:
    post:
      summary: Preview a promo code
      operationId: sessionPromo
      security: [{ buyerSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [code], properties: { code: { type: string } } }
      responses:
        "200":
          description: The promo
          content:
            application/json:
              schema:
                type: object
                properties:
                  code: { type: string }
                  kind: { type: string, enum: [percent, amount] }
                  value: { type: integer }
        default: { $ref: "#/components/responses/Error" }
  /designer-sessions:
    post:
      summary: Mint a designer session (embedded designer)
      description: |
        Secret key. A short-lived session for ONE of your users to work in the
        embedded designer, acting for your org in your key's mode. Mount it
        with `SeatMapOS(pk).designer.mount(el, { token })`. Permissions default
        to `edit` + `publish`; `ai` and `events` are opt-in. `venueSlug`
        confines it to one venue (yours, or not yet existing).
      operationId: createDesignerSession
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [externalUserId]
              properties:
                externalUserId: { type: string, maxLength: 120 }
                permissions: { type: array, items: { type: string, enum: [edit, publish, ai, events] } }
                venueSlug: { type: string, pattern: "^[a-z0-9-]{1,64}$" }
                ttlSec: { type: integer, minimum: 300, maximum: 28800, default: 3600 }
      responses:
        "201":
          description: Session
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string, description: "ds_…" }
                  expiresAt: { type: string, format: date-time }
                  permissions: { type: array, items: { type: string } }
                  venueSlug: { type: [string, "null"] }
                  embedUrl: { type: string }
        default: { $ref: "#/components/responses/Error" }
  /orders:
    get:
      summary: List orders
      description: Secret key. Newest first; includes buyer details.
      operationId: listOrders
      parameters:
        - { name: eventId, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
      responses:
        "200":
          description: Orders
          content:
            application/json:
              schema:
                type: object
                properties:
                  orders: { type: array, items: { $ref: "#/components/schemas/Order" } }
        default: { $ref: "#/components/responses/Error" }
  /orders/{orderId}:
    parameters:
      - $ref: "#/components/parameters/orderId"
    get:
      summary: Get an order
      description: Secret key.
      operationId: getOrder
      responses:
        "200":
          description: The order
          content:
            application/json:
              schema: { type: object, properties: { order: { $ref: "#/components/schemas/Order" } } }
        default: { $ref: "#/components/responses/Error" }
  /orders/{orderId}/tickets:
    parameters:
      - $ref: "#/components/parameters/orderId"
    get:
      summary: An order's tickets
      description: |
        Secret key. One ticket per seat, each with a signed QR (`qr`, null
        until a signing key is configured — run the event backfill then).
        A QR is a bearer credential: deliver it to the buyer, never put it in
        a URL or a log. Refunded orders' tickets are `revoked`. The same
        tickets arrive in the `order.confirmed` webhook (`data.tickets`).
      operationId: listOrderTickets
      responses:
        "200":
          description: The tickets
          content:
            application/json:
              schema: { type: object, properties: { tickets: { type: array, items: { $ref: "#/components/schemas/Ticket" } } } }
        default: { $ref: "#/components/responses/Error" }
  /events/{eventId}/tickets/backfill:
    parameters:
      - $ref: "#/components/parameters/eventId"
    post:
      summary: Issue and sign missing tickets
      description: |
        Secret key. Issues tickets for confirmed orders that have none and
        signs tickets issued while no signing key was configured. At most 200
        per call — repeat while `remaining` is true.
      operationId: backfillTickets
      responses:
        "200":
          description: What it did
          content:
            application/json:
              schema:
                type: object
                properties:
                  issued: { type: integer }
                  signed: { type: integer }
                  remaining: { type: boolean }
        default: { $ref: "#/components/responses/Error" }
  /orders/{orderId}/refund:
    parameters:
      - $ref: "#/components/parameters/orderId"
    post:
      summary: Refund an order
      description: |
        Secret key. Marks the order refunded and puts its seats straight back
        on sale. Returning the money is your payment provider's job.
        Idempotent.
      operationId: refundOrder
      responses:
        "200":
          description: Refunded
          content:
            application/json:
              schema:
                type: object
                properties:
                  order: { $ref: "#/components/schemas/Order" }
                  alreadyRefunded: { type: boolean }
        default: { $ref: "#/components/responses/Error" }
components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: "`pk_…` or `sk_…` API key"
    buyerSession:
      type: http
      scheme: bearer
      description: "`bs_…` buyer session from POST /buyer-sessions"
  parameters:
    eventId: { name: eventId, in: path, required: true, schema: { type: string } }
    holdId: { name: holdId, in: path, required: true, schema: { type: string } }
    orderId: { name: orderId, in: path, required: true, schema: { type: string } }
  responses:
    Error:
      description: An error
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
    Venue:
      type: object
      properties:
        id: { type: string }
        slug: { type: string }
        name: { type: string }
        city: { type: [string, "null"] }
        seatCount: { type: integer }
        publishedAt: { type: [string, "null"], format: date-time }
    Event:
      type: object
      properties:
        id: { type: string }
        venueId: { type: string }
        name: { type: string }
        startsAt: { type: [string, "null"] }
        status: { type: string, enum: [draft, onsale, closed] }
        mode: { type: string, enum: [test, live] }
        selectionMode: { type: string, enum: [seat, row, section] }
        checkout: { type: string, enum: [simulated, external] }
    Manifest:
      type: object
      properties:
        event: { $ref: "#/components/schemas/Event" }
        venue:
          type: object
          properties: { id: { type: string }, slug: { type: string }, name: { type: string } }
        seatmap:
          type: object
          properties:
            url: { type: string }
            contentType: { type: string }
            available: { type: boolean }
        pricing:
          type: object
          properties:
            sections: { type: object, additionalProperties: { type: integer } }
            seats: { type: [object, "null"], additionalProperties: { type: integer } }
            tiers:
              type: array
              items:
                type: object
                properties:
                  id: { type: string }
                  code: { type: string }
                  name: { type: string }
                  color: { type: [string, "null"] }
                  priceCents: { type: integer }
        fees:
          type: object
          properties:
            currency: { type: string }
            servicePct: { type: number }
            serviceFlatCentsPerTicket: { type: integer }
            processingPct: { type: number }
    Availability:
      type: object
      properties:
        eventId: { type: string }
        seats:
          type: object
          additionalProperties:
            type: object
            properties:
              status: { type: string, enum: [held, confirmed] }
              expiresAt: { type: integer, description: epoch ms }
        blocked: { type: array, items: { type: string } }
    Hold:
      type: object
      properties:
        holdId: { type: string }
        seatIds: { type: array, items: { type: string } }
        expiresAt: { type: [string, "null"], format: date-time }
    Ticket:
      type: object
      description: |
        One seat's ticket. Verify `qr` offline with the Ed25519 public keys at
        GET https://os.ofpixel.com/api/tickets/keys — format
        `SML1.<b64url body>.<b64url signature>`, body `{ t, e, n, k }`
        (ticket id, event id, serial, key id).
      properties:
        ticketId: { type: string }
        seatId: { type: string }
        serial: { type: integer, description: "Per event; the manual-entry fallback at the gate." }
        status: { type: string, enum: [valid, revoked] }
        qr: { type: [string, "null"] }
    HoldQuote:
      type: object
      properties:
        holdId: { type: string }
        status: { type: string, enum: [held] }
        seats:
          type: array
          items:
            type: object
            properties:
              seatId: { type: string }
              section: { type: string }
              row: { type: string }
              label: { type: string }
              priceCents: { type: integer }
        subtotalCents: { type: integer }
        serviceCents: { type: integer }
        processingCents: { type: integer }
        feesCents: { type: integer }
        totalCents: { type: integer }
        currency: { type: string }
        expiresAt: { type: [string, "null"], format: date-time }
    Order:
      type: object
      properties:
        orderId: { type: string }
        eventId: { type: string }
        buyerName: { type: [string, "null"] }
        buyerEmail: { type: [string, "null"] }
        seatIds: { type: array, items: { type: string } }
        subtotalCents: { type: integer }
        discountCents: { type: integer }
        feesCents: { type: integer }
        totalCents: { type: integer }
        promoCode: { type: [string, "null"] }
        status: { type: string, enum: [confirmed, refunded, cancelled] }
        createdAt: { type: string, format: date-time }
        externalRef: { type: [string, "null"] }
