Tito API v1 reference

Tito API

One JSON API per account, served by the account itself: events, orders, tickets, check-in, reports and webhooks, plus the whole account as a SQLite file.

This is the generic copy of the reference. Every account serves its own at https://<yourslug>.go.tito.io/docs/api with that account's real event in every example.

Base URL https://<yourslug>.go.tito.io/api/v1, or the root-domain form https://go.tito.io/<yourslug>/api/v1: either host, same key, same answers.

Try requests from this page

Open this page on one of your own accounts to send requests against it, or paste an existing key.

Using in every example below.

Request
curl https://<yourslug>.go.tito.io/api/v1/events \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/events", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Response 200
{
  "events": [
    {
      "slug": "annual-conf",
      "path": "annual-conf",
      "name": "Annual Conference 2026",
      "description": "Two days of talks, workshops and hallway conversations.",
      "venue": "The Round Room, Dublin",
      "venue_address": "Rotunda, Parnell Square, Dublin 1",
      "venue_lat": "53.3529",
      "venue_lng": "-6.2634",
      "venue_place_id": "",
      "map_provider": "google",
      "starts_at": "2026-10-14T09:00:00Z",
      "timezone": "Europe/Dublin",
      "currency": "eur",
      "currencies": [
        "eur",
        "gbp"
      ],
      "currency_assignment": "switcher",
      "created_at": "2026-03-02T11:20:41Z",
      "draft": false,
      "secret": false,
      "no_index": false,
      "series": {
        "slug": "annual-conf",
        "name": "Ada Lovelace"
      },
      "fields": {},
      "sections": [
        {
          "id": 1,
          "name": "Tickets",
          "position": 1
        }
      ]
    }
  ]
}

Authentication

Every request carries an account API key as a bearer token. Keys are created at Settings → API in the account admin, shown once, and stored hashed. If you lose one, create another.

A key carries a set of capabilities, the same permissions the admin's roles use (events.view, orders.manage, checkin.manage, export.pii, …). Each endpoint names the capability it needs; a key without it gets 403. The temporary key this page mints is read-only.

Keys minted on a test-mode account (sandbox Stripe) start with titogo_sk_test_; everything else starts with titogo_sk_. The prefix is descriptive: the account is either test or live, and there is no switch inside it.

Authenticated request
curl https://<yourslug>.go.tito.io/api/v1/events/annual-conf \
  -H "Authorization: Bearer titogo_sk_your_key"
Without a key 401
{
  "error": {
    "code": "missing_key",
    "message": "send Authorization: Bearer <api key>"
  }
}
Key lacks the capability 403
{
  "error": {
    "code": "missing_capability",
    "message": "this key does not carry orders.view"
  }
}

Errors

Every error is the same shape: an HTTP status that says what class of problem it is, and a JSON body with a machine code and a sentence for a human. Check the status first, then the code.

  • 200It worked. The body is the resource.
  • 401missing_key or unknown_key: no bearer header, or a key that was revoked or has expired.
  • 403missing_capability: the key is valid but doesn't carry what this endpoint needs. The message names the capability.
  • 404not_found: no such event, order, or list. Also any path not in this reference.
  • 422A write's body didn't validate. The message says which field.
  • 500internal: our fault. Safe to retry; if it persists, the request is in the account's API log at Settings → API.

Error messages are always English; they're for logs and developers, not for the people buying tickets.

Error shape 404
{
  "error": {
    "code": "not_found",
    "message": "no such event"
  }
}

Pagination

Lists that can grow (orders, tickets, workflow runs) are cursor-paged, newest first. Each page carries next_after: pass it back as ?after= to get the next page. When it's null, you've seen everything.

?limit= sets the page size (default 50, maximum 200). A full final page still returns a cursor, which then yields an empty page. Loop until the list comes back empty or next_after is null, whichever you prefer.

Events and check-in lists aren't paged: an account has tens of those, not thousands.

Walking every order
curl "https://<yourslug>.go.tito.io/api/v1/events/annual-conf/orders?limit=100" \
  -H "Authorization: Bearer titogo_sk_your_key"

# then, with the next_after from that page:
curl "https://<yourslug>.go.tito.io/api/v1/events/annual-conf/orders?limit=100&after=H7K2PX" \
  -H "Authorization: Bearer titogo_sk_your_key"

Conventions

MoneyInteger minor units (amount_cents: 14900 is €149.00; a zero-decimal currency like JPY has none) with a lowercase ISO 4217 currency beside every amount. Never a float, never a formatted string.
TimeRFC 3339 in UTC (2026-10-14T09:00:00Z). A time that hasn't happened yet is null: an unpaid order's paid_at, for instance.
IdentifiersEvents by slug. Orders and tickets by ref, the short code printed on a ticket. Refs are unique across the account and safe to quote in support, but they are names, not secrets. A buyer's order-page link is never returned by the API.
WritesBodies are JSON with Content-Type: application/json. Machine checkout accepts an Idempotency-Key header so a retried create never makes two orders.
ListsAn empty list is [], never null.
Rate limitsNone yet. Be reasonable; an integration that hurts an account will be asked to slow down before anything automated is.
LoggingEvery request, including denied ones, is recorded against the key that made it and shown at Settings → API for 90 days.
Money and time, as emitted
{
  "amount_cents": 29800,
  "currency": "eur",
  "created_at": "2026-05-18T14:02:11Z",
  "paid_at": "2026-05-18T14:03:07Z",
  "refunded_at": null
}

Account

The account itself: its slug, its display name, and where its data currently lives.

Get the account

GET /account

The account's own identity: slug, display name, and — where the platform places accounts by region — the region its data currently lives in.

Requires events.view

Response fields

#
slug string
The account's own URL identifier.
#
name string
Display name.
#
region string
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.

Responses

  • 200The account calling.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/account \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/account", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "slug": "acme",
  "name": "Acme Events",
  "region": "uk"
}

Events

Events are the things you sell tickets to, and series group them. Every other resource hangs off an event, addressed by its slug.

List events

GET /events

Requires events.view

Response fields

#
events array of Event
#
events[].slug string
URL identifier, unique within the account. Appears in the public event URL.
#
events[].path string
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.
#
events[].name string
Display name.
#
events[].description string
Organizer-written description, plain text.
#
events[].venue string
Where it happens, as entered by the organizer.
#
events[].venue_address string
Organizer-entered address text, in the event's default locale. Empty when no structured venue is set.
#
events[].venue_lat string
Decimal latitude of the venue's map pin, as a string. Empty when the event has no pin.
#
events[].venue_lng string
Decimal longitude of the venue's map pin, as a string. Empty when the event has no pin.
#
events[].venue_place_id string
Google's opaque place id for the venue, if the address was chosen from the picker. Empty otherwise.
#
events[].map_provider string
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.
one of googleapple
#
events[].starts_at string (RFC 3339) or null
Null when the event has no start time set (stored as Unix 0).
#
events[].timezone string
IANA zone the event is scheduled in, e.g. "Europe/Dublin".
#
events[].currency string
Lowercase ISO 4217 currency code, e.g. "eur".
#
events[].currencies array of string
Every currency the event sells in: currency first, then the extra currencies the organizer priced. A one-currency event lists just currency.
#
events[].currency_assignment string
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.
one of switcherlocalelink
#
events[].created_at string (RFC 3339) or null
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).
#
events[].draft boolean
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.
#
events[].secret boolean
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".
#
events[].no_index boolean
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.
#
events[].series object
The series this event belongs to, absent when it is in none. An event belongs to at most one.
#
events[].series.slug string
#
events[].series.name string
#
events[].fields object
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.
#
events[].sections array of EventSection
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.
#
events[].sections[].id integer
#
events[].sections[].name string
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.
#
events[].sections[].position integer
Zero-based place in the page, top to bottom. The list is already returned in this order.

Responses

  • 200Events, newest first.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/events \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/events", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "events": [
    {
      "slug": "annual-conf",
      "path": "annual-conf",
      "name": "Annual Conference 2026",
      "description": "Two days of talks, workshops and hallway conversations.",
      "venue": "The Round Room, Dublin",
      "venue_address": "Rotunda, Parnell Square, Dublin 1",
      "venue_lat": "53.3529",
      "venue_lng": "-6.2634",
      "venue_place_id": "",
      "map_provider": "google",
      "starts_at": "2026-10-14T09:00:00Z",
      "timezone": "Europe/Dublin",
      "currency": "eur",
      "currencies": [
        "eur",
        "gbp"
      ],
      "currency_assignment": "switcher",
      "created_at": "2026-03-02T11:20:41Z",
      "draft": false,
      "secret": false,
      "no_index": false,
      "series": {
        "slug": "annual-conf",
        "name": "Ada Lovelace"
      },
      "fields": {},
      "sections": [
        {
          "id": 1,
          "name": "Tickets",
          "position": 1
        }
      ]
    }
  ]
}

Get an event

GET /events/{slug}

Get an event by slug.

Requires events.view

Path parameters

slug string required
The event's slug.

Response fields

#
slug string
URL identifier, unique within the account. Appears in the public event URL.
#
path string
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.
#
name string
Display name.
#
description string
Organizer-written description, plain text.
#
venue string
Where it happens, as entered by the organizer.
#
venue_address string
Organizer-entered address text, in the event's default locale. Empty when no structured venue is set.
#
venue_lat string
Decimal latitude of the venue's map pin, as a string. Empty when the event has no pin.
#
venue_lng string
Decimal longitude of the venue's map pin, as a string. Empty when the event has no pin.
#
venue_place_id string
Google's opaque place id for the venue, if the address was chosen from the picker. Empty otherwise.
#
map_provider string
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.
one of googleapple
#
starts_at string (RFC 3339) or null
Null when the event has no start time set (stored as Unix 0).
#
timezone string
IANA zone the event is scheduled in, e.g. "Europe/Dublin".
#
currency string
Lowercase ISO 4217 currency code, e.g. "eur".
#
currencies array of string
Every currency the event sells in: currency first, then the extra currencies the organizer priced. A one-currency event lists just currency.
#
currency_assignment string
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.
one of switcherlocalelink
#
created_at string (RFC 3339) or null
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).
#
draft boolean
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.
#
secret boolean
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".
#
no_index boolean
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.
#
series object
The series this event belongs to, absent when it is in none. An event belongs to at most one.
#
series.slug string
#
series.name string
#
fields object
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 array of EventSection
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.
#
sections[].id integer
#
sections[].name string
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.
#
sections[].position integer
Zero-based place in the page, top to bottom. The list is already returned in this order.

Responses

  • 200The event.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 404No such resource.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/events/annual-conf \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/events/annual-conf", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "slug": "annual-conf",
  "path": "annual-conf",
  "name": "Annual Conference 2026",
  "description": "Two days of talks, workshops and hallway conversations.",
  "venue": "The Round Room, Dublin",
  "venue_address": "Rotunda, Parnell Square, Dublin 1",
  "venue_lat": "53.3529",
  "venue_lng": "-6.2634",
  "venue_place_id": "",
  "map_provider": "google",
  "starts_at": "2026-10-14T09:00:00Z",
  "timezone": "Europe/Dublin",
  "currency": "eur",
  "currencies": [
    "eur",
    "gbp"
  ],
  "currency_assignment": "switcher",
  "created_at": "2026-03-02T11:20:41Z",
  "draft": false,
  "secret": false,
  "no_index": false,
  "series": {
    "slug": "annual-conf",
    "name": "Ada Lovelace"
  },
  "fields": {},
  "sections": [
    {
      "id": 1,
      "name": "Tickets",
      "position": 1
    }
  ]
}

List ticket types

GET /events/{slug}/ticket-types

List an event's ticket types. Archived ticket types are not included.

Requires events.view

Path parameters

slug string required
The event's slug.

Response fields

