{
  "openapi": "3.1.0",
  "info": {
    "title": "Tito API",
    "version": "1",
    "description": "One machine-facing API per account, served by the instance itself (docs/api.md, slice 1: core + reads). Money fields are integer amounts of the currency's minor unit — usually cents, but a zero-decimal currency has none (JPY 1299 is `1299`, not `129900`) and a three-decimal one has three (BHD 1.299 is `1299`); currency is a lowercase ISO 4217 code next to every amount. Timestamps are RFC 3339 UTC and, where noted, nullable (the event has not happened yet — e.g. an order that has not been paid or refunded)."
  },
  "servers": [
    {
      "url": "/api/v1"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "paths": {
    "/account": {
      "get": {
        "operationId": "getAccount",
        "tags": [
          "Account"
        ],
        "summary": "Get the account",
        "x-capability": "events.view",
        "responses": {
          "200": {
            "description": "The account calling.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Account"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "description": "The account's own identity: slug, display name, and — where the platform places accounts by region — the region its data currently lives in."
      }
    },
    "/events": {
      "get": {
        "operationId": "listEvents",
        "tags": [
          "Events"
        ],
        "summary": "List events",
        "x-capability": "events.view",
        "responses": {
          "200": {
            "description": "Events, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "events": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Event"
                      }
                    }
                  },
                  "required": [
                    "events"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/series": {
      "get": {
        "operationId": "listSeries",
        "tags": [
          "Events"
        ],
        "summary": "List series",
        "x-capability": "events.view",
        "responses": {
          "200": {
            "description": "Series, by name.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "series": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Series"
                      }
                    }
                  },
                  "required": [
                    "series"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/events/{slug}": {
      "get": {
        "operationId": "getEvent",
        "tags": [
          "Events"
        ],
        "summary": "Get an event",
        "x-capability": "events.view",
        "parameters": [
          {
            "$ref": "#/components/parameters/EventSlug"
          }
        ],
        "responses": {
          "200": {
            "description": "The event.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Event"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "description": "Get an event by slug."
      }
    },
    "/events/{slug}/ticket-types": {
      "get": {
        "operationId": "listTicketTypesForEvent",
        "tags": [
          "Events"
        ],
        "summary": "List ticket types",
        "x-capability": "events.view",
        "parameters": [
          {
            "$ref": "#/components/parameters/EventSlug"
          }
        ],
        "responses": {
          "200": {
            "description": "The event's ticket types, sorted by their configured display order. `ticket_types` is always `[]`, never null, when the event has none (including an unknown slug, which matches nothing rather than 404ing).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ticket_types": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/TicketType"
                      }
                    }
                  },
                  "required": [
                    "ticket_types"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "description": "List an event's ticket types. Archived ticket types are not included."
      }
    },
    "/events/{slug}/income": {
      "get": {
        "operationId": "getEventIncome",
        "tags": [
          "Reports"
        ],
        "summary": "Income report",
        "description": "Report an event's income and deductions, with a month-by-month breakdown. The data behind the admin's Income and deductions report: gross before anything comes off, less discount codes, less refunds, giving what was collected from buyers; less the tax to remit, giving net of tax. Only orders that actually took money are counted, whichever stamp `on` names — money committed but not landed is reported separately as `pending_cents` and is in none of the other figures.\n\nThen less what the payments cost: `fees_cents` is what the payment provider took and `platform_fee_cents` is Tito's own, giving `net_cents` — what the organizer keeps. Fees are never handed back on a refund, so a refunded order still carries the cost of taking it.\n\nA payment whose cost is not established — a card charge Stripe has not settled yet, or one whose fee settled in a different currency from the order's — is counted in `unpriced_payments` and is in neither fee figure. It is not a payment that cost nothing, and a caller summing fees must treat a nonzero `unpriced_payments` as an incomplete total.\n\nThis is still not a profit and loss: nothing here knows what a venue, catering or staff cost, and none of it is what reaches a bank account. Tito does not remit the tax it reports here.\n\nMonths bucket in the event's own timezone (reported as `timezone`), and the month rows decompose the totals exactly.",
        "x-capability": "events.view",
        "parameters": [
          {
            "$ref": "#/components/parameters/EventSlug"
          },
          {
            "name": "range",
            "in": "query",
            "required": false,
            "description": "The window, measured against whichever stamp `on` names. One of `30d`, `7d`, `24h`, `month` (this calendar month to date), `all`, or `custom` — which takes its bounds from `from` and `to` and is the one range that does not travel with the clock. Anything else is `30d`.",
            "schema": {
              "type": "string",
              "default": "30d",
              "enum": [
                "30d",
                "7d",
                "24h",
                "month",
                "all",
                "custom"
              ]
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "The first day of a custom window, as `YYYY-MM-DD`, cut at midnight in the event's own timezone. Read only when `range=custom`, and optional even then — a custom window with only `to` set runs from the first record. A date that will not parse is no bound at all, and `range=custom` with neither bound falls back to `30d`.",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "The last day of a custom window, as `YYYY-MM-DD`, INCLUSIVE — the window runs to the end of that day in the event's own timezone. Read only when `range=custom`. Given `from` and `to` the wrong way round, the window is still the one between them.",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "on",
            "in": "query",
            "required": false,
            "description": "Which stamp a paid order is dated by: `paid` (default) or `started`. Pending money has no payment day either way, so `pending_cents` always counts from the day the order started.",
            "schema": {
              "type": "string",
              "default": "paid",
              "enum": [
                "paid",
                "started"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The ledger's own figures, the months behind them, and the query that actually ran. `months` and `discount_codes` are always `[]`, never null; months sort oldest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "range": {
                      "type": "string",
                      "description": "The range the report ran with, after clamping."
                    },
                    "from": {
                      "type": "string",
                      "description": "The first day of the window that ran, as `YYYY-MM-DD`. Always present, empty unless the range is `custom`."
                    },
                    "to": {
                      "type": "string",
                      "description": "The last day of the window that ran, inclusive, as `YYYY-MM-DD`. Always present, empty unless the range is `custom`."
                    },
                    "on": {
                      "type": "string",
                      "description": "The stamp paid orders were dated by: `paid` or `started`."
                    },
                    "timezone": {
                      "type": "string",
                      "description": "The event's IANA timezone — the zone month buckets are cut in."
                    },
                    "currency": {
                      "type": "string",
                      "description": "The event's currency. Orders taken in another currency are still summed into these amounts."
                    },
                    "orders": {
                      "type": "integer",
                      "description": "Paid orders in the window — the ones every figure below except the pending pair is built from."
                    },
                    "gross_cents": {
                      "type": "integer",
                      "description": "Before anything comes off: what those orders charged plus the discount that came off them."
                    },
                    "discount_cents": {
                      "type": "integer",
                      "description": "Discount taken off by codes, as a positive amount. Recorded per ORDER, never per line, so it cannot be attributed to a ticket type."
                    },
                    "discount_codes": {
                      "type": "array",
                      "description": "Every code redeemed on those orders, sorted, each once.",
                      "items": {
                        "type": "string"
                      }
                    },
                    "refund_cents": {
                      "type": "integer",
                      "description": "Succeeded refunds against those orders, as a positive amount."
                    },
                    "refunded_orders": {
                      "type": "integer",
                      "description": "How many of those orders were refunded in full or in part."
                    },
                    "collected_cents": {
                      "type": "integer",
                      "description": "`gross_cents` minus `discount_cents` minus `refund_cents` — what was actually collected from buyers."
                    },
                    "tax_cents": {
                      "type": "integer",
                      "description": "Tax to remit, as a positive amount: the tax snapshotted on each order line when it was sold, so a rate change never rewrites a filed figure. Note the snapshot has no rate id, so two rates sharing a name and percentage are indistinguishable."
                    },
                    "fees_cents": {
                      "type": "integer",
                      "description": "What the payment provider took, as a positive amount. Counts only payments whose cost is established and settled in the event's own currency — see `unpriced_payments`."
                    },
                    "platform_fee_cents": {
                      "type": "integer",
                      "description": "Tito's own fee, as a positive amount. Always 0 for an account paying through its own Stripe key, which is charged no platform fee."
                    },
                    "unpriced_payments": {
                      "type": "integer",
                      "description": "How many payments in range have no established cost and are therefore in neither fee figure. Nonzero means the fee total is incomplete, not that those payments were free."
                    },
                    "net_cents": {
                      "type": "integer",
                      "description": "`collected_cents` minus `tax_cents` minus `fees_cents` minus `platform_fee_cents` — what the organizer keeps of the money that moved, before anything the event itself cost."
                    },
                    "pending_cents": {
                      "type": "integer",
                      "description": "Money committed but not landed: orders still pending, counted from the day they started. In none of the figures above."
                    },
                    "pending_orders": {
                      "type": "integer",
                      "description": "How many orders `pending_cents` covers."
                    },
                    "months": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/IncomeMonth"
                      }
                    }
                  },
                  "required": [
                    "range",
                    "from",
                    "to",
                    "on",
                    "timezone",
                    "currency",
                    "orders",
                    "gross_cents",
                    "discount_cents",
                    "discount_codes",
                    "refund_cents",
                    "refunded_orders",
                    "collected_cents",
                    "tax_cents",
                    "fees_cents",
                    "platform_fee_cents",
                    "unpriced_payments",
                    "net_cents",
                    "pending_cents",
                    "pending_orders",
                    "months"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/events/{slug}/availability": {
      "get": {
        "operationId": "getEventAvailability",
        "tags": [
          "Reports"
        ],
        "summary": "Availability report",
        "description": "Report how much of every sellable thing is taken and how much is left. The data behind the admin's Availability report: every ticket type, session offering, good and donation on the event, with its cap, how much is sold, how much is held by pending orders, and what is left.\n\nPending counts as taken. An unpaid order holds its stock until it pays or its hold expires, so `remaining` is the cap less `taken` (sold plus pending) — for a good, literally the same ledger the checkout gate enforces, which is what makes this safe to drive a stock display from.\n\n`capacity` and `remaining` are `null` when the thing has no limit, never `0` — a caller must not read an absent cap as sold out. Donations have no stock to run out of, so their capacity is always `null`.\n\nKinds and status words are English. Rows arrive grouped by kind in a fixed order (tickets, sessions, goods, donations); a kind the event doesn't sell contributes no rows.",
        "x-capability": "events.view",
        "parameters": [
          {
            "$ref": "#/components/parameters/EventSlug"
          }
        ],
        "responses": {
          "200": {
            "description": "Every sellable thing, plus the totals the report opens with. `rows` is always `[]`, never null.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "rows": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "kind": {
                            "type": "string",
                            "enum": [
                              "tickets",
                              "sessions",
                              "goods",
                              "donations"
                            ],
                            "description": "Which group the row sits in."
                          },
                          "name": {
                            "type": "string",
                            "description": "The organiser's own name for the thing. Never empty. A block of session options the organiser left unnamed is reported by when it runs (\"Fri 5 Mar, 10:00\u201311:30\", or both dates where the block spans more than one day), formatted in English like the rest of this response."
                          },
                          "capacity": {
                            "type": [
                              "integer",
                              "null"
                            ],
                            "description": "The cap, or null when there is no limit."
                          },
                          "sold": {
                            "type": "integer",
                            "description": "Units on paid orders."
                          },
                          "pending": {
                            "type": "integer",
                            "description": "Units under a live hold that has not been paid for — a checkout in progress, a portal basket, an extra added to an existing order. Stock that comes back if it is not paid in time."
                          },
                          "taken": {
                            "type": "integer",
                            "description": "sold plus pending: everything currently counting against the cap."
                          },
                          "via_portals": {
                            "type": "integer",
                            "description": "The share of `taken` that arrived through a portal rather than the main page — either bought in the portal's own store, or claimed from one of its invitations. Always present, including for events running no portals. Units, never money: this counts a portal's drawdown, not a revenue line."
                          },
                          "portals": {
                            "type": "array",
                            "description": "`via_portals` split by the portal that took it, biggest share first. Always present; `[]` when no portal has taken any, and also `[]` for a key that does not carry the `invitations.view` scope — naming the portals is portal information, so a key that needs the split must carry that scope on top of `events.view`. Every figure, `via_portals` included, is served either way. The entries sum to `via_portals`: both are counted through this offering's own ledger. One caveat, and only for `sessions` — a held place carries no order until its ticket is issued, so a session's split can sum to less than its `taken`.",
                            "items": {
                              "type": "object",
                              "properties": {
                                "portal": {
                                  "type": "string",
                                  "description": "The portal's own name."
                                },
                                "units": {
                                  "type": "integer",
                                  "description": "Units of this offering that went out through that portal."
                                }
                              },
                              "required": [
                                "portal",
                                "units"
                              ]
                            }
                          },
                          "remaining": {
                            "type": [
                              "integer",
                              "null"
                            ],
                            "description": "capacity less taken, floored at 0; null when there is no limit. A 0 here means sold out."
                          },
                          "status": {
                            "type": "string",
                            "description": "`sold out`, `off sale`, or empty when the thing is simply on sale. Off sale wins over sold out."
                          }
                        },
                        "required": [
                          "kind",
                          "name",
                          "capacity",
                          "sold",
                          "pending",
                          "taken",
                          "via_portals",
                          "portals",
                          "remaining",
                          "status"
                        ]
                      }
                    },
                    "listed": {
                      "type": "integer",
                      "description": "How many rows the report carries in total."
                    },
                    "sold": {
                      "type": "integer",
                      "description": "Units sold across every row."
                    },
                    "pending": {
                      "type": "integer",
                      "description": "Units under a live unpaid hold across every row."
                    },
                    "sold_out": {
                      "type": "integer",
                      "description": "How many rows have a cap and nothing left."
                    }
                  },
                  "required": [
                    "rows",
                    "listed",
                    "sold",
                    "pending",
                    "sold_out"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/events/{slug}/report": {
      "get": {
        "operationId": "getEventReport",
        "tags": [
          "Reports"
        ],
        "summary": "Orders report",
        "description": "Report an event's orders grouped by day, week, month, audience, status or discount code. The data behind the admin's custom report: the same grouping, range and filters, aggregated at ORDER grain over `orders.amount_cents` — what actually changed hands, discounts and non-ticket items included. Rows carry no personal data, and there is no drill-through to the orders behind a row. Every parameter clamps to a known value rather than erroring, so the response echoes the query it actually ran. Days bucket in the event's own timezone (reported as `timezone`). This endpoint reports on ORDERS only, and `source` is the one parameter that does not clamp: it must be omitted or `orders`, and anything else is a 400. A grouping is a different view of the same records, so falling back to a default is safe; a source is a different record set, so answering an attendee question with order figures under a 200 would be a wrong number no caller could see was wrong. The admin's other sources carry personal data under their own capabilities, and widening this key scope to reach them is a decision that has not been taken.",
        "x-capability": "events.view",
        "parameters": [
          {
            "$ref": "#/components/parameters/EventSlug"
          },
          {
            "name": "source",
            "in": "query",
            "required": false,
            "description": "The record set to report on. Only `orders` is served here; omit it or pass `orders`. Any other value is rejected with `unsupported_source` rather than quietly answered with order figures.",
            "schema": {
              "type": "string",
              "default": "orders",
              "enum": [
                "orders"
              ]
            }
          },
          {
            "name": "group",
            "in": "query",
            "required": false,
            "description": "The axis rows are grouped on. One of `day`, `week` (weeks start Monday), `month`, `audience`, `status`, `code` (discount codes). Anything else is `day`.",
            "schema": {
              "type": "string",
              "default": "day",
              "enum": [
                "day",
                "week",
                "month",
                "audience",
                "status",
                "code"
              ]
            }
          },
          {
            "name": "range",
            "in": "query",
            "required": false,
            "description": "The window, measured against whichever stamp `on` names. One of `30d`, `7d`, `24h`, `month` (this calendar month to date), `all`, or `custom` — which takes its bounds from `from` and `to` and is the one range that does not travel with the clock. Anything else is `30d`.",
            "schema": {
              "type": "string",
              "default": "30d",
              "enum": [
                "30d",
                "7d",
                "24h",
                "month",
                "all",
                "custom"
              ]
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "The first day of a custom window, as `YYYY-MM-DD`, cut at midnight in the event's own timezone. Read only when `range=custom`, and optional even then — a custom window with only `to` set runs from the first record. A date that will not parse is no bound at all, and `range=custom` with neither bound falls back to `30d`.",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "The last day of a custom window, as `YYYY-MM-DD`, INCLUSIVE — the window runs to the end of that day in the event's own timezone. Read only when `range=custom`. Given `from` and `to` the wrong way round, the window is still the one between them.",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "on",
            "in": "query",
            "required": false,
            "description": "Which stamp an order is dated by: `paid` (default) or `started`. Measured against payment, an order that has never been paid has no date and is simply not in the report — filter by `status=pending` to see those.",
            "schema": {
              "type": "string",
              "default": "paid",
              "enum": [
                "paid",
                "started"
              ]
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Keep only orders in this status. One of `paid`, `pending`, `refunded`, `canceled`; anything else (including absent) means any status.",
            "schema": {
              "type": "string",
              "enum": [
                "paid",
                "pending",
                "refunded",
                "canceled"
              ]
            }
          },
          {
            "name": "audience",
            "in": "query",
            "required": false,
            "description": "Keep only orders from this audience, matched against the same bucket `group=audience` puts the order in — an audience's slug, or `-` for orders that came in off the event's own page. Absent means any audience.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "code",
            "in": "query",
            "required": false,
            "description": "Keep only orders carrying this discount code, matched against the same bucket `group=code` puts the order in — the codes an order redeemed, comma-separated in redemption order when it redeemed more than one, or `-` for orders with no code at all. Absent means any.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "currency",
            "in": "query",
            "required": false,
            "description": "Read the report in this currency — one at a time, because money is never summed across currencies. Only a currency the event sells in is accepted; absent, or anything else, reads in the event's own `currency`. Call once per entry of the event's `currencies` to read them all. Echoed back as `currency`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "paid",
            "in": "query",
            "required": false,
            "description": "`1` keeps only orders that actually took money, whichever stamp `on` names — which is not the same as `status=paid`, because a refunded order did take money. Anything else (including absent) keeps every order. Measured against payment this changes nothing, since an unpaid order has no paid day to be counted on.",
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The grouped rows and the totals line, plus the query that actually ran. `rows` is always `[]`, never null, when nothing falls in the window. Time groupings sort oldest first; every other axis sorts by gross, biggest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "source": {
                      "type": "string",
                      "description": "The record set reported on. Always `orders` today."
                    },
                    "group": {
                      "type": "string",
                      "description": "The grouping the report ran with, after clamping."
                    },
                    "range": {
                      "type": "string",
                      "description": "The range the report ran with, after clamping."
                    },
                    "from": {
                      "type": "string",
                      "description": "The first day of the window that ran, as `YYYY-MM-DD`. Always present, empty unless the range is `custom`."
                    },
                    "to": {
                      "type": "string",
                      "description": "The last day of the window that ran, inclusive, as `YYYY-MM-DD`. Always present, empty unless the range is `custom`."
                    },
                    "on": {
                      "type": "string",
                      "description": "The stamp orders were dated by: `paid` or `started`."
                    },
                    "status": {
                      "type": "string",
                      "description": "The status filter that ran; empty means any status."
                    },
                    "audience": {
                      "type": "string",
                      "description": "The audience filter that ran; empty means any audience, `-` means orders that came in off the event's own page."
                    },
                    "code": {
                      "type": "string",
                      "description": "The discount-code filter that ran; empty means any, `-` means orders with no code."
                    },
                    "paid": {
                      "type": "boolean",
                      "description": "Whether the report was narrowed to orders that took money."
                    },
                    "timezone": {
                      "type": "string",
                      "description": "The event's IANA timezone — the zone day, week and month buckets are cut in."
                    },
                    "currency": {
                      "type": "string",
                      "description": "The event's currency, repeated on every row and on the totals."
                    },
                    "rows": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/ReportRow"
                      }
                    },
                    "totals": {
                      "$ref": "#/components/schemas/ReportRow"
                    }
                  },
                  "required": [
                    "group",
                    "range",
                    "from",
                    "to",
                    "on",
                    "status",
                    "audience",
                    "timezone",
                    "currency",
                    "rows",
                    "totals"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/events/{slug}/reports": {
      "get": {
        "operationId": "listSavedReports",
        "tags": [
          "Reports"
        ],
        "summary": "List saved reports",
        "description": "List an event's saved reports. The reports somebody at this account named and kept. Built-in reports are absent by design — they are the same handful of definitions on every event, not rows here. Each report's whole definition is its `query`: append it to the report endpoint (or the admin's own report URL) to run it.",
        "x-capability": "events.view",
        "parameters": [
          {
            "$ref": "#/components/parameters/EventSlug"
          }
        ],
        "responses": {
          "200": {
            "description": "The event's saved reports, oldest first. `reports` is always `[]`, never null, when there are none.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "reports": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/SavedReport"
                      }
                    }
                  },
                  "required": [
                    "reports"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/events/{slug}/orders": {
      "get": {
        "operationId": "listOrdersForEvent",
        "tags": [
          "Orders"
        ],
        "summary": "List orders",
        "x-capability": "orders.view",
        "parameters": [
          {
            "$ref": "#/components/parameters/EventSlug"
          },
          {
            "$ref": "#/components/parameters/After"
          },
          {
            "$ref": "#/components/parameters/Limit"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of orders, each with its ticket seats (`items`), its whole priced ledger (`lines`) and the promotion codes redeemed against it (`discounts`). `orders` is always `[]`, never null, when the event has none.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "orders": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Order"
                      }
                    },
                    "next_after": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Order ref to pass as `?after=` for the next page; null when there are no further pages; a full final page yields a cursor to an empty page."
                    }
                  },
                  "required": [
                    "orders",
                    "next_after"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "description": "List an event's orders (paged, newest first)."
      },
      "post": {
        "operationId": "createOrder",
        "tags": [
          "Orders"
        ],
        "summary": "Create an order",
        "description": "Create an order (machine checkout). Creates an order through the same pipeline the shop's own checkout runs: the ticket types must be on sale on this event's main page, the per-order quantity cap and the availability check are the shop's, and the total is priced by the shop's own pricing core (group discounts and tax included). A payable order is created **pending** and answers with `payment_url`, the buyer's own pay page. Nothing is charged here and no Stripe call is made, so an account whose Stripe key is not yet configured can still create orders. A zero-total order has nothing to pay, so it is fulfilled immediately (tickets minted, ticket email sent) and answers `status: \"paid\"` with `url`, the buyer's order page; because it is fulfilled on the spot, `email` is required for that case only.\n\n**A pending order holds its stock for as long as it can still be paid.** The shop's own checkout is released by Stripe's session-expired webhook, and an order created here has no session until the buyer opens `payment_url`, so its hold instead runs to the event's own payment window — the organizer's configured expiry, or (the default) a day past the event start, and never less than a day away. That is the same hold an admin-issued payment link takes. Cancel abandoned orders with `POST /orders/{ref}/cancel` to release their places early — create orders when a buyer is real, and cancel any you strand.\n\n**Goods included with a ticket come too.** An offering an organizer set to *Included* on a ticket type is not something a caller asks for, so it is not in `items`: it is added for you, one per connected ticket, as a zero-price line, and it holds that good's stock exactly as a bought one does. The order this endpoint creates therefore matches the order the same selection would have produced on the event page. A good that has run out refuses the whole create with 409 `sold_out` naming it, and nothing is written.\n\n**What this endpoint does not collect.** Questions — including required ones — are not asked for here: the order arrives with no answers, and the organizer chases them (or the buyer fills them in on the order page). Ticket types whose product carries a scheduled entitlement cannot be sold this way at all, because a session would have to be chosen; they answer 422 `invalid_items` naming the type. Nor can audience-only types: the API sells what the event's main page sells.\n\n`payment_url` and `url` are the ONE documented exception to the rule that buyer capability URLs never appear in API responses: handing the buyer their pay link is the whole product of this endpoint. Treat both as secrets — whoever holds the URL holds the order.\n\nSend `Idempotency-Key` to make retries safe: the first call creates the order, and every later call with the same key answers 200 (not 201) with that same order's current state — a still-pending order returns its `payment_url` again, an order that has since been paid returns `status: \"paid\"` and `url`. The body is not compared against the original; the key alone identifies the order, and racing calls with one key resolve to one order. Without the header there is no deduplication and every call creates a new order. Scoped to the API key that sends it: two keys reusing the same value get independent orders.",
        "x-capability": "orders.manage",
        "parameters": [
          {
            "$ref": "#/components/parameters/EventSlug"
          },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "description": "Optional caller-chosen key, at most 200 characters, that makes this create safe to retry. Reusing a key returns the order it already created (200), without holding a second lot of stock. Scoped to the API key that sends it: two keys reusing the same value get independent orders.",
            "schema": {
              "type": "string",
              "maxLength": 200
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "items": {
                    "type": "array",
                    "minItems": 1,
                    "description": "One line per ticket type; a type may appear only once.",
                    "items": {
                      "type": "object",
                      "properties": {
                        "ticket_type_id": {
                          "type": "integer",
                          "description": "A ticket type id from GET /events/{slug}/ticket-types. It must belong to this event and be on sale."
                        },
                        "quantity": {
                          "type": "integer",
                          "minimum": 1,
                          "description": "How many tickets of this type — capped by the type's own max_per_order when it has one, and by what is left. A type that sets no cap of its own has none on this endpoint."
                        }
                      },
                      "required": [
                        "ticket_type_id",
                        "quantity"
                      ]
                    }
                  },
                  "email": {
                    "type": "string",
                    "description": "Buyer email. Required only for a zero-total order, which is fulfilled immediately; on a payable order Stripe collects it at checkout, so it is optional here."
                  },
                  "name": {
                    "type": "string",
                    "description": "Buyer name. Always optional."
                  }
                },
                "required": [
                  "items"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A replay: this Idempotency-Key already created an order, and this is that order's current state. Nothing was created and no extra stock is held.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreatedOrder"
                }
              }
            }
          },
          "201": {
            "description": "The order was created — pending with `payment_url` when it is payable, paid with `url` when it is free (its tickets already exist).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreatedOrder"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "`sold_out` — not enough left of one of the requested ticket types, or of a good included with one. Two checks produce it: the availability read the shop's own event page uses, which names the type (or the included good) and how many remain, and the capacity check inside the write transaction, which is the one that cannot be raced and reports only that something sold out mid-create. Re-read the ticket types and retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "The request cannot become an order: `invalid_body` (not JSON, a wrong field type, an explicit null, or an unknown field), `invalid_idempotency_key` (the header is longer than 200 characters), `invalid_items` (no items, a quantity below 1, a repeated ticket type, a quantity over the type's per-order cap, a type that is unknown, belongs to another event or is not on sale, or a type that needs a session chosen at checkout — the message names it), `sales_closed` (sales for this event have ended), `email_required` (a zero-total order is fulfilled immediately and needs somewhere to send the tickets), or `amount_too_small` (the total is below the smallest amount a card can be charged).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/orders/{ref}": {
      "get": {
        "operationId": "getOrder",
        "tags": [
          "Orders"
        ],
        "summary": "Get an order",
        "x-capability": "orders.view",
        "parameters": [
          {
            "$ref": "#/components/parameters/OrderRef"
          }
        ],
        "responses": {
          "200": {
            "description": "The order: its ticket seats (`items`), its whole priced ledger (`lines` — the goods and donations too), the promotion codes redeemed against it (`discounts`), the refs of the tickets it produced, and what the buyer told us, both the order form's own answers and what they said about each thing they bought. Never includes the buyer's order-page token (a URL capability, not API data).",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Order"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "tickets": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          },
                          "description": "Ticket refs belonging to this order. Empty array, never null, when the order produced none (e.g. still pending)."
                        },
                        "answers": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Answer"
                          },
                          "description": "Answers to the questions asked on the order form itself, once for the whole order. Always an array, never null. Includes any metadata the organizer records against the order and keeps to themselves — this read is gated by orders.view, the very capability that gates the admin's own order page. Per-attendee answers are not here: they belong to the ticket, and the ticket reads carry them."
                        },
                        "item_answers": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/ItemAnswers"
                          },
                          "description": "What the buyer said about each individual thing they bought — one entry per (product, position), and only for the ones something was answered about. Always an array, never null."
                        }
                      },
                      "required": [
                        "tickets",
                        "answers",
                        "item_answers"
                      ]
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "description": "Get an order by ref, account-wide (refs are globally unique)."
      },
      "patch": {
        "operationId": "updateOrderBuyer",
        "tags": [
          "Orders"
        ],
        "summary": "Update an order's buyer",
        "description": "Correct the name and email on an order. Corrects the name and email address the ORDER carries — where its confirmation, receipt and invoice go. This is not a ticket's attendee: moving a ticket to a different person is `PATCH /tickets/{ref}`, which reassigns it, rotates its link and emails both people. Nothing is emailed here. The organizer's own form offers to re-send the order link to the corrected address; an API caller decides that for itself, so this call only writes. Absent fields are left unchanged; an explicit `null` is refused, and so is an unknown field. `email` is checked for syntax only, the same bargain `PATCH /tickets/{ref}` makes. Links already sent to the old address keep working unless `revoke_link` is true. Answers the order in the read shape.",
        "x-capability": "orders.manage",
        "parameters": [
          {
            "$ref": "#/components/parameters/OrderRef"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OrderBuyerPatch"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The order, in the same shape `GET /orders/{ref}` answers with.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Order"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "description": "The body was unreadable, carried an unknown field or an explicit null, blanked the name, or the email isn't an email address (`invalid_body`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/orders/{ref}/refund": {
      "post": {
        "operationId": "refundOrder",
        "tags": [
          "Orders"
        ],
        "summary": "Refund an order",
        "description": "Refund a whole paid order. Refunds the order's remaining balance through the same core the admin's refund button uses: the order's own rail first (Stripe, PayPal, or a sandbox's simulated payment), then the local flip (order `refunded`, its tickets voided, stock released, a refund row in the payments ledger, the buyer's refund email). Money moves — the key must carry the standalone `refund` capability, which no other capability implies. Send no request body; any body is ignored. Idempotent: an order that is already `refunded` answers 200 with `already: true` and never reaches its provider again. An order in any other status (pending, canceled) answers 422 `not_refundable` — canceling a pending order is a different verb this API does not have yet. A paid order whose balance has already come back in full (every ticket refunded one by one) answers 422 `not_refundable` too: there is nothing left to refund, so this is a refusal and not a failure to retry. A paid order with no recorded payment on any rail (a free/zero-cent order, a complimentary one, or one whose reference was never persisted) answers 422 `no_payment_intent`, the same refusal the admin makes — and that check comes first, so a free order on an account with no payments configured gets that 422 rather than a 503 about payments it never needed. `refunded_cents` is the order total less any refunds already made against it (ticket or line refunds) — exactly what the provider moves and what the ledger records. Inside an account sandbox a simulated payment refunds the same way and no processor is called: the refund is simulated too, and the ledger says so.",
        "x-capability": "refund",
        "parameters": [
          {
            "$ref": "#/components/parameters/OrderRef"
          }
        ],
        "responses": {
          "200": {
            "description": "The order is refunded — either just now (with `refunded_cents`) or already (`already: true`), which is a success, not an error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RefundResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "description": "The order is not in a refundable state: it is pending or canceled, or its balance is already fully refunded (`not_refundable`); or it is paid but carries no payment to refund against (`no_payment_intent`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "$ref": "#/components/responses/RefundFailed"
          },
          "503": {
            "description": "Payments are not configured on this account, so no refund can be sent (`payments_unavailable`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/orders/{ref}/cancel": {
      "post": {
        "operationId": "cancelOrder",
        "tags": [
          "Orders"
        ],
        "summary": "Cancel an order",
        "description": "Cancel a pending order. Cancels a still-pending order and releases the stock it holds — the API's copy of the admin's payment-link cancel. Only a `pending` order is cancelable; this exists because an API-created pending order holds stock for the event's whole payment window with no Stripe session to expire it early, unlike the browser checkout's 35-minute hold. Send no request body; any body is ignored. A door (card-reader) sale's pending order backs a live card-present PaymentIntent: that intent is voided at Stripe before the order is released, and if Stripe cannot prove the charge is off the cancel is refused with `cancel_failed` (502) and nothing changes. Known exposure, recorded deliberately: a buyer who already opened `payment_url` holds a live Stripe session that this call does not expire — they can still complete that payment after the order is canceled locally.",
        "x-capability": "orders.manage",
        "parameters": [
          {
            "$ref": "#/components/parameters/OrderRef"
          }
        ],
        "responses": {
          "200": {
            "description": "The order is canceled and its stock released.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CancelResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "The order is not pending — it is paid, refunded, or already canceled (`not_pending`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "The card payment behind the order could not be voided at Stripe (`cancel_failed`) — nothing was changed; retry, or resolve the payment from the Stripe dashboard.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/orders/{ref}/mark-paid": {
      "post": {
        "operationId": "markOrderPaid",
        "tags": [
          "Orders"
        ],
        "summary": "Mark an order paid",
        "description": "Record an off-rail payment against a pending order. Records a payment taken outside Tito — a cheque, cash, a bank transfer the organizer watched arrive — against a still-pending order, and fulfils it through the same core a card payment runs: the tickets are issued, the buyer's confirmation email goes out, workflows fire. Send no request body; any body is ignored. Nothing is recorded as collected, because no money moved on a rail Tito can see: no `payments` row is written and no payment id is stamped, which is the same shape a complimentary/paid-offline order has — so the order can be canceled afterwards but never refunded through Tito. An order awaiting a bank transfer is the one exception: its invoice is marked funded and it keeps the payment it already names. Whatever could still charge the order (an open Checkout Session, a card-present intent on a reader, the virtual IBAN behind an invoice) is retired first, best-effort. Only a `pending` order can be marked paid; anything else answers 409 `not_pending`.",
        "x-capability": "orders.manage",
        "parameters": [
          {
            "$ref": "#/components/parameters/OrderRef"
          }
        ],
        "responses": {
          "200": {
            "description": "The order is paid and its tickets are issued.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarkPaidResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "The order is not pending — it is paid, refunded, or canceled (`not_pending`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/events/{slug}/tickets": {
      "get": {
        "operationId": "listTicketsForEvent",
        "tags": [
          "Tickets"
        ],
        "summary": "List tickets",
        "x-capability": "attendees.view",
        "parameters": [
          {
            "$ref": "#/components/parameters/EventSlug"
          },
          {
            "$ref": "#/components/parameters/After"
          },
          {
            "$ref": "#/components/parameters/Limit"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of tickets. `tickets` is always `[]`, never null, when the event has none.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "tickets": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Ticket"
                      }
                    },
                    "next_after": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Ticket ref to pass as `?after=` for the next page; null when there are no further pages; a full final page yields a cursor to an empty page."
                    }
                  },
                  "required": [
                    "tickets",
                    "next_after"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "description": "List an event's tickets/attendees (paged, newest first)."
      }
    },
    "/tickets/{ref}": {
      "patch": {
        "operationId": "updateTicketHolder",
        "tags": [
          "Tickets"
        ],
        "summary": "Update a ticket's holder",
        "description": "Update a ticket's holder (name and/or email). Changes who a ticket is for. Both body fields are optional and independent: a field absent from the JSON is left unchanged, which is not the same as sending it empty. Changing `holder_email` rotates the ticket's token — the previous holder's ticket link stops working — and clears the assignment-notified stamp, exactly as the admin's attendee edit does (shared code). Unlike the admin edit, this endpoint sends no email (neither to the previous holder nor to the new one), writes no audit row, and does not touch the ticket's custom-field answers. A void ticket, or a ticket of an anonymous type (which carries no holder by design), answers 422 `not_editable`.",
        "x-capability": "orders.manage",
        "parameters": [
          {
            "$ref": "#/components/parameters/TicketRef"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "holder_name": {
                    "type": "string",
                    "description": "The holder's full name. Omit to leave it unchanged (an explicit null is rejected, not treated as unchanged). Trimmed, capped at 200 characters, and reparsed into the ticket's stored name parts. A blank or whitespace-only value is rejected (422 `invalid_body`) — the admin's form refuses it too."
                  },
                  "holder_email": {
                    "type": "string",
                    "description": "The holder's email address. Omit to leave it unchanged (an explicit null is rejected, not treated as unchanged). A value that is not an email address is rejected (422 `invalid_body`). Any change to the stored value is a reassignment: the ticket's token rotates and the assignment-notified stamp clears."
                  }
                },
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated ticket, in the same shape the ticket list returns. The ticket's token never appears here, rotated or not.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ticket"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "description": "The body is not a JSON object of the documented fields — malformed JSON, a wrong field type, an explicit `null` (omit a field to leave it unchanged; a write never silently ignores something you did send), or an unknown field name (`invalid_body`) — or a supplied value is blank/not an email (`invalid_body`), or the ticket cannot carry a holder edit: it is void, or its type is anonymous (`not_editable`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/tickets/{ref}/refund": {
      "post": {
        "operationId": "refundTicket",
        "tags": [
          "Tickets"
        ],
        "summary": "Refund a ticket",
        "description": "Refund one ticket and void it. Refunds a single ticket's share of its own order line (line total ÷ quantity, so it can never over-refund) and voids the ticket, releasing its stock — the same ticket-level path the admin's single-line refund and the buyer's self-service removal use. Money moves: the key must carry the standalone `refund` capability. Send no request body; any body is ignored. The order itself stays `paid`; use the order refund to take back the whole thing. Idempotent: an already-void ticket answers 200 with `already: true` and never reaches Stripe. Unlike the admin's line refund, one ticket on a multi-ticket line is refundable here — a single ticket is exactly what this endpoint addresses. A ticket whose order is not paid, or that has no active line left to refund, answers 422 `not_refundable`; a ticket with nothing to refund against (payments unconfigured, or no recorded payment intent) answers 422 `no_payment_intent`. A zero-cent ticket needs neither and is simply voided.",
        "x-capability": "refund",
        "parameters": [
          {
            "$ref": "#/components/parameters/TicketRef"
          }
        ],
        "responses": {
          "200": {
            "description": "The ticket is refunded and void — either just now (with `refunded_cents`, which is 0 for a free ticket) or already (`already: true`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RefundResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "description": "The ticket is not refundable: its order is not paid, or it has no active order line left to refund (`not_refundable`); or there is no payment to refund it against (`no_payment_intent`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "$ref": "#/components/responses/RefundFailed"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/events/{slug}/checkin-lists": {
      "get": {
        "operationId": "listCheckinLists",
        "tags": [
          "Check-in"
        ],
        "summary": "List check-in lists",
        "x-capability": "checkin.view",
        "parameters": [
          {
            "$ref": "#/components/parameters/EventSlug"
          }
        ],
        "responses": {
          "200": {
            "description": "All check-in lists for the event. `checkin_lists` is always `[]`, never null, when there are none.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "checkin_lists": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CheckinListSummary"
                      }
                    }
                  },
                  "required": [
                    "checkin_lists"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "description": "List an event's check-in lists."
      }
    },
    "/events/{slug}/checkin-lists/{id}": {
      "get": {
        "operationId": "getCheckinList",
        "tags": [
          "Check-in"
        ],
        "summary": "Get a check-in list",
        "x-capability": "checkin.view",
        "parameters": [
          {
            "$ref": "#/components/parameters/EventSlug"
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The check-in list's numeric row id (check-in lists carry no ref — admin-only, never a public surface).",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The list's metadata plus every check-in recorded against it, filtered exactly as `checked_in_count` is (valid tickets only, entitlement-scoped where the list is). `checkins` is always `[]`, never null, when the list has none. `id` from another event's list 404s rather than leaking cross-event data.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer"
                    },
                    "name": {
                      "type": "string"
                    },
                    "created_at": {
                      "type": "string",
                      "format": "date-time",
                      "description": "Integer Unix timestamp in storage, rendered as RFC 3339 UTC. Not nullable — a list always has a creation time (NOT NULL column)."
                    },
                    "checked_in_count": {
                      "type": "integer"
                    },
                    "checkins": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Checkin"
                      }
                    }
                  },
                  "required": [
                    "id",
                    "name",
                    "created_at",
                    "checked_in_count",
                    "checkins"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "description": "Get a check-in list's state (who has been checked in)."
      }
    },
    "/events/{slug}/checkin-lists/{id}/checkins": {
      "post": {
        "operationId": "createCheckin",
        "tags": [
          "Check-in"
        ],
        "summary": "Check a ticket in",
        "description": "Check a ticket in on a list. Resolves `ticket` (a ticket ref) and stamps it onto the list via the same eligibility rule the admin door page uses (valid tickets only, entitlement-scoped where the list is). A fresh stamp and an already-stamped ticket both answer 200 — the latter is the idempotent, door-tool-friendly case (`already: true`). A ticket that fails eligibility (void, or ineligible for an entitlement-scoped list) answers 422 `not_eligible`. `id` must belong to the slug's event, and `ticket` must belong to the same event as the list — either mismatch, or an unknown list/ticket, answers 404.",
        "x-capability": "checkin.manage",
        "parameters": [
          {
            "$ref": "#/components/parameters/EventSlug"
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The check-in list's numeric row id (check-in lists carry no ref — admin-only, never a public surface).",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "ticket": {
                    "type": "string",
                    "description": "The ticket's ref."
                  }
                },
                "required": [
                  "ticket"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The ticket is checked in on this list — either just now (`already` absent/false) or already (`already: true`), which is still a success, not an error.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "checked_in": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    },
                    "already": {
                      "type": "boolean",
                      "description": "Present and true only when the ticket was already stamped on this list before this call."
                    }
                  },
                  "required": [
                    "checked_in"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "description": "The body is not `{\"ticket\":\"<ref>\"}` (`invalid_body`), or the ticket is void or not eligible for this list's entitlement (`not_eligible`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/events/{slug}/checkin-lists/{id}/checkins/{ref}": {
      "delete": {
        "operationId": "deleteCheckin",
        "tags": [
          "Check-in"
        ],
        "summary": "Undo a check-in",
        "description": "Undo a check-in (mis-scans happen at real doors). Idempotent: removing a stamp that doesn't exist (already undone, never stamped, or an unresolvable/cross-event ticket ref) is still 200, not an error. Only an unknown `id`/`slug` 404s.",
        "x-capability": "checkin.manage",
        "parameters": [
          {
            "$ref": "#/components/parameters/EventSlug"
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The check-in list's numeric row id.",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "ref",
            "in": "path",
            "required": true,
            "description": "The ticket's ref.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The ticket is not checked in on this list (either it just got undone, or there was nothing to undo).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "checked_in": {
                      "type": "boolean",
                      "enum": [
                        false
                      ]
                    }
                  },
                  "required": [
                    "checked_in"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/events/{slug}/widgets": {
      "get": {
        "operationId": "listWidgets",
        "tags": [
          "Widgets"
        ],
        "summary": "List widgets",
        "x-capability": "events.view",
        "parameters": [
          {
            "$ref": "#/components/parameters/EventSlug"
          }
        ],
        "responses": {
          "200": {
            "description": "Every widget on the event. `widgets` is always `[]`, never null. The pasteable snippet is deliberately not returned: it is built from the widget's slug and the account's own host, and pinning that string here would freeze a format that belongs to the widget.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "widgets": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WidgetSummary"
                      }
                    }
                  },
                  "required": [
                    "widgets"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "description": "List an event's widgets (the ticket lists an organiser pastes into their own website)."
      }
    },
    "/widget-domains": {
      "get": {
        "operationId": "listWidgetDomains",
        "tags": [
          "Widgets"
        ],
        "summary": "List widget domains",
        "x-capability": "events.view",
        "responses": {
          "200": {
            "description": "Account-level, not event-scoped: a domain is a statement of trust about a website, not a property of one ticket list. Rows only ever arrive by being SEEN — an organiser never types a domain. Only `approved` domains are named in the widget's `frame-ancestors` response header, which is what the browser enforces. `widget_domains` is always `[]`, never null.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "widget_domains": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WidgetDomain"
                      }
                    }
                  },
                  "required": [
                    "widget_domains"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "description": "List the websites the account's widgets have been seen on, and their standing."
      }
    },
    "/webhooks": {
      "get": {
        "operationId": "listWebhooks",
        "tags": [
          "Webhooks"
        ],
        "summary": "List webhook endpoints",
        "description": "List the account's webhook endpoints. The account's outbound-webhook subscriptions (Settings → Webhooks). `webhooks` is always `[]`, never null, when there are none. **The signing secret is never returned here or anywhere else in `/api/v1` — it is admin-shaped data, shown only on the Settings → Webhooks detail page.** Endpoint create/delete are not in v1's API surface for the same reason (see below); this route and the deliveries route beneath it are read-only.\n\n**The outbound contract, in full — everything a receiver needs to verify and parse a delivery:**\n\n*Envelope.* Every delivery POSTs this JSON body: `{\"id\": <delivery id, integer>, \"type\": \"<event type>\", \"created_at\": <integer Unix timestamp>, \"account\": \"<account slug>\", \"data\": { ... event-specific payload ... }}`.\n\n*Event types* (the only values `event_types` may name, and the only values `type` ever carries): `order.created`, `order.paid`, `order.cancelled` (two l's — the workflow engine's own trigger spelling, which this vocabulary is drawn from verbatim, superseding an earlier single-l draft), `order.refunded`, `ticket.created`, `ticket.voided`, `ticket.checked_in`, `ticket.updated`, `checkin.created`. A `checkin.created` delivery fires once per fresh check-in stamp, on every surface that records one (the door scanner, the desk, group check-in, and the API), and its `data` adds a `checkin` object beside the usual `ticket`/`order`/`event`/`account` blocks: `{\"id\": <check-in id>, \"checked_in_at\": <integer Unix timestamp>, \"checked_in_at_text\": \"<localized>\", \"checkin_list\": {\"id\": ..., \"name\": \"...\"}}` — the same moment `ticket.checked_in` fires, but naming which check-in list the ticket came through. Undoing a check-in sends nothing; a later re-check-in delivers a fresh `checkin.created`.\n\n*Request headers* on every delivery attempt: `Tito-Signature: t=<unix timestamp>,v1=<hex-encoded HMAC-SHA256>` — the same signed-timestamp-plus-body shape Stripe's own webhook signatures use; `Tito-Webhook-Event: <event type>`, the same string as the envelope's `type`; `Tito-Webhook-Delivery: <delivery id>`, the same integer as the envelope's `id`, useful for receiver-side deduplication since delivery is at-least-once.\n\n*Verifying a delivery:* read `t` and `v1` out of `Tito-Signature`; recompute HMAC-SHA256, keyed with the endpoint's own signing secret (shown once per visit on its Settings → Webhooks detail page, stored plaintext there because it must be recoverable to sign outgoing requests), over the byte string `\"<t>.\" + <raw request body>`; compare the resulting hex digest to `v1` with a constant-time comparison. Reject the delivery if they don't match, or if `t` is further in the past than your own replay-window tolerance.\n\n*Retry schedule:* a failed attempt (transport error, or a non-2xx status) is retried at 0s (the first attempt itself), then after 1 minute, 5 minutes, 30 minutes, 2 hours, and 8 hours — 6 attempts total before a delivery is marked permanently failed. A 2xx response at any attempt marks it succeeded and stops the schedule.\n\n*Auto-disable:* an endpoint that racks up 10 consecutive permanently-failed deliveries (each one having exhausted its own 6-attempt schedule) is automatically disabled — no further deliveries are attempted — and the organizer is emailed a notice. A manual re-enable from Settings → Webhooks clears the failure count and resumes delivery; it does not replay what was missed (v1 has no manual redeliver — the retry schedule plus this visible delivery log are the exposed mechanism instead of a second one).",
        "x-capability": "settings.manage",
        "responses": {
          "200": {
            "description": "The account's webhook endpoints, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "webhooks": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WebhookEndpoint"
                      }
                    }
                  },
                  "required": [
                    "webhooks"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/webhooks/{id}/deliveries": {
      "get": {
        "operationId": "listWebhookDeliveries",
        "tags": [
          "Webhooks"
        ],
        "summary": "List deliveries",
        "description": "List one endpoint's recent delivery attempts. The endpoint's delivery log, newest first, capped at 50 rows — the same query and the same limit the Settings → Webhooks detail page reads. `deliveries` is always `[]`, never null, when the endpoint has none yet. Terminal deliveries (`succeeded` or `failed`) older than 90 days are pruned from this log; `pending` deliveries are never pruned.",
        "x-capability": "settings.manage",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The webhook endpoint's numeric row id.",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Up to the 50 most recent delivery attempts for this endpoint.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "deliveries": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WebhookDelivery"
                      }
                    }
                  },
                  "required": [
                    "deliveries"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/export/db": {
      "get": {
        "operationId": "exportDB",
        "tags": [
          "Export"
        ],
        "summary": "Export the database",
        "description": "A `VACUUM INTO` snapshot of the account's SQLite file, streamed and then discarded server-side. A full DB download is a full PII export, so it gates behind `export.pii` rather than any of the narrower `.view` capabilities. Admin-origin secrets are scrubbed from the snapshot before it streams: the `csrf_token` and `admin_token_hash` rows of `config` are deleted. Buyer capability tokens (`orders.token`, `tickets.token`) are account data and do ride along.",
        "x-capability": "export.pii",
        "responses": {
          "200": {
            "description": "The account's SQLite database file.",
            "content": {
              "application/vnd.sqlite3": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/openapi.json": {
      "get": {
        "operationId": "getOpenAPIDocument",
        "tags": [
          "Meta"
        ],
        "summary": "OpenAPI document",
        "description": "Public: it's the contract, not data. No Authorization header required.",
        "security": [],
        "responses": {
          "200": {
            "description": "This OpenAPI 3.1 document.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/workflows": {
      "get": {
        "operationId": "listWorkflows",
        "tags": [
          "Workflows"
        ],
        "summary": "List workflows",
        "x-capability": "settings.manage",
        "responses": {
          "200": {
            "description": "Every workflow, without its definition. `workflows` is always `[]`, never null, when the account has none.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "workflows": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Workflow"
                      }
                    }
                  },
                  "required": [
                    "workflows"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/workflows/{id}": {
      "get": {
        "operationId": "getWorkflow",
        "tags": [
          "Workflows"
        ],
        "summary": "Get a workflow",
        "x-capability": "settings.manage",
        "parameters": [
          {
            "$ref": "#/components/parameters/WorkflowID"
          }
        ],
        "responses": {
          "200": {
            "description": "The workflow plus its subscribed events and its definition — `definition` when the stored JSON parses, `definition_raw` (the raw text) when it does not, so a corrupt definition never breaks this endpoint.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Workflow"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "description": "Get a workflow by id, including its definition."
      }
    },
    "/workflows/{id}/runs": {
      "get": {
        "operationId": "listWorkflowRuns",
        "tags": [
          "Workflows"
        ],
        "summary": "List runs",
        "x-capability": "settings.manage",
        "parameters": [
          {
            "$ref": "#/components/parameters/WorkflowID"
          },
          {
            "name": "after",
            "in": "query",
            "required": false,
            "description": "Cursor: the run id returned as the previous page's `next_after`. Omit for the first page.",
            "schema": {
              "type": "integer"
            }
          },
          {
            "$ref": "#/components/parameters/Limit"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of runs, newest first. `runs` is always `[]`, never null, when the workflow has none.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "runs": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WorkflowRun"
                      }
                    },
                    "next_after": {
                      "type": [
                        "integer",
                        "null"
                      ],
                      "description": "Run id to pass as `?after=` for the next page; null when there are no further pages."
                    }
                  },
                  "required": [
                    "runs",
                    "next_after"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "description": "List a workflow's runs (paged, newest first)."
      }
    },
    "/workflows/{id}/runs/{runID}": {
      "get": {
        "operationId": "getWorkflowRun",
        "tags": [
          "Workflows"
        ],
        "summary": "Get a run",
        "x-capability": "settings.manage",
        "parameters": [
          {
            "$ref": "#/components/parameters/WorkflowID"
          },
          {
            "$ref": "#/components/parameters/RunID"
          }
        ],
        "responses": {
          "200": {
            "description": "The run plus its step log (`log`, present only here).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WorkflowRun"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "description": "Get one run, including its step log."
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "An account API key (`titogo_sk_...`, or `titogo_sk_test_...` on a test-mode instance) sent as `Authorization: Bearer <key>`. Scopes are `internal/auth` RBAC capabilities; a route whose capability the key does not carry answers 403."
      }
    },
    "parameters": {
      "EventSlug": {
        "name": "slug",
        "in": "path",
        "required": true,
        "description": "The event's slug.",
        "schema": {
          "type": "string"
        },
        "example": "annual-conf"
      },
      "OrderRef": {
        "name": "ref",
        "in": "path",
        "required": true,
        "description": "The order's ref (account-wide, not scoped to an event).",
        "schema": {
          "type": "string"
        },
        "example": "H7K2PX"
      },
      "TicketRef": {
        "name": "ref",
        "in": "path",
        "required": true,
        "description": "The ticket's ref (account-wide, not scoped to an event), matched against the ticket's own ref — an order ref is not a ticket ref, and answers 404 here. The ticket's token — the buyer's URL capability — is never accepted here and never returned.",
        "schema": {
          "type": "string"
        },
        "example": "T9QW4M"
      },
      "After": {
        "name": "after",
        "in": "query",
        "required": false,
        "description": "Cursor: the ref returned as the previous page's `next_after`. Omit for the first page.",
        "schema": {
          "type": "string"
        },
        "example": "H7K2PX"
      },
      "Limit": {
        "name": "limit",
        "in": "query",
        "required": false,
        "description": "Page size. Default 50, max 200. Non-positive or unparseable values fall back to the default.",
        "schema": {
          "type": "integer",
          "default": 50,
          "maximum": 200,
          "minimum": 1
        },
        "example": 50
      },
      "WorkflowID": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "The workflow's numeric row id.",
        "schema": {
          "type": "integer"
        },
        "example": 1
      },
      "RunID": {
        "name": "runID",
        "in": "path",
        "required": true,
        "description": "The run's numeric row id (run ids are a single global sequence across every workflow in the account — a run belonging to a different workflow than the one in the path 404s).",
        "schema": {
          "type": "integer"
        },
        "example": 1
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "No key, an unknown key, or a revoked key.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Forbidden": {
        "description": "The key does not carry the required capability.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "No such resource.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ServerError": {
        "description": "Internal error.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RefundFailed": {
        "description": "The refund did not complete (`refund_failed`): either the payment provider refused it — in which case nothing moved and nothing changed locally — or it succeeded and the local write that follows it failed, which the instance journals as CRITICAL. The two are not distinguishable from here, and re-reading the resource does not separate them either (after the second case the order still reads `paid`). **Retry: it is safe, and it is the repair.** The provider idempotency key is derived from the order ref, so a retry replays the original refund rather than moving money twice, and the local write that failed gets another run. Left alone, the provider's own refund webhook converges the local state anyway.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "The one error shape returned by every route.",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string",
                "description": "Machine-readable error code, e.g. \"not_found\", \"missing_key\", \"unknown_key\", \"missing_capability\", \"internal\".",
                "example": "not_found"
              },
              "message": {
                "type": "string",
                "description": "Human-readable, deliberately English (machine/API error strings are outside the i18n scope).",
                "example": "no such event"
              }
            },
            "required": [
              "code",
              "message"
            ]
          }
        },
        "required": [
          "error"
        ]
      },
      "RefundResult": {
        "type": "object",
        "description": "What a refund endpoint answers. `refunded` is always true on a 200 — a refusal is an error response, never a `false` here.",
        "properties": {
          "refunded": {
            "type": "boolean",
            "enum": [
              true
            ]
          },
          "already": {
            "type": "boolean",
            "description": "Present and true only when the refund had already happened before this call — the replay-safe case. No money moved and no provider call was made; `refunded_cents` is omitted, since this call refunded nothing."
          },
          "refunded_cents": {
            "type": "integer",
            "description": "Integer cents this call refunded (0 for a free/zero-cent ticket). Omitted when `already` is true."
          }
        },
        "required": [
          "refunded"
        ]
      },
      "CancelResult": {
        "type": "object",
        "description": "What the cancel endpoint answers on a 200 — always a fresh cancellation; a non-pending order is an error response, never a `false` here.",
        "properties": {
          "canceled": {
            "type": "boolean",
            "enum": [
              true
            ]
          },
          "ref": {
            "type": "string",
            "description": "The canceled order's ref."
          }
        },
        "required": [
          "canceled",
          "ref"
        ]
      },
      "MarkPaidResult": {
        "type": "object",
        "description": "What the mark-paid endpoint answers on a 200 — always a fresh fulfillment; an order that was already paid is an error response, never a `false` here.",
        "properties": {
          "paid": {
            "type": "boolean",
            "enum": [
              true
            ]
          },
          "ref": {
            "type": "string",
            "description": "The paid order's ref."
          }
        },
        "required": [
          "paid",
          "ref"
        ]
      },
      "OrderBuyerPatch": {
        "type": "object",
        "description": "The order's own buyer details. Send either field, both, or neither (an empty object changes nothing). An absent field is left as it was; `null` is refused.",
        "properties": {
          "name": {
            "type": "string",
            "description": "The buyer's name on the order. Cannot be blank when sent."
          },
          "email": {
            "type": "string",
            "format": "email",
            "description": "Where this order's confirmation, receipt and invoice go. Lower-cased on the way in."
          },
          "revoke_link": {
            "type": "boolean",
            "default": false,
            "description": "Take the order away from the address it is moving off: the order's own link is replaced, and so is the link of every ticket that address was sent (a ticket belonging to somebody else's address keeps its own — naming a holder already rotated it). Ignored unless `email` actually changes the address. Nothing is emailed, so the buyer is left without a working link until you send them one."
          }
        },
        "additionalProperties": false
      },
      "Account": {
        "type": "object",
        "properties": {
          "slug": {
            "type": "string",
            "example": "acme",
            "description": "The account's own URL identifier."
          },
          "name": {
            "type": "string",
            "example": "Acme Events",
            "description": "Display name."
          },
          "region": {
            "type": "string",
            "example": "uk",
            "description": "Where the account's data currently lives, on deployments that place accounts by region. Absent on a standalone instance with no placement — never an empty string."
          }
        },
        "required": [
          "slug",
          "name"
        ]
      },
      "Event": {
        "type": "object",
        "properties": {
          "slug": {
            "type": "string",
            "example": "annual-conf",
            "description": "URL identifier, unique within the account. Appears in the public event URL."
          },
          "path": {
            "type": "string",
            "description": "The event's public address. Equal to the slug for an event in no series; for one inside a series the series carries the first segment, so the address reads \"meet-tito/london\" while the slug stays \"meet-tito-london\". Every endpoint here is keyed on the slug — link buyers to the path.",
            "example": "annual-conf"
          },
          "name": {
            "type": "string",
            "example": "Annual Conference 2026",
            "description": "Display name."
          },
          "description": {
            "type": "string",
            "example": "Two days of talks, workshops and hallway conversations.",
            "description": "Organizer-written description, plain text."
          },
          "venue": {
            "type": "string",
            "example": "The Round Room, Dublin",
            "description": "Where it happens, as entered by the organizer."
          },
          "venue_address": {
            "type": "string",
            "description": "Organizer-entered address text, in the event's default locale. Empty when no structured venue is set.",
            "example": "Rotunda, Parnell Square, Dublin 1"
          },
          "venue_lat": {
            "type": "string",
            "description": "Decimal latitude of the venue's map pin, as a string. Empty when the event has no pin.",
            "example": "53.3529"
          },
          "venue_lng": {
            "type": "string",
            "description": "Decimal longitude of the venue's map pin, as a string. Empty when the event has no pin.",
            "example": "-6.2634"
          },
          "venue_place_id": {
            "type": "string",
            "description": "Google's opaque place id for the venue, if the address was chosen from the picker. Empty otherwise.",
            "example": ""
          },
          "map_provider": {
            "type": "string",
            "enum": [
              "google",
              "apple"
            ],
            "description": "Which service the organizer asked to draw the map on the event page. A stored preference, not a promise about what people see: an event set to \"apple\" on an instance without Apple credentials falls back to the Google embed, and an event with no map credentials at all shows the address and its open-in links.",
            "example": "google"
          },
          "starts_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Null when the event has no start time set (stored as Unix 0).",
            "example": "2026-10-14T09:00:00Z"
          },
          "timezone": {
            "type": "string",
            "example": "Europe/Dublin",
            "description": "IANA zone the event is scheduled in, e.g. \"Europe/Dublin\"."
          },
          "currency": {
            "type": "string",
            "description": "Lowercase ISO 4217 currency code, e.g. \"eur\".",
            "example": "eur"
          },
          "currencies": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Every currency the event sells in: `currency` first, then the extra currencies the organizer priced. A one-currency event lists just `currency`.",
            "example": [
              "eur",
              "gbp"
            ]
          },
          "currency_assignment": {
            "type": "string",
            "enum": [
              "switcher",
              "locale",
              "link"
            ],
            "description": "How a buyer lands in one of `currencies`: a picker on the event page, the browser's country, or only a link carrying `?currency=`. Meaningful only when `currencies` has more than one entry.",
            "example": "switcher"
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Null when the underlying Unix timestamp is 0 (should not occur in practice for created_at, but the same nullable encoding is used for every timestamp field).",
            "example": "2026-03-02T11:20:41Z"
          },
          "draft": {
            "type": "boolean",
            "description": "True while the event is a draft: its public page answers 404 and it is absent from the account's public event list. A duplicated event lands as a draft until it is published.",
            "example": false
          },
          "secret": {
            "type": "boolean",
            "description": "True when the event is secret: finished and selling, but deliberately not advertised. Unlike no_index this DOES gate visibility and a listing must respect it — the event's main page answers 404 to everybody but a signed-in organizer, and the event is absent from the account's public event list and from its series page. It is not a second draft: a draft can take no money, while a secret event sells all day through every door its organizer handed somebody on purpose (audience pages, portal links, invitations, and order and ticket links already sent). Read it as \"do not advertise\", never as \"closed for business\".",
            "example": false
          },
          "no_index": {
            "type": "boolean",
            "description": "True when the organizer has asked search engines not to list this event. Unlike draft this gates NOTHING: the event's page answers exactly as it did before, to exactly the same people, the event stays on whatever lists it was already on, and every link still sells. It is independent of secret in both directions, so this flag says nothing about whether the event is reachable — read secret for that. All it does is add a robots noindex directive to the event's own page, at every address that page answers on. Do not treat it as a visibility flag; the one thing it should change for an integration is that a page rebuilt elsewhere carries the same request across.",
            "example": false
          },
          "series": {
            "type": "object",
            "description": "The series this event belongs to, absent when it is in none. An event belongs to at most one.",
            "properties": {
              "slug": {
                "type": "string"
              },
              "name": {
                "type": "string"
              }
            },
            "required": [
              "slug",
              "name"
            ]
          },
          "fields": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "The event's DETAILS — content the organizer typed about this event (a video-call link, a hashtag) under Settings, Metadata, keyed by the detail's short name, and what a theme reads as event.fields.<key>. Only details about the EVENT appear; anything about a person is a per-attendee answer or a per-attendee detail (some of them internal) and is never included. The value is resolved: a detail this event has not answered for itself reports its account default, because that is what every page and email will show. A detail with no value anywhere is omitted rather than reported blank — blank is a normal answer for a detail. Every value is a string, a number detail included: it reports its digits with no grouping (`50000`), so it parses, while the organizer's own screens and a themed page write it out for their reader's language. Present on the single-event endpoint only, and omitted entirely when the event has nothing set."
          },
          "sections": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/EventSection"
            },
            "description": "The event page's sections, in the order people meet them — what a ticket type's section_id points at. Present on the single-event endpoint only."
          }
        },
        "required": [
          "slug",
          "name",
          "description",
          "venue",
          "venue_address",
          "venue_lat",
          "venue_lng",
          "venue_place_id",
          "map_provider",
          "starts_at",
          "timezone",
          "currency",
          "created_at"
        ],
        "description": "An event: something you sell tickets to. Addressed everywhere by its slug."
      },
      "EventSection": {
        "type": "object",
        "description": "One section of the event page: a heading and a place, and nothing else. There are no dates and no visibility rules — a section that can be scheduled is a different feature.",
        "properties": {
          "id": {
            "type": "integer",
            "example": 1
          },
          "name": {
            "type": "string",
            "description": "The heading people read above this section. Empty when the section deliberately has none — its things render with nothing above them. A section still carrying one of the platform's own default headings reports it in English; this API negotiates no locale.",
            "example": "Tickets"
          },
          "position": {
            "type": "integer",
            "description": "Zero-based place in the page, top to bottom. The list is already returned in this order.",
            "example": 1
          }
        },
        "required": [
          "id",
          "name",
          "position"
        ]
      },
      "TicketTier": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "example": 1
          },
          "name": {
            "type": "string",
            "description": "The rung's public name, and often empty — a blank name is a real answer, and people then see the price on its own.",
            "example": "First 100"
          },
          "price_cents": {
            "type": "integer",
            "description": "Integer cents; see the type's currency.",
            "example": 12900
          },
          "quantity": {
            "type": [
              "integer",
              "null"
            ],
            "description": "This rung's own allocation, on the same convention as the type's: null when uncapped, otherwise how many exist, and 0 means none available. It is NOT a share of the type's total — the type's total is the sum of the rungs. What the rung can actually sell may be larger than this, because a rung whose window closed with tickets unsold rolls them forward to the next one.",
            "example": 100
          },
          "sold": {
            "type": "integer",
            "description": "Seats bought at this rung, from paid orders. Every order line records the rung it was bought at, so this stays attributed to the price actually paid even after the ladder has moved on — which is also what returns a refunded ticket to the right rung.",
            "example": 100
          },
          "starts_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the rung opens; null for no start. RFC 3339.",
            "example": null
          },
          "ends_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the rung closes, EXCLUSIVE — the same contract as a product's sales_end_at. null for no end. RFC 3339.",
            "example": "2026-06-01T00:00:00Z"
          },
          "live": {
            "type": "boolean",
            "description": "Whether this is the rung on sale right now. At most one rung is live, and none is when the ladder sits between windows or every rung is spent.",
            "example": false
          },
          "prices": {
            "type": "object",
            "additionalProperties": {
              "type": "integer"
            },
            "description": "This rung's price in every currency it is offered in, minor units keyed by lowercase currency code — the type's own `currency` \u2192 `price_cents` always, plus each extra currency the organizer priced the rung in. A currency the event sells in but this rung has no key for is one the type is not offered in while this rung is live.",
            "example": {
              "eur": 14900,
              "gbp": 12900
            }
          }
        },
        "required": [
          "id",
          "name",
          "price_cents",
          "quantity",
          "sold",
          "starts_at",
          "ends_at",
          "live"
        ]
      },
      "TicketBand": {
        "type": "object",
        "properties": {
          "from_qty": {
            "type": "integer",
            "description": "The order quantity this band starts at — two or more, always. One ticket is the type's own price_cents.",
            "example": 3
          },
          "price_cents": {
            "type": "integer",
            "description": "What ONE ticket costs from from_qty upwards. Integer cents; see the type's currency.",
            "example": 8000
          },
          "prices": {
            "type": "object",
            "additionalProperties": {
              "type": "integer"
            },
            "description": "This band's price in every currency it is offered in, minor units keyed by lowercase currency code — the type's own `currency` \u2192 `price_cents` always, plus each extra currency the organizer priced the band in. A currency the type is sold in but this band has no key for is one the band is not offered in: an order in that currency pays the band above it.",
            "example": {
              "eur": 8000,
              "gbp": 6900
            }
          }
        },
        "description": "One rung of a volume ladder. It has no id, no name and no `live` flag, and that is the difference from a price tier: a band is not a thing that opens and closes on a clock, it is a price the size of the order selects. Which band applies is therefore a property of the basket, not of the moment."
      },
      "TicketType": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "example": 1
          },
          "name": {
            "type": "string",
            "example": "Early Bird"
          },
          "price_cents": {
            "type": "integer",
            "description": "Integer cents; see currency. On a ticket with price tiers this is the LIVE rung's price — what a buyer would be charged right now — so a caller that only wants the price needs to know nothing about tiers. Between two tier windows with no bridge price set it falls back to the type's own dormant base and nothing can be bought; `tiers` is what tells the two apart.",
            "example": 14900
          },
          "currency": {
            "type": "string",
            "description": "Lowercase ISO 4217 currency code — the event's currency; ticket types carry no currency of their own.",
            "example": "eur"
          },
          "prices": {
            "type": "object",
            "additionalProperties": {
              "type": "integer"
            },
            "description": "The type's price in every currency it is offered in, minor units keyed by lowercase currency code — `currency` → `price_cents` always, plus each extra currency the organizer priced it in. A currency the event sells in but this type has no key for is one the type is not offered in.",
            "example": {
              "eur": 14900,
              "gbp": 12900
            }
          },
          "on_sale": {
            "type": "boolean",
            "example": true
          },
          "quantity": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Capacity for this type: null when the type is uncapped, otherwise how many exist. 0 is a real answer and means none available — the type reads as sold out. Until 2026-08-12 this was always an integer and 0 meant unlimited, which left no way to say sold out; a caller that special-cased 0 must now special-case null.",
            "example": 200
          },
          "sold": {
            "type": "integer",
            "description": "Count of this type's tickets that are still status='valid' — the same rule the admin's sold count uses. A voided ticket drops out.",
            "example": 142
          },
          "tiers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TicketTier"
            },
            "description": "The ticket's price ladder, in the order people move through it. Absent entirely for a ticket priced once, which is most of them. A tiered ticket is still ONE ticket type — one row on the event page, one set of questions, one cap — that charges a different amount as tickets sell or as dates pass. This is the LIVE ladder: a tier the organizer removed after it had sold tickets is hidden from it, because nobody can buy at that tier again — but its sales are still counted in the type's own `sold`, so the tiers here will not always add up to it. The organizer's Sales report is where a removed tier is still named."
          },
          "bands": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TicketBand"
            },
            "description": "The ticket's volume ladder: the price drops as the order gets bigger, and the WHOLE order takes one band's price rather than a graduated mix. Absent entirely for a ticket that has none, which is most of them.\n\nUnlike `tiers`, this does NOT move `price_cents`: a tier's price is what everybody pays right now, so the type reports it, while a band's price is what a big enough order pays. So `price_cents` is always what ONE ticket costs, and a caller pricing a basket resolves the band itself — the last band whose `from_qty` is at or below the quantity, else `price_cents`. A caller that skips this and multiplies `price_cents` by six will quote a figure checkout does not charge.\n\nThe quantity that selects a band is the number of THIS type in ONE order, counting only the tickets being paid for — a ticket earned free by a group discount does not count towards a band.\n\nNever present alongside `tiers`: a ticket has one ladder or the other."
          },
          "position": {
            "type": "integer",
            "description": "This type's place on the event page, in the one position space it shares with the event's goods and donations — so a caller rebuilding the page can tell that a t-shirt sits between two tickets rather than after all of them. Lower comes first. The list is already returned in this order; the number is here because array order alone cannot say how a ticket relates to a good.",
            "example": 1
          },
          "section_id": {
            "type": "integer",
            "description": "The section of the event page this type renders under — one of the sections listed on GET /events/{slug}. This is where the page actually puts it, not the raw stored column: a type nobody has placed by hand falls to the section that claims its kind.",
            "example": 1
          },
          "companion": {
            "allOf": [
              {
                "$ref": "#/components/schemas/CompanionRule"
              }
            ],
            "nullable": true,
            "description": "This type's own companion rule, or null when it has none."
          }
        },
        "required": [
          "id",
          "name",
          "price_cents",
          "currency",
          "on_sale",
          "quantity",
          "sold",
          "position",
          "section_id",
          "companion"
        ]
      },
      "OrderItem": {
        "type": "object",
        "description": "One run of ticket seats. Tickets only — goods, donations and post-order adjustments report through `lines` (OrderLine).",
        "properties": {
          "ticket_type": {
            "type": "string",
            "description": "The ticket type's name, joined in so the raw ticket_type id is never exposed.",
            "example": "Early Bird"
          },
          "quantity": {
            "type": "integer",
            "example": 2,
            "description": "How many of this ticket type the line holds."
          },
          "unit_price_cents": {
            "type": "integer",
            "description": "Integer cents; see the order's currency.",
            "example": 14900
          }
        },
        "required": [
          "ticket_type",
          "quantity",
          "unit_price_cents"
        ]
      },
      "Answer": {
        "type": "object",
        "description": "One answered question, or one piece of metadata the organizer recorded. The same shape wherever a value appears — on an order, on one of the things bought, or on a ticket. `field_id` spans both: a question and a detail are one kind of record, and the organizer's Questions and Metadata sections are two views of it.",
        "properties": {
          "field_id": {
            "type": "integer",
            "example": 3
          },
          "key": {
            "type": "string",
            "description": "The stable machine handle: unique, and fixed when the question or detail was created. Match on this rather than on `label`, which the organizer may rewrite any afternoon.",
            "example": "dietary"
          },
          "label": {
            "type": "string",
            "description": "The question, or the name of the detail, as the organizer wrote it, in the account's own language. Per-locale overlays are for people reading a page; a response has no reader whose language it could mean.",
            "example": "Dietary requirements"
          },
          "kind": {
            "type": "string",
            "description": "How it was asked, and so how to read `value`: \"text\", \"textarea\", \"select\", \"multi_select\", \"checkbox\", \"yes_no\", \"number\", \"date\", \"phone\" or \"file\".",
            "example": "text"
          },
          "value": {
            "type": "string",
            "description": "The answer exactly as stored, never as displayed: a yes/no reads \"yes\" or \"no\" rather than the word a page would show it as, a ticked checkbox reads \"yes\", a multi-select is its choices as one comma-separated list, and a phone number is the digits as given. A question left blank is omitted from the list rather than reported as an empty answer. On a \"file\" question this names what was uploaded; the bytes themselves are not served by the API.",
            "example": "Vegetarian"
          }
        },
        "required": [
          "field_id",
          "key",
          "label",
          "kind",
          "value"
        ]
      },
      "SyncStanding": {
        "type": "object",
        "description": "One connected service's standing with one ticket — the same ledger the admin attendee page's chip and the workflow run log both read.",
        "properties": {
          "service": {
            "type": "string",
            "description": "The connected service's id, e.g. \"brella\"."
          },
          "remote_id": {
            "type": "string",
            "description": "The id the service knows this ticket by. Empty when nothing has synced yet."
          },
          "state": {
            "type": "string",
            "enum": [
              "synced",
              "failed",
              "deleted"
            ]
          },
          "synced_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the last successful sync completed; null when it has never succeeded."
          },
          "error": {
            "type": "string",
            "description": "The service's own words for its last failure, verbatim. Empty when the last attempt succeeded."
          }
        },
        "required": [
          "service",
          "remote_id",
          "state",
          "synced_at",
          "error"
        ]
      },
      "ItemAnswers": {
        "type": "object",
        "description": "One of the things bought, and what the buyer said about that one — a question can be attached to a good and asked once per one of them bought, which is how each t-shirt gets its own size.",
        "properties": {
          "product_id": {
            "type": "integer",
            "example": 1
          },
          "product": {
            "type": "string",
            "description": "The good's name, in the account's own language.",
            "example": "Early Bird"
          },
          "position": {
            "type": "integer",
            "description": "Which of this product's units this is: zero-based, and counted per PRODUCT across the whole order rather than per line item, because a good split across price bands is still one list of t-shirts to the person buying them. Position 0 is the one the buyer's own order page labels \"T-shirt 1\".",
            "example": 1
          },
          "answers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Answer"
            },
            "description": "This item's answers, in a stable order. Never empty — an item with nothing answered about it has no entry at all."
          }
        },
        "required": [
          "product_id",
          "product",
          "position",
          "answers"
        ]
      },
      "OrderLine": {
        "type": "object",
        "description": "One priced row of an order's ledger — a ticket, a good, a donation, or a post-order adjustment such as an upgrade.",
        "properties": {
          "product_id": {
            "type": "integer",
            "description": "The catalog row this line sold. The same handle `item_answers` is keyed on, so what the buyer said about each of these joins to the line that sold them. 0 when no product row backs the line (a ticket type predating the product backfill, whose seats are read out of order_items instead).",
            "example": 1
          },
          "product": {
            "type": "string",
            "description": "The product's name. A ticket line carries the live ticket type name, so it agrees with the matching `items` entry.",
            "example": "Early Bird"
          },
          "kind": {
            "type": "string",
            "description": "The product's kind: \"ticket\", \"item\" or \"donation\".",
            "example": "ticket",
            "enum": [
              "ticket",
              "item",
              "donation"
            ]
          },
          "quantity": {
            "type": "integer",
            "example": 2
          },
          "unit_price_cents": {
            "type": "integer",
            "description": "Integer cents; see the line's currency.",
            "example": 14900
          },
          "subtotal_cents": {
            "type": "integer",
            "description": "Integer cents, before any tax added on top.",
            "example": 29800
          },
          "tax_cents": {
            "type": "integer",
            "description": "Integer cents. The tax snapshot taken when the line was written; 0 when the line carries no tax.",
            "example": 0
          },
          "total_cents": {
            "type": "integer",
            "description": "Integer cents. Subtotal plus an exclusive rate's tax; the subtotal alone for an inclusive one.",
            "example": 29800
          },
          "currency": {
            "type": "string",
            "description": "Lowercase ISO 4217 currency code, carried per line.",
            "example": "eur"
          },
          "status": {
            "type": "string",
            "description": "\"active\", \"removed\" or \"refunded\". Only active lines count towards money; removed and refunded lines are reported rather than dropped, so an order can be reconciled.",
            "example": "active",
            "enum": [
              "active",
              "removed",
              "refunded"
            ]
          },
          "tier_id": {
            "type": "integer",
            "format": "int64",
            "description": "The price tier this line was SOLD at. Absent when the ticket has no price ladder. It is reported because a caller cannot work it out after the fact: which rung was live is decided by the clock and by what that rung had left, and by the time an old order is read the ladder has moved on. A tier the organizer removed after it had sold tickets is hidden from the ticket type's `tiers`, so a line can name a tier that is no longer listed there — the organizer's Sales report is where it is still named.",
            "example": 0
          },
          "band_id": {
            "type": "integer",
            "format": "int64",
            "description": "The volume band this line was CHARGED at. Absent both when the ticket has no volume ladder and when the buyer paid the ticket's own price — the ladder's first rung, which has no band of its own. It is reported rather than left to be re-derived because a ladder is editable: the band that priced these seats may not exist any more, and resolving `quantity` against today's ladder would name a rung the buyer was never charged at. Matches an entry of the ticket type's `bands` while the band is still on sale.",
            "example": 0
          },
          "ticket_ref": {
            "type": "string",
            "description": "The ticket this line hangs off — an upgrade, or something bought for one person after the fact. Absent when the line hangs off no ticket, which is every ordinary ticket line.",
            "example": ""
          }
        },
        "required": [
          "product_id",
          "product",
          "kind",
          "quantity",
          "unit_price_cents",
          "subtotal_cents",
          "tax_cents",
          "total_cents",
          "currency",
          "status"
        ]
      },
      "OrderDiscount": {
        "type": "object",
        "description": "One promotion code redeemed against an order. Not a line: a discount names no product and has no quantity or tax, and the ticket lines it discounted keep their full price. Subtract these from the active `lines` totals to reach the order's `amount_cents`.",
        "properties": {
          "code": {
            "type": "string",
            "description": "The promotion code as the buyer typed it, recorded on the redemption — so it still reads back after the code itself has been renamed or deleted.",
            "example": "EARLYBIRD"
          },
          "amount_cents": {
            "type": "integer",
            "description": "What this code took off the order, in integer minor units. POSITIVE, and subtracted: the buyer's own receipt renders it as a negative row, while the API reports the amount as the redemption holds it.",
            "example": 2000
          },
          "currency": {
            "type": "string",
            "description": "Lowercase ISO 4217 currency code.",
            "example": "eur"
          }
        },
        "required": [
          "code",
          "amount_cents",
          "currency"
        ]
      },
      "OrderPriceAdjustment": {
        "type": "object",
        "description": "A price an organizer set for a whole order when they created it by hand, recorded as the difference against what the order's lines come to. Not a line: it names no product and has no quantity or tax, and the lines it adjusts keep their own prices. Not a discount either, which is why it is reported separately: `discounts` entries are magnitudes that always reduce an order, while this one is signed and can add to it.",
        "properties": {
          "label": {
            "type": "string",
            "description": "What the organizer called the difference, for example \"Sponsor rate\". This is the text the buyer reads beside the amount on their payment page and their receipt.",
            "example": "Speaker discount"
          },
          "amount_cents": {
            "type": "integer",
            "description": "The difference against the order's lines, in integer minor units. SIGNED and added: negative took money off the order, positive added to it.",
            "example": -5000
          },
          "currency": {
            "type": "string",
            "description": "Lowercase ISO 4217 currency code.",
            "example": "eur"
          }
        },
        "required": [
          "label",
          "amount_cents",
          "currency"
        ]
      },
      "Order": {
        "type": "object",
        "properties": {
          "ref": {
            "type": "string",
            "example": "H7K2PX",
            "description": "Short reference printed on receipts and read out at the desk. Unique across the account."
          },
          "legacy_ref": {
            "type": "string",
            "description": "The ref this order carried on the platform it was migrated from; empty for orders sold on Tito. It is not always the same as `ref`: refs are unique per account here and were unique per event on the source, so a migrated order whose code was already in use was renumbered on import. This field is what the buyer's original confirmation says, and what an integration keyed on the old platform reconciles against.",
            "example": ""
          },
          "status": {
            "type": "string",
            "description": "The order's state. Pending orders hold stock until paid or expired.",
            "example": "paid",
            "enum": [
              "pending",
              "paid",
              "canceled",
              "refunded"
            ]
          },
          "email": {
            "type": "string",
            "example": "ada@example.com",
            "description": "The buyer's email address."
          },
          "name": {
            "type": "string",
            "example": "Ada Lovelace",
            "description": "The buyer's name."
          },
          "amount_cents": {
            "type": "integer",
            "description": "Integer cents; see currency.",
            "example": 29800
          },
          "currency": {
            "type": "string",
            "description": "Lowercase ISO 4217 currency code.",
            "example": "eur"
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "example": "2026-05-18T14:02:11Z",
            "description": "When the order was started; for a pending order, when checkout began."
          },
          "paid_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Null until the order is paid.",
            "example": "2026-05-18T14:03:07Z"
          },
          "refunded_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Null unless the order has been refunded.",
            "example": null
          },
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OrderItem"
            },
            "description": "The order's TICKET seats, in insertion order — not its whole contents; see `lines`. Always an array, never null — an order with no ticket items serializes as `[]`."
          },
          "lines": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OrderLine"
            },
            "description": "The order's whole priced ledger, in the order it was written: tickets, goods, donations and post-order adjustments. The active lines' `total_cents` sum to what the order was charged BEFORE any promotion code came off it — a redeemed code never touches a line, and is reported in `discounts` instead. Subtract those to reach `amount_cents`. Overlaps `items` on tickets by design: a ticket seat appears in both. Always an array, never null."
          },
          "discounts": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OrderDiscount"
            },
            "description": "The promotion codes redeemed against this order, in the order they were applied — what came OFF it, where `lines` is what went on. This is one of the two terms that make the total reconcilable, alongside `price_adjustments`. Almost always empty; always an array, never null."
          },
          "price_adjustments": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OrderPriceAdjustment"
            },
            "description": "A price the organizer set for this whole order, if they set one. Signed and added, so the full identity is: the active `lines` totals, less the `discounts` `amount_cents`, plus these `amount_cents`, equal the order's `amount_cents`. Empty on every order nobody hand-priced, which is almost all of them; always an array, never null."
          }
        },
        "required": [
          "ref",
          "status",
          "email",
          "name",
          "amount_cents",
          "currency",
          "created_at",
          "paid_at",
          "refunded_at",
          "items",
          "lines",
          "discounts"
        ],
        "description": "One purchase. A pending order holds stock from the moment checkout starts; paid, canceled and refunded orders are the history of that purchase."
      },
      "CreatedOrder": {
        "type": "object",
        "description": "What a machine checkout answers with. Deliberately small: the ref to quote, what it costs, where it stands, and the one URL the buyer needs. Read the order back through GET /orders/{ref} for its full shape.",
        "properties": {
          "ref": {
            "type": "string",
            "description": "The order's reference — the name a human quotes, never a credential.",
            "example": "H7K2PX"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "paid",
              "canceled",
              "refunded"
            ],
            "description": "\"pending\" for a payable order awaiting payment, \"paid\" for a zero-total order (fulfilled on creation). A replay reports whatever the order is now, canceled or refunded included.",
            "example": "pending"
          },
          "total_cents": {
            "type": "integer",
            "description": "The order total in integer cents; see currency.",
            "example": 29800
          },
          "currency": {
            "type": "string",
            "description": "Lowercase ISO 4217 currency code — the event's.",
            "example": "eur"
          },
          "payment_url": {
            "type": "string",
            "description": "Absolute URL of the buyer's pay page. Present only while the order is pending. A capability URL: treat it as a secret.",
            "example": "https://annual-conf.example/annual-conf/orders/…/pay"
          },
          "url": {
            "type": "string",
            "description": "Absolute URL of the buyer's order page. Present once the order is no longer pending (a free order, or a replay of one since paid). A capability URL: treat it as a secret.",
            "example": "https://annual-conf.example/annual-conf/orders/…"
          }
        },
        "required": [
          "ref",
          "status",
          "total_cents",
          "currency"
        ]
      },
      "Ticket": {
        "type": "object",
        "properties": {
          "ref": {
            "type": "string",
            "example": "T9QW4M",
            "description": "Short reference printed on the ticket. Unique across the account."
          },
          "legacy_ref": {
            "type": "string",
            "description": "The ref this ticket carried on the platform it was migrated from; empty for tickets sold on Tito. See the same field on Order.",
            "example": ""
          },
          "status": {
            "type": "string",
            "example": "valid",
            "description": "\"valid\", or \"void\" once the ticket has been cancelled.",
            "enum": [
              "valid",
              "void"
            ]
          },
          "ticket_type": {
            "type": "string",
            "description": "The ticket type's name.",
            "example": "Early Bird"
          },
          "holder_name": {
            "type": "string",
            "example": "Ada Lovelace",
            "description": "The attendee's name, as assigned."
          },
          "holder_email": {
            "type": "string",
            "example": "ada@example.com",
            "description": "The attendee's email address."
          },
          "order_ref": {
            "type": "string",
            "description": "The ref of the order this ticket belongs to.",
            "example": "H7K2PX"
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "example": "2026-05-18T14:03:07Z",
            "description": "When the ticket was issued."
          },
          "answers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Answer"
            },
            "description": "What this ticket's holder was asked, plus any metadata the organizer records against them. Always an array, never null — a ticket with neither serializes as `[]`. Includes the metadata the organizer keeps to their own team: this read is gated by attendees.view, the very capability that gates the admin's own attendee page.\n\n**A question can be asked because of the ticket type OR because of a session the ticket got this person into.** An organizer can attach a question to a session offering and narrow it to some of its options, so 'what's your level of Rust?' is asked only of the people who picked the Rust workshop. Those answers arrive here, in this same array and this same shape — there is no separate field and nothing says which of the two asked it. Two holders of the same ticket type can therefore carry different questions, and moving somebody to another option drops the answers the new option does not ask for."
          },
          "sync": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SyncStanding"
            },
            "description": "Where this ticket stands with each connected service. Always an array, never null — a ticket never sent to any service serializes as `[]`."
          }
        },
        "required": [
          "ref",
          "status",
          "ticket_type",
          "holder_name",
          "holder_email",
          "order_ref",
          "created_at",
          "answers",
          "sync"
        ],
        "description": "One admission, produced when an order is paid. Held by an attendee, who may differ from the buyer."
      },
      "WidgetSummary": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "example": 1
          },
          "slug": {
            "type": "string",
            "description": "What the pasted markup names. Unique across the ACCOUNT, not per event, so the snippet can carry the widget's name and nothing else.",
            "example": "main"
          },
          "name": {
            "type": "string",
            "description": "The organiser's own label for the widget. Never shown publicly.",
            "example": "Homepage list"
          },
          "shape": {
            "type": "string",
            "enum": [
              "list",
              "button"
            ],
            "description": "Lives on the record, never in the pasted markup — changing it does not change the organiser's website.",
            "example": "list"
          },
          "secured_line": {
            "type": "boolean",
            "description": "Whether the widget shows the “Secured by Tito” line. On by default, switchable.",
            "example": false
          },
          "ticket_type_ids": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "The ticket types this widget lists, in its own order. Empty means it lists everything on sale."
          },
          "installed": {
            "type": "boolean",
            "description": "Whether the widget has ever rendered on an approved website. Once true it never returns to false.",
            "example": true
          },
          "installed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "example": "2026-04-10T10:00:00Z"
          },
          "closed": {
            "type": "boolean",
            "description": "Whether the organiser has switched this widget off. A closed widget still answers on their website and says tickets are not on sale, because an empty box there reads as a broken widget.",
            "example": false
          },
          "closed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "example": null
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-04-01T09:00:00Z"
          }
        },
        "required": [
          "id",
          "slug",
          "name",
          "shape",
          "secured_line",
          "ticket_type_ids",
          "installed",
          "installed_at",
          "created_at"
        ]
      },
      "WidgetDomain": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "example": 1
          },
          "domain": {
            "type": "string",
            "description": "Normalised host: lowercase, no scheme, no port, no leading www. Approving the bare form covers the www form too, so one row is one website.",
            "example": "annualconf.example"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "approved",
              "blocked"
            ],
            "description": "pending = seen, nobody has decided yet. Only approved renders.",
            "example": "approved"
          },
          "sightings": {
            "type": "integer",
            "example": 318
          },
          "first_seen_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-04-10T10:00:00Z"
          },
          "last_seen_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-08-27T18:12:00Z"
          }
        },
        "required": [
          "id",
          "domain",
          "status",
          "sightings",
          "first_seen_at",
          "last_seen_at"
        ]
      },
      "SavedReport": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "The report's row id; saved reports carry no ref (admin-only, never a public surface).",
            "example": 1
          },
          "name": {
            "type": "string",
            "description": "What the account called it. Up to 80 characters, never empty.",
            "example": "Sales by week"
          },
          "source": {
            "type": "string",
            "description": "Which records the report counts. `orders` is the only source today.",
            "example": "orders"
          },
          "query": {
            "type": "string",
            "description": "The whole definition, as a URL query string — grouping, date range, what it measures against, and any filters. Empty means the report's defaults (grouped by day, last 30 days, measured against the day an order was paid).",
            "example": "group=week&range=all"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Integer Unix timestamp in storage, rendered as RFC 3339 UTC. Not nullable (NOT NULL column).",
            "example": "2026-05-01T09:00:00Z"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the name or the definition last changed. Equals `created_at` on a report nobody has edited.",
            "example": "2026-05-01T09:00:00Z"
          }
        },
        "required": [
          "id",
          "name",
          "source",
          "query",
          "created_at",
          "updated_at"
        ]
      },
      "CheckinListSummary": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "The list's row id; check-in lists carry no ref (admin-only, never a public surface).",
            "example": 1
          },
          "name": {
            "type": "string",
            "example": "Main entrance",
            "description": "The list's name, e.g. \"Main entrance\"."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Integer Unix timestamp in storage, rendered as RFC 3339 UTC. Not nullable — a list always has a creation time (NOT NULL column).",
            "example": "2026-09-01T08:00:00Z"
          },
          "checked_in_count": {
            "type": "integer",
            "description": "Check-ins recorded against this list, counted exactly as the admin's door page counts them: only tickets that are still valid (voided tickets drop out), and on an entitlement-scoped list only tickets holding that entitlement. 0 when the list has no check-ins yet, never a missing/null value.",
            "example": 412
          }
        },
        "required": [
          "id",
          "name",
          "created_at",
          "checked_in_count"
        ],
        "description": "A check-in list and its live count."
      },
      "Checkin": {
        "type": "object",
        "properties": {
          "ticket_ref": {
            "type": "string",
            "example": "T9QW4M",
            "description": "The ticket that was checked in."
          },
          "checked_in_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "example": "2026-10-14T08:41:19Z",
            "description": "When it was scanned."
          }
        },
        "required": [
          "ticket_ref",
          "checked_in_at"
        ],
        "description": "One recorded arrival."
      },
      "Workflow": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "example": 1
          },
          "name": {
            "type": "string",
            "example": "Thank-you email"
          },
          "enabled": {
            "type": "boolean",
            "example": true
          },
          "all_events": {
            "type": "boolean",
            "description": "Whether this workflow fires for every event, including ones created later (the workflow_subscriptions event_id=0 sentinel). When true, events is always [].",
            "example": true
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "example": "2026-04-01T09:00:00Z"
          },
          "updated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "example": "2026-04-01T09:00:00Z"
          },
          "events": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Slugs of the events subscribed to this workflow. Always an array, never null, when the workflow has none."
          },
          "definition": {
            "description": "The workflow's node/edge graph (internal/workflow.Definition), verbatim as stored — organizer-authored, no secrets. Present only on the single-workflow endpoint, and only when the stored definition parses as JSON; see definition_raw.",
            "type": "object"
          },
          "definition_raw": {
            "type": "string",
            "description": "The raw, unparsed definition text. Present instead of definition, on the single-workflow endpoint only, when the stored JSON fails to parse — a corrupt definition must not break this endpoint."
          }
        },
        "required": [
          "id",
          "name",
          "enabled",
          "all_events",
          "created_at",
          "updated_at",
          "events"
        ]
      },
      "WorkflowRun": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "example": 1
          },
          "trigger": {
            "type": "string",
            "description": "The event that fired this run, e.g. \"order.paid\".",
            "example": "order.paid"
          },
          "subject_type": {
            "type": "string",
            "description": "e.g. \"order\", \"ticket\".",
            "example": "order"
          },
          "subject_id": {
            "type": "integer",
            "description": "The subject's row id.",
            "example": 1
          },
          "status": {
            "type": "string",
            "description": "\"pending\", \"running\", \"completed\", or \"failed\". A run that fails on something worth retrying — a 5xx, a 429, or no answer at all — goes back to \"pending\" for its next attempt rather than straight to \"failed\".",
            "example": "completed",
            "enum": [
              "pending",
              "running",
              "completed",
              "failed"
            ]
          },
          "attempt": {
            "type": "integer",
            "description": "How many attempts have been made. A \"pending\" run with attempt above 0 is waiting on a retry rather than queued for the first time. Retries back off a minute, five, half an hour, then two hours; after that the run fails for good.",
            "example": 1
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "example": "2026-05-18T14:03:08Z"
          },
          "started_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Null until the run starts.",
            "example": "2026-05-18T14:03:08Z"
          },
          "finished_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Null until the run finishes.",
            "example": "2026-05-18T14:03:09Z"
          },
          "log": {
            "description": "The run's step log (internal/workflow.Step array), verbatim as stored. Present only on the single-run endpoint. Falls back to a JSON string of the raw stored text when it fails to parse as JSON — the log is a debugging surface and must not break this endpoint on a malformed row.",
            "type": "array",
            "items": {}
          }
        },
        "required": [
          "id",
          "trigger",
          "subject_type",
          "subject_id",
          "status",
          "attempt",
          "created_at",
          "started_at",
          "finished_at"
        ]
      },
      "CompanionRule": {
        "type": "object",
        "description": "A companion rule in the direction the organizer authored it: an order holding this ticket type needs `quantity` of `ticket_type_id` for every `per_quantity` of this one. The requirement always rounds up — four of this type at 1-per-3 needs two companions. Counted within a single order. API checkout is never blocked by a companion rule; this is reported so a caller can show the same requirement the event page does.",
        "properties": {
          "ticket_type_id": {
            "type": "integer",
            "description": "The companion — the ticket type this rule asks for.",
            "example": 2
          },
          "quantity": {
            "type": "integer",
            "description": "How many companions are needed per `per_quantity` of this type.",
            "example": 1
          },
          "per_quantity": {
            "type": "integer",
            "description": "How many of this type each set of companions covers.",
            "example": 1
          },
          "strictness": {
            "type": "string",
            "enum": [
              "recommended",
              "required"
            ],
            "description": "`recommended` shows people a tip and never blocks; `required` refuses a checkout that falls short — unless the companion is sold out, in which case the rule pauses so this type stays sellable.",
            "example": "recommended"
          }
        },
        "required": [
          "ticket_type_id",
          "quantity",
          "per_quantity",
          "strictness"
        ]
      },
      "ReportRow": {
        "type": "object",
        "description": "One grouped row of the custom report — and, in `totals`, the same shape summing every row. Amounts are integer minor units of `currency` (never a float), aggregated at order grain.",
        "properties": {
          "key": {
            "type": "string",
            "description": "The bucket's machine-stable id: an ISO date (`day` is `2026-08-05`, `week` the Monday it starts, `month` `2026-08`), an audience's flow slug, a status, a discount code, or a lowercase currency code. Legitimately EMPTY for the main page's audience, an order with no discount code, and the totals line.",
            "example": "2026-W20"
          },
          "label": {
            "type": "string",
            "description": "The same bucket worded for a human. Always English here, like every other machine-facing string in this API — the admin page localizes it; a caller with its own wording should read `key`.",
            "example": "Week of 11 May"
          },
          "orders": {
            "type": "integer",
            "description": "Orders in this bucket.",
            "example": 38
          },
          "tickets": {
            "type": "integer",
            "description": "Tickets those orders carry.",
            "example": 61
          },
          "gross_cents": {
            "type": "integer",
            "description": "What changed hands, before refunds — the orders' own amounts, so discounts are already applied and non-ticket items are included.",
            "example": 909000
          },
          "discount_cents": {
            "type": "integer",
            "description": "Discount taken off those orders, as a positive amount.",
            "example": 20000
          },
          "refund_cents": {
            "type": "integer",
            "description": "Succeeded refunds against those orders, as a positive amount.",
            "example": 14900
          },
          "tax_cents": {
            "type": "integer",
            "description": "Tax to remit on those orders, as a positive amount: the tax snapshotted on each order line when it was sold, summed over the lines still standing. An order refunded in full contributes nothing, and a ticket refunded off an order takes its own line's tax with it. NOT subtracted from `net_cents` — see `/events/{slug}/income` for the ledger that walks it down.",
            "example": 0
          },
          "net_cents": {
            "type": "integer",
            "description": "`gross_cents` minus `refund_cents`.",
            "example": 874100
          },
          "currency": {
            "type": "string",
            "description": "The event's currency. Orders taken in another currency are still summed into these amounts — group by `currency` to split them apart.",
            "example": "eur"
          }
        },
        "required": [
          "key",
          "label",
          "orders",
          "tickets",
          "gross_cents",
          "discount_cents",
          "refund_cents",
          "tax_cents",
          "net_cents",
          "currency"
        ]
      },
      "IncomeMonth": {
        "type": "object",
        "description": "One month of the income ledger. The months decompose the ledger exactly — column by column they sum to the top-level totals. Amounts are integer minor units of the event's `currency`, and deductions and tax are POSITIVE magnitudes: the admin page draws them with a minus because it is walking a figure down, but a caller doing arithmetic wants the amount and the operation stated separately.",
        "properties": {
          "key": {
            "type": "string",
            "description": "The month's machine-stable id, `2026-08`, cut in the event's own timezone.",
            "example": "2026-05"
          },
          "label": {
            "type": "string",
            "description": "The same month worded for a human. Always English here, like every other machine-facing string in this API.",
            "example": "May 2026"
          },
          "gross_cents": {
            "type": "integer",
            "description": "Before anything comes off: what the orders charged plus the discount that came off them.",
            "example": 909000
          },
          "deductions_cents": {
            "type": "integer",
            "description": "Discount codes plus refunds, as one positive amount.",
            "example": 34900
          },
          "tax_cents": {
            "type": "integer",
            "description": "Tax to remit, as a positive amount.",
            "example": 0
          },
          "fees_cents": {
            "type": "integer",
            "description": "What the payments cost, as a positive amount: the provider's cut and Tito's together. The ledger's own totals split the two.",
            "example": 18180
          },
          "net_cents": {
            "type": "integer",
            "description": "`gross_cents` minus `deductions_cents` minus `tax_cents` minus `fees_cents`.",
            "example": 855920
          }
        },
        "required": [
          "key",
          "label",
          "gross_cents",
          "deductions_cents",
          "tax_cents",
          "fees_cents",
          "net_cents"
        ]
      },
      "Series": {
        "type": "object",
        "description": "An account-level grouping of events with a listing page of its own.",
        "properties": {
          "slug": {
            "type": "string",
            "description": "The address the series page answers on. Unique across this account's events, series and custom pages.",
            "example": "annual-conference"
          },
          "name": {
            "type": "string",
            "example": "Annual Conference"
          },
          "intro": {
            "type": "string",
            "description": "Plain text shown under the name on the series page. Empty when unset.",
            "example": "Every autumn since 2019."
          },
          "listed": {
            "type": "boolean",
            "description": "Whether the series appears on the account's own front page. It keeps its address either way.",
            "example": true
          },
          "show_past": {
            "type": "boolean",
            "description": "Whether finished events stay visible on the series page.",
            "example": true
          },
          "event_count": {
            "type": "integer",
            "description": "How many events are in the series.",
            "example": 7
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "example": "2019-03-02T11:20:41Z"
          }
        },
        "required": [
          "slug",
          "name",
          "intro",
          "listed",
          "show_past",
          "event_count",
          "created_at"
        ]
      },
      "WebhookEndpoint": {
        "type": "object",
        "description": "One outbound-webhook subscription. Never carries its signing secret — that is admin-only data, shown once per visit on the Settings → Webhooks detail page, never in this API.",
        "properties": {
          "id": {
            "type": "integer",
            "example": 1
          },
          "url": {
            "type": "string",
            "description": "The receiver URL deliveries POST to. Always https.",
            "example": "https://example.com/hooks/tito"
          },
          "event_types": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "order.created",
                "order.paid",
                "order.cancelled",
                "order.refunded",
                "ticket.created",
                "ticket.voided",
                "ticket.checked_in",
                "ticket.updated",
                "checkin.created"
              ]
            },
            "description": "The event types this endpoint is subscribed to — a non-empty subset of the fixed v1 vocabulary."
          },
          "enabled": {
            "type": "boolean",
            "description": "False either because an admin turned it off, or because the drainer auto-disabled it after 10 consecutive permanently-failed deliveries (distinguish the two by `disabled_at`: auto-disable always stamps it, a manual toggle never does).",
            "example": true
          },
          "consecutive_failures": {
            "type": "integer",
            "description": "Consecutive permanently-failed deliveries (each having exhausted its own retry schedule) since the last success. Resets to 0 on the next successful delivery, or when the endpoint is re-enabled.",
            "example": 0
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-04-01T09:00:00Z"
          },
          "disabled_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Null unless the drainer auto-disabled this endpoint (10 consecutive permanently-failed deliveries) — a manual disable never sets this, which is what makes its presence the auto-disabled signal.",
            "example": null
          }
        },
        "required": [
          "id",
          "url",
          "event_types",
          "enabled",
          "consecutive_failures",
          "created_at",
          "disabled_at"
        ]
      },
      "WebhookDelivery": {
        "type": "object",
        "description": "One delivery attempt record — queue row and audit-trail row at once, exactly as the Settings → Webhooks detail page shows it.",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Also the value sent as the Tito-Webhook-Delivery header on the wire.",
            "example": 1
          },
          "event_type": {
            "type": "string",
            "description": "Also the value sent as the Tito-Webhook-Event header on the wire. \"webhook.test\" is a test delivery sent by hand from the admin rather than a real account event, so it is not a type an endpoint can subscribe to.",
            "example": "order.paid"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "succeeded",
              "failed"
            ],
            "description": "\"pending\" is still retrying (or awaiting its first attempt); \"succeeded\" got a 2xx response; \"failed\" exhausted the 6-attempt retry schedule without one.",
            "example": "succeeded"
          },
          "attempts": {
            "type": "integer",
            "description": "Delivery attempts made so far (0 before the first is sent).",
            "example": 1
          },
          "next_attempt_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the next attempt is due, for a delivery still on the retry schedule (6 attempts spread over about ten and a half hours). In the past for one that is due now; carries no meaning once status leaves \"pending\".",
            "example": ""
          },
          "last_status_code": {
            "type": "integer",
            "description": "The HTTP status the receiver answered on the most recent attempt. 0 when no attempt has completed yet, or every attempt so far failed at the transport level (refused/unreachable target, timeout) rather than getting an HTTP response.",
            "example": 200
          },
          "last_error": {
            "type": "string",
            "description": "The most recent attempt's failure reason (transport error, or \"endpoint answered <code>\" for a non-2xx response), truncated to 500 characters. Empty once a delivery has succeeded.",
            "example": ""
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "example": "2026-05-18T14:03:08Z"
          },
          "delivered_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Null until this delivery succeeds; set once, on the attempt that got a 2xx.",
            "example": "2026-05-18T14:03:09Z"
          }
        },
        "required": [
          "id",
          "event_type",
          "status",
          "attempts",
          "next_attempt_at",
          "last_status_code",
          "last_error",
          "created_at",
          "delivered_at"
        ]
      }
    }
  },
  "tags": [
    {
      "name": "Account",
      "description": "The account itself: its slug, its display name, and where its data currently lives."
    },
    {
      "name": "Events",
      "description": "Events are the things you sell tickets to, and series group them. Every other resource hangs off an event, addressed by its slug."
    },
    {
      "name": "Orders",
      "description": "An order is one purchase: a buyer, an amount, and the lines they bought. Refs are account-wide. Machine checkout creates one the same way the shop does."
    },
    {
      "name": "Tickets",
      "description": "A ticket is one admission produced by a paid order, held by an attendee who may differ from the buyer."
    },
    {
      "name": "Check-in",
      "description": "Check-in lists record who has arrived. Every list covers all of an event's tickets; state is per list. Door tools check in and undo here."
    },
    {
      "name": "Reports",
      "description": "Income, availability and grouped order reports for one event, the same numbers the admin's dashboards show."
    },
    {
      "name": "Widgets",
      "description": "The embeddable ticket lists an organizer pastes into their own site, and the domains they have been seen on."
    },
    {
      "name": "Webhooks",
      "description": "Outbound webhook endpoints and their delivery log. Endpoints are managed in the admin; this is the read side."
    },
    {
      "name": "Workflows",
      "description": "Automations that run when something happens in the account, and the runs they produced."
    },
    {
      "name": "Export",
      "description": "The whole account as one SQLite file: the escape hatch for BI, ad-hoc SQL, and portability."
    },
    {
      "name": "Meta",
      "description": "The contract itself."
    }
  ]
}
