openapi: 3.1.0
info:
  title: BoxOfficeTech Reseller API
  version: "1.0"
  description: |
    Read the events, venues, tiers, prices and live availability a promoter has
    assigned to your seller account. Every response is scoped to your grants;
    an event you were not assigned is a 404, never a 403.

    Authentication: `Authorization: Bearer bot_<mode>_<keyId>_<secret>`.
    `bot_test_` keys reach only the platform's flagged test tenants.

    Quotas: every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`
    and `X-RateLimit-Reset` (unix seconds). Over quota returns 429 with `Retry-After`.

    Errors: `{ "error": { "code": string, "message": string } }`.
    503 `not_configured` means the API is switched off platform-wide, not that
    your key is wrong.

    Totals: never re-implement the fee formula from `feeConfig` — call `POST /quote`.

    Webhooks (optional): register ONE https endpoint with `PUT /me/webhook`.
    The platform POSTs `{ id, type, createdAt, data }` with headers
    `X-BoxOfficeTech-Signature: t=<unix>,v1=<hex>` (HMAC-SHA256 with your
    signing secret over `<t>.<raw body>`; reject when |now − t| > 300 s),
    `X-BoxOfficeTech-Event` (the type) and `X-BoxOfficeTech-Delivery` (the
    delivery id — deliveries may repeat; dedupe on it). Answer 2xx within
    5 s. Delivery is a HINT — polling remains the contract: one attempt at
    the change, then retries at 1m/5m/30m/2h/6h for up to 24 h; fifty
    consecutive failures switch the endpoint off until you PUT it again.
    Types: availability.changed {eventId}, event.assigned / event.unassigned
    {promoterId, eventId}, grant.paused / grant.resumed / grant.revoked
    {promoterId}, key.revoked {keyId}, webhook.test.
servers:
  - url: https://www.boxofficetech.com/api/resellers/v1
security:
  - bearerKey: []
components:
  securitySchemes:
    bearerKey:
      type: http
      scheme: bearer
      description: A seller API key issued by the platform.
  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string, enum: [not_configured, unauthorized, invalid_key, seller_not_verified, seller_suspended, no_grants, rate_limited, not_found, bad_request, unpriceable, not_sellable, no_tickets, lookup_failed, unavailable, cap_exceeded, in_progress, not_cancellable, invalid_url, webhook_not_active, internal] }
            message: { type: string }
    PromoterRef:
      type: object
      properties: { id: { type: string }, slug: { type: string }, name: { type: string } }
    Tier:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: [string, "null"] }
        priceCents: { type: [integer, "null"], description: "null = unknown price; never 0 for unknown" }
        currency: { type: string }
        sellable: { type: boolean }
        seatBacked: { type: boolean, description: "Inventory lives per seat; the GA availability number is not meaningful for this tier" }
        maxPerOrder: { type: [integer, "null"] }
    FeeConfig:
      type: object
      description: Descriptive only. Use POST /quote for totals.
      properties:
        serviceFee: { $ref: "#/components/schemas/Fee" }
        parkingFee: { $ref: "#/components/schemas/Fee" }
        venueFee: { $ref: "#/components/schemas/Fee" }
        customFees: { type: array, items: { $ref: "#/components/schemas/Fee" } }
        salesTaxPercent: { type: number }
        applyPercentFeesOnDiscountedPrice: { type: boolean }
        note: { type: string }
    Fee:
      type: [object, "null"]
      properties:
        name: { type: string }
        amount: { type: number }
        type: { type: string, enum: [percentage, fixed] }
        scope: { type: string, enum: [per_ticket, per_transaction] }
    Event:
      type: object
      properties:
        id: { type: string }
        slug: { type: [string, "null"] }
        name: { type: string }
        description: { type: [string, "null"] }
        shortDescription: { type: [string, "null"] }
        images: { type: object, properties: { thumbnail: { type: [string, "null"] }, banner: { type: [string, "null"] } } }
        start: { type: object, properties: { date: { type: [string, "null"], format: date-time }, time: { type: [string, "null"] }, timezone: { type: [string, "null"] } } }
        end: { type: [object, "null"] }
        status: { type: string, description: "Always a live status; drafts are never listed" }
        promoter: { $ref: "#/components/schemas/PromoterRef" }
        venue: { type: [object, "null"], properties: { id: { type: string }, name: { type: string }, city: { type: [string, "null"] }, state: { type: [string, "null"] } } }
        seatingType: { type: string, enum: [general, reserved] }
        orderingOnly: { type: boolean, description: "true = food/merch only, no tickets" }
        tiers: { type: array, items: { $ref: "#/components/schemas/Tier" } }
        feeConfig: { $ref: "#/components/schemas/FeeConfig" }
        portalUrls: { type: object, properties: { event: { type: string }, tickets: { type: string } }, description: "Carry ?via=<sellerId> attribution for the hand-off case" }
        caps: { type: object, properties: { maxTickets: { type: [integer, "null"] } } }
        updatedAt: { type: [string, "null"], format: date-time }
    Layout:
      type: [object, "null"]
      description: The seat picker's data for reserved events. Seat ids are the canonical sale ids.
      properties:
        priceCategories: { type: array, items: { type: object, properties: { id: { type: string }, name: { type: string }, color: { type: [string, "null"] }, priceCents: { type: [integer, "null"] } } } }
        sections:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              name: { type: string }
              priceCategoryId: { type: [string, "null"] }
              rows:
                type: array
                items:
                  type: object
                  properties:
                    label: { type: string }
                    seats: { type: array, items: { type: object, properties: { number: { type: integer }, id: { type: string }, x: { type: number }, y: { type: number }, categoryId: { type: [string, "null"] }, accessible: { type: boolean } } } }
        gaZones: { type: array, items: { type: object, properties: { id: { type: string }, name: { type: string }, capacity: { type: integer }, priceCategoryId: { type: [string, "null"] } } } }
    Availability:
      type: object
      properties:
        asOf: { type: string, format: date-time }
        ga:
          type: array
          items:
            type: object
            properties:
              tierId: { type: string }
              available: { type: integer }
              soldOut: { type: boolean }
              capacity: { type: [integer, "null"], description: "Only when the promoter shares capacity with you" }
              sold: { type: integer, description: "Only when the promoter shares capacity with you" }
        gaZones: { type: array, items: { type: object, properties: { zoneId: { type: string }, name: { type: string }, available: { type: integer }, soldOut: { type: boolean } } } }
        reserved: { type: [object, "null"], properties: { unavailableSeats: { type: array, items: { type: string }, description: "sold, held and blocked seats merged" } } }
        sellerCap: { type: [object, "null"], properties: { maxTickets: { type: integer }, remaining: { type: integer } } }
        notes: { type: array, items: { type: string } }
    Venue:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        address: { type: [string, "null"] }
        city: { type: [string, "null"] }
        state: { type: [string, "null"] }
        zip: { type: [string, "null"] }
        country: { type: [string, "null"] }
        type: { type: [string, "null"] }
        capacity: { type: [integer, "null"] }
        coordinates: { type: [object, "null"], properties: { lat: { type: number }, lng: { type: number } } }
    QuoteRequest:
      type: object
      required: [eventId, items]
      properties:
        eventId: { type: string }
        items:
          type: array
          maxItems: 20
          items: { $ref: "#/components/schemas/QuoteItem" }
    QuoteItem:
      type: object
      required: [quantity]
      properties:
        tierId: { type: string, description: "A GA tier id or name" }
        quantity: { type: integer, minimum: 1 }
        seat: { type: object, properties: { sectionId: { type: string }, row: { type: string }, seat: { type: string } }, description: "A reserved seat (quantity must be 1)" }
        zoneId: { type: string, description: "A GA polygon zone id" }
    Quote:
      type: object
      properties:
        currency: { type: string }
        items: { type: array, items: { type: object, properties: { tierId: { type: [string, "null"] }, seat: { type: [object, "null"] }, zoneId: { type: [string, "null"] }, quantity: { type: integer }, unitPriceCents: { type: integer } } } }
        breakdown:
          type: object
          properties:
            subtotalCents: { type: integer }
            fees: { type: array, items: { type: object, properties: { name: { type: string }, amountCents: { type: integer } } } }
            salesTaxCents: { type: integer }
            totalCents: { type: integer }
        note: { type: string }
    Webhook:
      type: [object, "null"]
      properties:
        url: { type: string }
        active: { type: boolean }
        hasSigningSecret: { type: boolean }
        lastSuccessAt: { type: [string, "null"] }
        lastFailureAt: { type: [string, "null"] }
        lastError: { type: [string, "null"] }
        consecutiveFailures: { type: integer }
        disabledReason: { type: [string, "null"] }
        eventTypes: { type: array, items: { type: string } }
    WebhookEvent:
      type: object
      description: The POST body your endpoint receives.
      properties:
        id: { type: string, description: Delivery id — dedupe on it }
        type: { type: string, enum: [availability.changed, event.assigned, event.unassigned, grant.paused, grant.resumed, grant.revoked, key.revoked, order.recorded, webhook.test] }
        createdAt: { type: string, format: date-time }
        data: { type: object }
    HoldRequest:
      type: object
      required: [items]
      properties:
        items: { type: array, items: { $ref: "#/components/schemas/QuoteItem" }, description: Tier and seat items only — GA zones carry no holds and are verified when the order is recorded }
    Hold:
      type: object
      properties:
        holdId: { type: string, description: Pass this as `holdId` when recording the order }
        eventId: { type: string }
        expiresAt: { type: string, format: date-time, description: 10 minutes from creation }
        ga: { type: array, items: { type: object, properties: { ticketTypeId: { type: string }, quantity: { type: integer } } } }
        seats: { type: array, items: { type: object, properties: { sectionId: { type: string }, row: { type: string }, seat: { type: string } } } }
    OrderRequest:
      type: object
      required: [eventId, items, buyer, externalOrderId]
      properties:
        eventId: { type: string }
        holdId: { type: string, description: Required when the order contains reserved seats; recommended always }
        items: { type: array, items: { $ref: "#/components/schemas/QuoteItem" } }
        buyer: { type: object, required: [name, email], properties: { name: { type: string }, email: { type: string, format: email }, phone: { type: string } } }
        externalOrderId: { type: string, description: "Your order id. Idempotency key: a retry with the same value returns the SAME order, never a second one." }
        sellerFeeCents: { type: integer, description: Your own DISCLOSED fee to the buyer, recorded for audit — never part of the platform's pricing }
        sendBuyerEmail: { type: boolean, description: "false = you email the buyer yourself for this order (the promoter's grant can also switch it off)" }
    SellerOrder:
      type: object
      properties:
        id: { type: string }
        status: { type: string, enum: [completed, refunded, cancelled, pending] }
        paymentStatus: { type: [string, "null"] }
        eventId: { type: string }
        eventName: { type: [string, "null"] }
        externalOrderId: { type: [string, "null"] }
        buyer: { type: object, properties: { name: { type: string }, email: { type: string } } }
        totals: { type: object, properties: { currency: { type: string }, subtotalCents: { type: integer }, totalCents: { type: integer }, refundedCents: { type: integer } } }
        tickets:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              tierName: { type: string }
              seat: { type: [object, "null"], properties: { sectionId: { type: [string, "null"] }, section: { type: [string, "null"] }, row: { type: [string, "null"] }, seat: { type: [string, "null"] } } }
              status: { type: string, enum: [active, used, cancelled], description: "used = scanned at the gate" }
              qrValue: { type: string, description: Encode THIS in the QR you render — it is what the gate scanner reads }
        recordedAt: { type: [string, "null"] }
        refunds: { type: array, items: { type: object, properties: { amountCents: { type: integer }, ticketIds: { type: array, items: { type: string } }, reason: { type: [string, "null"] }, at: { type: [string, "null"] } } } }
  responses:
    Unauthorized: { description: Missing or invalid key, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    NotFound: { description: Not in your assignments, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    RateLimited: { description: Per-minute quota exceeded, headers: { Retry-After: { schema: { type: integer } } } }
paths:
  /me:
    get:
      summary: Your seller account, key mode and grants
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      seller: { type: object, properties: { id: { type: string }, name: { type: string } } }
                      key: { type: object, properties: { id: { type: string }, mode: { type: string, enum: [live, test] }, prefix: { type: string } } }
                      limits: { type: object, properties: { requestsPerMinute: { type: integer } } }
                      grants: { type: array, items: { type: object, properties: { promoter: { $ref: "#/components/schemas/PromoterRef" }, eventCount: { type: integer }, shareCapacity: { type: boolean } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /me/webhook:
    get:
      summary: Your webhook endpoint configuration (never the secret)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { webhook: { $ref: "#/components/schemas/Webhook" } } } } }
    put:
      summary: Register or update your endpoint; mints the signing secret on first registration
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri, description: https only, no embedded credentials, publicly routable host }
                rotateSecret: { type: boolean, description: Mint a new signing secret; the old one stops verifying immediately }
      responses:
        "200":
          description: OK. `signingSecret` is present ONLY when a secret was just minted — store it now.
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { webhook: { $ref: "#/components/schemas/Webhook" }, signingSecret: { type: string }, note: { type: string } } } } }
        "400": { description: "invalid_url" }
    delete:
      summary: Switch the endpoint off (the record and secret are kept)
      responses:
        "200": { description: OK }
    post:
      summary: Send a webhook.test event to your endpoint now
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { delivered: { type: boolean }, note: { type: string } } } } }
        "409": { description: "webhook_not_active" }
  /events:
    get:
      summary: Assigned live events across your grants
      parameters:
        - { name: promoter, in: query, schema: { type: string }, description: One promoter id }
        - { name: updatedSince, in: query, schema: { type: string, format: date-time }, description: "Delta: events updated since, plus tombstones for events no longer live. Reconcile against the full list periodically — un-assignment removes an event from scope entirely." }
        - { name: cursor, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, maximum: 100, default: 50 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      events: { type: array, items: { $ref: "#/components/schemas/Event" } }
                      removed: { type: array, items: { type: object, properties: { id: { type: string }, removed: { type: boolean } } } }
                  meta: { type: object, properties: { total: { type: integer }, nextCursor: { type: [string, "null"] }, asOf: { type: string } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /events/{eventId}:
    get:
      summary: One assigned event, with the seat layout for reserved events
      parameters: [{ name: eventId, in: path, required: true, schema: { type: string } }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { event: { $ref: "#/components/schemas/Event" }, layout: { $ref: "#/components/schemas/Layout" } } } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /events/{eventId}/availability:
    get:
      summary: Live availability (poll this; cached a few seconds server-side)
      parameters: [{ name: eventId, in: path, required: true, schema: { type: string } }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: "#/components/schemas/Availability" } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /venues/{venueId}:
    get:
      summary: A venue backing one of your assigned events
      parameters: [{ name: venueId, in: path, required: true, schema: { type: string } }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { venue: { $ref: "#/components/schemas/Venue" } } } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /quote:
    post:
      summary: The exact total the platform will charge for a cart (authoritative)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/QuoteRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: "#/components/schemas/Quote" } } }
        "400": { description: Bad request }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "A tier that cannot be sold through the API (member-only), or an ordering-only event" }
  /events/{eventId}/holds:
    post:
      summary: Hold tickets for up to 10 minutes while your buyer pays (the buyer site's own oversell guard)
      parameters: [{ name: eventId, in: path, required: true, schema: { type: string } }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/HoldRequest" } } } }
      responses:
        "201":
          description: Held
          content:
            application/json:
              schema: { type: object, properties: { data: { $ref: "#/components/schemas/Hold" } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "unavailable (with `conflicts`), cap_exceeded, or no_tickets" }
  /holds/{holdId}:
    delete:
      summary: Release a hold early (your own holds only)
      parameters: [{ name: holdId, in: path, required: true, schema: { type: string } }]
      responses:
        "200": { description: OK }
        "404": { $ref: "#/components/responses/NotFound" }
  /orders:
    post:
      summary: Record a sale you completed on your own checkout (SALES — you collected the money)
      description: |
        The platform re-derives every price, verifies inventory under your hold, writes a COMPLETED
        order, emails the buyer their tickets from the promoter's sender (unless switched off),
        and returns the tickets with the value to encode in your QR. Idempotent on `externalOrderId`.
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/OrderRequest" } } } }
      responses:
        "201":
          description: Recorded
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { order: { $ref: "#/components/schemas/SellerOrder" }, buyerEmailed: { type: boolean }, idempotent: { type: boolean } } } } }
        "400": { description: bad_request }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "unavailable (with `conflicts`), cap_exceeded, not_sellable, in_progress" }
  /orders/{orderId}:
    get:
      summary: One of your recorded orders — status and tickets, including used/cancelled
      parameters: [{ name: orderId, in: path, required: true, schema: { type: string } }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { order: { $ref: "#/components/schemas/SellerOrder" } } } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /orders/{orderId}/cancel:
    post:
      summary: Record a refund you made to your buyer — cancels the tickets and returns the inventory
      description: Money never moves through the platform here; the amount recorded is the tickets' proportional share of the order total.
      parameters: [{ name: orderId, in: path, required: true, schema: { type: string } }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [reason], properties: { reason: { type: string }, ticketIds: { type: array, items: { type: string }, description: Omit to cancel every remaining ticket } } }
      responses:
        "200":
          description: Recorded
          content:
            application/json:
              schema: { type: object, properties: { data: { type: object, properties: { order: { $ref: "#/components/schemas/SellerOrder" }, recorded: { type: object, properties: { amountCents: { type: integer }, fullyRefunded: { type: boolean } } } } } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: not_cancellable }