#
ticket_types array of TicketType
#
ticket_types[].id integer
#
ticket_types[].name string
#
ticket_types[].price_cents integer
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.
#
ticket_types[].currency string
Lowercase ISO 4217 currency code — the event's currency; ticket types carry no currency of their own.
#
ticket_types[].prices object
The type's price in every currency it is offered in, minor units keyed by lowercase currency code — currencyprice_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.
#
ticket_types[].on_sale boolean
#
ticket_types[].quantity integer or null
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.
#
ticket_types[].sold integer
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.
#
ticket_types[].tiers array of TicketTier
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.
#
ticket_types[].tiers[].id integer
#
ticket_types[].tiers[].name string
The rung's public name, and often empty — a blank name is a real answer, and people then see the price on its own.
#
ticket_types[].tiers[].price_cents integer
Integer cents; see the type's currency.
#
ticket_types[].tiers[].quantity integer or null
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.
#
ticket_types[].tiers[].sold integer
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.
#
ticket_types[].tiers[].starts_at string (RFC 3339) or null
When the rung opens; null for no start. RFC 3339.
#
ticket_types[].tiers[].ends_at string (RFC 3339) or null
When the rung closes, EXCLUSIVE — the same contract as a product's sales_end_at. null for no end. RFC 3339.
#
ticket_types[].tiers[].live boolean
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.
#
ticket_types[].tiers[].prices object
This rung's price in every currency it is offered in, minor units keyed by lowercase currency code — the type's own currencyprice_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.
#
ticket_types[].bands array of TicketBand
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.

Unlike 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.

The 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.

Never present alongside tiers: a ticket has one ladder or the other.

#
ticket_types[].bands[].from_qty integer
The order quantity this band starts at — two or more, always. One ticket is the type's own price_cents.
#
ticket_types[].bands[].price_cents integer
What ONE ticket costs from from_qty upwards. Integer cents; see the type's currency.
#
ticket_types[].bands[].prices object
This band's price in every currency it is offered in, minor units keyed by lowercase currency code — the type's own currencyprice_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.
#
ticket_types[].position integer
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.
#
ticket_types[].section_id integer
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.
#
ticket_types[].companion object
This type's own companion rule, or null when it has none.
#
ticket_types[].companion.ticket_type_id integer
The companion — the ticket type this rule asks for.
#
ticket_types[].companion.quantity integer
How many companions are needed per per_quantity of this type.
#
ticket_types[].companion.per_quantity integer
How many of this type each set of companions covers.
#
ticket_types[].companion.strictness string
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.
one of recommendedrequired

Responses

  • 200The 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).
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/events/annual-conf/ticket-types \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/events/annual-conf/ticket-types", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "ticket_types": [
    {
      "id": 1,
      "name": "Early Bird",
      "price_cents": 14900,
      "currency": "eur",
      "prices": {
        "eur": 14900,
        "gbp": 12900
      },
      "on_sale": true,
      "quantity": 200,
      "sold": 142,
      "tiers": [
        {
          "id": 1,
          "name": "First 100",
          "price_cents": 12900,
          "quantity": 100,
          "sold": 100,
          "starts_at": null,
          "ends_at": "2026-06-01T00:00:00Z",
          "live": false,
          "prices": {
            "eur": 14900,
            "gbp": 12900
          }
        }
      ],
      "bands": [
        {
          "from_qty": 3,
          "price_cents": 8000,
          "prices": {
            "eur": 8000,
            "gbp": 6900
          }
        }
      ],
      "position": 1,
      "section_id": 1,
      "companion": {
        "ticket_type_id": 2,
        "quantity": 1,
        "per_quantity": 1,
        "strictness": "recommended"
      }
    }
  ]
}

List series

GET /series

Requires events.view

Response fields

#
series array of Series
#
series[].slug string
The address the series page answers on. Unique across this account's events, series and custom pages.
#
series[].name string
#
series[].intro string
Plain text shown under the name on the series page. Empty when unset.
#
series[].listed boolean
Whether the series appears on the account's own front page. It keeps its address either way.
#
series[].show_past boolean
Whether finished events stay visible on the series page.
#
series[].event_count integer
How many events are in the series.
#
series[].created_at string (RFC 3339) or null

Responses

  • 200Series, by name.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
Request
curl https://<yourslug>.go.tito.io/api/v1/series \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/series", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "series": [
    {
      "slug": "annual-conference",
      "name": "Annual Conference",
      "intro": "Every autumn since 2019.",
      "listed": true,
      "show_past": true,
      "event_count": 7,
      "created_at": "2019-03-02T11:20:41Z"
    }
  ]
}

Orders

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.

List orders

GET /events/{slug}/orders

List an event's orders (paged, newest first).

Requires orders.view

Path parameters

slug string required
The event's slug.

Query parameters

after string
Cursor: the ref returned as the previous page's next_after. Omit for the first page.
limit integer
Page size. Default 50, max 200. Non-positive or unparseable values fall back to the default.

Response fields

#
orders array of Order
#
orders[].ref string
Short reference printed on receipts and read out at the desk. Unique across the account.
#
orders[].legacy_ref string
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.
#
orders[].status string
The order's state. Pending orders hold stock until paid or expired.
one of pendingpaidcanceledrefunded
#
orders[].email string
The buyer's email address.
#
orders[].name string
The buyer's name.
#
orders[].amount_cents integer
Integer cents; see currency.
#
orders[].currency string
Lowercase ISO 4217 currency code.
#
orders[].created_at string (RFC 3339) or null
When the order was started; for a pending order, when checkout began.
#
orders[].paid_at string (RFC 3339) or null
Null until the order is paid.
#
orders[].refunded_at string (RFC 3339) or null
Null unless the order has been refunded.
#
orders[].items array of OrderItem
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 [].
#
orders[].items[].ticket_type string
The ticket type's name, joined in so the raw ticket_type id is never exposed.
#
orders[].items[].quantity integer
How many of this ticket type the line holds.
#
orders[].items[].unit_price_cents integer
Integer cents; see the order's currency.
#
orders[].lines array of OrderLine
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.
#
orders[].lines[].product_id integer
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).
#
orders[].lines[].product string
The product's name. A ticket line carries the live ticket type name, so it agrees with the matching items entry.
#
orders[].lines[].kind string
The product's kind: "ticket", "item" or "donation".
one of ticketitemdonation
#
orders[].lines[].quantity integer
#
orders[].lines[].unit_price_cents integer
Integer cents; see the line's currency.
#
orders[].lines[].subtotal_cents integer
Integer cents, before any tax added on top.
#
orders[].lines[].tax_cents integer
Integer cents. The tax snapshot taken when the line was written; 0 when the line carries no tax.
#
orders[].lines[].total_cents integer
Integer cents. Subtotal plus an exclusive rate's tax; the subtotal alone for an inclusive one.
#
orders[].lines[].currency string
Lowercase ISO 4217 currency code, carried per line.
#
orders[].lines[].status string
"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.
one of activeremovedrefunded
#
orders[].lines[].tier_id integer
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.
#
orders[].lines[].band_id integer
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.
#
orders[].lines[].ticket_ref string
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.
#
orders[].discounts array of OrderDiscount
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.
#
orders[].discounts[].code string
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.
#
orders[].discounts[].amount_cents integer
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.
#
orders[].discounts[].currency string
Lowercase ISO 4217 currency code.
#
orders[].price_adjustments array of OrderPriceAdjustment
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.
#
orders[].price_adjustments[].label string
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.
#
orders[].price_adjustments[].amount_cents integer
The difference against the order's lines, in integer minor units. SIGNED and added: negative took money off the order, positive added to it.
#
orders[].price_adjustments[].currency string
Lowercase ISO 4217 currency code.
#
next_after string or null
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.

Responses

  • 200A 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.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/events/annual-conf/orders \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/events/annual-conf/orders", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "orders": [
    {
      "ref": "H7K2PX",
      "legacy_ref": "",
      "status": "paid",
      "email": "ada@example.com",
      "name": "Ada Lovelace",
      "amount_cents": 29800,
      "currency": "eur",
      "created_at": "2026-05-18T14:02:11Z",
      "paid_at": "2026-05-18T14:03:07Z",
      "refunded_at": null,
      "items": [
        {
          "ticket_type": "Early Bird",
          "quantity": 2,
          "unit_price_cents": 14900
        }
      ],
      "lines": [
        {
          "product_id": 1,
          "product": "Early Bird",
          "kind": "ticket",
          "quantity": 2,
          "unit_price_cents": 14900,
          "subtotal_cents": 29800,
          "tax_cents": 0,
          "total_cents": 29800,
          "currency": "eur",
          "status": "active",
          "tier_id": 0,
          "band_id": 0,
          "ticket_ref": ""
        }
      ],
      "discounts": [
        {
          "code": "EARLYBIRD",
          "amount_cents": 2000,
          "currency": "eur"
        }
      ],
      "price_adjustments": [
        {
          "label": "Speaker discount",
          "amount_cents": -5000,
          "currency": "eur"
        }
      ]
    }
  ],
  "next_after": "example"
}

Create an order

POST /events/{slug}/orders

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.

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.

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.

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.

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.

Send 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.

Requires orders.manage

Path parameters

slug string required
The event's slug.

Query parameters

Idempotency-Key string
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.

Request body

#
items array of object required
One line per ticket type; a type may appear only once.
#
items[].ticket_type_id integer required
A ticket type id from GET /events/{slug}/ticket-types. It must belong to this event and be on sale.
#
items[].quantity integer required
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.
#
email string
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 string
Buyer name. Always optional.

Response fields

#
ref string
The order's reference — the name a human quotes, never a credential.
#
status string
"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.
one of pendingpaidcanceledrefunded
#
total_cents integer
The order total in integer cents; see currency.
#
currency string
Lowercase ISO 4217 currency code — the event's.
#
payment_url string
Absolute URL of the buyer's pay page. Present only while the order is pending. A capability URL: treat it as a secret.
#
url string
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.

Response fields

#
ref string
The order's reference — the name a human quotes, never a credential.
#
status string
"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.
one of pendingpaidcanceledrefunded
#
total_cents integer
The order total in integer cents; see currency.
#
currency string
Lowercase ISO 4217 currency code — the event's.
#
payment_url string
Absolute URL of the buyer's pay page. Present only while the order is pending. A capability URL: treat it as a secret.
#
url string
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.

Responses

  • 201The order was created — pending with payment_url when it is payable, paid with url when it is free (its tickets already exist).
  • 200A 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.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 404No such resource.
  • 409sold_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.
  • 422The 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).
  • 500Internal error.
Request
curl -X POST https://<yourslug>.go.tito.io/api/v1/events/annual-conf/orders \
  -H "Authorization: Bearer titogo_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"items":[{"ticket_type_id":1,"quantity":2}]}'
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/events/annual-conf/orders", {
  method: "POST",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
  "items": [
    {
      "ticket_type_id": 1,
      "quantity": 2
    }
  ]
}),
});
const data = await res.json();
Example response 201

The order was created — pending with payment_url when it is payable, paid with url when it is free (its tickets already exist).

{
  "ref": "H7K2PX",
  "status": "pending",
  "total_cents": 29800,
  "currency": "eur",
  "payment_url": "https://annual-conf.example/annual-conf/orders/…/pay",
  "url": "https://annual-conf.example/annual-conf/orders/…"
}
Example response 200

A replay: this Idempotency-Key already created an order, and this is that order's current state.

{
  "ref": "H7K2PX",
  "status": "pending",
  "total_cents": 29800,
  "currency": "eur",
  "payment_url": "https://annual-conf.example/annual-conf/orders/…/pay",
  "url": "https://annual-conf.example/annual-conf/orders/…"
}

Get an order

GET /orders/{ref}

Get an order by ref, account-wide (refs are globally unique).

Requires orders.view

Path parameters

ref string required
The order's ref (account-wide, not scoped to an event).

Response fields

#
ref string
Short reference printed on receipts and read out at the desk. Unique across the account.
#
legacy_ref string
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.
#
status string
The order's state. Pending orders hold stock until paid or expired.
one of pendingpaidcanceledrefunded
#
email string
The buyer's email address.
#
name string
The buyer's name.
#
amount_cents integer
Integer cents; see currency.
#
currency string
Lowercase ISO 4217 currency code.
#
created_at string (RFC 3339) or null
When the order was started; for a pending order, when checkout began.
#
paid_at string (RFC 3339) or null
Null until the order is paid.
#
refunded_at string (RFC 3339) or null
Null unless the order has been refunded.
#
items array of OrderItem
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 [].
#
items[].ticket_type string
The ticket type's name, joined in so the raw ticket_type id is never exposed.
#
items[].quantity integer
How many of this ticket type the line holds.
#
items[].unit_price_cents integer
Integer cents; see the order's currency.
#
lines array of OrderLine
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.
#
lines[].product_id integer
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).
#
lines[].product string
The product's name. A ticket line carries the live ticket type name, so it agrees with the matching items entry.
#
lines[].kind string
The product's kind: "ticket", "item" or "donation".
one of ticketitemdonation
#
lines[].quantity integer
#
lines[].unit_price_cents integer
Integer cents; see the line's currency.
#
lines[].subtotal_cents integer
Integer cents, before any tax added on top.
#
lines[].tax_cents integer
Integer cents. The tax snapshot taken when the line was written; 0 when the line carries no tax.
#
lines[].total_cents integer
Integer cents. Subtotal plus an exclusive rate's tax; the subtotal alone for an inclusive one.
#
lines[].currency string
Lowercase ISO 4217 currency code, carried per line.
#
lines[].status string
"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.
one of activeremovedrefunded
#
lines[].tier_id integer
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.
#
lines[].band_id integer
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.
#
lines[].ticket_ref string
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.
#
discounts array of OrderDiscount
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.
#
discounts[].code string
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.
#
discounts[].amount_cents integer
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.
#
discounts[].currency string
Lowercase ISO 4217 currency code.
#
price_adjustments array of OrderPriceAdjustment
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.
#
price_adjustments[].label string
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.
#
price_adjustments[].amount_cents integer
The difference against the order's lines, in integer minor units. SIGNED and added: negative took money off the order, positive added to it.
#
price_adjustments[].currency string
Lowercase ISO 4217 currency code.
#
tickets array of string
Ticket refs belonging to this order. Empty array, never null, when the order produced none (e.g. still pending).
#
answers array of Answer
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.
#
answers[].field_id integer
#
answers[].key string
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.
#
answers[].label string
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.
#
answers[].kind string
How it was asked, and so how to read value: "text", "textarea", "select", "multi_select", "checkbox", "yes_no", "number", "date", "phone" or "file".
#
answers[].value string
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.
#
item_answers array of ItemAnswers
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.
#
item_answers[].product_id integer
#
item_answers[].product string
The good's name, in the account's own language.
#
item_answers[].position integer
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".
#
item_answers[].answers array of Answer
This item's answers, in a stable order. Never empty — an item with nothing answered about it has no entry at all.
#
item_answers[].answers[].field_id integer
#
item_answers[].answers[].key string
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.
#
item_answers[].answers[].label string
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.
#
item_answers[].answers[].kind string
How it was asked, and so how to read value: "text", "textarea", "select", "multi_select", "checkbox", "yes_no", "number", "date", "phone" or "file".
#
item_answers[].answers[].value string
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.

Responses

  • 200The 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).
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 404No such resource.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/orders/H7K2PX \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/orders/H7K2PX", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "ref": "H7K2PX",
  "legacy_ref": "",
  "status": "paid",
  "email": "ada@example.com",
  "name": "Ada Lovelace",
  "amount_cents": 29800,
  "currency": "eur",
  "created_at": "2026-05-18T14:02:11Z",
  "paid_at": "2026-05-18T14:03:07Z",
  "refunded_at": null,
  "items": [
    {
      "ticket_type": "Early Bird",
      "quantity": 2,
      "unit_price_cents": 14900
    }
  ],
  "lines": [
    {
      "product_id": 1,
      "product": "Early Bird",
      "kind": "ticket",
      "quantity": 2,
      "unit_price_cents": 14900,
      "subtotal_cents": 29800,
      "tax_cents": 0,
      "total_cents": 29800,
      "currency": "eur",
      "status": "active",
      "tier_id": 0,
      "band_id": 0,
      "ticket_ref": ""
    }
  ],
  "discounts": [
    {
      "code": "EARLYBIRD",
      "amount_cents": 2000,
      "currency": "eur"
    }
  ],
  "price_adjustments": [
    {
      "label": "Speaker discount",
      "amount_cents": -5000,
      "currency": "eur"
    }
  ],
  "tickets": [
    "T9QW4M"
  ],
  "answers": [
    {
      "field_id": 3,
      "key": "dietary",
      "label": "Dietary requirements",
      "kind": "text",
      "value": "Vegetarian"
    }
  ],
  "item_answers": [
    {
      "product_id": 1,
      "product": "Early Bird",
      "position": 1,
      "answers": [
        {
          "field_id": 3,
          "key": "dietary",
          "label": "Dietary requirements",
          "kind": "text",
          "value": "Vegetarian"
        }
      ]
    }
  ]
}

Update an order's buyer

PATCH /orders/{ref}

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.

Requires orders.manage

Path parameters

ref string required
The order's ref (account-wide, not scoped to an event).

Request body

#
name string
The buyer's name on the order. Cannot be blank when sent.
#
email string
Where this order's confirmation, receipt and invoice go. Lower-cased on the way in.

Response fields

#
ref string
Short reference printed on receipts and read out at the desk. Unique across the account.
#
legacy_ref string
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.
#
status string
The order's state. Pending orders hold stock until paid or expired.
one of pendingpaidcanceledrefunded
#
email string
The buyer's email address.
#
name string
The buyer's name.
#
amount_cents integer
Integer cents; see currency.
#
currency string
Lowercase ISO 4217 currency code.
#
created_at string (RFC 3339) or null
When the order was started; for a pending order, when checkout began.
#
paid_at string (RFC 3339) or null
Null until the order is paid.
#
refunded_at string (RFC 3339) or null
Null unless the order has been refunded.
#
items array of OrderItem
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 [].
#
items[].ticket_type string
The ticket type's name, joined in so the raw ticket_type id is never exposed.
#
items[].quantity integer
How many of this ticket type the line holds.
#
items[].unit_price_cents integer
Integer cents; see the order's currency.
#
lines array of OrderLine
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.
#
lines[].product_id integer
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).
#
lines[].product string
The product's name. A ticket line carries the live ticket type name, so it agrees with the matching items entry.
#
lines[].kind string
The product's kind: "ticket", "item" or "donation".
one of ticketitemdonation
#
lines[].quantity integer
#
lines[].unit_price_cents integer
Integer cents; see the line's currency.
#
lines[].subtotal_cents integer
Integer cents, before any tax added on top.
#
lines[].tax_cents integer
Integer cents. The tax snapshot taken when the line was written; 0 when the line carries no tax.
#
lines[].total_cents integer
Integer cents. Subtotal plus an exclusive rate's tax; the subtotal alone for an inclusive one.
#
lines[].currency string
Lowercase ISO 4217 currency code, carried per line.
#
lines[].status string
"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.
one of activeremovedrefunded
#
lines[].tier_id integer
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.
#
lines[].band_id integer
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.
#
lines[].ticket_ref string
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.
#
discounts array of OrderDiscount
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.
#
discounts[].code string
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.
#
discounts[].amount_cents integer
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.
#
discounts[].currency string
Lowercase ISO 4217 currency code.
#
price_adjustments array of OrderPriceAdjustment
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.
#
price_adjustments[].label string
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.
#
price_adjustments[].amount_cents integer
The difference against the order's lines, in integer minor units. SIGNED and added: negative took money off the order, positive added to it.
#
price_adjustments[].currency string
Lowercase ISO 4217 currency code.

Responses

  • 200The order, in the same shape GET /orders/{ref} answers with.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 404No such resource.
  • 422The body was unreadable, carried an unknown field or an explicit null, blanked the name, or the email isn't an email address (invalid_body).
  • 500Internal error.
Request
curl -X PATCH https://<yourslug>.go.tito.io/api/v1/orders/H7K2PX \
  -H "Authorization: Bearer titogo_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"name":"Ada Lovelace"}'
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/orders/H7K2PX", {
  method: "PATCH",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
  "name": "Ada Lovelace"
}),
});
const data = await res.json();
Example response 200
{
  "ref": "H7K2PX",
  "legacy_ref": "",
  "status": "paid",
  "email": "ada@example.com",
  "name": "Ada Lovelace",
  "amount_cents": 29800,
  "currency": "eur",
  "created_at": "2026-05-18T14:02:11Z",
  "paid_at": "2026-05-18T14:03:07Z",
  "refunded_at": null,
  "items": [
    {
      "ticket_type": "Early Bird",
      "quantity": 2,
      "unit_price_cents": 14900
    }
  ],
  "lines": [
    {
      "product_id": 1,
      "product": "Early Bird",
      "kind": "ticket",
      "quantity": 2,
      "unit_price_cents": 14900,
      "subtotal_cents": 29800,
      "tax_cents": 0,
      "total_cents": 29800,
      "currency": "eur",
      "status": "active",
      "tier_id": 0,
      "band_id": 0,
      "ticket_ref": ""
    }
  ],
  "discounts": [
    {
      "code": "EARLYBIRD",
      "amount_cents": 2000,
      "currency": "eur"
    }
  ],
  "price_adjustments": [
    {
      "label": "Speaker discount",
      "amount_cents": -5000,
      "currency": "eur"
    }
  ]
}

Cancel an order

POST /orders/{ref}/cancel

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.

Requires orders.manage

Path parameters

ref string required
The order's ref (account-wide, not scoped to an event).

Response fields

#
canceled boolean
one of true
#
ref string
The canceled order's ref.

Responses

  • 200The order is canceled and its stock released.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 404No such resource.
  • 409The order is not pending — it is paid, refunded, or already canceled (not_pending).
  • 500Internal error.
  • 502The 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.
Request
curl -X POST https://<yourslug>.go.tito.io/api/v1/orders/H7K2PX/cancel \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/orders/H7K2PX/cancel", {
  method: "POST",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "canceled": true,
  "ref": "H7K2PX"
}

Mark an order paid

POST /orders/{ref}/mark-paid

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.

Requires orders.manage

Path parameters

ref string required
The order's ref (account-wide, not scoped to an event).

Response fields

#
paid boolean
one of true
#
ref string
The paid order's ref.

Responses

  • 200The order is paid and its tickets are issued.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 404No such resource.
  • 409The order is not pending — it is paid, refunded, or canceled (not_pending).
  • 500Internal error.
Request
curl -X POST https://<yourslug>.go.tito.io/api/v1/orders/H7K2PX/mark-paid \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/orders/H7K2PX/mark-paid", {
  method: "POST",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "paid": true,
  "ref": "H7K2PX"
}

Refund an order

POST /orders/{ref}/refund

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.

Requires refund

Path parameters

ref string required
The order's ref (account-wide, not scoped to an event).

Response fields

#
refunded boolean
one of true
#
already boolean
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 integer
Integer cents this call refunded (0 for a free/zero-cent ticket). Omitted when already is true.

Responses

  • 200The order is refunded — either just now (with refunded_cents) or already (already: true), which is a success, not an error.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 404No such resource.
  • 422The 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).
  • 500Internal error.
  • 502The 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.
  • 503Payments are not configured on this account, so no refund can be sent (payments_unavailable).
Request
curl -X POST https://<yourslug>.go.tito.io/api/v1/orders/H7K2PX/refund \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/orders/H7K2PX/refund", {
  method: "POST",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "refunded": true,
  "already": true,
  "refunded_cents": 14900
}

Tickets

A ticket is one admission produced by a paid order, held by an attendee who may differ from the buyer.

List tickets

GET /events/{slug}/tickets

List an event's tickets/attendees (paged, newest first).

Requires attendees.view

Path parameters

slug string required
The event's slug.

Query parameters

after string
Cursor: the ref returned as the previous page's next_after. Omit for the first page.
limit integer
Page size. Default 50, max 200. Non-positive or unparseable values fall back to the default.

Response fields

#
tickets array of Ticket
#
tickets[].ref string
Short reference printed on the ticket. Unique across the account.
#
tickets[].legacy_ref string
The ref this ticket carried on the platform it was migrated from; empty for tickets sold on Tito. See the same field on Order.
#
tickets[].status string
"valid", or "void" once the ticket has been cancelled.
one of validvoid
#
tickets[].ticket_type string
The ticket type's name.
#
tickets[].holder_name string
The attendee's name, as assigned.
#
tickets[].holder_email string
The attendee's email address.
#
tickets[].order_ref string
The ref of the order this ticket belongs to.
#
tickets[].created_at string (RFC 3339) or null
When the ticket was issued.
#
tickets[].answers array of Answer
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.

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.

#
tickets[].answers[].field_id integer
#
tickets[].answers[].key string
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.
#
tickets[].answers[].label string
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.
#
tickets[].answers[].kind string
How it was asked, and so how to read value: "text", "textarea", "select", "multi_select", "checkbox", "yes_no", "number", "date", "phone" or "file".
#
tickets[].answers[].value string
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.
#
tickets[].sync array of SyncStanding
Where this ticket stands with each connected service. Always an array, never null — a ticket never sent to any service serializes as [].
#
tickets[].sync[].service string
The connected service's id, e.g. "brella".
#
tickets[].sync[].remote_id string
The id the service knows this ticket by. Empty when nothing has synced yet.
#
tickets[].sync[].state string
one of syncedfaileddeleted
#
tickets[].sync[].synced_at string (RFC 3339) or null
When the last successful sync completed; null when it has never succeeded.
#
tickets[].sync[].error string
The service's own words for its last failure, verbatim. Empty when the last attempt succeeded.
#
next_after string or null
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.

Responses

  • 200A page of tickets. tickets is always [], never null, when the event has none.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/events/annual-conf/tickets \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/events/annual-conf/tickets", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "tickets": [
    {
      "ref": "T9QW4M",
      "legacy_ref": "",
      "status": "valid",
      "ticket_type": "Early Bird",
      "holder_name": "Ada Lovelace",
      "holder_email": "ada@example.com",
      "order_ref": "H7K2PX",
      "created_at": "2026-05-18T14:03:07Z",
      "answers": [
        {
          "field_id": 3,
          "key": "dietary",
          "label": "Dietary requirements",
          "kind": "text",
          "value": "Vegetarian"
        }
      ],
      "sync": [
        {
          "service": "example",
          "remote_id": "example",
          "state": "synced",
          "synced_at": "2026-10-14T09:00:00Z",
          "error": "example"
        }
      ]
    }
  ],
  "next_after": "example"
}

Update a ticket's holder

PATCH /tickets/{ref}

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.

Requires orders.manage

Path parameters

ref string required
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.

Request body

#
holder_name string
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 string
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.

Response fields

#
ref string
Short reference printed on the ticket. Unique across the account.
#
legacy_ref string
The ref this ticket carried on the platform it was migrated from; empty for tickets sold on Tito. See the same field on Order.
#
status string
"valid", or "void" once the ticket has been cancelled.
one of validvoid
#
ticket_type string
The ticket type's name.
#
holder_name string
The attendee's name, as assigned.
#
holder_email string
The attendee's email address.
#
order_ref string
The ref of the order this ticket belongs to.
#
created_at string (RFC 3339) or null
When the ticket was issued.
#
answers array of Answer
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.

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.

#
answers[].field_id integer
#
answers[].key string
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.
#
answers[].label string
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.
#
answers[].kind string
How it was asked, and so how to read value: "text", "textarea", "select", "multi_select", "checkbox", "yes_no", "number", "date", "phone" or "file".
#
answers[].value string
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.
#
sync array of SyncStanding
Where this ticket stands with each connected service. Always an array, never null — a ticket never sent to any service serializes as [].
#
sync[].service string
The connected service's id, e.g. "brella".
#
sync[].remote_id string
The id the service knows this ticket by. Empty when nothing has synced yet.
#
sync[].state string
one of syncedfaileddeleted
#
sync[].synced_at string (RFC 3339) or null
When the last successful sync completed; null when it has never succeeded.
#
sync[].error string
The service's own words for its last failure, verbatim. Empty when the last attempt succeeded.

Responses

  • 200The updated ticket, in the same shape the ticket list returns. The ticket's token never appears here, rotated or not.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 404No such resource.
  • 422The 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).
  • 500Internal error.
Request
curl -X PATCH https://<yourslug>.go.tito.io/api/v1/tickets/H7K2PX \
  -H "Authorization: Bearer titogo_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"holder_name":"Ada Lovelace"}'
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/tickets/H7K2PX", {
  method: "PATCH",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
  "holder_name": "Ada Lovelace"
}),
});
const data = await res.json();
Example response 200
{
  "ref": "T9QW4M",
  "legacy_ref": "",
  "status": "valid",
  "ticket_type": "Early Bird",
  "holder_name": "Ada Lovelace",
  "holder_email": "ada@example.com",
  "order_ref": "H7K2PX",
  "created_at": "2026-05-18T14:03:07Z",
  "answers": [
    {
      "field_id": 3,
      "key": "dietary",
      "label": "Dietary requirements",
      "kind": "text",
      "value": "Vegetarian"
    }
  ],
  "sync": [
    {
      "service": "example",
      "remote_id": "example",
      "state": "synced",
      "synced_at": "2026-10-14T09:00:00Z",
      "error": "example"
    }
  ]
}

Refund a ticket

POST /tickets/{ref}/refund

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.

Requires refund

Path parameters

ref string required
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.

Response fields

#
refunded boolean
one of true
#
already boolean
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 integer
Integer cents this call refunded (0 for a free/zero-cent ticket). Omitted when already is true.

Responses

  • 200The ticket is refunded and void — either just now (with refunded_cents, which is 0 for a free ticket) or already (already: true).
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 404No such resource.
  • 422The 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).
  • 500Internal error.
  • 502The 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.
Request
curl -X POST https://<yourslug>.go.tito.io/api/v1/tickets/H7K2PX/refund \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/tickets/H7K2PX/refund", {
  method: "POST",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "refunded": true,
  "already": true,
  "refunded_cents": 14900
}

Check-in

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.

List check-in lists

GET /events/{slug}/checkin-lists

List an event's check-in lists.

Requires checkin.view

Path parameters

slug string required
The event's slug.

Response fields

#
checkin_lists array of CheckinListSummary
#
checkin_lists[].id integer
The list's row id; check-in lists carry no ref (admin-only, never a public surface).
#
checkin_lists[].name string
The list's name, e.g. "Main entrance".
#
checkin_lists[].created_at string (RFC 3339)
Integer Unix timestamp in storage, rendered as RFC 3339 UTC. Not nullable — a list always has a creation time (NOT NULL column).
#
checkin_lists[].checked_in_count integer
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.

Responses

  • 200All check-in lists for the event. checkin_lists is always [], never null, when there are none.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/events/annual-conf/checkin-lists \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/events/annual-conf/checkin-lists", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "checkin_lists": [
    {
      "id": 1,
      "name": "Main entrance",
      "created_at": "2026-09-01T08:00:00Z",
      "checked_in_count": 412
    }
  ]
}

Get a check-in list

GET /events/{slug}/checkin-lists/{id}

Get a check-in list's state (who has been checked in).

Requires checkin.view

Path parameters

slug string required
The event's slug.
id integer required
The check-in list's numeric row id (check-in lists carry no ref — admin-only, never a public surface).

Response fields

#
id integer
#
name string
#
created_at string (RFC 3339)
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 integer
#
checkins array of Checkin
#
checkins[].ticket_ref string
The ticket that was checked in.
#
checkins[].checked_in_at string (RFC 3339) or null
When it was scanned.

Responses

  • 200The 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.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 404No such resource.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/events/annual-conf/checkin-lists/1 \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/events/annual-conf/checkin-lists/1", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "id": 1,
  "name": "Ada Lovelace",
  "created_at": "2026-10-14T09:00:00Z",
  "checked_in_count": 42,
  "checkins": [
    {
      "ticket_ref": "T9QW4M",
      "checked_in_at": "2026-10-14T08:41:19Z"
    }
  ]
}

Check a ticket in

POST /events/{slug}/checkin-lists/{id}/checkins

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.

Requires checkin.manage

Path parameters

slug string required
The event's slug.
id integer required
The check-in list's numeric row id (check-in lists carry no ref — admin-only, never a public surface).

Request body

#
ticket string required
The ticket's ref.

Response fields

#
checked_in boolean
one of true
#
already boolean
Present and true only when the ticket was already stamped on this list before this call.

Responses

  • 200The 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.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 404No such resource.
  • 422The body is not {"ticket":"<ref>"} (invalid_body), or the ticket is void or not eligible for this list's entitlement (not_eligible).
  • 500Internal error.
Request
curl -X POST https://<yourslug>.go.tito.io/api/v1/events/annual-conf/checkin-lists/1/checkins \
  -H "Authorization: Bearer titogo_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"ticket":"T9QW4M"}'
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/events/annual-conf/checkin-lists/1/checkins", {
  method: "POST",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
  "ticket": "T9QW4M"
}),
});
const data = await res.json();
Example response 200
{
  "checked_in": true,
  "already": true
}

Undo a check-in

DELETE /events/{slug}/checkin-lists/{id}/checkins/{ref}

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.

Requires checkin.manage

Path parameters

slug string required
The event's slug.
id integer required
The check-in list's numeric row id.
ref string required
The ticket's ref.

Response fields

#
checked_in boolean
one of false

Responses

  • 200The ticket is not checked in on this list (either it just got undone, or there was nothing to undo).
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 404No such resource.
  • 500Internal error.
Request
curl -X DELETE https://<yourslug>.go.tito.io/api/v1/events/annual-conf/checkin-lists/1/checkins/H7K2PX \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/events/annual-conf/checkin-lists/1/checkins/H7K2PX", {
  method: "DELETE",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "checked_in": false
}

Reports

Income, availability and grouped order reports for one event, the same numbers the admin's dashboards show.

Availability report

GET /events/{slug}/availability

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.

Pending 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.

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.

Kinds 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.

Requires events.view

Path parameters

slug string required
The event's slug.

Response fields

#
rows array of object
#
rows[].kind string
Which group the row sits in.
one of ticketssessionsgoodsdonations
#
rows[].name string
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–11:30", or both dates where the block spans more than one day), formatted in English like the rest of this response.
#
rows[].capacity integer or null
The cap, or null when there is no limit.
#
rows[].sold integer
Units on paid orders.
#
rows[].pending integer
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.
#
rows[].taken integer
sold plus pending: everything currently counting against the cap.
#
rows[].via_portals integer
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.
#
rows[].portals array of object
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.
#
rows[].portals[].portal string
The portal's own name.
#
rows[].portals[].units integer
Units of this offering that went out through that portal.
#
rows[].remaining integer or null
capacity less taken, floored at 0; null when there is no limit. A 0 here means sold out.
#
rows[].status string
sold out, off sale, or empty when the thing is simply on sale. Off sale wins over sold out.
#
listed integer
How many rows the report carries in total.
#
sold integer
Units sold across every row.
#
pending integer
Units under a live unpaid hold across every row.
#
sold_out integer
How many rows have a cap and nothing left.

Responses

  • 200Every sellable thing, plus the totals the report opens with. rows is always [], never null.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 404No such resource.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/events/annual-conf/availability \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/events/annual-conf/availability", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "rows": [
    {
      "kind": "tickets",
      "name": "Ada Lovelace",
      "capacity": 500,
      "sold": 42,
      "pending": 1,
      "taken": 1,
      "via_portals": 1,
      "portals": [
        {
          "portal": "example",
          "units": 1
        }
      ],
      "remaining": 1,
      "status": "ticket"
    }
  ],
  "listed": 1,
  "sold": 42,
  "pending": 1,
  "sold_out": 42
}

Income report

GET /events/{slug}/income

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.

Then 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.

A 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.

This 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.

Months bucket in the event's own timezone (reported as timezone), and the month rows decompose the totals exactly.

Requires events.view

Path parameters

slug string required
The event's slug.

Query parameters

range string
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.
from string
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.
to string
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.
on string
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.

Response fields

#
range string
The range the report ran with, after clamping.
#
from string
The first day of the window that ran, as YYYY-MM-DD. Always present, empty unless the range is custom.
#
to string
The last day of the window that ran, inclusive, as YYYY-MM-DD. Always present, empty unless the range is custom.
#
on string
The stamp paid orders were dated by: paid or started.
#
timezone string
The event's IANA timezone — the zone month buckets are cut in.
#
currency string
The event's currency. Orders taken in another currency are still summed into these amounts.
#
orders integer
Paid orders in the window — the ones every figure below except the pending pair is built from.
#
gross_cents integer
Before anything comes off: what those orders charged plus the discount that came off them.
#
discount_cents integer
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 array of string
Every code redeemed on those orders, sorted, each once.
#
refund_cents integer
Succeeded refunds against those orders, as a positive amount.
#
refunded_orders integer
How many of those orders were refunded in full or in part.
#
collected_cents integer
gross_cents minus discount_cents minus refund_cents — what was actually collected from buyers.
#
tax_cents integer
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 integer
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 integer
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 integer
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 integer
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 integer
Money committed but not landed: orders still pending, counted from the day they started. In none of the figures above.
#
pending_orders integer
How many orders pending_cents covers.
#
months array of IncomeMonth
#
months[].key string
The month's machine-stable id, 2026-08, cut in the event's own timezone.
#
months[].label string
The same month worded for a human. Always English here, like every other machine-facing string in this API.
#
months[].gross_cents integer
Before anything comes off: what the orders charged plus the discount that came off them.
#
months[].deductions_cents integer
Discount codes plus refunds, as one positive amount.
#
months[].tax_cents integer
Tax to remit, as a positive amount.
#
months[].fees_cents integer
What the payments cost, as a positive amount: the provider's cut and Tito's together. The ledger's own totals split the two.
#
months[].net_cents integer
gross_cents minus deductions_cents minus tax_cents minus fees_cents.

Responses

  • 200The 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.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 404No such resource.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/events/annual-conf/income \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/events/annual-conf/income", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "range": "example",
  "from": "example",
  "to": "example",
  "on": "example",
  "timezone": "Europe/Dublin",
  "currency": "eur",
  "orders": 1,
  "gross_cents": 14900,
  "discount_cents": 14900,
  "discount_codes": [
    "EARLYBIRD"
  ],
  "refund_cents": 14900,
  "refunded_orders": 1,
  "collected_cents": 14900,
  "tax_cents": 14900,
  "fees_cents": 14900,
  "platform_fee_cents": 14900,
  "unpriced_payments": 1,
  "net_cents": 14900,
  "pending_cents": 14900,
  "pending_orders": 1,
  "months": [
    {
      "key": "2026-05",
      "label": "May 2026",
      "gross_cents": 909000,
      "deductions_cents": 34900,
      "tax_cents": 0,
      "fees_cents": 18180,
      "net_cents": 855920
    }
  ]
}

Orders report

GET /events/{slug}/report

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.

Requires events.view

Path parameters

slug string required
The event's slug.

Query parameters

source string
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.
group string
The axis rows are grouped on. One of day, week (weeks start Monday), month, audience, status, code (discount codes). Anything else is day.
range string
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.
from string
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.
to string
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.
on string
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.
status string
Keep only orders in this status. One of paid, pending, refunded, canceled; anything else (including absent) means any status.
audience string
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.
code string
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.
currency string
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.
paid string
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.

Response fields

#
source string
The record set reported on. Always orders today.
#
group string
The grouping the report ran with, after clamping.
#
range string
The range the report ran with, after clamping.
#
from string
The first day of the window that ran, as YYYY-MM-DD. Always present, empty unless the range is custom.
#
to string
The last day of the window that ran, inclusive, as YYYY-MM-DD. Always present, empty unless the range is custom.
#
on string
The stamp orders were dated by: paid or started.
#
status string
The status filter that ran; empty means any status.
#
audience string
The audience filter that ran; empty means any audience, - means orders that came in off the event's own page.
#
code string
The discount-code filter that ran; empty means any, - means orders with no code.
#
paid boolean
Whether the report was narrowed to orders that took money.
#
timezone string
The event's IANA timezone — the zone day, week and month buckets are cut in.
#
currency string
The event's currency, repeated on every row and on the totals.
#
rows array of ReportRow
#
rows[].key string
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.
#
rows[].label string
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.
#
rows[].orders integer
Orders in this bucket.
#
rows[].tickets integer
Tickets those orders carry.
#
rows[].gross_cents integer
What changed hands, before refunds — the orders' own amounts, so discounts are already applied and non-ticket items are included.
#
rows[].discount_cents integer
Discount taken off those orders, as a positive amount.
#
rows[].refund_cents integer
Succeeded refunds against those orders, as a positive amount.
#
rows[].tax_cents integer
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.
#
rows[].net_cents integer
gross_cents minus refund_cents.
#
rows[].currency string
The event's currency. Orders taken in another currency are still summed into these amounts — group by currency to split them apart.
#
totals object
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.
#
totals.key string
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.
#
totals.label string
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.
#
totals.orders integer
Orders in this bucket.
#
totals.tickets integer
Tickets those orders carry.
#
totals.gross_cents integer
What changed hands, before refunds — the orders' own amounts, so discounts are already applied and non-ticket items are included.
#
totals.discount_cents integer
Discount taken off those orders, as a positive amount.
#
totals.refund_cents integer
Succeeded refunds against those orders, as a positive amount.
#
totals.tax_cents integer
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.
#
totals.net_cents integer
gross_cents minus refund_cents.
#
totals.currency string
The event's currency. Orders taken in another currency are still summed into these amounts — group by currency to split them apart.

Responses

  • 200The 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.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 404No such resource.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/events/annual-conf/report \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/events/annual-conf/report", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "source": "example",
  "group": "example",
  "range": "example",
  "from": "example",
  "to": "example",
  "on": "example",
  "status": "ticket",
  "audience": "example",
  "code": "EARLYBIRD",
  "paid": true,
  "timezone": "Europe/Dublin",
  "currency": "eur",
  "rows": [
    {
      "key": "2026-W20",
      "label": "Week of 11 May",
      "orders": 38,
      "tickets": 61,
      "gross_cents": 909000,
      "discount_cents": 20000,
      "refund_cents": 14900,
      "tax_cents": 0,
      "net_cents": 874100,
      "currency": "eur"
    }
  ],
  "totals": {
    "key": "2026-W20",
    "label": "Week of 11 May",
    "orders": 38,
    "tickets": 61,
    "gross_cents": 909000,
    "discount_cents": 20000,
    "refund_cents": 14900,
    "tax_cents": 0,
    "net_cents": 874100,
    "currency": "eur"
  }
}

List saved reports

GET /events/{slug}/reports

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.

Requires events.view

Path parameters

slug string required
The event's slug.

Response fields

#
reports array of SavedReport
#
reports[].id integer
The report's row id; saved reports carry no ref (admin-only, never a public surface).
#
reports[].name string
What the account called it. Up to 80 characters, never empty.
#
reports[].source string
Which records the report counts. orders is the only source today.
#
reports[].query string
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).
#
reports[].created_at string (RFC 3339)
Integer Unix timestamp in storage, rendered as RFC 3339 UTC. Not nullable (NOT NULL column).
#
reports[].updated_at string (RFC 3339)
When the name or the definition last changed. Equals created_at on a report nobody has edited.

Responses

  • 200The event's saved reports, oldest first. reports is always [], never null, when there are none.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/events/annual-conf/reports \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/events/annual-conf/reports", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "reports": [
    {
      "id": 1,
      "name": "Sales by week",
      "source": "orders",
      "query": "group=week\u0026range=all",
      "created_at": "2026-05-01T09:00:00Z",
      "updated_at": "2026-05-01T09:00:00Z"
    }
  ]
}

Widgets

The embeddable ticket lists an organizer pastes into their own site, and the domains they have been seen on.

List widgets

GET /events/{slug}/widgets

List an event's widgets (the ticket lists an organiser pastes into their own website).

Requires events.view

Path parameters

slug string required
The event's slug.

Response fields

#
widgets array of WidgetSummary
#
widgets[].id integer
#
widgets[].slug string
What the pasted markup names. Unique across the ACCOUNT, not per event, so the snippet can carry the widget's name and nothing else.
#
widgets[].name string
The organiser's own label for the widget. Never shown publicly.
#
widgets[].shape string
Lives on the record, never in the pasted markup — changing it does not change the organiser's website.
one of listbutton
#
widgets[].secured_line boolean
Whether the widget shows the “Secured by Tito” line. On by default, switchable.
#
widgets[].ticket_type_ids array of integer
The ticket types this widget lists, in its own order. Empty means it lists everything on sale.
#
widgets[].installed boolean
Whether the widget has ever rendered on an approved website. Once true it never returns to false.
#
widgets[].installed_at string (RFC 3339) or null
#
widgets[].closed boolean
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.
#
widgets[].closed_at string (RFC 3339) or null
#
widgets[].created_at string (RFC 3339)

Responses

  • 200Every 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.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 404No such resource.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/events/annual-conf/widgets \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/events/annual-conf/widgets", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "widgets": [
    {
      "id": 1,
      "slug": "main",
      "name": "Homepage list",
      "shape": "list",
      "secured_line": false,
      "ticket_type_ids": [
        1
      ],
      "installed": true,
      "installed_at": "2026-04-10T10:00:00Z",
      "closed": false,
      "closed_at": null,
      "created_at": "2026-04-01T09:00:00Z"
    }
  ]
}

List widget domains

GET /widget-domains

List the websites the account's widgets have been seen on, and their standing.

Requires events.view

Response fields

#
widget_domains array of WidgetDomain
#
widget_domains[].id integer
#
widget_domains[].domain string
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.
#
widget_domains[].status string
pending = seen, nobody has decided yet. Only approved renders.
one of pendingapprovedblocked
#
widget_domains[].sightings integer
#
widget_domains[].first_seen_at string (RFC 3339)
#
widget_domains[].last_seen_at string (RFC 3339)

Responses

  • 200Account-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.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/widget-domains \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/widget-domains", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "widget_domains": [
    {
      "id": 1,
      "domain": "annualconf.example",
      "status": "approved",
      "sightings": 318,
      "first_seen_at": "2026-04-10T10:00:00Z",
      "last_seen_at": "2026-08-27T18:12:00Z"
    }
  ]
}

Webhooks

Outbound webhook endpoints and their delivery log. Endpoints are managed in the admin; this is the read side.

List webhook endpoints

GET /webhooks

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.

The outbound contract, in full — everything a receiver needs to verify and parse a delivery:

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 ... }}.

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.

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.

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.

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.

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).

Requires settings.manage

Response fields

#
webhooks array of WebhookEndpoint
#
webhooks[].id integer
#
webhooks[].url string
The receiver URL deliveries POST to. Always https.
#
webhooks[].event_types array of string
The event types this endpoint is subscribed to — a non-empty subset of the fixed v1 vocabulary.
#
webhooks[].enabled boolean
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).
#
webhooks[].consecutive_failures integer
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.
#
webhooks[].created_at string (RFC 3339)
#
webhooks[].disabled_at string (RFC 3339) or null
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.

Responses

  • 200The account's webhook endpoints, newest first.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/webhooks \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/webhooks", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "webhooks": [
    {
      "id": 1,
      "url": "https://example.com/hooks/tito",
      "event_types": [
        "order.created"
      ],
      "enabled": true,
      "consecutive_failures": 0,
      "created_at": "2026-04-01T09:00:00Z",
      "disabled_at": null
    }
  ]
}

List deliveries

GET /webhooks/{id}/deliveries

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.

Requires settings.manage

Path parameters

id integer required
The webhook endpoint's numeric row id.

Response fields

#
deliveries array of WebhookDelivery
#
deliveries[].id integer
Also the value sent as the Tito-Webhook-Delivery header on the wire.
#
deliveries[].event_type string
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.
#
deliveries[].status string
"pending" is still retrying (or awaiting its first attempt); "succeeded" got a 2xx response; "failed" exhausted the 6-attempt retry schedule without one.
one of pendingsucceededfailed
#
deliveries[].attempts integer
Delivery attempts made so far (0 before the first is sent).
#
deliveries[].next_attempt_at string (RFC 3339)
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".
#
deliveries[].last_status_code integer
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.
#
deliveries[].last_error string
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.
#
deliveries[].created_at string (RFC 3339)
#
deliveries[].delivered_at string (RFC 3339) or null
Null until this delivery succeeds; set once, on the attempt that got a 2xx.

Responses

  • 200Up to the 50 most recent delivery attempts for this endpoint.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 404No such resource.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/webhooks/1/deliveries \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/webhooks/1/deliveries", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "deliveries": [
    {
      "id": 1,
      "event_type": "order.paid",
      "status": "succeeded",
      "attempts": 1,
      "next_attempt_at": "",
      "last_status_code": 200,
      "last_error": "",
      "created_at": "2026-05-18T14:03:08Z",
      "delivered_at": "2026-05-18T14:03:09Z"
    }
  ]
}

Workflows

Automations that run when something happens in the account, and the runs they produced.

List workflows

GET /workflows

Requires settings.manage

Response fields

#
workflows array of Workflow
#
workflows[].id integer
#
workflows[].name string
#
workflows[].enabled boolean
#
workflows[].all_events boolean
Whether this workflow fires for every event, including ones created later (the workflow_subscriptions event_id=0 sentinel). When true, events is always [].
#
workflows[].created_at string (RFC 3339) or null
#
workflows[].updated_at string (RFC 3339) or null
#
workflows[].events array of string
Slugs of the events subscribed to this workflow. Always an array, never null, when the workflow has none.
#
workflows[].definition object
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.
#
workflows[].definition_raw string
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.

Responses

  • 200Every workflow, without its definition. workflows is always [], never null, when the account has none.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/workflows \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/workflows", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "workflows": [
    {
      "id": 1,
      "name": "Thank-you email",
      "enabled": true,
      "all_events": true,
      "created_at": "2026-04-01T09:00:00Z",
      "updated_at": "2026-04-01T09:00:00Z",
      "events": [
        "example"
      ],
      "definition": {},
      "definition_raw": "example"
    }
  ]
}

Get a workflow

GET /workflows/{id}

Get a workflow by id, including its definition.

Requires settings.manage

Path parameters

id integer required
The workflow's numeric row id.

Response fields

#
id integer
#
name string
#
enabled boolean
#
all_events boolean
Whether this workflow fires for every event, including ones created later (the workflow_subscriptions event_id=0 sentinel). When true, events is always [].
#
created_at string (RFC 3339) or null
#
updated_at string (RFC 3339) or null
#
events array of string
Slugs of the events subscribed to this workflow. Always an array, never null, when the workflow has none.
#
definition object
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.
#
definition_raw string
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.

Responses

  • 200The 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.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 404No such resource.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/workflows/1 \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/workflows/1", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "id": 1,
  "name": "Thank-you email",
  "enabled": true,
  "all_events": true,
  "created_at": "2026-04-01T09:00:00Z",
  "updated_at": "2026-04-01T09:00:00Z",
  "events": [
    "example"
  ],
  "definition": {},
  "definition_raw": "example"
}

List runs

GET /workflows/{id}/runs

List a workflow's runs (paged, newest first).

Requires settings.manage

Path parameters

id integer required
The workflow's numeric row id.

Query parameters

after integer
Cursor: the run id returned as the previous page's next_after. Omit for the first page.
limit integer
Page size. Default 50, max 200. Non-positive or unparseable values fall back to the default.

Response fields

#
runs array of WorkflowRun
#
runs[].id integer
#
runs[].trigger string
The event that fired this run, e.g. "order.paid".
#
runs[].subject_type string
e.g. "order", "ticket".
#
runs[].subject_id integer
The subject's row id.
#
runs[].status string
"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".
one of pendingrunningcompletedfailed
#
runs[].attempt integer
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.
#
runs[].created_at string (RFC 3339) or null
#
runs[].started_at string (RFC 3339) or null
Null until the run starts.
#
runs[].finished_at string (RFC 3339) or null
Null until the run finishes.
#
runs[].log array of any
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.
#
next_after integer or null
Run id to pass as ?after= for the next page; null when there are no further pages.

Responses

  • 200A page of runs, newest first. runs is always [], never null, when the workflow has none.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 404No such resource.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/workflows/1/runs \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/workflows/1/runs", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "runs": [
    {
      "id": 1,
      "trigger": "order.paid",
      "subject_type": "order",
      "subject_id": 1,
      "status": "completed",
      "attempt": 1,
      "created_at": "2026-05-18T14:03:08Z",
      "started_at": "2026-05-18T14:03:08Z",
      "finished_at": "2026-05-18T14:03:09Z",
      "log": [
        null
      ]
    }
  ],
  "next_after": 1
}

Get a run

GET /workflows/{id}/runs/{runID}

Get one run, including its step log.

Requires settings.manage

Path parameters

id integer required
The workflow's numeric row id.
runID integer required
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).

Response fields

#
id integer
#
trigger string
The event that fired this run, e.g. "order.paid".
#
subject_type string
e.g. "order", "ticket".
#
subject_id integer
The subject's row id.
#
status string
"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".
one of pendingrunningcompletedfailed
#
attempt integer
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.
#
created_at string (RFC 3339) or null
#
started_at string (RFC 3339) or null
Null until the run starts.
#
finished_at string (RFC 3339) or null
Null until the run finishes.
#
log array of any
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.

Responses

  • 200The run plus its step log (log, present only here).
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 404No such resource.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/workflows/1/runs/1 \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/workflows/1/runs/1", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();
Example response 200
{
  "id": 1,
  "trigger": "order.paid",
  "subject_type": "order",
  "subject_id": 1,
  "status": "completed",
  "attempt": 1,
  "created_at": "2026-05-18T14:03:08Z",
  "started_at": "2026-05-18T14:03:08Z",
  "finished_at": "2026-05-18T14:03:09Z",
  "log": [
    null
  ]
}

Export

The whole account as one SQLite file: the escape hatch for BI, ad-hoc SQL, and portability.

Export the database

GET /export/db

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.

Requires export.pii

Responses

  • 200The account's SQLite database file.
  • 401No key, an unknown key, or a revoked key.
  • 403The key does not carry the required capability.
  • 500Internal error.
Request
curl https://<yourslug>.go.tito.io/api/v1/export/db \
  -H "Authorization: Bearer titogo_sk_your_key"
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/export/db", {
  method: "GET",
  headers: {
    Authorization: "Bearer titogo_sk_your_key",
  },
});
const data = await res.json();

Meta

The contract itself.

OpenAPI document

GET /openapi.json

Public: it's the contract, not data. No Authorization header required.

No key required

Responses

  • 200This OpenAPI 3.1 document.
Request
curl https://<yourslug>.go.tito.io/api/v1/openapi.json
// Node 18+, Deno, Bun, or a browser on the same origin
const res = await fetch("https://<yourslug>.go.tito.io/api/v1/openapi.json", {
  method: "GET",
  headers: {
  },
});
const data = await res.json();
Example response 200
{}

Objects

The shapes the endpoints above return, one attribute at a time.

The Error object

The one error shape returned by every route.

Attributes

#
error object
#
error.code string
Machine-readable error code, e.g. "not_found", "missing_key", "unknown_key", "missing_capability", "internal".
#
error.message string
Human-readable, deliberately English (machine/API error strings are outside the i18n scope).
The Error object
{
  "error": {
    "code": "not_found",
    "message": "no such event"
  }
}

The RefundResult object

What a refund endpoint answers. refunded is always true on a 200 — a refusal is an error response, never a false here.

Attributes

#
refunded boolean
one of true
#
already boolean
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 integer
Integer cents this call refunded (0 for a free/zero-cent ticket). Omitted when already is true.
The RefundResult object
{
  "refunded": true,
  "already": true,
  "refunded_cents": 14900
}

The CancelResult object

What the cancel endpoint answers on a 200 — always a fresh cancellation; a non-pending order is an error response, never a false here.

Attributes

#
canceled boolean
one of true
#
ref string
The canceled order's ref.
The CancelResult object
{
  "canceled": true,
  "ref": "H7K2PX"
}

The MarkPaidResult object

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.

Attributes

#
paid boolean
one of true
#
ref string
The paid order's ref.
The MarkPaidResult object
{
  "paid": true,
  "ref": "H7K2PX"
}

The OrderBuyerPatch object

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.

Attributes

#
name string
The buyer's name on the order. Cannot be blank when sent.
#
email string
Where this order's confirmation, receipt and invoice go. Lower-cased on the way in.
The OrderBuyerPatch object
{
  "name": "Ada Lovelace",
  "email": "ada@example.com",
  "revoke_link": true
}

The Account object

Attributes

#
slug string
The account's own URL identifier.
#
name string
Display name.
#
region string
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.
The Account object
{
  "slug": "acme",
  "name": "Acme Events",
  "region": "uk"
}

The Event object

An event: something you sell tickets to. Addressed everywhere by its slug.

Attributes

#
slug string
URL identifier, unique within the account. Appears in the public event URL.
#
path string
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.
#
name string
Display name.
#
description string
Organizer-written description, plain text.
#
venue string
Where it happens, as entered by the organizer.
#
venue_address string
Organizer-entered address text, in the event's default locale. Empty when no structured venue is set.
#
venue_lat string
Decimal latitude of the venue's map pin, as a string. Empty when the event has no pin.
#
venue_lng string
Decimal longitude of the venue's map pin, as a string. Empty when the event has no pin.
#
venue_place_id string
Google's opaque place id for the venue, if the address was chosen from the picker. Empty otherwise.
#
map_provider string
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.
one of googleapple
#
starts_at string (RFC 3339) or null
Null when the event has no start time set (stored as Unix 0).
#
timezone string
IANA zone the event is scheduled in, e.g. "Europe/Dublin".
#
currency string
Lowercase ISO 4217 currency code, e.g. "eur".
#
currencies array of string
Every currency the event sells in: currency first, then the extra currencies the organizer priced. A one-currency event lists just currency.
#
currency_assignment string
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.
one of switcherlocalelink
#
created_at string (RFC 3339) or null
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).
#
draft boolean
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.
#
secret boolean
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".
#
no_index boolean
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.
#
series object
The series this event belongs to, absent when it is in none. An event belongs to at most one.
#
series.slug string
#
series.name string
#
fields object
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 array of EventSection
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.
#
sections[].id integer
#
sections[].name string
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.
#
sections[].position integer
Zero-based place in the page, top to bottom. The list is already returned in this order.
The Event object
{
  "slug": "annual-conf",
  "path": "annual-conf",
  "name": "Annual Conference 2026",
  "description": "Two days of talks, workshops and hallway conversations.",
  "venue": "The Round Room, Dublin",
  "venue_address": "Rotunda, Parnell Square, Dublin 1",
  "venue_lat": "53.3529",
  "venue_lng": "-6.2634",
  "venue_place_id": "",
  "map_provider": "google",
  "starts_at": "2026-10-14T09:00:00Z",
  "timezone": "Europe/Dublin",
  "currency": "eur",
  "currencies": [
    "eur",
    "gbp"
  ],
  "currency_assignment": "switcher",
  "created_at": "2026-03-02T11:20:41Z",
  "draft": false,
  "secret": false,
  "no_index": false,
  "series": {
    "slug": "annual-conf",
    "name": "Ada Lovelace"
  },
  "fields": {},
  "sections": [
    {
      "id": 1,
      "name": "Tickets",
      "position": 1
    }
  ]
}

The EventSection object

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.

Attributes

#
id integer
#
name string
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.
#
position integer
Zero-based place in the page, top to bottom. The list is already returned in this order.
The EventSection object
{
  "id": 1,
  "name": "Tickets",
  "position": 1
}

The TicketTier object

Attributes

#
id integer
#
name string
The rung's public name, and often empty — a blank name is a real answer, and people then see the price on its own.
#
price_cents integer
Integer cents; see the type's currency.
#
quantity integer or null
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.
#
sold integer
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.
#
starts_at string (RFC 3339) or null
When the rung opens; null for no start. RFC 3339.
#
ends_at string (RFC 3339) or null
When the rung closes, EXCLUSIVE — the same contract as a product's sales_end_at. null for no end. RFC 3339.
#
live boolean
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.
#
prices object
This rung's price in every currency it is offered in, minor units keyed by lowercase currency code — the type's own currencyprice_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.
The TicketTier object
{
  "id": 1,
  "name": "First 100",
  "price_cents": 12900,
  "quantity": 100,
  "sold": 100,
  "starts_at": null,
  "ends_at": "2026-06-01T00:00:00Z",
  "live": false,
  "prices": {
    "eur": 14900,
    "gbp": 12900
  }
}

The TicketBand object

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.

Attributes

#
from_qty integer
The order quantity this band starts at — two or more, always. One ticket is the type's own price_cents.
#
price_cents integer
What ONE ticket costs from from_qty upwards. Integer cents; see the type's currency.
#
prices object
This band's price in every currency it is offered in, minor units keyed by lowercase currency code — the type's own currencyprice_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.
The TicketBand object
{
  "from_qty": 3,
  "price_cents": 8000,
  "prices": {
    "eur": 8000,
    "gbp": 6900
  }
}

The TicketType object

Attributes

#
id integer
#
name string
#
price_cents integer
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.
#
currency string
Lowercase ISO 4217 currency code — the event's currency; ticket types carry no currency of their own.
#
prices object
The type's price in every currency it is offered in, minor units keyed by lowercase currency code — currencyprice_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.
#
on_sale boolean
#
quantity integer or null
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.
#
sold integer
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.
#
tiers array of TicketTier
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.
#
tiers[].id integer
#
tiers[].name string
The rung's public name, and often empty — a blank name is a real answer, and people then see the price on its own.
#
tiers[].price_cents integer
Integer cents; see the type's currency.
#
tiers[].quantity integer or null
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.
#
tiers[].sold integer
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.
#
tiers[].starts_at string (RFC 3339) or null
When the rung opens; null for no start. RFC 3339.
#
tiers[].ends_at string (RFC 3339) or null
When the rung closes, EXCLUSIVE — the same contract as a product's sales_end_at. null for no end. RFC 3339.
#
tiers[].live boolean
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.
#
tiers[].prices object
This rung's price in every currency it is offered in, minor units keyed by lowercase currency code — the type's own currencyprice_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.
#
bands array of TicketBand
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.

Unlike 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.

The 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.

Never present alongside tiers: a ticket has one ladder or the other.

#
bands[].from_qty integer
The order quantity this band starts at — two or more, always. One ticket is the type's own price_cents.
#
bands[].price_cents integer
What ONE ticket costs from from_qty upwards. Integer cents; see the type's currency.
#
bands[].prices object
This band's price in every currency it is offered in, minor units keyed by lowercase currency code — the type's own currencyprice_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.
#
position integer
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.
#
section_id integer
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.
#
companion object
This type's own companion rule, or null when it has none.
#
companion.ticket_type_id integer
The companion — the ticket type this rule asks for.
#
companion.quantity integer
How many companions are needed per per_quantity of this type.
#
companion.per_quantity integer
How many of this type each set of companions covers.
#
companion.strictness string
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.
one of recommendedrequired
The TicketType object
{
  "id": 1,
  "name": "Early Bird",
  "price_cents": 14900,
  "currency": "eur",
  "prices": {
    "eur": 14900,
    "gbp": 12900
  },
  "on_sale": true,
  "quantity": 200,
  "sold": 142,
  "tiers": [
    {
      "id": 1,
      "name": "First 100",
      "price_cents": 12900,
      "quantity": 100,
      "sold": 100,
      "starts_at": null,
      "ends_at": "2026-06-01T00:00:00Z",
      "live": false,
      "prices": {
        "eur": 14900,
        "gbp": 12900
      }
    }
  ],
  "bands": [
    {
      "from_qty": 3,
      "price_cents": 8000,
      "prices": {
        "eur": 8000,
        "gbp": 6900
      }
    }
  ],
  "position": 1,
  "section_id": 1,
  "companion": {
    "ticket_type_id": 2,
    "quantity": 1,
    "per_quantity": 1,
    "strictness": "recommended"
  }
}

The OrderItem object

One run of ticket seats. Tickets only — goods, donations and post-order adjustments report through lines (OrderLine).

Attributes

#
ticket_type string
The ticket type's name, joined in so the raw ticket_type id is never exposed.
#
quantity integer
How many of this ticket type the line holds.
#
unit_price_cents integer
Integer cents; see the order's currency.
The OrderItem object
{
  "ticket_type": "Early Bird",
  "quantity": 2,
  "unit_price_cents": 14900
}

The Answer object

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.

Attributes

#
field_id integer
#
key string
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.
#
label string
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.
#
kind string
How it was asked, and so how to read value: "text", "textarea", "select", "multi_select", "checkbox", "yes_no", "number", "date", "phone" or "file".
#
value string
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.
The Answer object
{
  "field_id": 3,
  "key": "dietary",
  "label": "Dietary requirements",
  "kind": "text",
  "value": "Vegetarian"
}

The SyncStanding object

One connected service's standing with one ticket — the same ledger the admin attendee page's chip and the workflow run log both read.

Attributes

#
service string
The connected service's id, e.g. "brella".
#
remote_id string
The id the service knows this ticket by. Empty when nothing has synced yet.
#
state string
one of syncedfaileddeleted
#
synced_at string (RFC 3339) or null
When the last successful sync completed; null when it has never succeeded.
#
error string
The service's own words for its last failure, verbatim. Empty when the last attempt succeeded.
The SyncStanding object
{
  "service": "example",
  "remote_id": "example",
  "state": "synced",
  "synced_at": "2026-10-14T09:00:00Z",
  "error": "example"
}

The ItemAnswers object

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.

Attributes

#
product_id integer
#
product string
The good's name, in the account's own language.
#
position integer
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".
#
answers array of Answer
This item's answers, in a stable order. Never empty — an item with nothing answered about it has no entry at all.
#
answers[].field_id integer
#
answers[].key string
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.
#
answers[].label string
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.
#
answers[].kind string
How it was asked, and so how to read value: "text", "textarea", "select", "multi_select", "checkbox", "yes_no", "number", "date", "phone" or "file".
#
answers[].value string
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.
The ItemAnswers object
{
  "product_id": 1,
  "product": "Early Bird",
  "position": 1,
  "answers": [
    {
      "field_id": 3,
      "key": "dietary",
      "label": "Dietary requirements",
      "kind": "text",
      "value": "Vegetarian"
    }
  ]
}

The OrderLine object

One priced row of an order's ledger — a ticket, a good, a donation, or a post-order adjustment such as an upgrade.

Attributes

#
product_id integer
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).
#
product string
The product's name. A ticket line carries the live ticket type name, so it agrees with the matching items entry.
#
kind string
The product's kind: "ticket", "item" or "donation".
one of ticketitemdonation
#
quantity integer
#
unit_price_cents integer
Integer cents; see the line's currency.
#
subtotal_cents integer
Integer cents, before any tax added on top.
#
tax_cents integer
Integer cents. The tax snapshot taken when the line was written; 0 when the line carries no tax.
#
total_cents integer
Integer cents. Subtotal plus an exclusive rate's tax; the subtotal alone for an inclusive one.
#
currency string
Lowercase ISO 4217 currency code, carried per line.
#
status string
"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.
one of activeremovedrefunded
#
tier_id integer
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.
#
band_id integer
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.
#
ticket_ref string
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.
The OrderLine object
{
  "product_id": 1,
  "product": "Early Bird",
  "kind": "ticket",
  "quantity": 2,
  "unit_price_cents": 14900,
  "subtotal_cents": 29800,
  "tax_cents": 0,
  "total_cents": 29800,
  "currency": "eur",
  "status": "active",
  "tier_id": 0,
  "band_id": 0,
  "ticket_ref": ""
}

The OrderDiscount object

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.

Attributes

#
code string
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.
#
amount_cents integer
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.
#
currency string
Lowercase ISO 4217 currency code.
The OrderDiscount object
{
  "code": "EARLYBIRD",
  "amount_cents": 2000,
  "currency": "eur"
}

The OrderPriceAdjustment object

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.

Attributes

#
label string
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.
#
amount_cents integer
The difference against the order's lines, in integer minor units. SIGNED and added: negative took money off the order, positive added to it.
#
currency string
Lowercase ISO 4217 currency code.
The OrderPriceAdjustment object
{
  "label": "Speaker discount",
  "amount_cents": -5000,
  "currency": "eur"
}

The Order object

One purchase. A pending order holds stock from the moment checkout starts; paid, canceled and refunded orders are the history of that purchase.

Attributes

#
ref string
Short reference printed on receipts and read out at the desk. Unique across the account.
#
legacy_ref string
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.
#
status string
The order's state. Pending orders hold stock until paid or expired.
one of pendingpaidcanceledrefunded
#
email string
The buyer's email address.
#
name string
The buyer's name.
#
amount_cents integer
Integer cents; see currency.
#
currency string
Lowercase ISO 4217 currency code.
#
created_at string (RFC 3339) or null
When the order was started; for a pending order, when checkout began.
#
paid_at string (RFC 3339) or null
Null until the order is paid.
#
refunded_at string (RFC 3339) or null
Null unless the order has been refunded.
#
items array of OrderItem
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 [].
#
items[].ticket_type string
The ticket type's name, joined in so the raw ticket_type id is never exposed.
#
items[].quantity integer
How many of this ticket type the line holds.
#
items[].unit_price_cents integer
Integer cents; see the order's currency.
#
lines array of OrderLine
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.
#
lines[].product_id integer
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).
#
lines[].product string
The product's name. A ticket line carries the live ticket type name, so it agrees with the matching items entry.
#
lines[].kind string
The product's kind: "ticket", "item" or "donation".
one of ticketitemdonation
#
lines[].quantity integer
#
lines[].unit_price_cents integer
Integer cents; see the line's currency.
#
lines[].subtotal_cents integer
Integer cents, before any tax added on top.
#
lines[].tax_cents integer
Integer cents. The tax snapshot taken when the line was written; 0 when the line carries no tax.
#
lines[].total_cents integer
Integer cents. Subtotal plus an exclusive rate's tax; the subtotal alone for an inclusive one.
#
lines[].currency string
Lowercase ISO 4217 currency code, carried per line.
#
lines[].status string
"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.
one of activeremovedrefunded
#
lines[].tier_id integer
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.
#
lines[].band_id integer
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.
#
lines[].ticket_ref string
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.
#
discounts array of OrderDiscount
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.
#
discounts[].code string
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.
#
discounts[].amount_cents integer
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.
#
discounts[].currency string
Lowercase ISO 4217 currency code.
#
price_adjustments array of OrderPriceAdjustment
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.
#
price_adjustments[].label string
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.
#
price_adjustments[].amount_cents integer
The difference against the order's lines, in integer minor units. SIGNED and added: negative took money off the order, positive added to it.
#
price_adjustments[].currency string
Lowercase ISO 4217 currency code.
The Order object
{
  "ref": "H7K2PX",
  "legacy_ref": "",
  "status": "paid",
  "email": "ada@example.com",
  "name": "Ada Lovelace",
  "amount_cents": 29800,
  "currency": "eur",
  "created_at": "2026-05-18T14:02:11Z",
  "paid_at": "2026-05-18T14:03:07Z",
  "refunded_at": null,
  "items": [
    {
      "ticket_type": "Early Bird",
      "quantity": 2,
      "unit_price_cents": 14900
    }
  ],
  "lines": [
    {
      "product_id": 1,
      "product": "Early Bird",
      "kind": "ticket",
      "quantity": 2,
      "unit_price_cents": 14900,
      "subtotal_cents": 29800,
      "tax_cents": 0,
      "total_cents": 29800,
      "currency": "eur",
      "status": "active",
      "tier_id": 0,
      "band_id": 0,
      "ticket_ref": ""
    }
  ],
  "discounts": [
    {
      "code": "EARLYBIRD",
      "amount_cents": 2000,
      "currency": "eur"
    }
  ],
  "price_adjustments": [
    {
      "label": "Speaker discount",
      "amount_cents": -5000,
      "currency": "eur"
    }
  ]
}

The CreatedOrder object

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.

Attributes

#
ref string
The order's reference — the name a human quotes, never a credential.
#
status string
"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.
one of pendingpaidcanceledrefunded
#
total_cents integer
The order total in integer cents; see currency.
#
currency string
Lowercase ISO 4217 currency code — the event's.
#
payment_url string
Absolute URL of the buyer's pay page. Present only while the order is pending. A capability URL: treat it as a secret.
#
url string
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.
The CreatedOrder object
{
  "ref": "H7K2PX",
  "status": "pending",
  "total_cents": 29800,
  "currency": "eur",
  "payment_url": "https://annual-conf.example/annual-conf/orders/…/pay",
  "url": "https://annual-conf.example/annual-conf/orders/…"
}

The Ticket object

One admission, produced when an order is paid. Held by an attendee, who may differ from the buyer.

Attributes

#
ref string
Short reference printed on the ticket. Unique across the account.
#
legacy_ref string
The ref this ticket carried on the platform it was migrated from; empty for tickets sold on Tito. See the same field on Order.
#
status string
"valid", or "void" once the ticket has been cancelled.
one of validvoid
#
ticket_type string
The ticket type's name.
#
holder_name string
The attendee's name, as assigned.
#
holder_email string
The attendee's email address.
#
order_ref string
The ref of the order this ticket belongs to.
#
created_at string (RFC 3339) or null
When the ticket was issued.
#
answers array of Answer
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.

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.

#
answers[].field_id integer
#
answers[].key string
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.
#
answers[].label string
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.
#
answers[].kind string
How it was asked, and so how to read value: "text", "textarea", "select", "multi_select", "checkbox", "yes_no", "number", "date", "phone" or "file".
#
answers[].value string
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.
#
sync array of SyncStanding
Where this ticket stands with each connected service. Always an array, never null — a ticket never sent to any service serializes as [].
#
sync[].service string
The connected service's id, e.g. "brella".
#
sync[].remote_id string
The id the service knows this ticket by. Empty when nothing has synced yet.
#
sync[].state string
one of syncedfaileddeleted
#
sync[].synced_at string (RFC 3339) or null
When the last successful sync completed; null when it has never succeeded.
#
sync[].error string
The service's own words for its last failure, verbatim. Empty when the last attempt succeeded.
The Ticket object
{
  "ref": "T9QW4M",
  "legacy_ref": "",
  "status": "valid",
  "ticket_type": "Early Bird",
  "holder_name": "Ada Lovelace",
  "holder_email": "ada@example.com",
  "order_ref": "H7K2PX",
  "created_at": "2026-05-18T14:03:07Z",
  "answers": [
    {
      "field_id": 3,
      "key": "dietary",
      "label": "Dietary requirements",
      "kind": "text",
      "value": "Vegetarian"
    }
  ],
  "sync": [
    {
      "service": "example",
      "remote_id": "example",
      "state": "synced",
      "synced_at": "2026-10-14T09:00:00Z",
      "error": "example"
    }
  ]
}

The WidgetSummary object

Attributes

#
id integer
#
slug string
What the pasted markup names. Unique across the ACCOUNT, not per event, so the snippet can carry the widget's name and nothing else.
#
name string
The organiser's own label for the widget. Never shown publicly.
#
shape string
Lives on the record, never in the pasted markup — changing it does not change the organiser's website.
one of listbutton
#
secured_line boolean
Whether the widget shows the “Secured by Tito” line. On by default, switchable.
#
ticket_type_ids array of integer
The ticket types this widget lists, in its own order. Empty means it lists everything on sale.
#
installed boolean
Whether the widget has ever rendered on an approved website. Once true it never returns to false.
#
installed_at string (RFC 3339) or null
#
closed boolean
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.
#
closed_at string (RFC 3339) or null
#
created_at string (RFC 3339)
The WidgetSummary object
{
  "id": 1,
  "slug": "main",
  "name": "Homepage list",
  "shape": "list",
  "secured_line": false,
  "ticket_type_ids": [
    1
  ],
  "installed": true,
  "installed_at": "2026-04-10T10:00:00Z",
  "closed": false,
  "closed_at": null,
  "created_at": "2026-04-01T09:00:00Z"
}

The WidgetDomain object

Attributes

#
id integer
#
domain string
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.
#
status string
pending = seen, nobody has decided yet. Only approved renders.
one of pendingapprovedblocked
#
sightings integer
#
first_seen_at string (RFC 3339)
#
last_seen_at string (RFC 3339)
The WidgetDomain object
{
  "id": 1,
  "domain": "annualconf.example",
  "status": "approved",
  "sightings": 318,
  "first_seen_at": "2026-04-10T10:00:00Z",
  "last_seen_at": "2026-08-27T18:12:00Z"
}

The SavedReport object

Attributes

#
id integer
The report's row id; saved reports carry no ref (admin-only, never a public surface).
#
name string
What the account called it. Up to 80 characters, never empty.
#
source string
Which records the report counts. orders is the only source today.
#
query string
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).
#
created_at string (RFC 3339)
Integer Unix timestamp in storage, rendered as RFC 3339 UTC. Not nullable (NOT NULL column).
#
updated_at string (RFC 3339)
When the name or the definition last changed. Equals created_at on a report nobody has edited.
The SavedReport object
{
  "id": 1,
  "name": "Sales by week",
  "source": "orders",
  "query": "group=week\u0026range=all",
  "created_at": "2026-05-01T09:00:00Z",
  "updated_at": "2026-05-01T09:00:00Z"
}

The CheckinListSummary object

A check-in list and its live count.

Attributes

#
id integer
The list's row id; check-in lists carry no ref (admin-only, never a public surface).
#
name string
The list's name, e.g. "Main entrance".
#
created_at string (RFC 3339)
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 integer
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.
The CheckinListSummary object
{
  "id": 1,
  "name": "Main entrance",
  "created_at": "2026-09-01T08:00:00Z",
  "checked_in_count": 412
}

The Checkin object

One recorded arrival.

Attributes

#
ticket_ref string
The ticket that was checked in.
#
checked_in_at string (RFC 3339) or null
When it was scanned.
The Checkin object
{
  "ticket_ref": "T9QW4M",
  "checked_in_at": "2026-10-14T08:41:19Z"
}

The Workflow object

Attributes

#
id integer
#
name string
#
enabled boolean
#
all_events boolean
Whether this workflow fires for every event, including ones created later (the workflow_subscriptions event_id=0 sentinel). When true, events is always [].
#
created_at string (RFC 3339) or null
#
updated_at string (RFC 3339) or null
#
events array of string
Slugs of the events subscribed to this workflow. Always an array, never null, when the workflow has none.
#
definition object
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.
#
definition_raw string
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.
The Workflow object
{
  "id": 1,
  "name": "Thank-you email",
  "enabled": true,
  "all_events": true,
  "created_at": "2026-04-01T09:00:00Z",
  "updated_at": "2026-04-01T09:00:00Z",
  "events": [
    "example"
  ],
  "definition": {},
  "definition_raw": "example"
}

The WorkflowRun object

Attributes

#
id integer
#
trigger string
The event that fired this run, e.g. "order.paid".
#
subject_type string
e.g. "order", "ticket".
#
subject_id integer
The subject's row id.
#
status string
"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".
one of pendingrunningcompletedfailed
#
attempt integer
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.
#
created_at string (RFC 3339) or null
#
started_at string (RFC 3339) or null
Null until the run starts.
#
finished_at string (RFC 3339) or null
Null until the run finishes.
#
log array of any
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.
The WorkflowRun object
{
  "id": 1,
  "trigger": "order.paid",
  "subject_type": "order",
  "subject_id": 1,
  "status": "completed",
  "attempt": 1,
  "created_at": "2026-05-18T14:03:08Z",
  "started_at": "2026-05-18T14:03:08Z",
  "finished_at": "2026-05-18T14:03:09Z",
  "log": [
    null
  ]
}

The CompanionRule object

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.

Attributes

#
ticket_type_id integer
The companion — the ticket type this rule asks for.
#
quantity integer
How many companions are needed per per_quantity of this type.
#
per_quantity integer
How many of this type each set of companions covers.
#
strictness string
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.
one of recommendedrequired
The CompanionRule object
{
  "ticket_type_id": 2,
  "quantity": 1,
  "per_quantity": 1,
  "strictness": "recommended"
}

The ReportRow object

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.

Attributes

#
key string
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.
#
label string
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.
#
orders integer
Orders in this bucket.
#
tickets integer
Tickets those orders carry.
#
gross_cents integer
What changed hands, before refunds — the orders' own amounts, so discounts are already applied and non-ticket items are included.
#
discount_cents integer
Discount taken off those orders, as a positive amount.
#
refund_cents integer
Succeeded refunds against those orders, as a positive amount.
#
tax_cents integer
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.
#
net_cents integer
gross_cents minus refund_cents.
#
currency string
The event's currency. Orders taken in another currency are still summed into these amounts — group by currency to split them apart.
The ReportRow object
{
  "key": "2026-W20",
  "label": "Week of 11 May",
  "orders": 38,
  "tickets": 61,
  "gross_cents": 909000,
  "discount_cents": 20000,
  "refund_cents": 14900,
  "tax_cents": 0,
  "net_cents": 874100,
  "currency": "eur"
}

The IncomeMonth object

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.

Attributes

#
key string
The month's machine-stable id, 2026-08, cut in the event's own timezone.
#
label string
The same month worded for a human. Always English here, like every other machine-facing string in this API.
#
gross_cents integer
Before anything comes off: what the orders charged plus the discount that came off them.
#
deductions_cents integer
Discount codes plus refunds, as one positive amount.
#
tax_cents integer
Tax to remit, as a positive amount.
#
fees_cents integer
What the payments cost, as a positive amount: the provider's cut and Tito's together. The ledger's own totals split the two.
#
net_cents integer
gross_cents minus deductions_cents minus tax_cents minus fees_cents.
The IncomeMonth object
{
  "key": "2026-05",
  "label": "May 2026",
  "gross_cents": 909000,
  "deductions_cents": 34900,
  "tax_cents": 0,
  "fees_cents": 18180,
  "net_cents": 855920
}

The Series object

An account-level grouping of events with a listing page of its own.

Attributes

#
slug string
The address the series page answers on. Unique across this account's events, series and custom pages.
#
name string
#
intro string
Plain text shown under the name on the series page. Empty when unset.
#
listed boolean
Whether the series appears on the account's own front page. It keeps its address either way.
#
show_past boolean
Whether finished events stay visible on the series page.
#
event_count integer
How many events are in the series.
#
created_at string (RFC 3339) or null
The Series object
{
  "slug": "annual-conference",
  "name": "Annual Conference",
  "intro": "Every autumn since 2019.",
  "listed": true,
  "show_past": true,
  "event_count": 7,
  "created_at": "2019-03-02T11:20:41Z"
}

The WebhookEndpoint object

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.

Attributes

#
id integer
#
url string
The receiver URL deliveries POST to. Always https.
#
event_types array of string
The event types this endpoint is subscribed to — a non-empty subset of the fixed v1 vocabulary.
#
enabled boolean
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).
#
consecutive_failures integer
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.
#
created_at string (RFC 3339)
#
disabled_at string (RFC 3339) or null
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.
The WebhookEndpoint object
{
  "id": 1,
  "url": "https://example.com/hooks/tito",
  "event_types": [
    "order.created"
  ],
  "enabled": true,
  "consecutive_failures": 0,
  "created_at": "2026-04-01T09:00:00Z",
  "disabled_at": null
}

The WebhookDelivery object

One delivery attempt record — queue row and audit-trail row at once, exactly as the Settings → Webhooks detail page shows it.

Attributes

#
id integer
Also the value sent as the Tito-Webhook-Delivery header on the wire.
#
event_type string
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.
#
status string
"pending" is still retrying (or awaiting its first attempt); "succeeded" got a 2xx response; "failed" exhausted the 6-attempt retry schedule without one.
one of pendingsucceededfailed
#
attempts integer
Delivery attempts made so far (0 before the first is sent).
#
next_attempt_at string (RFC 3339)
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".
#
last_status_code integer
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.
#
last_error string
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.
#
created_at string (RFC 3339)
#
delivered_at string (RFC 3339) or null
Null until this delivery succeeds; set once, on the attempt that got a 2xx.
The WebhookDelivery object
{
  "id": 1,
  "event_type": "order.paid",
  "status": "succeeded",
  "attempts": 1,
  "next_attempt_at": "",
  "last_status_code": 200,
  "last_error": "",
  "created_at": "2026-05-18T14:03:08Z",
  "delivered_at": "2026-05-18T14:03:09Z"
}