Introduction

TicketConnect White-Label API lets you launch a fully branded ticketing product — events, paid tickets, QR check-in, resale, payouts — without building any of the hard parts yourself. You integrate the same way you'd integrate Stripe or SendGrid: REST + an API key + signed webhooks, all in plain business language.

The hard parts are handled for you. Every ticket is tamper-proof and anti-scalping is on by default (configurable per event). You work with events, tickets, fiat prices, customer emails, QR codes, and bank payouts — the platform does all the rest behind the scenes.

Running your own events instead? These docs are for licensees — teams embedding TicketConnect behind their own product. If you're an event organizer selling on the TicketConnect platform itself, switch to the For organizers docs.

What you can build

  • Sell tickets for events in your own currency, paid by card.
  • Issue, transfer, refund, and upgrade tickets programmatically.
  • Deliver tickets as an opaque QR string your app renders however it likes.
  • Sell high-demand on-sales fairly with a built-in waiting-room queue and published price schedules.
  • Run a secondary marketplace with automatic royalties and anti-scalping.
  • Validate tickets at the door online, in requests of up to 200 codes, and record check-ins through the attendance endpoint. Or scan with no network at all through the offline door protocol (/v1/scanner) and its SDK.
  • Receive signed webhooks for every important event.
  • Transfer proven platform-held Stripe funds to your connected account; bank payouts follow Stripe’s separate lifecycle.

Everything you call lives under the /v1 prefix and is automatically scoped to your account — you can only ever see and touch your own data.

Base URL

https://api.ticketconnect.example/v1

Replace the host with the environment you were given. Health, the OpenAPI document, and the interactive docs are public; everything else needs an API key.

Your first call

Terminal
curl https://api.ticketconnect.example/v1/account \
  -H "Authorization: Bearer sk_test_your_key_here"

If that returns your account, you're ready. Head to the Quickstart to sell your first ticket end-to-end, or jump straight to the API reference.

Where to go next

If you want to…Start here
Sell a ticket end-to-endQuickstart
Understand keys, scopes, and modesAuthentication
Learn the mental modelHow it works
Receive real-time eventsWebhooks
Look up a specific endpointAPI reference

Interactive reference

A live, try-it-out version of the full API (Swagger UI) is available at /v1/docs, backed by the machine-readable OpenAPI document at /v1/openapi.json — import it into Postman or use it for code generation.

Quickstart

This guide takes you end-to-end: you'll create an event, add a ticket tier, register a customer, take a card payment, issue a ticket, fetch its QR pass, and check it in at the door. Every step is a single REST call you can copy and paste.

Note: Run the whole tutorial with a test key (sk_test_…). Test mode hits the same code paths as production but is fully isolated from your live data. See Test & live modes.

Base URL for every call:

https://api.ticketconnect.example/v1

Conventions used throughout:

  • Authenticate with Authorization: Bearer sk_test_….
  • Money is always in your account's currency (fiat) and is computed server-side — you never send an amount.
  • Mutating POSTs accept an optional Idempotency-Key header so retries are safe.
  • Errors come back as { "error": { "type", "code", "message", "param", "request_id" } }.

Step 1 — Get your API key

Create a secret key in your dashboard. You'll get a key that starts with sk_test_ (sandbox) or sk_live_ (production). The key is shown once at creation, so store it somewhere safe.

For this tutorial you only need a test key. For the full breakdown of key formats, the Authorization: Bearer transport, and the scopes each call requires, read Authentication.

Warning: Treat your secret key like a password. Never commit it to source control or ship it in client-side code.


Step 2 — Confirm your key

A quick way to verify your key works and see which mode it's in.

Endpoint: GET /v1/account — requires a valid API key (no special scope).

cURL

Terminal
curl https://api.ticketconnect.example/v1/account \
  -H "Authorization: Bearer sk_test_your_key_here"

Node (fetch)

js
const BASE = "https://api.ticketconnect.example/v1";
const KEY = "sk_test_your_key_here";

const res = await fetch(`${BASE}/account`, {
  headers: { Authorization: `Bearer ${KEY}` },
});
const account = await res.json();
console.log(account);

Example response:

JSON
{
  "id": "ten_8a1f...",
  "name": "Acme Tickets",
  "mode": "test",
  "status": "active",
  "currency": "USD"
}

The mode field tells you whether this key is test or live. The currency is your account's settlement currency — every price in this guide is in that currency.


Step 3 — Create an event

Endpoint: POST /v1/events — requires the events:write scope.

Only name and date are required. The other fields (venue, city, description, etc.) are optional.

cURL

Terminal
curl https://api.ticketconnect.example/v1/events \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: evt-create-001" \
  -d '{
    "name": "Summer Sounds 2026",
    "date": "2026-08-15T19:00:00Z",
    "venue": "Riverside Arena",
    "city": "Austin",
    "country": "US"
  }'

Node (fetch)

js
const res = await fetch(`${BASE}/events`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "evt-create-001",
  },
  body: JSON.stringify({
    name: "Summer Sounds 2026",
    date: "2026-08-15T19:00:00Z",
    venue: "Riverside Arena",
    city: "Austin",
    country: "US",
  }),
});
const event = await res.json();
console.log(event.id); // evt_...

Example response (trimmed):

JSON
{
  "id": "evt_3f9c2a7b1e4d...",
  "name": "Summer Sounds 2026",
  "status": "draft",
  "date": "2026-08-15T19:00:00.000Z",
  "venue": "Riverside Arena",
  "city": "Austin",
  "country": "US",
  "currency": "USD",
  "ticketPools": [],
  "created_at": "2026-06-05T12:00:00.000Z"
}

Save the id (e.g. evt_3f9c2a7b1e4d...) — you'll use it as :id in the next steps.

Note: A new event starts in draft status with no ticket tiers yet. The date field accepts any value the standard date parser understands; an ISO 8601 timestamp is the safest choice.


Step 4 — Add a ticket tier

A tier defines a name, a fiat price, and how many tickets are available.

Endpoint: POST /v1/events/:id/tiers — requires the events:write scope.

Required fields: name (string), price (a non-negative number in your currency), and totalSupply (a positive integer). You may also send perks, upgradesEnabled, and advanced sale options (a published price_steps schedule, release_at / release_after_sold_out auto-release) — see the Events reference.

cURL

Terminal
curl https://api.ticketconnect.example/v1/events/evt_3f9c2a7b1e4d.../tiers \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "General Admission",
    "price": 49.00,
    "totalSupply": 500,
    "upgradesEnabled": true
  }'

Node (fetch)

js
const eventId = "evt_3f9c2a7b1e4d...";

const res = await fetch(`${BASE}/events/${eventId}/tiers`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "General Admission",
    price: 49.0,
    totalSupply: 500,
    upgradesEnabled: true,
  }),
});
const event = await res.json();
console.log(event.ticketPools);

Example response (trimmed):

JSON
{
  "id": "evt_3f9c2a7b1e4d...",
  "name": "Summer Sounds 2026",
  "status": "draft",
  "currency": "USD",
  "ticketPools": [
    {
      "name": "General Admission",
      "price": 49,
      "totalSupply": 500,
      "issued": 0,
      "upgradesEnabled": true
    }
  ],
  "created_at": "2026-06-05T12:00:00.000Z"
}

The response is the full updated event. The tier you just added appears in ticketPools with issued: 0. You'll reference the tier later by its name (here, "General Admission").


Step 5 — Create a customer

A customer is just an email and a name, plus your own externalId so you can match the customer back to your system.

Endpoint: POST /v1/customers — requires the customers:write scope.

Required fields: externalId (your own id for this customer) and email. name is optional.

cURL

Terminal
curl https://api.ticketconnect.example/v1/customers \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "externalId": "user_42",
    "email": "fan@example.com",
    "name": "Jordan Rivera"
  }'

Node (fetch)

js
const res = await fetch(`${BASE}/customers`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    externalId: "user_42",
    email: "fan@example.com",
    name: "Jordan Rivera",
  }),
});
const customer = await res.json();
console.log(customer.id); // "user_42"

Example response:

JSON
{
  "id": "user_42",
  "email": "fan@example.com",
  "name": "Jordan Rivera",
  "status": "active",
  "created_at": "2026-06-05T12:01:00.000Z"
}

The customer's id is the externalId you supplied — use it as customer_id when issuing the ticket. The first time you reference a new customer the platform sets everything up behind the scenes, so the response is the same whether the customer is brand new or already on file.


Step 6 — Take payment

Because the tier above is priced above zero, the ticket must be paid for before it can be issued. Create a payment intent for the event and tier; the amount is computed server-side from the tier price, so you never send an amount.

Endpoint: POST /v1/payment_intents — requires the payments:write scope.

Required fields: event_id and tier. You may also pass customer_id.

cURL

Terminal
curl https://api.ticketconnect.example/v1/payment_intents \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: pi-summer-001" \
  -d '{
    "event_id": "evt_3f9c2a7b1e4d...",
    "tier": "General Admission",
    "customer_id": "user_42"
  }'

Node (fetch)

js
const res = await fetch(`${BASE}/payment_intents`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "pi-summer-001",
  },
  body: JSON.stringify({
    event_id: "evt_3f9c2a7b1e4d...",
    tier: "General Admission",
    customer_id: "user_42",
  }),
});
const intent = await res.json();
console.log(intent.id, intent.status);

Example response (trimmed):

JSON
{
  "object": "payment_intent",
  "id": "pi_1QabcDEF...",
  "amount": 49,
  "currency": "USD",
  "status": "requires_payment",
  "client_payload": { "client_secret": "pi_1QabcDEF..._secret_..." }
}

You collect the card payment on your side using client_payload (for example, with the standard payment SDK in your frontend). Once the payment succeeds, you'll pass the intent's id to the issuance call in the next step.

Note: In test mode, use the standard test card numbers to simulate a successful payment — no real money moves. See Test & live modes.


Step 7 — Issue the ticket

With a verified payment in hand, issue the ticket to your customer.

Endpoint: POST /v1/events/:id/tickets — requires the tickets:issue scope.

Required fields: customer_id, tier, and — for any paid tier — payment_intent_id. The platform re-verifies the payment, checks it matches this event and amount, and guards against using the same payment twice.

High-demand on-sale? If the event uses the on-sale waiting room (its queue field is non-null), the customer must be admitted by the queue before this call succeeds — otherwise it returns a 403 with a queue_error (not_admitted). Join and poll with POST /v1/events/:id/queue/join and GET /v1/events/:id/queue?customer_id=… (scope tickets:issue), then issue once status is admitted. Events without a queue are unaffected.

cURL

Terminal
curl https://api.ticketconnect.example/v1/events/evt_3f9c2a7b1e4d.../tickets \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: issue-summer-001" \
  -d '{
    "customer_id": "user_42",
    "tier": "General Admission",
    "payment_intent_id": "pi_1QabcDEF..."
  }'

Node (fetch)

js
const eventId = "evt_3f9c2a7b1e4d...";

const res = await fetch(`${BASE}/events/${eventId}/tickets`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "issue-summer-001",
  },
  body: JSON.stringify({
    customer_id: "user_42",
    tier: "General Admission",
    payment_intent_id: "pi_1QabcDEF...",
  }),
});
const ticket = await res.json();
console.log(ticket.id); // tkt_...

Example response (trimmed):

JSON
{
  "id": "tkt_5b8e1c2d3f4a...",
  "event_id": "evt_3f9c2a7b1e4d...",
  "event_name": "Summer Sounds 2026",
  "status": "valid",
  "tier": "General Admission",
  "price": 49,
  "currency": "USD",
  "qr": {
    "data": "a1b2c3d4e5f6...",
    "format": "QR"
  },
  "created_at": "2026-06-05T12:02:00.000Z"
}

Every ticket is tamper-proof and anti-scalping is on by default — all handled for you (and configurable per event; see Marketplace and resale). The qr.data string is the opaque code your scanner reads at the door.

Tip: The same data is also delivered to your webhook as a ticket.issued event, so your systems can react in real time. See the Webhooks guide.


Step 8 — Get the ticket's QR / pass

You usually already have the ticket from Step 7, but you can fetch it any time to get its current status and delivery data.

Endpoint: GET /v1/tickets/:id — requires the tickets:read scope.

cURL

Terminal
curl https://api.ticketconnect.example/v1/tickets/tkt_5b8e1c2d3f4a... \
  -H "Authorization: Bearer sk_test_your_key_here"

Node (fetch)

js
const ticketId = "tkt_5b8e1c2d3f4a...";

const res = await fetch(`${BASE}/tickets/${ticketId}`, {
  headers: { Authorization: `Bearer ${KEY}` },
});
const ticket = await res.json();
console.log(ticket.qr.data);

Example response (trimmed):

JSON
{
  "id": "tkt_5b8e1c2d3f4a...",
  "event_name": "Summer Sounds 2026",
  "status": "valid",
  "tier": "General Admission",
  "qr": {
    "data": "a1b2c3d4e5f6...",
    "format": "QR"
  },
  "created_at": "2026-06-05T12:02:00.000Z"
}

Render qr.data as a QR code and deliver it through your own app, email, or ticket page. No internal identifiers are ever returned.


Step 9 — Check in at the door

When the attendee arrives, validate the QR they present. Validation returns a verdict without consuming the ticket.

Endpoint: POST /v1/scan/validate — requires the scanning:write scope.

Required field: qr (the qr.data value from the ticket).

cURL

Terminal
curl https://api.ticketconnect.example/v1/scan/validate \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "qr": "a1b2c3d4e5f6..." }'

Node (fetch)

js
const res = await fetch(`${BASE}/scan/validate`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ qr: "a1b2c3d4e5f6..." }),
});
const verdict = await res.json();
console.log(verdict.valid); // true

Example response (valid ticket):

JSON
{
  "valid": true,
  "status": "valid",
  "tier": "General Admission",
  "ticket_id": "tkt_5b8e1c2d3f4a..."
}

If the ticket has already been used or refunded, valid is false and reason explains why (for example "already_used" or "refunded").

Record the actual check-in. POST /v1/scan/validate only checks validity. To mark a ticket as attended, call POST /v1/tickets/:id/attendance (also scanning:write). It is safe to retry and reports already_used if the ticket was already checked in. To validate many codes at once while online, use POST /v1/scan/batch, which takes up to 200 codes in one call. For gates without a reliable connection, see Offline scanning.


Step 10 (optional) — Receive a webhook

Instead of polling, register a webhook endpoint and let TicketConnect push events to you — ticket.issued when a ticket is created, attendance.verified on check-in, and more. Webhooks are signed so you can verify them with existing SDKs.

Manage endpoints with POST /v1/webhook_endpoints (requires the webhooks:manage scope). See the full walkthrough in the Webhooks guide.


You did it

You just sold and scanned a ticket end-to-end:

  1. Confirmed your key with GET /v1/account.
  2. Created an event and a ticket tier.
  3. Registered a customer.
  4. Took a card payment and issued a tamper-proof ticket.
  5. Fetched its QR pass and validated it at the door.

Next steps

Authentication

Every server-to-server request to the TicketConnect White-Label API is authenticated with a secret API key, sent as a bearer token. Keys are scoped so each one can do only what you allow — and every endpoint documents the scope it requires. A second, narrower kind of key — the publishable key — exists solely for the browser-facing hosted checkout surface (see below).

https://api.ticketconnect.example/v1

Secret keys

Your secret key authenticates server-to-server calls. Keys come in two flavours, distinguished by their prefix:

PrefixModeUse it for
sk_test_…Test (sandbox)Building and testing your integration. Fully isolated from live data.
sk_live_…Live (production)Real events, real customers, real payments.

Test and live keys hit the same code paths, but their data never mixes. See Test & live modes for the full picture.

Warning: A secret key can create events, take payments, and issue tickets. Treat it like a password. Use it only from your backend — never embed it in a mobile app, single-page app, or anything that runs on a user's device.

Keys are shown once

When you create a key, the full value is returned exactly once, at creation. After that, TicketConnect stores only a hashed version and can never show you the plaintext again. Copy it immediately into a secrets manager or environment variable.

If you lose a key, you can't recover it — create a new one and revoke the old.

Self-serve key management

You can mint, list, and revoke keys through the API itself — no dashboard round-trip needed for rotation:

  • POST /v1/account/keys mints a new key (the plaintext is in that response, once). A minted key's scopes must be a subset of the calling key's, and a test-mode key can never mint a live-mode key — no privilege escalation. Minting a live-mode key additionally requires live mode to be activated on the account; until then the request is rejected with mode_unavailable.
  • GET /v1/account/keys lists your keys (prefix, label, scopes, timestamps); revoked keys stay listed as an audit trail. Secrets are never returned.
  • DELETE /v1/account/keys/{prefix} revokes a key immediately. The calling key can never revoke itself, so a rotation always overlaps.

All three require the keys:manage scope, and active keys are capped at 25 per account. See the Account reference for the full endpoint details.

Publishable keys

Alongside secret keys, you can be issued publishable keys with a pk_ prefix (pk_test_… / pk_live_…). They exist for one purpose: the browser-facing hosted checkout surface. A publishable key:

  • is safe to expose in a browser — it is always confined to the checkout:read and checkout:write scopes, regardless of what is stored on the key;
  • only works on the /v1/checkout/… endpoints — every secret endpoint rejects a publishable key with a 401;
  • carries the same test/live mode split in its prefix as a secret key.

The reverse also holds: secret (sk_) keys are rejected on the publishable checkout surface, so a leaked page can never be escalated. See the Payments reference for the checkout endpoints themselves.

The Authorization header

Send your key as a bearer token in the Authorization header on every request:

Authorization: Bearer sk_test_your_key_here

cURL

Terminal
curl https://api.ticketconnect.example/v1/account \
  -H "Authorization: Bearer sk_test_your_key_here"

Node (fetch)

js
const res = await fetch("https://api.ticketconnect.example/v1/account", {
  headers: { Authorization: "Bearer sk_test_your_key_here" },
});
const account = await res.json();
console.log(account.mode); // "test" or "live"

A good first call is GET /v1/account — it confirms the key is valid and tells you which mode it operates in.

If your HTTP client makes the Authorization header awkward to set, the API also accepts the same key in an x-api-key header. Prefer the Bearer transport — it is what every example in these docs uses.

Note: A few endpoints are public and need no key at all: GET /v1/health (uptime checks), GET /v1/openapi.json (the machine-readable spec, useful for codegen before you have a key), and GET /v1/docs (the interactive Swagger UI). Everything else requires authentication.

Key safety & rotation

  • Store keys as secrets. Use environment variables or a secrets manager — never source control.
  • Use one key per environment. Keep test and live keys separate, and ideally scope keys per integration so you can revoke one without breaking others.
  • Rotate regularly, and immediately if a key may have leaked. Mint a new key with POST /v1/account/keys, deploy it, then revoke the old one with DELETE /v1/account/keys/{prefix}. Because keys are stored hashed, revocation is the only remediation for a compromised key.
  • Grant least privilege. Give each key only the scopes it needs (see below). A scanning app at the door, for example, needs scanning:write and nothing more.

Scopes

Each key carries a set of scopes that define exactly which operations it can perform. If a key is missing a scope an operation needs, the request is rejected with the standard error envelope and an HTTP 403:

JSON
{
  "error": {
    "type": "authorization_error",
    "code": "forbidden",
    "message": "Missing required scope: events:write",
    "request_id": "8f2c1a4e-…"
  }
}

The full set of scopes and what each unlocks:

ScopeMeaningUnlocks
events:readRead events, organizations, and the catalogGET /v1/events, GET /v1/events/:id, GET /v1/events/:id/seatmap, GET /v1/events/:id/similar, GET /v1/organizations, GET /v1/organizations/:id
events:writeCreate and update events, tiers, and discountsPOST /v1/events, PATCH /v1/events/:id, POST /v1/events/:id/tiers, all /v1/discounts operations (including discount triggers under /v1/discounts/:id/triggers…)
tickets:readRead issued tickets and guest lists, mint delivery linksGET /v1/tickets, GET /v1/tickets/:id, POST /v1/tickets/:id/delivery_link, GET /v1/events/:id/comps
tickets:issueIssue tickets (paid, comp, and seated) and manage the on-sale waiting roomPOST /v1/events/:id/tickets, POST /v1/events/:id/comps, POST/DELETE /v1/events/:id/seats/hold…, GET /v1/events/:id/queue, POST /v1/events/:id/queue/join, POST /v1/events/:id/queue/leave
tickets:manageManage a ticket's lifecyclePOST /v1/tickets/:id/transfer, POST /v1/tickets/:id/refund, POST /v1/tickets/:id/revoke, POST /v1/tickets/:id/upgrade, GET /v1/tickets/:id/upgrade-options
customers:writeCreate and read customers, compute and save segmentsPOST /v1/customers, GET /v1/customers/:id, GET /v1/customers/segments/:kind, all /v1/segments saved-segment operations
payments:writeCreate payment intents (also grants read-back)POST /v1/payment_intents, POST /v1/payment_intents/:id/confirm, GET /v1/payments/:id
marketplace:readRead secondary-market resale listingsGET /v1/marketplace/listings, GET /v1/marketplace/listings/:id
reports:readRead sales and attendance reportsGET /v1/reports/sales, GET /v1/reports/attendance
scanning:writeValidate and check in tickets; mint offline scanner sessionsPOST /v1/scan/validate, POST /v1/scan/batch, POST /v1/tickets/:id/attendance, POST /v1/scanner/sessions
staff:manageManage door staff: roster, permissions, event assignments, scan stats, scanner devicesPOST/GET /v1/staff, GET/PATCH/DELETE /v1/staff/:id, POST /v1/staff/:id/assignments, DELETE /v1/staff/:id/assignments/:eventId, GET /v1/staff/:id/stats, GET /v1/staff/:id/devices, POST /v1/staff/:id/devices/:deviceId/revoke
webhooks:manageManage webhook endpoints and deliveriesall /v1/webhook_endpoints operations (including POST …/:id/ping), GET /v1/events/deliveries, POST /v1/events/deliveries/:id/replay
keys:manageMint, list, and revoke API keysGET/POST /v1/account/keys, DELETE /v1/account/keys/:prefix
payouts:readRead balance and payout historyGET /v1/balance, GET /v1/payouts
payouts:claimSet up payouts and request a payoutGET/POST /v1/connect/account, POST /v1/payouts (and the read operations above)

Read access is implied by write where it makes sense. A payments:write key can read back the payments it created, and payouts:claim includes everything payouts:read can do. When in doubt, the per-endpoint documentation in the API reference states the exact scope required.

Next steps

Test & live modes

TicketConnect gives you two fully separate environments, selected entirely by which key you use. There is no separate sandbox URL and no flag to flip — the key's prefix decides the mode.

ModeKey prefixWhat it is
Testsk_test_…Isolated data and mode-specific sandbox providers.
Livesk_live_…Production data and explicitly configured live payment providers.

Note: Live mode must be activated on your account before it can be used. Until it is, sk_live_ keys cannot be created, and any live key presented to the API is rejected with a 403 — never silently downgraded to test. Test mode is always available.

https://api.ticketconnect.example/v1

The base URL is the same for both. Send a test key and you're in the sandbox; send a live key and you're in production.

Same code paths, isolated data

A test key exercises the same API contract as a live key. Provider credentials, Connect accounts, chain configuration, and original payment proof must also match the selected mode:

  • Test events, tiers, customers, tickets, payments, and payouts live in a separate sandbox and never appear in your live data (and vice versa).
  • Payments in test mode are simulated — use the standard test card numbers to trigger success or failure. No real money ever moves.
  • Webhooks, reports, balances, and scanning all work the same way in test, scoped to your test data only.

Warning: The two worlds never cross. A sk_test_ key cannot read or modify live data, and a sk_live_ key cannot see anything you created in test. An id created in one mode is meaningless in the other.

Telling which mode a key is in

There are two reliable ways:

  1. The prefix. sk_test_… is test; sk_live_… is live.
  2. Ask the API. GET /v1/account returns a mode field:

cURL

Terminal
curl https://api.ticketconnect.example/v1/account \
  -H "Authorization: Bearer sk_test_your_key_here"

Node (fetch)

js
const res = await fetch("https://api.ticketconnect.example/v1/account", {
  headers: { Authorization: "Bearer sk_test_your_key_here" },
});
const { mode } = await res.json();
console.log(mode); // "test"
JSON
{
  "id": "ten_8a1f...",
  "name": "Acme Tickets",
  "mode": "test",
  "status": "active",
  "currency": "USD"
}

Best practices

  • Build in test first. Wire up your entire flow — create an event, take a payment, issue a ticket, scan it — using a sk_test_ key before you touch live. Run the Quickstart end-to-end in test.
  • Key your config by mode. Store sk_test_ and sk_live_ keys in separate environment variables and select the right one per deployment. Never hardcode a key or let a live key leak into a staging build.
  • Test the unhappy paths too. Use test cards to simulate declined payments, and exercise refunds, already-used scans, and missing-scope errors so your error handling is solid before launch.
  • Verify webhooks in test. Register a test webhook endpoint and confirm you receive and verify signed events before relying on them in production. See the Webhooks guide.
  • Activate and configure live mode. Enable live access, configure live provider credentials, and complete live Connect onboarding. Then select the live key and check GET /v1/account reports "mode": "live". Missing or mismatched provider configuration is rejected; the API does not borrow live credentials for a test request.

Provider credentials and Connect account IDs are mode-specific. PayPal uses its sandbox endpoint in test and production endpoint in live. Crypto proof binds the original chain, token, recipient, and successful receipt to the requested mode. An old account ID or payment without provable mode cannot authorize a live money operation.

A successful sandbox test checks your integration contract. It does not confirm live provider capabilities, funded balances, or bank settlement.

Next steps

How it works

The White-Label API gives you one job: call REST over /v1. Everything that makes a ticket trustworthy and every cent of settlement happens behind that boundary, automatically. You think in events, tickets, prices, and customers — nothing else.

You make a normal HTTPS request with a bearer key; we do the hard parts and return plain JSON. No SDK is required, though the OpenAPI document makes one easy to generate.

The object model

Everything you create lives in a simple hierarchy, plus a few free-standing resources that hang off it.

Account  (you — the partner)
  └─ Events               a show, match, conference, etc.
        └─ Tiers           price levels within an event (GA, VIP, …)
              └─ Tickets   issued to a customer, each tamper-proof

Organizations  read-only organizer directory your account can browse
Customers      email-first people who hold tickets
Payments       card charges that gate issuance and upgrades
Queues         on-sale waiting rooms for high-demand events
Listings       secondary-market resale entries for tickets
Payouts        fiat transfers of your balance to your bank
ObjectWhat it isReference
AccountYour partner account — branding, mode, settlement currency.Account
OrganizationAn event organizer your account can read from the catalog.Events
EventA single event you sell tickets for.Events
TierA price level on an event (name, price, supply, perks).Events
TicketAn issued, tamper-proof ticket held by a customer.Tickets
CustomerA person who holds tickets, identified by email.Customers
PaymentA card charge, computed and verified server-side.Payments
QueueThe waiting room gating issuance for a high-demand on-sale.Events
ListingA secondary-market resale of a ticket.Marketplace
PayoutA fiat transfer of your balance to your bank.Payouts

Built-in guarantees

Several hard problems are handled by the platform itself — there is nothing to integrate, configure, or operate on your side. You get the benefits without the complexity:

  • Tamper-proof tickets — every ticket is verifiable and cannot be forged or duplicated.
  • Built-in anti-scalping — resale rules are enforced automatically on the marketplace.
  • Automatic resale royalties — you keep earning on the secondary market with no extra work.
  • Plain fiat, end to end — prices, charges, refunds, and payouts are all in your account's currency.

Note: You work entirely in business language: events, tiers, fiat prices, customer emails, QR codes, and bank payouts. The tamper-proof layer is an implementation detail you never have to learn.

Test vs live

Every account has an isolated test mode (sk_test_…) and live mode (sk_live_…); test data never mixes with live. See Test & live modes.

Customers

A customer is the person who holds a ticket. Customers are email-first: you create one with your own externalId and an email (plus an optional name), and from then on you reference it by its id — which is simply the externalId you supplied. That is the entire model you need to know.

Tip: No account or credential is ever requested from a customer or returned to you. A customer is just { id, email, name, status } plus the tickets they hold.

Creating a customer

Send your own externalId and an email (a name is optional). You get back a customer whose id is that same externalId — use it everywhere else (issuance, transfers, lookups).

curl

Terminal
curl https://api.ticketconnect.example/v1/customers \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "externalId": "user_42",
    "email": "ada@example.com",
    "name": "Ada Lovelace"
  }'

Response

JSON
{
  "id": "user_42",
  "email": "ada@example.com",
  "name": "Ada Lovelace",
  "status": "active",
  "created_at": "2026-06-05T12:01:00.000Z"
}

Requires the customers:write scope. This endpoint accepts an Idempotency-Key header so retries are safe.

Provisioning happens transparently

Creating the customer is the only setup step. The moment you POST /v1/customers, the platform quietly does everything needed to make that customer's future tickets tamper-proof and deliverable behind the scenes:

  • It is idempotent — creating the same externalId again returns the existing customer unchanged; nothing is set up twice, and retries are safe.
  • It is invisible — none of the internal setup is exposed in your request or response, and it never asks the customer for anything.
  • It is required before issuance — a ticket can only be issued to a customer that already exists; referencing an unknown customer_id at issuance returns a 404.

In short: create the customer first, then issue tickets. The platform handles the rest.

The relationship to tickets

Every ticket belongs to exactly one customer at a time. You reference the customer's id as customer_id when issuing, and retrieve the customer record itself at any time:

Terminal
curl https://api.ticketconnect.example/v1/customers/user_42 \
  -H "Authorization: Bearer sk_test_your_key_here"

When you transfer a ticket, it simply moves from one customer to another — the ticket's identity and tamper-proof guarantees are unchanged.

Reference

Method & pathScopePurpose
POST /v1/customerscustomers:writeCreate a customer from { externalId, email, name? }.
GET /v1/customers/:idcustomers:writeRetrieve a customer by the externalId you supplied.

See the full Customers API reference for field details.

Money & currencies

Public prices, payment amounts, balances, and payouts use the currency's major unit. 49.50 USD means $49.50; 1000 JPY means ¥1000. Internally, payments and allocations use whole native minor units: USD 49.50 is 4950 cents, while JPY 1000 is 1000 yen.

The server derives the amount and currency from the event, tier, quantity, and applicable discount. A request cannot replace that price or change the currency: a supplied currency must match the server's currency. Fractional minor units, non-finite amounts, and unsupported payment rails are rejected.

Payment proof and allocations

A paid ticket requires a completed payment with the correct account, mode, currency, and sufficient amount. The platform reserves each allocation atomically with ticket stock. A payment can fund supported partial or batch allocations, but their combined amount cannot exceed the original confirmed collection. Issuance and upgrades share this budget.

Your account's payment provider supplies the client payment details. Stripe uses a client secret, PayPal uses an approval URL, and an external provider records your assertion that you collected payment elsewhere. The v1 crypto adapter supports its verified USD/USDC rail; it does not convert arbitrary fiat currencies into USDC.

A completed external, PayPal, or crypto payment can prove a purchase without funding the platform's Stripe payout balance. Payment completion and money available for a Stripe transfer are separate facts.

Refunds

Refunds use the original payment allocations, currency, and mode. The original purchase and each paid upgrade are separate sources; changing a tier price later does not change the refund budget. The total refunded from a source cannot exceed its confirmed paid amount.

A refund reservation reverses the booked seller net and fee history before provider effects. Only a confirmed refund completes the allocation. Pending receipts stay held for recovery; missing provenance, failed or canceled receipts, depleted refundable balances, and unsupported resale clawbacks require review. See Transfer, refund & upgrade.

Available balance and transfers

GET /v1/balance reports available, proven platform-held Stripe funds after fees, refunds, and payout reservations. It excludes externally collected funds, PayPal and crypto collections, unknown legacy credits, and money already routed to a connected account.

POST /v1/payouts transfers that balance to your mode-specific Stripe Connect account. transferred confirms the Stripe transfer; it does not confirm a deposit in your bank account. Stripe's connected-account bank payout follows its own lifecycle. See Payouts.

The ledger balance is not Stripe’s net available-cash report. Processor costs are a platform expense requiring separate liquidity; a provider can refuse a transfer when its actual available balance is insufficient.

Idempotency

Send one Idempotency-Key for each logical mutation and reuse it with the same request whenever you retry. Use it on every payment, issuance, upgrade, refund, and payout integration call. POST /v1/payouts requires a non-empty key of at most 255 characters; endpoints that retain optional headers should still receive a key from your integration.

How it works

The platform atomically claims the key before an effect starts. The claim is scoped to your account and mode and includes the request method, path, and body hash.

  • A completed retry returns the stored status and response.
  • The same key with a different request returns 409 idempotency_key_reuse.
  • An operation already running can return 409 idempotency_in_progress; money operations with a durable reservation can instead return their pending state.
  • A crash or ambiguous provider response keeps the original operation held for recovery. Sending a fresh key does not resolve that uncertainty.

Completed response snapshots expire after approximately 24 hours. Unresolved claims do not expire as completed responses do. Durable financial operation records and unique payment allocations remain authoritative after the response cache expires.

For an unknown Stripe transfer or refund with no receipt ID, automatic retries stop before the provider's idempotency retention can expire: the platform uses a 23-hour ceiling from the original attempt and holds the operation for review. A known Stripe or PayPal refund receipt is recovered by GET, including after that ceiling, without creating another refund.

Choose one key per operation

A random UUID is a convenient choice. Store it with your order or refund record before sending the request.

Terminal
KEY=$(uuidgen)
curl https://api.ticketconnect.example/v1/events/evt_123/tickets \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Idempotency-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "user_42", "tier": "General Admission" }'

If the request times out, retry this exact request with the stored key. Do not generate a replacement key for the retry.

Supported endpoints

The header is honored on mutating POST endpoints including customers, events, tiers, ticket issuance, payment intents, ticket refunds and upgrades, attendance, and payouts. Unsupported endpoints may ignore it; the header does not add replay protection to an endpoint that has no idempotent handler.

A provider receipt marked failed, canceled, or pending is not a completed refund. Preserve the original key and follow the returned state. See Errors, Payments, and Payouts.

Errors

Every error response uses a single, predictable envelope — the same shape across the entire API. Branch your handling on the machine-readable type and code; show or log the message; quote the request_id to support.

The error envelope

JSON
{
  "error": {
    "type": "invalid_request_error",
    "code": "parameter_missing",
    "message": "tier is required.",
    "param": "tier"
  }
}
FieldAlways presentDescription
typeyesHigh-level category — branch your error handling on this.
codeyesSpecific machine-readable code within the type.
messageyesHuman-readable description. Safe to log; don't parse it.
paramnoThe offending request parameter, when one applies. Omitted otherwise.
request_idnoCorrelation id — present on authentication, authorization, rate-limit, and unexpected platform errors. Every response (success or error) also carries it in the x-request-id header.

Error types

typeMeaningTypical HTTP status
invalid_request_errorThe request was malformed, missing a field, or violated a business rule (e.g. sold-out tier, unverified payment).400, 402, 404, 409
authentication_errorThe API key is missing, invalid, revoked, or of the wrong kind for the endpoint.401
authorization_errorThe key is valid but not allowed to do this — usually a missing scope.403
idempotency_errorAn Idempotency-Key was reused with a different request.409
queue_errorThe customer has not (yet) been admitted by the event's on-sale waiting room.403
rate_limit_errorYou've made too many requests too quickly.429
api_errorAn unexpected error on the platform side. Safe to retry.500, 502

Common codes

codetypeWhen you'll see it
unauthorizedauthentication_errorThe key is unknown, revoked, malformed, or the wrong kind for the endpoint.
forbiddenauthorization_errorThe key is missing the scope this operation requires.
idempotency_key_reuseidempotency_errorSame Idempotency-Key, different request body.
parameter_missing / parameter_invalidinvalid_request_errorA required field is absent or a field has a bad value — param names it.
resource_missinginvalid_request_errorThe resource doesn't exist, or isn't yours.
sold_outinvalid_request_errorIssuing from a tier with no remaining supply.
payment_requiredinvalid_request_errorA paid ticket or upgrade was requested without a payment_intent_id.
not_admittedqueue_errorThe customer must be admitted by the waiting room before issuance.

Note: New code values can be added over time within an existing type. Always handle the type you recognize and treat unknown codes within it gracefully rather than failing hard.

HTTP status mapping

The HTTP status and the error.type agree, so you can react to either:

StatusMeaning
400Invalid request — fix the payload or the business condition.
401Authentication failed — check your key, its kind, and mode.
402Payment required — a paid ticket or upgrade lacks a verified payment.
403Not allowed — missing scope, a not-yet-released tier, or a customer not admitted by the waiting room.
404The resource doesn't exist, or isn't yours.
409Conflict — idempotency-key reuse, sold-out supply, or a payment already used.
429Rate limited — back off and retry.
500 / 502Platform or payment-provider error — safe to retry, ideally with backoff.

Using request_id for support

Every response — success or error — carries a correlation id in the x-request-id response header; authentication, authorization, rate-limit, and unexpected platform errors also include it in the envelope as error.request_id. When something goes wrong, capture and log it. If you contact support, quoting the id lets us locate the exact request instantly — no guessing required.

Terminal
# Log the correlation id from any response.
curl -si https://api.ticketconnect.example/v1/events/evt_does_not_exist \
  -H "Authorization: Bearer sk_test_your_key_here" \
  | grep -i x-request-id

Example error responses

401 Authentication

JSON
{
  "error": {
    "type": "authentication_error",
    "code": "unauthorized",
    "message": "Invalid API key",
    "request_id": "1b9d33a0-…"
  }
}

409 Idempotency

JSON
{
  "error": {
    "type": "idempotency_error",
    "code": "idempotency_key_reuse",
    "message": "This Idempotency-Key was used with a different request."
  }
}

429 Rate limit

JSON
{
  "error": {
    "type": "rate_limit_error",
    "code": "rate_limited",
    "message": "Rate limit exceeded",
    "request_id": "4e8a90fb-…"
  }
}

See Idempotency for the 409 case in context.

Pagination

List endpoints return results in pages using cursor pagination. You control the page size with limit and walk forward with starting_after, using the id of the last item you received as the cursor for the next page.

The list envelope

Every list endpoint returns the same shape:

JSON
{
  "object": "list",
  "data": [
    { "id": "evt_300", "...": "..." },
    { "id": "evt_299", "...": "..." }
  ],
  "has_more": true
}
FieldDescription
objectAlways "list".
dataThe items on this page, newest first.
has_moretrue if more items exist after this page.

Query parameters

ParameterDefaultDescription
limit20Items per page. Clamped to the range 1–100.
starting_after—An item id. Returns the page of items after it.

Results are ordered newest-first. To fetch the next page, take the id of the last item in data and pass it as starting_after. Keep going while has_more is true.

Which endpoints support it

Cursor pagination applies to every list endpoint, including:

  • GET /v1/events
  • GET /v1/tickets
  • GET /v1/marketplace/listings
  • GET /v1/discounts
  • GET /v1/payouts
  • GET /v1/events/deliveries (webhook deliveries)

Some list endpoints also accept filters (for example event_id on GET /v1/tickets); filters compose with pagination.

Paging through every result

curl

Terminal
#!/usr/bin/env bash
KEY="sk_test_your_key_here"
BASE="https://api.ticketconnect.example/v1/events?limit=100"
after=""

while :; do
  url="$BASE"
  [ -n "$after" ] && url="$BASE&starting_after=$after"

  page=$(curl -s "$url" -H "Authorization: Bearer $KEY")

  echo "$page" | jq -c '.data[]'

  has_more=$(echo "$page" | jq -r '.has_more')
  [ "$has_more" != "true" ] && break

  after=$(echo "$page" | jq -r '.data[-1].id')
done

Node

js
const KEY = "sk_test_your_key_here";
const BASE = "https://api.ticketconnect.example/v1/events";

async function* listAllEvents() {
  let startingAfter;

  while (true) {
    const url = new URL(BASE);
    url.searchParams.set("limit", "100");
    if (startingAfter) url.searchParams.set("starting_after", startingAfter);

    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${KEY}` },
    });
    const page = await res.json();

    for (const item of page.data) yield item;

    if (!page.has_more) break;
    startingAfter = page.data[page.data.length - 1].id;
  }
}

for await (const event of listAllEvents()) {
  console.log(event.id);
}

Note: Use the largest limit (100) when bulk-syncing to minimize round trips, and a smaller limit for interactive, user-facing lists.

Issue a ticket

Issuing a ticket is how you put a real, scannable ticket in a customer's hands. You tell TicketConnect which event, which tier, and which customer, and (for paid tiers) attach the payment that funds it. The platform creates a tamper-proof ticket and returns it with everything you need to deliver it.

Use this guide when you've already built your event catalog and you're ready to sell. If you haven't created an event, a tier, or a customer yet, do that first — they're prerequisites for every issuance.

Before you start

You'll need:

  • An API key with the tickets:issue scope (and payments:write if you also create the payment yourself — see Take a payment).
  • An event that exists under your account.
  • A ticket tier on that event (name, fiat price, supply).
  • A customer record that is fully provisioned (status: "active"). A customer created moments ago may still be pending; issuance to a non-provisioned customer is rejected.

Base URL for every call:

https://api.ticketconnect.example/v1

Step 1 — Take payment for the ticket

Paid tiers require a verified payment before a ticket can be issued. Create a payment intent for the event and tier; the amount is computed server-side from the tier price, so you never pass an amount yourself.

Terminal
curl https://api.ticketconnect.example/v1/payment_intents \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "event_id": "evt_summerfest",
    "tier": "General Admission",
    "customer_id": "user_a1b2c3"
  }'
JSON
{
  "object": "payment_intent",
  "id": "pi_3PxyzABC",
  "provider": "stripe",
  "client_payload": { "client_secret": "pi_3PxyzABC_secret_..." },
  "amount": 49.00,
  "currency": "USD",
  "status": "requires_payment"
}

Collect the card details and complete the payment on the client using the returned client_payload (for Stripe, its client_secret), exactly as you would with Stripe. Once the payment status is succeeded, you're ready to issue. Full details, including test cards, are in Take a payment.

Free and comp tickets skip this step. If the tier price is 0, you can issue without a payment_intent_id.

Step 2 — Issue the ticket

Call the issuance endpoint with the customer, the tier, and the payment that funds it.

High-demand on-sale? Get the customer admitted first. If the event runs an on-sale waiting room (its event object has a non-null queue), issuance is gated on queue admission: join the queue with POST /v1/events/:id/queue/join, poll GET /v1/events/:id/queue?customer_id=... until status is admitted (with a live access_expires_at), then issue. Issuing before admission returns 403 with error type queue_error and code not_admitted. Events without a waiting room are unaffected.

Endpoint: POST /v1/events/:id/tickets · Scope: tickets:issue

Terminal
curl https://api.ticketconnect.example/v1/events/evt_summerfest/tickets \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: issue_order_88231" \
  -d '{
    "customer_id": "user_a1b2c3",
    "tier": "General Admission",
    "payment_intent_id": "pi_3PxyzABC"
  }'
JSON
{
  "id": "tkt_4f8a9c0d1e2f",
  "event_id": "evt_summerfest",
  "event_name": "Summer Fest 2026",
  "status": "valid",
  "tier": "General Admission",
  "price": 49.00,
  "currency": "USD",
  "perks": { "entry": "main_gate" },
  "qr": {
    "data": "8f2c1a...e7",
    "format": "QR"
  },
  "created_at": "2026-06-05T14:22:00.000Z"
}

A 201 response means the ticket is issued. The customer now holds it.

What the returned ticket contains

FieldWhat it is
idThe ticket id (tkt_...). Use it for delivery, transfer, refund, and upgrade.
statusvalid for a freshly issued ticket.
tierThe tier the customer bought.
price / currencyThe fiat amount charged, in your account's currency.
perksWhatever perks the tier carries.
qr.dataThe opaque QR string — the guaranteed minimum way to deliver the ticket.
qr.formatThe barcode format (e.g. QR).

To turn this into something a customer can use — rendered in your app, email, or the hosted checkout — see Deliver tickets.

Reserved seating: seatmap → hold → issue

If the event has a published seat map, its seated tiers are sold by the seat, and issuing on such a tier without a hold is rejected with 400 seat_required. The flow adds one step in front of payment:

  1. Read the map. GET /v1/events/:id/seatmap (scope events:read) returns the layout, live availability (sold / held / blocked seat ids), and per-tier pricing — render it and let the customer pick seats.
  2. Hold the seats. POST /v1/events/:id/seats/hold with { "seat_ids": ["S1-A-1", "S1-A-2"] } (scope tickets:issue, up to 10 seats) atomically claims them for 10 minutes and returns a hold_id. If someone got there first you get 409 seats_unavailable naming the contested seats.
  3. Take the payment. The payment must cover the tier price × the number of held seats. Card entry running long? POST /v1/events/:id/seats/hold/:holdId/extend resets the clock to 10 minutes from now.
  4. Issue with the hold. Call POST /v1/events/:id/tickets as usual, plus "hold_id": "..." — one ticket is issued per held seat, each bound to its seat, and the response becomes a batch:
JSON
{
  "tickets": [
    { "id": "tkt_...", "tier": "Stalls", "seat": "A-1", "qr": { "data": "...", "format": "QR" } },
    { "id": "tkt_...", "tier": "Stalls", "seat": "A-2", "qr": { "data": "...", "format": "QR" } }
  ]
}

Pass "quantity": 2 alongside hold_id as a safety check — issuance fails with 400 quantity_mismatch unless it equals the number of held seats (any quantity other than 1 requires a hold_id). If the customer walks away, release the hold with DELETE /v1/events/:id/seats/hold/:holdId (or just let it lapse). An expired hold fails issuance with 409 hold_expired — re-hold and retry. See Reserved seating for the full endpoint contracts.

How issuance protects you

The endpoint enforces several rules so you can't accidentally oversell or double-charge:

  • Payment must be verified, matching, and unused. For a paid tier, the payment_intent_id you pass is checked server-side against the expected amount, currency, event, and your account. A payment that doesn't match, or that has already funded a ticket, is rejected.
  • No double-spend. A single payment can fund at most one ticket. Reusing a payment returns 409 payment_already_used.
  • No overselling. Supply for each tier is claimed atomically. If the last seat is taken by a concurrent issue, you get 409 sold_out and no ticket is created.
  • Waiting-room admission. On events with an on-sale queue, a ticket is only issued to a customer who currently holds a live admission window — see the hint in Step 2.
  • Per-person purchase caps. If the event sets a per-person ticket limit (max_tickets_per_identity), issuance past the cap — including free and comp tickets — returns 403 purchase_limit_reached.

Always send an Idempotency-Key. If a network hiccup makes you retry an issuance, the same key returns the original ticket instead of creating a second one (and double-claiming supply). Keys are honored for 24 hours.

Common errors

StatuscodeMeaning
400parameter_missingcustomer_id or tier was not supplied.
404event_not_foundNo such event under your account.
404customer_not_provisionedThe customer isn't ready yet (pending).
403tier_not_releasedThe tier isn't on sale yet — it opens at a scheduled time or when another tier sells out.
403not_admittedThe event runs a waiting room and the customer doesn't hold a live admission window (error type queue_error).
403purchase_limit_reachedThe customer hit the event's per-person ticket limit.
402payment_requiredPaid tier with no payment_intent_id.
402payment_failedThe payment couldn't be verified or didn't match.
409payment_already_usedThat payment already funded a ticket.
409tier_not_foundNo tier with that name exists on the event.
409sold_outThe tier has no remaining supply.
400seat_requiredThe tier is reserved seating — hold seats first and pass hold_id.
409hold_not_foundThe hold is missing, released, or already used.
409hold_expiredThe hold lapsed — hold the seats again and retry.
409seat_tier_mismatchA held seat belongs to a different tier than the one being issued.
400quantity_mismatchquantity doesn't equal the number of held seats.

See also

Take a payment

A paid ticket needs a verified payment from your account's configured provider. Create a payment intent for an event and tier, complete the provider's payment flow, and attach that reference when issuing or upgrading a ticket. Stripe returns a client secret, PayPal an approval URL, and external payments require your server-side confirmation.

If you've integrated Stripe before, this will feel familiar: a server-created intent, a client-side confirmation, and a status you can poll. The key difference is that TicketConnect computes the amount for you — you never send a price, so a client can't tamper with what gets charged.

Before you start

You'll need:

  • An API key with the payments:write scope to create payments.
  • An event with at least one priced tier.

Reading a payment back (GET /v1/payments/:id) is allowed with either payments:write or payments:read.

Base URL:

https://api.ticketconnect.example/v1

Step 1 — Create a payment intent

Endpoint: POST /v1/payment_intents · Scope: payments:write

Tell the API which event and tier the customer is buying. The amount is derived server-side from that tier's fiat price.

Terminal
curl https://api.ticketconnect.example/v1/payment_intents \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: pay_order_88231" \
  -d '{
    "event_id": "evt_summerfest",
    "tier": "General Admission",
    "customer_id": "user_a1b2c3"
  }'
JSON
{
  "object": "payment_intent",
  "id": "pi_3PxyzABC",
  "provider": "stripe",
  "client_payload": { "client_secret": "pi_3PxyzABC_secret_..." },
  "amount": 49.00,
  "currency": "USD",
  "status": "requires_payment"
}

The amount is server-authoritative. There is no amount field in the request body, and any extra keys are ignored. The charge always equals the tier price on your event, in the server's event/account currency; a supplied currency must match. This is what stops a manipulated client from underpaying.

The client_payload carries the details your client needs to confirm the card (for the Stripe provider, a client_secret). Pass it to the TicketConnect client integration the same way you'd hand a client secret to Stripe's SDK, and confirm the card in the browser or app.

Step 2 — Confirm the card

Card confirmation happens on the client, with the customer's card details — they never touch your server. After the customer submits their card, the payment moves toward succeeded.

Test cards

In test mode (sk_test_... keys), use your payment provider's standard test cards to simulate outcomes — a card that always succeeds, one that always declines, one that triggers authentication, and so on. Test-mode payments are fully isolated from live data, so you can run a full issuance end-to-end without charging anyone.

Step 3 — Retrieve a payment

Check a payment's current status at any time.

Endpoint: GET /v1/payments/:id · Scope: payments:read or payments:write

Terminal
curl https://api.ticketconnect.example/v1/payments/pi_3PxyzABC \
  -H "Authorization: Bearer sk_test_..."
JSON
{
  "object": "payment_intent",
  "id": "pi_3PxyzABC",
  "amount": 49.00,
  "currency": "USD",
  "status": "succeeded"
}

A payment is only ever visible to the account that created it; requesting a payment that belongs to another account returns 404 resource_missing.

How a payment ties into issuance and upgrades

A payment on its own doesn't put a ticket in anyone's hands — it's the funding proof you attach to the next step:

  • Issuance. Pass the payment as payment_intent_id to POST /v1/events/:id/tickets. The payment must be succeeded, match the tier amount, currency, account, and mode. Its combined allocations across tickets and upgrades cannot exceed the original confirmed collection. See Issue a ticket.
  • Upgrades. When a customer moves to a more expensive tier, you create a payment for the price difference and attach it to the upgrade call. See Transfer, refund & upgrade.

Waiting-room events: charge after admission. If the event runs an on-sale queue (its event object has a non-null queue), admission is enforced when the ticket is issued — not when the intent is created. Join the queue and poll GET /v1/events/:id/queue?customer_id=... until status is admitted before you take the payment; otherwise the charge can succeed and issuance still return 403 not_admitted until the customer is admitted.

Reserved seating: hold the seats before you charge. On events with a published seat map, hold the customer's seats first (POST /v1/events/:id/seats/hold — seats stay yours for 10 minutes), then take the payment, then issue with the returned hold_id. Pass quantity: <held seats> when creating the payment intent — the server charges tier price × quantity, matching what issuance will verify. If card entry runs long, extend the hold (POST /v1/events/:id/seats/hold/:holdId/extend) so the seats don't lapse mid-payment. See Issue a ticket for the full flow.

Payment completion does not always fund a Stripe payout. External, PayPal, and crypto collections remain on their original rail; only proven platform-held Stripe funds contribute to the v1 payout balance. A payout transfers to your Stripe Connect account and does not confirm a bank deposit. See Money & currencies.

Use an Idempotency-Key on every create. Retrying a payment-intent creation with the same key returns the original intent instead of creating a duplicate charge. Completed response snapshots last about 24 hours; unresolved financial operations retain their original claim for recovery. See Idempotency.

Common errors

StatuscodeMeaning
400parameter_missingevent_id or tier was not supplied.
400parameter_invalidThe named tier doesn't exist on that event.
404resource_missingNo such event under your account (or payment not found on retrieve).
502payment_provider_errorThe provider couldn't create the payment; retry.

See also

Deliver tickets

Once a ticket is issued, you need to put it somewhere the customer can use it at the door. Every issued ticket carries an opaque QR string — the delivery contract — that you render as a QR code wherever fits your product: your own app, an email, a printed PDF.

Use this guide after you've issued a ticket (see Issue a ticket). Everything here reads from a single endpoint: GET /v1/tickets/:id.

The delivery options

OptionWhat you getWhen to use it
Raw QR stringqr.data — an opaque scannable stringYou render the code yourself, or embed it in your own app. The guaranteed minimum — always present.
Hosted ticket pageA signed, expiring link (POST /v1/tickets/:id/delivery_link) to a mobile page with the event, holder, live status badge, and the QRYou want a ready-made page to email, text, or hand to the customer — zero front-end work, always shows the ticket's current state.
Hosted checkoutThe branded checkout page shows the buyer their QR on-screen right after purchaseYou sell through the hosted checkout (GET /v1/checkout/:eventId?key=pk_...) and want zero front-end work.

The raw QR string is always available the moment a ticket is issued, and it is the same code the hosted ticket page and the hosted checkout display.

Before you start

You'll need:

  • An API key with the tickets:read scope.
  • An issued ticket id (tkt_...).

Base URL:

https://api.ticketconnect.example/v1

Step 1 — Fetch the ticket

Endpoint: GET /v1/tickets/:id · Scope: tickets:read

Terminal
curl https://api.ticketconnect.example/v1/tickets/tkt_4f8a9c0d1e2f \
  -H "Authorization: Bearer sk_test_..."
JSON
{
  "id": "tkt_4f8a9c0d1e2f",
  "event_id": "evt_summerfest",
  "event_name": "Summer Fest 2026",
  "status": "valid",
  "tier": "General Admission",
  "qr": {
    "data": "8f2c1a...e7",
    "format": "QR"
  },
  "created_at": "2026-06-05T14:22:00.000Z"
}

The qr.data field is your raw QR string, and qr.format tells you how to render it (e.g. QR).

Step 2 — Present the ticket to your customer

Option A — Render the QR string yourself

Take qr.data and render it as a QR code with any standard library in your stack, then show it in your app, your email, or a printed PDF. This is the most flexible option and works everywhere.

qr.data is an opaque string. It's a random value that uniquely identifies the ticket for scanning — it carries no personal or sensitive data, and it isn't a price, a customer id, or anything you should parse. Just render it.

Option B — Let the hosted checkout show it

If the ticket was bought through the hosted checkout page (GET /v1/checkout/:eventId?key=pk_...), the buyer sees the rendered QR code on-screen the moment the purchase completes — no front-end work on your side. It's the same qr.data you can fetch later with GET /v1/tickets/:id, so you can re-deliver it through your own channels at any time.

Option C — Send a hosted ticket page link

Mint a signed link to a ready-made mobile ticket page and deliver that — in an email, an SMS, or a "view your ticket" button:

Terminal
curl https://api.ticketconnect.example/v1/tickets/tkt_4f8a9c0d1e2f/delivery_link \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{ "expires_in_days": 30 }'
JSON
{
  "object": "delivery_link",
  "url": "https://api.ticketconnect.example/v1/t/dGt0XzRmOGE5YzBkLi4u...",
  "expires_at": "2026-08-10T14:22:00.000Z"
}

Links are bound to the QR code they were minted for. A transfer rotates the ticket's code, so every previously minted delivery link stops working — the old holder's link can never show the new holder's code. Mint a fresh link after a transfer. Revoking the signing secret (TICKET_LINK_SECRET) invalidates all outstanding links at once.

The page shows your logo, the event details, the holder's name and masked email, a live status badge, and the rendered entry QR — self-contained, mobile first, no login. It is live: a refund or revocation flips the badge the moment it happens, so the page is always truthful even while the link stays valid. Links expire after expires_in_days (1–90, default 30); expired or tampered links all render one identical 404 page. Minting needs only the tickets:read scope. See the API reference for the full contract.

No pass file through this API. /v1 does not expose an Apple/Google pass file for a ticket — the hosted ticket page is the ready-made delivery surface, and qr.data remains the delivery contract for everything you build yourself. Holders who keep the ticket in a ticket wallet on the TicketConnect rails can save it to Apple/Google Wallet from there — but only for events with live_codes off: a wallet pass is a static barcode, and the platform refuses one where a live, refreshing code is enforced (that refusal is what makes the anti-screenshot layer real).

At the door

Whichever way the customer receives it, your scanners validate it through the same endpoints. The ticket's status reflects its lifecycle — a freshly issued ticket is valid, and it becomes used after check-in. A refunded ticket surfaces as refunded here (a transfer keeps the ticket valid — same ticket, new owner), and a revoked ticket keeps its status but carries revoked: true and fails every scan. A quick GET /v1/tickets/:id always tells you whether a ticket is good to admit.

Holders may also present the ticket from a ticket wallet. A customer who keeps their ticket in a wallet on the TicketConnect rails (Gettix, our flagship licensee, is the reference holder experience) shows a live code that refreshes every few seconds instead of your static one. Your scanning endpoints validate both — send whatever was scanned, verbatim, and read the verdict (see Scanning & check-in). Your qr.data stays valid alongside it. One consequence to know about: once the holder has opened the ticket in a wallet, the ticket is locked to them — POST /v1/tickets/:id/transfer then returns 409 ticket_not_transferable. Live codes are on by default and togglable per event (live_codes on the events API).

See also

Transfer, refund & upgrade

Issuing a ticket isn't the end of its life. Customers change plans, want a better seat, or need their money back. This guide covers the three lifecycle operations you'll reach for most: transferring a ticket to another customer, refunding it, and upgrading it to a higher tier.

All of these operations use the same scope and act on a ticket id (tkt_...).

Before you start

You'll need:

  • An API key with the tickets:manage scope — required for every operation in this guide.
  • An issued ticket id.
  • For upgrades that cost money, a payment for the price difference (scope payments:write; see Take a payment).

Base URL:

https://api.ticketconnect.example/v1

Every operation returns the updated ticket, and each mutating call accepts an Idempotency-Key header for safe retries.

Transfer a ticket

Reassign a ticket to a different customer — for example, when the original buyer gives it to a friend.

Endpoint: POST /v1/tickets/:id/transfer · Scope: tickets:manage

Pass the recipient as to_customer_id. The recipient must be an existing, provisioned customer under your account.

Terminal
curl https://api.ticketconnect.example/v1/tickets/tkt_4f8a9c0d1e2f/transfer \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: transfer_88231" \
  -d '{ "to_customer_id": "user_99887766" }'
JSON
{
  "id": "tkt_4f8a9c0d1e2f",
  "event_id": "evt_summerfest",
  "event_name": "Summer Fest 2026",
  "status": "valid",
  "tier": "General Admission",
  "qr": { "data": "8f2c1a...e7", "format": "QR" },
  "created_at": "2026-06-05T14:22:00.000Z"
}

The ticket now belongs to the new customer — and the QR code rotates: the response (and the ticket.transferred webhook) carries a fresh qr.data, and the previous code stops scanning immediately. That kills any screenshot the previous holder kept, and it also invalidates any hosted delivery links minted before the transfer — mint a new one for the new holder.

The recipient must exist first. If to_customer_id doesn't match a provisioned customer, you get 404 customer_not_found. Create the customer before transferring.

A ticket opened in a holder's ticket wallet can't be transferred. Once the holder has opened the ticket in a wallet built on the TicketConnect rails (any holder experience built on those rails — Gettix, our flagship licensee, is the reference one), it is locked to them and POST /v1/tickets/:id/transfer returns 409 ticket_not_transferable (the same code a used or refunded ticket gets). Refunds are unaffected — a refunded ticket fails every scan regardless.

Refund a ticket

Cancel a ticket and reverse the customer's payment.

Endpoint: POST /v1/tickets/:id/refund · Scope: tickets:manage

Terminal
curl https://api.ticketconnect.example/v1/tickets/tkt_4f8a9c0d1e2f/refund \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: refund_88231" \
  -X POST
JSON
{
  "id": "tkt_4f8a9c0d1e2f",
  "event_name": "Summer Fest 2026",
  "status": "refunded",
  "tier": "General Admission",
  "qr": { "data": "8f2c1a...e7", "format": "QR" },
  "created_at": "2026-06-05T14:22:00.000Z"
}

The platform reserves the refund and blocks entry before asking the provider to return money. It refunds the original purchase and each paid upgrade as separate allocations in their original currency and mode. It reverses the original booked seller net and fees rather than the ticket's current displayed price.

The ticket becomes refunded and emits ticket.refunded only when every required refund is confirmed. A pending Stripe or PayPal refund keeps its receipt ID and is recovered by GET, without another refund POST. A failed or canceled receipt, missing original proof, unsupported external refund, or depleted refundable funds stays held for review; it is not reported as refunded.

Retry with the original Idempotency-Key and identical request. A completed replay returns the original result. A separate request for an already refunded ticket can return 409 already_refunded.

Upgrade a ticket to a higher tier

Move a ticket up to a more expensive tier, charging the customer the fiat difference. The ticket keeps its id and its QR code — only the tier, perks, and price change.

Step 1 — List the available upgrade options

Endpoint: GET /v1/tickets/:id/upgrade-options · Scope: tickets:manage

This returns the higher tiers the ticket can move into — those that are priced above the current tier, have upgrades enabled, and still have supply — each with its price, the fiat price difference (delta), remaining supply, and perks.

Terminal
curl https://api.ticketconnect.example/v1/tickets/tkt_4f8a9c0d1e2f/upgrade-options \
  -H "Authorization: Bearer sk_test_..."
JSON
{
  "current_tier": "General Admission",
  "options": [
    {
      "name": "VIP",
      "price": 149.00,
      "delta": 100.00,
      "available": 42,
      "perks": { "lounge": true }
    }
  ]
}

Step 2 — Pay the difference

If the upgrade has a positive price difference (the option's delta), take a payment for that amount first (see Take a payment), and keep the resulting payment_intent_id. The upgrade reserves its exact native-unit difference against the same original payment budget used for issuance. Allocations cannot exceed the verified collection, and an already consumed allocation cannot be claimed twice.

Step 3 — Upgrade

Endpoint: POST /v1/tickets/:id/upgrade · Scope: tickets:manage

Pass the target_tier and the payment that covers the difference.

Terminal
curl https://api.ticketconnect.example/v1/tickets/tkt_4f8a9c0d1e2f/upgrade \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: upgrade_88231" \
  -d '{
    "target_tier": "VIP",
    "payment_intent_id": "pi_9QabcDEF"
  }'
JSON
{
  "id": "tkt_4f8a9c0d1e2f",
  "event_name": "Summer Fest 2026",
  "status": "valid",
  "tier": "VIP",
  "price": 149.00,
  "currency": "USD",
  "perks": { "lounge": true },
  "qr": { "data": "8f2c1a...e7", "format": "QR" },
  "created_at": "2026-06-05T14:22:00.000Z"
}

The ticket is now on the higher tier, with the new perks and price, and the same QR code the customer already has.

Upgrades can work even after check-in. As long as the target tier allows upgrades, a ticket can be moved up even if it's already been used — handy for on-site "upgrade to VIP at the door" flows. The price difference is computed server-side from the tiers, never trusted from the request.

Common upgrade errors

StatuscodeMeaning
400parameter_missingtarget_tier was not supplied.
400not_an_upgradeThe target tier isn't priced above the current one.
400upgrades_disabledThe target tier doesn't allow upgrades.
402payment_requiredThe upgrade costs money but no payment_intent_id was attached.
402payment_failedThe payment couldn't be verified or didn't match the difference.
404tier_not_foundNo such tier on the event.
409payment_already_usedThat payment already funded an upgrade.
409sold_outThe target tier has no remaining supply.

See also

Scanning & Check-in

When the doors open you need a fast, reliable way to decide one thing for every person walking up: let them in, or not? The scanning endpoints answer exactly that. You send the QR string read off a ticket and get back a clear verdict — valid, already used, refunded, or not found — plus the ticket's tier so your staff know which section it's for.

Every ticket is tamper-proof, so you can trust the verdict without any extra checks of your own. That covers both shapes of code a holder can present: the opaque QR string you deliver yourself, and the live, refreshing code shown for tickets held in a holder's ticket wallet — the platform verifies the latter cryptographically on your behalf. None of that machinery is visible to you: you send the scanned string, you get a verdict.

Scopes

All three endpoints on this page require the scanning:write scope.

EndpointMethodScope
/v1/scan/validatePOSTscanning:write
/v1/scan/batchPOSTscanning:write
/v1/tickets/{id}/attendancePOSTscanning:write

Validate vs. mark attendance

There are two ways to check someone in, and they map to two different endpoints:

  • Validate (online): POST /v1/scan/validate (and its batch sibling) looks a ticket up by its QR string and returns a verdict. It is the right call when your scanner has a live connection. It does not mark the ticket as used — it only reports whether the ticket is currently valid.
  • Mark attendance: POST /v1/tickets/{id}/attendance is what actually records the check-in and flips the ticket to used. This is the state-changing step.

For a venue with a steady connection, the simplest flow is: read the QR, call attendance by ticket id, and admit on a used result.

If your scanners have spotty connectivity (patchy Wi-Fi at the gate, a remote field), don't queue codes for later validation. POST /v1/scan/batch judges codes by their current validity, so it can't tell you whether a queued live code was valid at the moment it was scanned. Use the offline door protocol instead: a scanner device armed through /v1/scanner decides at the gate with no network, verifying signed wallet codes cryptographically and matching your opaque qr.data against its armed ticket list. It uploads device-signed scans when it reconnects. The @ticketconnect/offline-scanner SDK implements the device side, and Gettix's staff mode (Gettix is our flagship licensee) runs on the same protocol. See Offline scanning.

Validate a single ticket at the door

Send the QR string exactly as your scanner read it. Don't parse, trim, or re-encode it — a live wallet code is longer and looks like a JSON blob; it must arrive verbatim.

Terminal
curl https://api.ticketconnect.example/v1/scan/validate \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "qr": "QR_STRING_FROM_THE_TICKET"
  }'

A valid ticket returns:

JSON
{
  "valid": true,
  "status": "valid",
  "tier": "General Admission",
  "ticket_id": "tkt_8f2c..."
}

A ticket that has already been scanned in returns a verdict you can show your staff directly:

JSON
{
  "valid": false,
  "reason": "already_used",
  "tier": "General Admission",
  "ticket_id": "tkt_8f2c..."
}

The possible reason values for an invalid result are not_found, refunded, revoked, already_used, and — for live wallet codes — code_expired, live_code_required, and invalid_qr (see below). A not_found result carries no ticket_id.

Note: POST /v1/scan/validate is read-only — it never changes the ticket. Use it to preview a verdict, then call the attendance endpoint to actually check the person in.

Live codes from wallet-held tickets

Some holders keep their ticket in a ticket wallet — any holder experience built on the TicketConnect rails, Gettix, our flagship licensee, being the reference one — instead of (or besides) whatever you delivered. The wallet shows a live code that refreshes every few seconds — an anti-screenshot measure — and your scanning endpoints validate it exactly like any other code: send the scanned string verbatim, get a verdict. The platform checks the code's authenticity and freshness cryptographically before the usual lifecycle verdict.

Three extra reason values exist only for these codes, and each maps to a clear instruction for door staff:

reasonWhat happenedWhat staff should do
code_expiredThe code was real but too old — typically a screenshot or a phone that has been sitting on the same frame.Ask the holder to reopen the ticket in their wallet and re-scan.
live_code_requiredA static copy of a wallet ticket was presented for an event that requires the live, refreshing code.Refuse — ask for the live code in the wallet (or scan the code you delivered, which stays valid).
invalid_qrThe code failed cryptographic verification — tampered or forged.Refuse entry.

Freshness comes before lifecycle: a stale code on an already-used ticket reads code_expired, not already_used. Have the holder refresh, re-scan, and trust the second verdict.

The static QR string you deliver (qr.data) is unaffected by any of this — it keeps validating by itself, side by side with the wallet's live code.

Live codes are on by default for every event you create and togglable per event: set live_codes: false at creation or flip it later with PATCH /v1/events/{id} (see the events API reference). With live codes off, wallet-held tickets show a static code and none of the extra reason values above can occur.

Batch validation

To check many codes at once while online, send them in one call. Pass an array of QR strings in qrs (up to 200 per request). Each code is judged on its current validity, so live codes must still be fresh when the request arrives. Batch validation is not an offline mode. To scan without a connection, use the offline door protocol, where devices push up to 1000 signed scans per request when they reconnect.

Terminal
curl https://api.ticketconnect.example/v1/scan/batch \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "qrs": [
      "QR_STRING_1",
      "QR_STRING_2",
      "QR_STRING_3"
    ]
  }'

You get one verdict per QR, in a results array:

JSON
{
  "results": [
    { "qr": "QR_STRING_1", "valid": true,  "status": "valid", "tier": "VIP", "ticket_id": "tkt_a1..." },
    { "qr": "QR_STRING_2", "valid": false, "reason": "already_used", "tier": "VIP", "ticket_id": "tkt_b2..." },
    { "qr": "QR_STRING_3", "valid": false, "reason": "not_found" }
  ]
}

Warning: A single batch may contain at most 200 entries. Larger backlogs should be split into multiple requests. Like validate, batch is a read-only check: it reports verdicts but does not mark tickets used.

Mark attendance by ticket id

This is the call that records the check-in. Use the ticket's id (not the QR string), which you have from issuance, from a list, or from a validate response.

Terminal
curl https://api.ticketconnect.example/v1/tickets/tkt_8f2c.../attendance \
  -X POST \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json"

A first successful check-in returns:

JSON
{ "id": "tkt_8f2c...", "status": "used" }

Idempotent re-scans

Door scanning is messy — the same person gets scanned twice, a request times out and the device retries, two lanes scan the same group. The attendance endpoint is built for this:

  • Calling it again on a ticket that is already checked in is a safe no-op. Instead of an error, you get a 200 with status: "already_used":

    JSON
    { "id": "tkt_8f2c...", "status": "already_used" }
    
  • For exactly-once semantics on a flaky connection, send an Idempotency-Key header. A replay with the same key returns the original response rather than processing the check-in twice.

    Terminal
    curl https://api.ticketconnect.example/v1/tickets/tkt_8f2c.../attendance \
      -X POST \
      -H "Authorization: Bearer sk_test_..." \
      -H "Idempotency-Key: door-lane-2-scan-90183" \
      -H "Content-Type: application/json"
    

A successful check-in also triggers an attendance.verified webhook, so any back-office dashboards stay in sync automatically.

Note: A ticket that doesn't exist (or isn't yours) returns 404 with a resource_missing error. A refunded ticket is reported as invalid by validate; don't mark it attended.

Multi-day events

By default a ticket admits once: after it is marked attended, every later scan is already_used. For a festival or any event that runs over several days, give the event admission days — the instants at which each day starts — and a ticket admits once per day:

Terminal
curl https://api.ticketconnect.example/v1/events \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Three Days",
    "date": "2026-11-13T17:00:00Z",
    "end_date": "2026-11-15T22:00:00Z",
    "admission_days": ["2026-11-13T05:00:00Z", "2026-11-14T05:00:00Z", "2026-11-15T05:00:00Z"]
  }'
  • The day of a scan is decided by its time alone: it belongs to the last admission day that has started. A scan before the first one counts as the first day.
  • Put the boundaries where your event is quiet. 05:00Z above is 06:00 in Warsaw, so a night that runs past midnight stays one day.
  • A tier is a pass valid every day unless you give it valid_days when you add it — [0] makes a first-day ticket.
  • validate answers already_used only for a ticket admitted today, and wrong_day for a tier that is not valid today. Attendance records one check-in per day; the attendance.verified webhook carries admission_day.
  • Transfers, resale and refunds follow the first check-in, as for any ticket: a pass that has been used on day one can no longer be passed on.

Without admission_days nothing changes, even when the event has an end_date: the end date only keeps scanning open for the whole run.

See also

Offline scanning

Venue Wi-Fi fails at the worst moment: thousands of phones in one room, a basement club, a field with one bar of signal. An online gate (Scanning & Check-in) stops when the network does. TicketConnect's door protocol keeps it running: a scanner device downloads what it needs before doors open, decides at the gate with no network at all, and uploads a signed log of every admission when it reconnects.

The protocol is public on /v1 under /v1/scanner/*, and the @ticketconnect/offline-scanner SDK implements the device side for you. Gettix's staff mode (Gettix is our flagship licensee, built on TicketConnect) runs on this same protocol. Every endpoint is documented in the Scanner protocol reference.

Which codes can be scanned offline

The offline gate accepts both shapes of code a holder can present:

  • TicketConnect-signed wallet codes. This is the signed code shown by a holder's ticket wallet on the TicketConnect rails (Gettix is the reference wallet), including its live, refreshing variant. The device verifies it cryptographically with the event key, and checks live codes for freshness.
  • Your opaque qr.data. This is the code you deliver through /v1. It carries no signature. Instead, the ticket feed includes a barcode_sha256 for each ticket: the SHA-256 of the barcode, never the barcode itself. Any scanned string that doesn't start with { is treated as an opaque code: the device hashes it exactly as read and looks the hash up in its armed ticket list. An unknown hash is ticket_not_found. Opaque codes skip the live-code check, just as they do on the online gate.

Caveat: an opaque code is only as fresh as the last ticket sync. While online, the device refreshes its ticket list every 60 seconds. If a transfer rotates a ticket's barcode after the device's last refresh, the new code is unknown to that gate, and the old one still matches, until the next refresh. A signed wallet code doesn't have this gap because it is verified on its own merits. That makes wallet codes the stronger option at an offline door.

Trust model in plain language

No single piece of the gate relies on the network or on trusting the phone:

  • Session tokens, minted server-side. Your backend calls POST /v1/scanner/sessions with its secret sk_ key and passes the resulting scanner session token to the device. Never ship an sk_ key to a device. This is the same pattern as Stripe Terminal connection tokens. A session covers one staff member and one event, and the event comes from the token itself, so no device route takes an event id. A session for event A cannot read or write event B. The token lasts at most 12 hours and never outlives the event's grace cutoff. The SDK keeps it in your secure key-value store, never in the scan database.
  • Sessions carry only door capabilities. A scanner session can scan tickets and redeem perks, nothing else. Selling, staff management and stats are always off in it, and it is refused (401) everywhere except the /v1/scanner device endpoints.
  • Live permissions. On every request, a session's effective permissions are what the token grants and what the staff member currently has. Turning off can_scan_tickets or can_redeem_perks, deactivating or suspending the staff member, unassigning them from the event, suspending your account, or revoking the API key that minted the session takes effect within about 60 seconds. The device then gets 401, or its pushed rows come back rejected with reason staff_lacks_permission.
  • Per-event signing keys. Every event has its own Ed25519 key pair. Ticket codes are signed with the private key, which never leaves the server. Devices verify them with the event's public key from the manifest.
  • Device keys and device certificates. The device generates an Ed25519 key for each staff member who signs in on it and registers the public half. Arming fails until registration succeeds. The platform returns a platform-signed device certificate scoped to that event, listing the device's capabilities (scan, perk or both, following the staff member's permissions). It expires at the event's grace cutoff or after 7 days, whichever comes first. Nearby scanners check each other's certificates against the platform key (which the SDK pins on first sight and never lets change) and drop packet types the certificate doesn't grant, so a phone can't pose as a gate and a perk-only phone can't mark tickets used. A staff member can hold at most 10 active devices per event.
  • A local used-set that only moves forward. Admitting a ticket marks it used and appends the device-signed scan record in one transaction, so a crash can't admit someone without leaving the signed record that reconciles it. A server refresh can mark a ticket used but never makes it unused again, because the server may not have heard about a local or gossiped admission yet.
  • Signed reconciliation, earliest wins. When a device syncs, the server checks that each row belongs to the session's staff member and one of their own devices, then verifies its Ed25519 signature against that device's registered key. Each row is stored as pending, its effect is applied, and only then is it final. Resending an identical row returns its stored outcome (or finishes it, if an earlier attempt was cut off) and changes nothing else, so retries are safe. If two gates admitted the same ticket within the 10-second gossip window, the earlier scan wins and the later one is superseded. Scans of the same ticket more than 10 seconds apart are both flagged suspect_duplicate for your review.
  • Revocation of tickets and devices. Revoke a ticket (POST /v1/tickets/{id}/revoke) and it leaves the valid-ticket feed and appears in the revoked-ticket feed. Revoke a lost phone (POST /v1/staff/{id}/devices/{device_id}/revoke) and its signed scans are rejected at reconciliation, while armed peers drop its gossip. Deactivating, suspending or deleting a staff member, or removing their can_scan_tickets or can_redeem_perks, revokes all of their devices. Unassigning them from an event revokes their devices for that event only; the devices keep working at their other events. Revocation is permanent but not retroactive: rows the device signed up to 60 seconds after the revocation are still processed (and flagged for your review), and only later rows are rejected as device_revoked. The same applies to scans and redemptions made before a permission was removed. On device_revoked or device_conflict during registration, the SDK generates a new device identity and registers again.
  • A grace cutoff. The scanning window closes at event end + 24 hours. After that, new sessions return 410 event_ended, the server rejects late scans with event_ended, and the SDK refuses to scan.
  • Live rotating codes. On events with live codes, a holder's code refreshes every 30 seconds and is signed with a key only that ticket has. The device verifies it with the per-ticket public key delivered in the ticket feed (rot_pub_key). The rotation master key never leaves the server, so an armed phone can't forge a live code.

Arming sequence

Arm each device while it is still online, before doors open. arm() in the SDK does all of this; a custom client must follow the same order:

  1. Session: get a token from your backend (which called POST /v1/scanner/sessions).
  2. Manifest: GET /v1/scanner/manifest returns the event id (check that it matches), the event public key, the platform certificate key, the event end and grace cutoff, the live-code settings, the session's permissions and — on a multi-day event with daily admission — an admission object: day_starts (when each admission day starts) and tiers (lowercased tier name → the day indexes it is valid on).
  3. Device: load the device key for this staff member (or generate one), then POST /v1/scanner/devices to register it and receive a device certificate. Pin the platform certificate key once. Registration is required: if it fails, arm() rejects and the gate stays not_armed. On 409 device_conflict or 403 device_revoked the SDK creates a new identity and retries once.
  4. Revoked devices: read the full list for this event from GET /v1/scanner/devices/revoked.
  5. Valid tickets: page through all of GET /v1/scanner/tickets.
  6. Revoked tickets: page through the full list from GET /v1/scanner/tickets/revoked.
  7. Perk grants: page through GET /v1/scanner/perk-grants.
  8. Used-set: pull what other gates already admitted, using GET /v1/scanner/scans and GET /v1/scanner/perk-redemptions.

Scan decision order

scanTicket(qr) runs these checks in order and stops at the first failure. Everything here runs on the device.

#CheckRejection reason
1The device is armednot_armed
2The session grants scan_tickets (no recorded permissions counts as no)permission_denied
3Now is before the grace cutoff (event end + 24 h)event_ended
4QR envelope: JSON, required fields, a valid Ed25519 signature from the event key, not past validUntilmalformed_qr, missing_required_fields, verify_threw, bad_signature, expired
5The QR's event is this eventwrong_event
6The ticket is known locallyticket_not_found
7On events that enforce live codes: the per-ticket key is present and the live code is fresh (current 30-second step ±1)rot_key_unavailable, rot_missing, rot_malformed, rot_stale, rot_mismatch
8The ticket is not revoked (revocation outranks "used")revoked
9Daily admission only: the ticket's tier is valid on the current admission daywrong_day
10Sign the scan with the device keydevice_key_error
11Atomically mark the ticket used and append the signed scanalready_used, unknown_ticket

An opaque code (anything that doesn't start with {) replaces checks 4 to 7 with the barcode_sha256 lookup: an unknown hash is ticket_not_found, and a known one continues at check 8.

On an event with daily admission, "used" means used today. When a new admission day starts the device re-opens the tickets admitted on earlier days (once, and durably), and from then on ignores earlier days' entries from the ticket feed, the scan pull and gossip. The server reconciles the same way: two scans of one ticket collide only when they fall on the same admission day.

Success returns { ok: true, ticketId, seatNumber, … } (qr is null for an opaque code). After an admission the device broadcasts a gossip packet and starts a best-effort sync. redeemPerk(qr) follows the same pattern, with permission_denied when the session lacks redeem_perks.

Sync order

startSync() runs a sync immediately and then every 15 seconds. Each run goes in this order, so local admissions reach the server before any server state is applied:

  1. Push pending scans: POST /v1/scanner/scans.
  2. Push pending perk redemptions: POST /v1/scanner/perk-redemptions.
  3. Refresh valid tickets (at most once every 60 seconds).
  4. Pull the revoked-ticket delta (every run).
  5. Pull the revoked-device delta (every run).
  6. Refresh perk grants (at most once every 60 seconds).
  7. Pull other gates' scans, then their perk redemptions.

If either push fails with a network error, 408, 429 or a 5xx, the rows stay pending and that run skips both refreshes (steps 3 and 6), because the server's view would lag the unpushed rows. The revocation pulls still run. Any other whole-batch 4xx (for example 400) is final: the batch's rows are marked rejected with the error code, local used state is kept, and a push_rejected alert with final: true is raised through onAlert. Per-row outcomes are final too, except staff_mismatch, which should never happen: that row stays pending and raises a push_rejected alert with final: false. A row rejected with staff_lacks_permission also makes the SDK re-read the manifest, so the gate starts answering permission_denied. Refreshes can only add "used" state, never remove it. While a perk grant has unconfirmed local or gossiped redemptions, the device keeps the lower of the server's and its own remaining uses.

When the token has 5 minutes or less left, or a request returns 401, the SDK asks your SessionProvider for a new session. The gate disarms until the next arm if the manifest names a different staff member, or the device shows up as revoked.

Shared phones. Each row belongs to the staff member who signed it, and a sync pushes only the current staff member's rows. If someone else signs in on the same phone, the previous staff member's unsynced scans and redemptions stay queued on the device until that person signs in on it again while online (countPendingOfOtherStaff() reports how many; Gettix shows a notice on its scanner screens). If a staff member loses every scanner permission, no session can be minted for them, so their queued rows wait until a permission is restored.

Peer gossip (optional)

Gates that can reach each other can share admissions within seconds, with no server involved. After an admission, a device sends a small signed packet that carries the scan, its signature and the sender's device certificate. A receiver accepts it only after these checks, in order:

  1. The packet's size and event are right, and it is a v: 2 packet with a certificate. Anything else is dropped.
  2. The sender is not on the event's revoked-device list.
  3. Flood control, keyed on the sender's network source when the transport reports one (else on the claimed device id). Senders without a verified certificate get 20 packets/s per key before any cryptography, within a 200/s total. Senders with a verified certificate get 20 packets/s per (device id, source), within a 200 verifications/s CPU guard. One spoofed key can use at most 20 of those 200, so junk traffic can't crowd out real gates.
  4. The certificate matches the sender, is for this event, is unexpired, grants the packet type (scan or perk), and is signed by the pinned platform key.
  5. The row signature verifies. Only then does the packet count against the sender's 20/s budget and mark itself as seen, so a forged copy can't block the genuine one.

The receiver then records the scan in its own used-set, atomically. Nothing about peers is persisted: each gossip session verifies a peer's certificate on its first packet. A device without a certificate doesn't broadcast.

Gossip is transport-agnostic. You supply one or more PeerTransports: UDP on the venue LAN, an iOS/Android phone-to-phone mesh, BLE, or anything else that moves strings. Pass the packet's source (for example the UDP sender address or the mesh peer name) so flood budgets can't be exhausted by someone spoofing a gate's id. The packet format (gossip v2 packets, v3 certificates) and the canonical signed strings are specified in the SDK README (sections Gossip protocol and Canonical strings). The canonical strings are also listed in the Scanner protocol reference.

Limits

LimitValue
Rows per push (scans or redemptions)1000; more returns 413 batch_too_large (the SDK pushes 200 at a time)
Push row fieldsids up to 128 characters; sig exactly 128 lowercase hex; timestamps ISO-8601 with Z or an offset
GET /v1/scanner/tickets, /tickets/revoked, /perk-grants, /devices/revoked page sizedefault 1000, max 5000 (larger values are clamped)
GET /v1/scanner/scans, /perk-redemptions page sizedefault 500, max 2000
Active devices10 per staff member per event (409 device_limit_reached)
Session token lifetimeup to 12 h, never past event end + 24 h (minimum 60 s)
Permission, staff and account changeseffective within about 60 s
Device certificate lifetimeuntil event end + 24 h, capped at 7 days (24 h for an undated event)
Gossip reconciliation window10 s: earliest wins inside it; suspect_duplicate outside it
Gossip packet size2048 characters
Gossip rate caps (per receiver)per key (source, else device id): 20 packets/s; unverified senders 200/s in total; verified senders 200 verifications/s CPU guard
Gossip memory bounds4000 seen packets, 512 tracked peers, 1000 rejected certificates
Live code30 s steps, ±1 step tolerance

SDK quick start

1. Install

The SDK is the package @ticketconnect/offline-scanner (v0.2.0). It is distributed as the tarball ticketconnect-offline-scanner-0.2.0.tgz:

Terminal
npm install ./ticketconnect-offline-scanner-0.2.0.tgz

v0.2.0 adds daily admission for multi-day events. A gate on v0.1.0 keeps working at such an event but treats a ticket as used for good, so it refuses re-entry on later days.

It ships ESM, CommonJS and TypeScript declarations. Its runtime dependencies are @noble/curves and @noble/hashes, and it doesn't use React, React Native, Expo or Node-only APIs, so it runs in React Native, the browser or Node.

2. Mint sessions on your server

Add an endpoint to your backend that your staff app calls after the staff member signs in. It is the only place your sk_ key is used. The key needs the scanning:write scope.

js
// Your backend (Node / Express). The sk_ key never leaves this server.
app.post("/scanner-session", requireStaffLogin, async (req, res) => {
  const r = await fetch("https://api.ticketconnect.example/v1/scanner/sessions", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.TICKETCONNECT_SECRET_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      staff_id: req.user.ticketconnectStaffId, // from POST /v1/staff
      event_id: req.body.event_id,
    }),
  });
  const body = await r.json();
  if (!r.ok) return res.status(r.status).json(body); // 404 / 403 / 409 / 410
  // body: { object: "scanner_session", token, expires_at, event_id, staff_id }
  res.json({ token: body.token, expires_at: body.expires_at });
});

The staff member must be active, assigned to the event (POST /v1/staff/{id}/assignments), and have can_scan_tickets or can_redeem_perks.

3. Implement the ports

The engine does not do its own I/O. The host app supplies:

PortWhat you provide
SessionProvidermint(): Promise<{ token, expiresAt }>, which calls the endpoint from step 2.
ScannerStoreDurable storage, one instance per event (for example SQLite): meta, tickets (with an indexed getTicketByBarcodeHash lookup), revocation flags, perk grants, and the signed scan and perk outboxes. Methods marked ATOMIC (upsertTickets, markTicketsRevoked, commitLocalScan, recordPeerScan, upsertPerkGrants, commitLocalPerkRedemption, recordPeerPerkRedemption) must save all of their writes or none, without interleaving. On SQLite, use a single BEGIN IMMEDIATE … COMMIT. upsertTickets must never turn a used ticket back to unused. For daily admission also implement the optional, atomic reopenTicketsUsedBefore(cutoffIso): every used ticket whose usedAt is before the cutoff becomes unused again (the engine calls it once when a new admission day starts); without it the gate refuses re-entry on later days. The store keeps no peer table.
KeyValueStoreSecure string storage (Keychain / Keystore) for the device identity (private key, public key and device id, one set per staff member), the scanner session token, device certificates and the pinned platform key.
RandomSourcebytes(n) from a cryptographically secure generator.
PeerTransport (optional)A lossy string channel for gossip: start(onPacket, self), stop(), send(json). Call onPacket(json, source) with the sender's address or peer name when you know it.
Clock, Logger (optional)Default to Date.now() and no logging.

MemoryScannerStore and MemoryKeyValueStore are included for tests and prototypes. Replace them with durable, secure implementations in production.

4. Arm, scan, sync, gossip

ts
import {
  createOfflineScanner,
  MemoryScannerStore,
  MemoryKeyValueStore,
  type SessionProvider,
} from "@ticketconnect/offline-scanner";

const session: SessionProvider = {
  async mint() {
    const res = await fetch("https://your-backend.example/scanner-session", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ event_id: "evt_42" }),
    });
    if (!res.ok) throw new Error(`session mint failed: ${res.status}`);
    const body = await res.json(); // { token, expires_at }
    return { token: body.token, expiresAt: body.expires_at };
  },
};

// One engine per event, shared by your ticket and perk screens.
const scanner = createOfflineScanner({
  eventId: "evt_42",
  baseUrl: "https://api.ticketconnect.example", // paths are appended as /v1/scanner/...
  session,
  store: new MemoryScannerStore(),      // production: durable, transactional store
  keyValue: new MemoryKeyValueStore(),  // production: Keychain / Keystore
  random: { bytes: (n) => crypto.getRandomValues(new Uint8Array(n)) },
  transports: [],                       // add your PeerTransport(s) for gossip
  device: { platform: "ios", appVersion: "1.0.0" },
});

// Arm while online. ensureArmed() reuses a stored session when it is still fresh.
await scanner.arm();

// Keep reconciling in the background: runs now, then every 15 s.
scanner.startSync();
const offAlerts = scanner.onAlert((alert) => {
  if (alert.outcome === "push_rejected") {
    console.error("server refused rows", alert.code, alert.final ? "rejected for good" : "kept queued");
    return;
  }
  console.warn("possible duplicate entry", alert.ticketId);
});

// Optional phone-to-phone gossip. Returns false if the device isn't armed.
await scanner.startGossip();

// At the gate. Works with no network.
const result = await scanner.scanTicket(qrText);
if (result.ok) {
  admit(result.ticketId, result.seatNumber);
} else {
  refuse(result.reason); // "already_used" | "revoked" | "permission_denied" | "rot_stale" | …
}

// Perks use the same engine.
const perk = await scanner.redeemPerk(perkQrText);

// Teardown.
await scanner.stopGossip();
scanner.stopSync();
offAlerts();

Debouncing belongs in your camera handler, not the engine. The Gettix reference ignores the same payload for 10 seconds and any read within 200 ms of the previous one.

What the SDK does not include

  • Camera and scanning UI. You decode the QR and pass the string to scanTicket, then design your own result screens.
  • A native mesh transport. The SDK handles packet format, flood control and all cryptography, while the host supplies the transports. Gettix's staff mode is the reference implementation of the ports: an SQLite store, secure storage, UDP on the LAN, and a phone-to-phone mesh over MultipeerConnectivity on iOS and Nearby Connections on Android. That code is part of the Gettix app and is not included in the SDK.
  • Durable storage. The in-memory stores are for tests and prototypes.
  • Session minting and staff sign-in. These live in your backend and app (step 2).
  • Gate sales and wristbands. There are no /v1 POS endpoints.

See also

Marketplace & Resale

TicketConnect runs a built-in secondary market so your customers can resell tickets they can no longer use — safely, at a fair price, and with you earning a royalty on every resale. This guide covers how to read that market from the API: list the active resale listings for an event and fetch a single listing.

You don't operate the resale checkout. Listing a ticket for resale, taking the buyer's payment, transferring the ticket, and splitting the proceeds are all handled by the platform. From the API you read the live listings; the purchase and settlement happen on the platform side.

Scopes

The read endpoints on this page require the marketplace:read scope.

EndpointMethodScope
/v1/marketplace/listingsGETmarketplace:read
/v1/marketplace/listings/{id}GETmarketplace:read

How resale works (at a glance)

  • Fiat pricing. Every listing is priced in your settlement currency. You see one number — the resale price — just like any other amount in the API.
  • Verified royalty settlement. The configured royalty is part of the frozen resale split. Each required leg must have original payment proof and a confirmed settlement on its rail; unknown legs remain pending. A resale royalty does not automatically become platform-held Stripe funds in your v1 payout balance.
  • Anti-scalping, on by default. Every event ships with anti-scalping enforced and a resale ceiling of 150% of face value. Both the bounds (floor/ceiling), the resale-open window, and whether resale or anti-scalping is on at all are per-event settings you control when you create or update the event (marketplace_settings on Events) — the same controls organizers get in the panel. Enforcement itself is automatic and can't be bypassed by a reseller; you tune the policy, the platform applies it.

Because of all this, the listing objects you read are intentionally lean: a listing id, the ticket it belongs to, and the fiat price. No internal seller or settlement details are ever exposed.

List listings

Returns your resale listings, newest first, cursor-paginated via limit and starting_after. Each listing carries its status, so you can pick out the active ones.

Terminal
curl "https://api.ticketconnect.example/v1/marketplace/listings?limit=20" \
  -H "Authorization: Bearer sk_test_..."
JSON
{
  "object": "list",
  "data": [
    {
      "id": "list_9a3f1c2e4b6d8f0a1b2c3d4e5f60718",
      "ticket_id": "tkt_8f2c...",
      "price": 75.00,
      "currency": "USD",
      "status": "active",
      "created_at": "2026-06-05T12:00:00.000Z"
    }
  ],
  "has_more": false
}

To page through more results, use starting_after on the next request:

Terminal
curl "https://api.ticketconnect.example/v1/marketplace/listings?limit=20&starting_after=list_9a3f1c2e4b6d8f0a1b2c3d4e5f60718" \
  -H "Authorization: Bearer sk_test_..."

Note: A listing's status tells you where it is in its life. Once a listing sells, it stops being active and you'll receive a marketplace.sale.completed webhook — see Webhooks. Use ticket_id to tie a listing back to the ticket (GET /v1/tickets/:id) and, through it, the event.

Retrieve a single listing

Terminal
curl https://api.ticketconnect.example/v1/marketplace/listings/list_9a3f1c2e4b6d8f0a1b2c3d4e5f60718 \
  -H "Authorization: Bearer sk_test_..."
JSON
{
  "id": "list_9a3f1c2e4b6d8f0a1b2c3d4e5f60718",
  "ticket_id": "tkt_8f2c...",
  "price": 75.00,
  "currency": "USD",
  "status": "active",
  "created_at": "2026-06-05T12:00:00.000Z"
}

A listing id that doesn't exist (or isn't yours) returns 404 with a resource_missing error.

See also

  • Webhooks — listen for marketplace.sale.completed and ticket.transferred.
  • Payouts — which proven funds are available for Stripe transfers.
  • API reference — full endpoint details.

Discounts

Discount codes let you run promotions: a flat amount off, or a percentage off, redeemable at checkout. This guide covers the full lifecycle — create, list, retrieve, update, and delete — and how a code is applied when a customer buys a ticket.

Scopes

Every discount endpoint requires the events:write scope.

EndpointMethodScope
/v1/discountsPOSTevents:write
/v1/discountsGETevents:write
/v1/discounts/{id}GETevents:write
/v1/discounts/{id}PATCHevents:write
/v1/discounts/{id}DELETEevents:write

The discount object

JSON
{
  "id": "64fa1b2c9e7d654321fedcba",
  "code": "SUMMER10",
  "type": "percentage",
  "value": 10,
  "max_uses": 500,
  "used_count": 0,
  "expires_at": "2026-09-01T00:00:00.000Z",
  "active": true,
  "created_at": "2026-06-05T12:00:00.000Z"
}
FieldMeaning
codeThe string the customer types at checkout. If you omit it on create, one is generated for you.
type"fixed" or "percentage".
valueFor fixed, the amount off in your currency. For percentage, a number from 0–100.
max_usesTotal redemptions allowed across all customers (null = unlimited).
used_countHow many times it's been redeemed so far.
expires_atWhen it stops working (null = no expiry).
activeWhether it's currently usable.

Create a percentage discount

value is a percentage between 0 and 100. The example below is 10% off, capped at 500 redemptions, expiring on a fixed date.

Terminal
curl https://api.ticketconnect.example/v1/discounts \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "code": "SUMMER10",
    "type": "percentage",
    "value": 10,
    "max_uses": 500,
    "expires_at": "2026-09-01T00:00:00.000Z"
  }'
JSON
{
  "id": "64fa1b2c9e7d654321fedcba",
  "code": "SUMMER10",
  "type": "percentage",
  "value": 10,
  "max_uses": 500,
  "used_count": 0,
  "expires_at": "2026-09-01T00:00:00.000Z",
  "active": true,
  "created_at": "2026-06-05T12:00:00.000Z"
}

Create a fixed-amount discount

For a fixed discount, value is an amount in your settlement currency. This is $15 off:

Terminal
curl https://api.ticketconnect.example/v1/discounts \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "code": "WELCOME15",
    "type": "fixed",
    "value": 15
  }'
JSON
{
  "id": "64fa77c1e9b7d654321fedcb",
  "code": "WELCOME15",
  "type": "fixed",
  "value": 15,
  "max_uses": null,
  "used_count": 0,
  "expires_at": null,
  "active": true,
  "created_at": "2026-06-05T12:05:00.000Z"
}

Note: You can scope a code to a single event by passing its id in the event_ids array on create (only the first entry is used — one code, one event). Omit it for an account-wide code. If you don't pass a code, the API generates a unique one (e.g. DISC-A1B2C3D4E5F6) and returns it. Codes are matched uppercase at redemption, so create codes in uppercase.

How discounts apply at checkout

A discount changes the amount your customer pays — nothing more:

  • The customer enters the code at checkout.
  • The platform validates it — active, not expired, under max_uses, not already redeemed by that customer (each customer can use a given code once), and applicable to the event — and applies it to the order.
  • A percentage code subtracts that percentage from the order total. fixed codes can be created and managed through the API, but checkout currently applies percentage codes only.
  • The amount is always recomputed server-side from the event/tier price — a client can never dictate the final price — and used_count is incremented on redemption.

Warning: Amounts are server-authoritative. The discount only adjusts the order total the platform calculates; you never send a final price from the client.

List discounts

Cursor-paginated via limit (and starting_after).

Terminal
curl "https://api.ticketconnect.example/v1/discounts?limit=20" \
  -H "Authorization: Bearer sk_test_..."

Retrieve a discount

Terminal
curl https://api.ticketconnect.example/v1/discounts/64fa1b2c9e7d654321fedcba \
  -H "Authorization: Bearer sk_test_..."

Update a discount

PATCH accepts value, max_uses, expires_at, and active. (To deactivate a code without deleting it, set active: false.) The value is validated against the discount's existing type — a percentage still must be 0–100.

Terminal
curl https://api.ticketconnect.example/v1/discounts/64fa1b2c9e7d654321fedcba \
  -X PATCH \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "max_uses": 1000,
    "active": false
  }'

The full updated discount object is returned.

Delete a discount

Terminal
curl https://api.ticketconnect.example/v1/discounts/64fa1b2c9e7d654321fedcba \
  -X DELETE \
  -H "Authorization: Bearer sk_test_..."
JSON
{ "id": "64fa1b2c9e7d654321fedcba", "deleted": true }

A missing or non-owned id returns 404 with a resource_missing error on any of the retrieve/update/delete operations.

See also

Webhooks

Webhooks push real-time notifications to your server whenever something happens in your account — a ticket is issued, a payment succeeds, a resale completes, a payout is sent. Instead of polling the API, you register a URL once and we POST a signed JSON event to it every time.

If you've used Stripe webhooks, this will feel identical: signed payloads you can verify with an HMAC, automatic retries with backoff, and a delivery log you can inspect and replay.

The payout route emits payout.transferred after a confirmed Stripe transfer to the connected account. The legacy payout.paid subscription remains accepted for compatibility; it is not bank-paid confirmation emitted by this route. Pending or review-held transfers do not emit completion.

Scopes

All webhook-management endpoints require the webhooks:manage scope.

EndpointMethodScope
/v1/webhook_endpointsPOSTwebhooks:manage
/v1/webhook_endpointsGETwebhooks:manage
/v1/webhook_endpoints/{id}DELETEwebhooks:manage
/v1/webhook_endpoints/{id}/pingPOSTwebhooks:manage
/v1/events/deliveriesGETwebhooks:manage
/v1/events/deliveries/{id}/replayPOSTwebhooks:manage

1. Register an endpoint

Register the HTTPS URL on your server that should receive events. You may optionally subscribe to a subset of event types via enabled_events; omit it (or pass ["*"]) to receive all events.

Terminal
curl https://api.ticketconnect.example/v1/webhook_endpoints \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.yourapp.com/ticketconnect",
    "enabled_events": ["ticket.issued", "payment.succeeded", "payout.transferred"]
  }'
JSON
{
  "id": "663b1c9e2a7d654321fedcba",
  "url": "https://hooks.yourapp.com/ticketconnect",
  "enabled_events": ["ticket.issued", "payment.succeeded", "payout.transferred"],
  "status": "active",
  "secret": "whsec_Hk9...redacted..."
}

The signing secret is returned exactly once, here at creation. It is never shown again in list or other responses. Store it securely — you need it to verify every incoming webhook. If you lose it, delete the endpoint and create a new one.

Your URL is validated at registration. It must be http(s) — and https in production — and it must resolve to a publicly routable address. URLs that point at private, link-local, or otherwise internal address space are rejected with a 400 invalid_webhook_url error. The same check re-runs on every delivery, so a URL that later starts resolving to an internal address has its deliveries marked failed rather than retried.

List endpoints

Terminal
curl https://api.ticketconnect.example/v1/webhook_endpoints \
  -H "Authorization: Bearer sk_test_..."

The secret is not included in list responses.

Delete an endpoint

Terminal
curl https://api.ticketconnect.example/v1/webhook_endpoints/663b1c9e2a7d654321fedcba \
  -X DELETE \
  -H "Authorization: Bearer sk_test_..."
JSON
{ "id": "663b1c9e2a7d654321fedcba", "deleted": true }

Ping your endpoint

Before wiring real traffic, send yourself a test ping. The ping travels through the exact same delivery machinery as production events — same Webhook-Signature and Webhook-Id headers, same 10-second timeout — so it is an end-to-end check of your signature verification. The response is synchronous:

Terminal
curl https://api.ticketconnect.example/v1/webhook_endpoints/663b1c9e2a7d654321fedcba/ping \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{ "message": "hello from staging" }'
JSON
{
  "id": "evt_8c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
  "endpoint_id": "663b1c9e2a7d654321fedcba",
  "type": "ping",
  "delivered": true,
  "response_code": 200,
  "error": null
}

Your endpoint receives a normal envelope with type: "ping" and data: { message } (message is optional, up to 256 characters; a default is supplied when omitted). Pings are never retried — a failed attempt is reported immediately in the response (delivered: false, plus error or a non-2xx response_code). Like any real delivery, a failed ping counts toward the endpoint's auto-disable failure streak, and every ping shows up in the deliveries log.

2. Event types

These are the event types the platform emits. Subscribe to the ones you care about via enabled_events, or omit it to receive them all.

Event typeWhen it fires
event.createdA new event is created.
event.updatedAn event's details change.
ticket.issuedA ticket is issued to a customer.
ticket.confirmedA purchased ticket's tamper-proof record is finalized shortly after issuance. Informational — nothing to act on.
ticket.confirmation_failedFinalization did not complete after issuance. The ticket stays valid and scannable — treat this as an operational alert.
ticket.transferredA ticket changes hands to another holder.
ticket.refundedA ticket is refunded.
ticket.revokedA ticket is invalidated without a refund (fraud, chargeback, comp clawback). It fails scans from that moment.
ticket.upgradedA ticket is moved to a higher tier.
ticket.redeemedA ticket is checked in / used at the door.
payment.succeededA card payment completes successfully.
payment.failedA card payment fails.
marketplace.sale.completedA secondary-market resale completes.
attendance.verifiedA check-in is recorded for a ticket.
payout.transferredStripe confirms a transfer to the connected account; this is not bank-paid confirmation.

Note: Every issuance produces ticket.issued immediately, followed shortly by exactly one of ticket.confirmed or ticket.confirmation_failed. On ticket.confirmation_failed the ticket itself keeps working (only refunded or used tickets fail a scan) — no customer action is needed, but if you see these recur, contact support. All event payloads are plain business data (ids, amounts, currency) — no settlement or platform internals ever appear.

3. The event payload

Every delivery is a POST with a JSON body in this envelope:

JSON
{
  "id": "evt_4f8a2c1e9b7d6543210fedcba9876543",
  "type": "payout.transferred",
  "created": 1749124800,
  "data": {
    "amount": 250.00,
    "currency": "USD",
    "reference": "tr_1Q9..."
  }
}
  • id — unique event id (prefixed evt_). Use it for idempotent handling.
  • type — one of the event types above.
  • created — Unix timestamp (seconds) when the event was generated.
  • data — the event-specific payload.

Each request also carries these headers:

HeaderDescription
Webhook-SignatureThe signature to verify, format t=<timestamp>,v1=<hex>.
Webhook-IdThe event id (matches id in the body) — handy for logging.
Content-TypeAlways application/json.

4. Verify the signature

The Webhook-Signature header is Stripe-compatible. Its value looks like:

Webhook-Signature: t=1749124800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

To verify, recompute the HMAC and compare:

  1. Parse t (timestamp) and v1 (signature hex) from the header.
  2. Build the signed payload string: `${t}.${rawBody}` — the timestamp, a literal ., then the raw, unparsed request body.
  3. Compute HMAC-SHA256(secret, signedPayload) and hex-encode it.
  4. Compare it to v1 using a constant-time comparison.
  5. Optionally, reject the request if t is too far from the current time (a tolerance window) to guard against replay.

Verify against the raw request body, exactly as received — before any JSON parsing or re-serialization. If your framework reparses and re-stringifies the body, the bytes change and the signature will not match.

Verification snippet (Node.js)

js
const crypto = require('crypto');

/**
 * Verify a TicketConnect webhook signature.
 * @param {string} secret    - your endpoint's whsec_... signing secret
 * @param {string} header    - the Webhook-Signature header value
 * @param {string} rawBody   - the raw request body (string/Buffer, unparsed)
 * @param {number} [toleranceSec] - optional max age, in seconds
 */
function verifyWebhook(secret, header, rawBody, toleranceSec) {
  if (!header) return false;

  let timestamp = null;
  let signature = null;
  for (const part of header.split(',')) {
    const idx = part.indexOf('=');
    if (idx === -1) continue;
    const key = part.slice(0, idx).trim();
    const value = part.slice(idx + 1).trim();
    if (key === 't') timestamp = Number(value);
    else if (key === 'v1') signature = value;
  }
  if (timestamp === null || Number.isNaN(timestamp) || !signature) return false;

  if (toleranceSec !== undefined) {
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - timestamp) > toleranceSec) return false;
  }

  const expectedHex = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  const expectedBuf = Buffer.from(expectedHex, 'hex');
  let providedBuf;
  try {
    providedBuf = Buffer.from(signature, 'hex');
  } catch {
    return false;
  }
  if (expectedBuf.length !== providedBuf.length) return false;
  return crypto.timingSafeEqual(expectedBuf, providedBuf);
}

Wire it up in an Express handler. Note the use of the raw body parser:

js
const express = require('express');
const app = express();

app.post(
  '/ticketconnect',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody = req.body; // Buffer, untouched
    const sig = req.header('Webhook-Signature');

    if (!verifyWebhook(process.env.TC_WEBHOOK_SECRET, sig, rawBody, 300)) {
      return res.status(400).send('invalid signature');
    }

    const event = JSON.parse(rawBody.toString());
    // ... handle event.type ...

    res.status(200).send('ok'); // ack fast
  }
);

Because the scheme is Stripe-compatible, you can also verify with existing Stripe-style SDK helpers — just point them at the Webhook-Signature header and your whsec_ secret.

5. Responding, retries, and backoff

Your endpoint should return a 2xx status as soon as it has safely received the event. Anything else (a non-2xx, a timeout, or a connection error) is treated as a failed delivery and retried.

  • Timeout: each delivery attempt waits up to 10 seconds for your 2xx. Acknowledge first, then do slow work asynchronously.

  • Retries: failed deliveries are retried on a fixed backoff schedule, up to 7 total attempts (the initial send plus 6 retries):

    RetryDelay after previous attempt
    11 minute
    25 minutes
    330 minutes
    42 hours
    510 hours
    624 hours

    After the last attempt, the delivery is marked failed.

  • Auto-disable: if an endpoint racks up 20 consecutive failures, it is automatically disabled and stops receiving events until you fix and re-create (or re-enable) it.

Warning: Return your 2xx quickly — within the 10-second window — and offload heavy processing to a background job. A slow handler looks like a failed delivery and gets retried.

6. Idempotent handling on your side

Retries (and manual replays) mean your endpoint can receive the same event more than once. Make your handler idempotent:

  • Use the event id (the evt_... value, also in the Webhook-Id header) as a dedupe key.
  • On receipt, check whether you've already processed that id; if so, return 200 and do nothing else.
  • Only then apply the side effect (update a record, send an email, etc.).

This guarantees that a redelivered payment.succeeded doesn't double-fulfil an order.

7. Inspect deliveries

Every delivery attempt is logged. List recent ones (cursor-paginated via limit) to debug your endpoint:

Terminal
curl "https://api.ticketconnect.example/v1/events/deliveries?limit=20" \
  -H "Authorization: Bearer sk_test_..."
JSON
{
  "object": "list",
  "data": [
    {
      "id": "665c2a1f9e7d654321fedcba",
      "event_id": "evt_4f8a...",
      "type": "payout.transferred",
      "status": "delivered",
      "attempts": 1,
      "last_response_code": 200,
      "created_at": "2026-06-05T12:00:00.000Z"
    }
  ],
  "has_more": false
}

status is one of pending, delivered, or failed. last_response_code is the HTTP status your endpoint returned on the most recent attempt.

8. Replay a delivery

Fixed an outage on your side? Re-send a past delivery to your registered endpoint:

Terminal
curl https://api.ticketconnect.example/v1/events/deliveries/665c2a1f9e7d654321fedcba/replay \
  -X POST \
  -H "Authorization: Bearer sk_test_..."
JSON
{ "id": "665c2a1f9e7d654321fedcba", "status": "pending", "replayed": true }

The delivery is re-queued and dispatched shortly after. (Because a replay can re-deliver an event you already handled, idempotent handling — section 6 — matters here too.)

See also

Payouts

The payout API transfers available platform-held Stripe funds to your Stripe Connect account. It reports transferred once Stripe confirms that transfer. A deposit in your bank account is a separate Stripe connected-account payout and is not confirmed by this API.

Scopes

EndpointMethodScope
/v1/connect/accountPOST, GETpayouts:claim
/v1/balanceGETpayouts:read or payouts:claim
/v1/payoutsGETpayouts:read or payouts:claim
/v1/payoutsPOSTpayouts:claim

Use a read-only key for dashboards and a payouts:claim key for the service that moves funds.

1. Complete Connect onboarding

Terminal
curl https://api.ticketconnect.example/v1/connect/account \
  -X POST -H "Authorization: Bearer sk_test_..."
JSON
{
  "account_id": "acct_1Q...",
  "onboarding_url": "https://connect.stripe.com/setup/e/acct_1Q.../..."
}

Redirect the user to onboarding_url. A later call resumes the same account with a fresh link. Test and live Connect accounts are stored separately; a test account or an unverified legacy account ID cannot receive a live transfer.

Read GET /v1/connect/account to check capabilities:

JSON
{
  "connected": true,
  "charges_enabled": true,
  "payouts_enabled": true,
  "details_submitted": true
}

Before onboarding, the response is { "connected": false }. Capability flags describe account readiness, not a completed bank payout.

2. Read the available balance

Terminal
curl https://api.ticketconnect.example/v1/balance \
  -H "Authorization: Bearer sk_test_..."
JSON
{ "object": "balance", "available": 1284.50, "currency": "USD" }

This balance includes only proven platform-held Stripe credits after booked fees, refund debits, and outstanding payout reservations. External, PayPal, crypto, unknown legacy credits, and funds already sent to a destination account do not authorize a transfer from the platform Stripe treasury.

Refunds reverse the original booked net. If refundable funds have already been paid out, the refund may require review and remain held; the API does not guarantee that a debit alone returns money to the buyer. Keep a refund buffer and follow refund completion states.

The ledger balance is not Stripe’s net available-cash report. Processor costs are a platform expense requiring separate liquidity; a provider can refuse a transfer when its actual available balance is insufficient.

3. Request a transfer

A payout requires an Idempotency-Key. Send a positive amount in major units that converts to whole minor units, or omit it to request the full available balance. The currency defaults to the account's settlement currency.

Terminal
curl https://api.ticketconnect.example/v1/payouts \
  -X POST \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: payout-2026-09-30-001" \
  -H "Content-Type: application/json" \
  -d '{ "amount": 500.00, "currency": "USD" }'

A confirmed transfer returns 201:

JSON
{
  "object": "payout",
  "id": "po_opaque_operation_id",
  "amount": 500.00,
  "currency": "USD",
  "status": "transferred",
  "reference": "tr_1Q9...",
  "created_at": "2026-09-30T14:22:00.000Z"
}

The platform reserves the funded balance atomically before calling Stripe. Concurrent requests with different keys cannot spend the same balance. A confirmed operation emits payout.transferred. The legacy payout.paid subscription name remains accepted for compatibility; it is not a bank-paid confirmation emitted by this route.

Pending operations and retries

An ambiguous response returns 202 with pending or review_required status. Funds remain reserved. Retry the same request with the original key; recovery uses the original provider operation and destination. Never request a replacement transfer under a fresh key to resolve an unknown result.

Automatic retries stop after 23 hours from the original attempt when there is no definitive outcome, before Stripe can discard the provider key. The operation then stays held for reconciliation. A definitive provider rejection marks it failed and releases its reservation; the route returns 502.

Invalid or missing keys, non-positive or fractional-minor amounts, insufficient funded balance, and missing Connect accounts are rejected before a transfer.

4. Read transfer history

GET /v1/payouts?limit=20 lists durable operations, newest first. Each row includes the operation ID, amount, currency, state, optional Stripe transfer reference, and creation time. Use starting_after for pagination and inspect the state: a pending row is not a completed transfer.

See API reference: payouts and Webhooks.

Ad tracking & attribution

Configure Ad tracking in the white-label portal. Only the organization owner can change the Meta Pixel ID, GTM container ID or write-only CAPI token. The token is encrypted at rest and is never returned by the API.

Browser and server events

The hosted checkout loads your Meta Pixel and published GTM container only after the buyer accepts optional cookies. Cookie settings lets the buyer withdraw that choice. Consent is stored separately for each pixel/container. Test-mode checkouts load neither tracker and test purchases never dispatch CAPI.

ActionMeta browser eventGTM dataLayer event
Page loaded after consentPageViewpage_view
Ticket/event viewedViewContentview_item
Checkout startedInitiateCheckoutbegin_checkout
Paid ticket confirmedPurchasepurchase

purchase includes an ecommerce object with transaction_id, value, currency and items. Amount and currency come from the completed server purchase, including scheduled tier prices. Free tickets emit no Purchase.

The server sends a consented Meta CAPI Purchase after ticket issuance. Browser and server use the same event ID for deduplication. A CAPI failure never prevents a ticket from being issued.

GTM setup

Saving a GTM ID loads the container; you must publish the container and configure tags for the events above yourself. Tags must honor the buyer's Consent Mode v2 settings. All four storage/advertising settings start denied. If you also send Meta through GTM, preserve event_id as Meta's event ID; otherwise remove that duplicate Meta tag and use the built-in pixel.

Available surfaces

SurfaceBrowser trackingServer tracking
TicketConnect-hosted checkoutMeta + GTM, with a built-in consent bannerTenant CAPI Purchase, with shared browser/server ID
Your own frontend using /v1Implement your own pixel/GTM and consent UIPass optional attribution on issuance or checkout completion; tenant CAPI only
Gettix siteConsent-gated Meta + GTM; a dedicated white-label deployment resolves IDs from your panelFunnel relay; confirmed card resale Purchase. External seller purchases emit browser Purchase after confirmed completion

A dedicated Gettix deployment must have TICKETCONNECT_TENANT_SLUG set on its backend. Failed tenant lookup disables tracking instead of falling back to the platform pixel. The standard Gettix deployment uses TicketConnect's platform tracking IDs. Public configuration never contains the CAPI token.

Attribution

UTM fields and referrer accompany the order for the Sales sources report. Advertising click IDs, Meta cookie IDs, browser event ID, source page and user agent require marketingConsent: true. On your own frontend, that flag is your declaration that the buyer consented.

Pass the browser page as sourceUrl and the browser's userAgent in the attribution object. Only HTTP(S) source URLs are accepted; credentials, query parameters and fragments are removed. See ticket attribution.

Verify the integration

First check that neither Meta nor GTM requests occur before consent, then accept and check the network requests and dataLayer. Complete a real, paid live-mode purchase and compare the amount, currency and event ID in the browser Purchase and CAPI payload. Verify reception and deduplication in Meta Events Manager using your own pixel and token. Test-mode ticket purchases deliberately do not reach the production pixel.

META_CAPI_TEST_EVENT_CODE is an optional server setting for a coordinated Meta Test Events check; do not leave it set during normal production tracking.

API reference

This is the complete reference for the TicketConnect White-Label API. Every endpoint lives under the /v1 prefix and is automatically scoped to your account. The API is a plain REST + JSON service modeled closely on Stripe, so if you have integrated Stripe before this will feel familiar.

Base URL

https://api.ticketconnect.example/v1

Replace the host with the environment you were issued. All paths below include the /v1 prefix.

Authentication

Send your secret key as a bearer token on every request except the public meta endpoints (/v1/health, /v1/openapi.json, /v1/docs):

Authorization: Bearer sk_test_your_key_here

The key is also accepted in an x-api-key header as a fallback. Use sk_test_… keys against the sandbox and sk_live_… keys against production; the two are fully isolated. Keys are scoped — each endpoint documents the scope it requires (for example events:read). A wildcard * scope satisfies any check. Calling an endpoint without the required scope returns a 403 with type authorization_error and code forbidden. See Authentication for how to manage keys and scopes. Hosted-checkout endpoints are the one exception to the secret-key rule: they authenticate with browser-safe publishable pk_… keys instead (see Payments).

Response & error envelope

Successful responses return the resource object directly. Most resources carry an object discriminator (for example "object": "payout").

Errors always use one consistent envelope:

JSON
{
  "error": {
    "type": "invalid_request_error",
    "code": "parameter_missing",
    "message": "name is required.",
    "param": "name"
  }
}
FieldDescription
typeHigh-level category: invalid_request_error, authentication_error, authorization_error, rate_limit_error, idempotency_error, or api_error.
codeMachine-readable code, e.g. parameter_missing, resource_missing, insufficient_balance.
messageHuman-readable explanation.
paramThe offending request field, when applicable.
request_idRequest correlation id, when available — quote it in support requests.

See Errors for the full catalog of codes and statuses.

Pagination

List endpoints are cursor-based and return a Stripe-style list envelope:

JSON
{
  "object": "list",
  "data": [ { "...": "..." } ],
  "has_more": true
}
Query paramTypeDescription
limitintegerPage size, 1–100. Defaults to 20.
starting_afterstringAn object id; returns items created before it.

To page forward, pass the id of the last item in data as starting_after on the next request. Keep paging while has_more is true.

Idempotency

Mutating POST requests accept an Idempotency-Key header. If you retry a request with the same key, the API returns the original response instead of performing the action twice. Replays are honored for 24 hours. See Idempotency.

Idempotency-Key: a1b2c3d4-...

Money

All amounts are in your account's settlement currency (fiat) and are expressed in major units (for example 49.99, not 4999). Prices are always computed server-side from the event/tier; a client-supplied amount is never trusted. See Money and currencies.

Resource groups

GroupEndpoints
MetaHealth probe, OpenAPI document (public).
AccountRetrieve the authenticated account; mint, list, and revoke API keys.
EventsCreate, list, and update events, tiers, media, and organizations; on-sale waiting room (queue).
TicketsIssue, list, transfer, refund, revoke, and upgrade tickets; hosted delivery links.
CustomersCreate and retrieve customers.
SegmentsBehavioral customer segments — repeat buyers, attendees, no-shows.
CompsComplimentary tickets and per-event guest lists.
Ticket extrasMedia vault, check-in stub, stamps, polls, and crafting.
Reserved seatingSeat maps, atomic seat holds, seat-bound issuance.
Similar eventsClosest matches from your own catalog, scored.
PaymentsCreate and retrieve payment intents.
MarketplaceList and retrieve resale listings.
ReportsPer-tier sales and attendance reports, with CSV download.
ScanningValidate tickets and record check-in.
DiscountsManage discount codes.
WebhooksManage endpoints, ping them, and inspect deliveries.
PayoutsConnect onboarding, balance, and payouts.

Interactive reference

A live, try-it-out Swagger UI is hosted at /v1/docs, backed by the machine-readable OpenAPI 3.0 document at /v1/openapi.json. Import the OpenAPI document into Postman or use it for client/SDK code generation.

Meta

Public discovery endpoints. Both are unauthenticated — call them before you have an API key for uptime monitoring or code generation.

GET /v1/health

Liveness probe. Returns 200 whenever the API is reachable.

Scope: Public (no auth)

Example request

Terminal
curl https://api.ticketconnect.example/v1/health

Example response

JSON
{
  "status": "ok",
  "version": "v1"
}

GET /v1/openapi.json

Returns the machine-readable OpenAPI 3.0 contract for the entire API. Fetch it for client/SDK generation or to import the API into Postman.

Scope: Public (no auth)

Example request

Terminal
curl https://api.ticketconnect.example/v1/openapi.json

Example response

JSON
{
  "openapi": "3.0.3",
  "info": {
    "title": "TicketConnect White-Label API",
    "version": "1.0.0"
  },
  "paths": { "...": "..." }
}

A live Swagger UI rendering of this document is hosted at /v1/docs.

Account

Your partner account — branding, mode, and settlement currency — plus self-serve API key management: list, mint, and revoke keys without leaving the API.

GET /v1/account

Retrieve the authenticated account. A quick way to confirm an API key is valid and learn which mode (live or test) it operates in.

Scope: Authenticated (any valid key)

Example request

Terminal
curl https://api.ticketconnect.example/v1/account \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "id": "ten_8f2a1c4d5e6f7a8b9c0d1e2f",
  "name": "Acme Live Events",
  "mode": "test",
  "status": "active",
  "branding": {
    "logoUrl": "https://acme.example/logo.png",
    "supportEmail": "support@acme.example",
    "primaryColor": "#e11d48"
  },
  "currency": "USD"
}
FieldTypeDescription
idstringYour account id.
namestringAccount display name.
modestringlive or test — which environment this key operates in.
statusstringAccount status, e.g. active.
brandingobjectBranding configuration for hosted pages — logoUrl, supportEmail, primaryColor, emailDomain.
currencystringYour settlement currency (defaults to USD).

GET /v1/account/keys

List your API keys, newest first. Revoked keys stay in the list (with revoked_at set) as an audit trail. The secret itself is never returned — only its prefix.

Scope: keys:manage

Example request

Terminal
curl https://api.ticketconnect.example/v1/account/keys \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "data": [
    {
      "prefix": "sk_test_9f8e7d",
      "label": "Backend (staging)",
      "mode": "test",
      "kind": "secret",
      "scopes": ["events:read", "tickets:issue"],
      "created_at": "2026-07-01T10:00:00.000Z",
      "last_used_at": "2026-07-11T08:59:00.000Z",
      "revoked_at": null
    }
  ]
}

POST /v1/account/keys

Mint a new API key. The plaintext secret is returned exactly once, in this response — copy it straight into your secrets manager.

No privilege escalation is possible: the minted key's scopes must be a subset of the calling key's scopes (a * wildcard key can mint anything, including another * key), and a test-mode key can never mint a live-mode key. Active keys are capped at 25 per account (shared with keys created in the dashboard).

Scope: keys:manage

Request body

FieldTypeRequiredDescription
labelstringNoA display label, up to 80 characters.
modestringNolive or test. Defaults to the calling key's mode. A test key cannot mint a live key.
scopesstring[]NoScopes for the new key — must be a subset of the calling key's. Defaults to the caller's scopes.
kindstringNosecret (default) or publishable.

Example request

Terminal
curl https://api.ticketconnect.example/v1/account/keys \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "Door scanner",
    "scopes": ["scanning:write"]
  }'

Example response

JSON
{
  "prefix": "sk_test_2b4c6d",
  "label": "Door scanner",
  "mode": "test",
  "kind": "secret",
  "scopes": ["scanning:write"],
  "created_at": "2026-07-11T09:00:00.000Z",
  "last_used_at": null,
  "revoked_at": null,
  "secret": "sk_test_2b4c6d...shown_only_once"
}

Errors: 400 parameter_invalid (bad label/scopes/mode/kind), 403 scope_escalation (requested scopes exceed the caller's), 403 mode_escalation (test key minting live), 409 key_limit_reached (25 active keys — revoke unused keys first).


DELETE /v1/account/keys/{prefix}

Revoke a key by its prefix. Revocation is immediate — the next request with the revoked key gets a 401 — and idempotent. The calling key can never revoke itself, so a rotation always overlaps: mint the new key, deploy it, then revoke the old one with the new key (or any other key holding keys:manage).

Scope: keys:manage

Path params

ParamTypeRequiredDescription
prefixstringYesThe key prefix, as shown in GET /v1/account/keys.

Example request

Terminal
curl https://api.ticketconnect.example/v1/account/keys/sk_test_9f8e7d \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -X DELETE

Example response

JSON
{ "prefix": "sk_test_9f8e7d", "revoked": true, "revoked_at": "2026-07-11T09:05:00.000Z" }

Errors: 400 cannot_revoke_self (a key cannot revoke itself), 404 resource_missing (no key with that prefix on your account).

Events

Events are the things you sell tickets to. Each event holds one or more ticket tiers (name, price, supply, perks). Organizations are read-only sub-accounts you can list and retrieve alongside your events.

A serialized event object looks like this:

JSON
{
  "id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
  "name": "Summer Festival 2026",
  "status": "draft",
  "date": "2026-08-15T18:00:00.000Z",
  "venue": "Riverside Park",
  "city": "Austin",
  "country": "US",
  "category": "music",
  "description": "Two stages, twenty acts.",
  "currency": "USD",
  "imageUrl": "https://acme.example/img/summerfest-card.jpg",
  "bannerUrl": "https://acme.example/img/summerfest-hero.jpg",
  "ticketPools": [
    {
      "name": "General Admission",
      "price": 49.99,
      "totalSupply": 5000,
      "issued": 0,
      "upgradesEnabled": false,
      "perks": null,
      "release": { "released": true }
    }
  ],
  "queue": null,
  "live_codes": true,
  "created_at": "2026-06-05T12:00:00.000Z"
}

The issued field on a tier is the number of tickets already issued from that tier; totalSupply - issued is what remains.

Tier price is always the schedule-effective price — what checkout charges right now. A tier that runs a published price schedule additionally carries:

FieldTypeDescription
base_pricenumberThe tier's base price before scheduled steps.
price_schedulearrayEvery published step: { starts_at, price, label?, status } with status one of past, active, upcoming.
next_price_changeobject | nullThe next step, { at, price, label? }, or null when no further change is published.

Every tier carries a release object describing its auto-release state: { "released": true } for tiers on sale, or { "released": false } plus release_at (unlock time) and/or awaiting_sold_out_of (the sibling tier that must sell out first). Locked tiers are visible but cannot be issued from.

queue is non-null only when the event uses an on-sale waiting room (see Waiting room (queue)): { "enabled": true, "sales_start_at": "2026-08-01T10:00:00.000Z" }.

live_codes says whether tickets held in a holder's ticket wallet (any holder experience built on the TicketConnect rails — Gettix, our flagship licensee, is the reference one) show a live, refreshing entry code for this event (an anti-screenshot measure — see Scanning & check-in). It is true by default, settable at creation and togglable with PATCH /v1/events/{id}. It never affects the static qr.data you deliver yourself. It also gates phone passes: a wallet-held ticket can be saved to Apple/Google Wallet only when live codes are off (a phone pass is a static barcode, so the platform refuses one where a live code is enforced).

imageUrl (card/list image) and bannerUrl (wide hero) are the event's two media slots — set them at creation via images or later with PATCH /v1/events/{id}. Events with a published seat map sell seated tiers by the seat (see Reserved seating), and every event can list its closest catalog neighbours via GET /v1/events/{id}/similar.


GET /v1/events

List your events, newest first.

Scope: events:read

Query params

ParamTypeRequiredDescription
limitintegerNoPage size, 1–100 (default 20).
starting_afterstringNoCursor — an event id to page after.

Example request

Terminal
curl "https://api.ticketconnect.example/v1/events?limit=2" \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "object": "list",
  "has_more": false,
  "data": [
    { "id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "name": "Summer Festival 2026", "status": "draft", "ticketPools": [] }
  ]
}

POST /v1/events

Create an event under your account. Returns 201 with the new event. New events start in draft status with no tiers — add tiers with POST /v1/events/{id}/tiers.

Scope: events:write

Request body

FieldTypeRequiredDescription
namestringYesEvent name.
datestring | numberYesEvent start, any value the date parser accepts (ISO 8601 recommended).
venuestringNoVenue name.
locationstringNoFree-form location/address.
citystringNoCity.
countrystringNoCountry code.
categorystringNoCategory, e.g. music.
descriptionstringNoEvent description.
currencystringNoCurrency for this event; defaults to your account currency.
end_datestringNoWhen the event ends (ISO 8601), for an event that runs longer than one day. Must not be before date. Scanning stays open until the end date plus 24 hours.
admission_daysstring[]NoDaily admission: the ISO 8601 instants at which each admission day starts, ascending (1–31). With it a ticket admits once per day instead of once for the whole event.
max_tickets_per_identityintegerNoPer-person purchase cap for this event, 1–50. The platform default applies when unset.
imagesobjectNoEvent media: { "image_url": "...", "banner_url": "..." }. Each must be an https:// URL of at most 2048 characters (http:// and data: URIs are rejected). There is no gallery — images.gallery_urls returns 400 parameter_unknown.
marketplace_settingsobjectNoPer-event resale / anti-scalping policy (see below). Platform defaults apply when unset.
live_codesbooleanNoWhether wallet-held tickets show a live, refreshing entry code (default true). Your own qr.data delivery is unaffected.
ticket_extrasobjectNoOptional ticket extras — stub, stamps and crafting. Omit it and the event has none.

Accepts an Idempotency-Key header.

The marketplace_settings object (all fields optional; supplied keys override the platform defaults):

FieldTypeDescription
resale_enabledbooleanMaster switch for the secondary market. Default true. When false, tickets for this event can't be relisted at all.
anti_scalping_enabledbooleanEnforce the resale price floor/ceiling. Default true. When false, resale price is uncapped.
max_resale_price_percentintegerResale ceiling as % of face value, 100–1000. Default 150.
min_resale_price_percentintegerResale floor as % of face value, 0–100. Default 0.
resale_start_modestringWhen resale opens: immediate, hours_before_event, or days_before_event. Default immediate.
resale_start_valueintegerHours or days before the event, paired with resale_start_mode. Default 0.

royalty_rate is platform-set per organization and is not accepted here — sending it returns 400 parameter_unknown. It appears read-only on the event object so you can see your effective rate.

Example request

Terminal
curl https://api.ticketconnect.example/v1/events \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: evt-create-001" \
  -d '{
    "name": "Summer Festival 2026",
    "date": "2026-08-15T18:00:00Z",
    "venue": "Riverside Park",
    "city": "Austin",
    "country": "US",
    "currency": "USD"
  }'

Example response

JSON
{
  "id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
  "name": "Summer Festival 2026",
  "status": "draft",
  "date": "2026-08-15T18:00:00.000Z",
  "currency": "USD",
  "ticketPools": [],
  "queue": null,
  "live_codes": true,
  "created_at": "2026-06-05T12:00:00.000Z"
}

GET /v1/events/{id}

Retrieve a single event by id.

Scope: events:read

Path params

ParamTypeRequiredDescription
idstringYesEvent id.

Example request

Terminal
curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2 \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
  "name": "Summer Festival 2026",
  "status": "draft",
  "date": "2026-08-15T18:00:00.000Z",
  "ticketPools": [
    { "name": "General Admission", "price": 49.99, "totalSupply": 5000, "issued": 12 }
  ],
  "created_at": "2026-06-05T12:00:00.000Z"
}

PATCH /v1/events/{id}

Update an event's media, resale policy, live-code setting and/or ticket extras, end date and/or admission days. Accepts images, marketplace_settings, live_codes, ticket_extras, end_date and/or admission_days — at least one is required. Partial: only the keys you send change. Pass null to unset a media slot; omitted keys are untouched. Naturally idempotent (no Idempotency-Key needed). Emits an event.updated webhook.

Scope: events:write

Path params

ParamTypeRequiredDescription
idstringYesEvent id.

Request body

FieldTypeRequiredDescription
imagesobjectConditional{ "image_url": string | null, "banner_url": string | null }. URLs must be https://, at most 2048 characters. null unsets the slot.
marketplace_settingsobjectConditionalSame fields as on create — send only the keys you want to change.
live_codesbooleanConditionalToggle live, refreshing entry codes for wallet-held tickets. Turning it off and back on is safe: codes already delivered to holders' wallets keep working. Turning it on for an event whose holders already opened their ticket in a wallet requires them to reopen it online once before the gate.
end_datestring | nullConditionalThe event's end (ISO 8601); null removes it.
admission_daysstring[] | nullConditionalReplaces the list of admission-day starts; null or [] switches daily admission off. Changing it while the event is running moves the day boundaries for scans that follow.
ticket_extrasobject | nullConditionalTicket extras. Each of stub, stamps and crafting is replaced when sent, kept when omitted and removed when null; null for the whole object removes all three.

Example request

Terminal
curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2 \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -X PATCH \
  -d '{
    "images": {
      "image_url": "https://acme.example/img/summerfest-card.jpg",
      "banner_url": null
    }
  }'

Example response

JSON
{
  "id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
  "name": "Summer Festival 2026",
  "status": "draft",
  "imageUrl": "https://acme.example/img/summerfest-card.jpg",
  "created_at": "2026-06-05T12:00:00.000Z"
}

Errors: 400 parameter_missing (none of images, marketplace_settings, live_codes, ticket_extras), 400 parameter_invalid (bad media URL, out-of-range resale bound, non-boolean live_codes, or an invalid ticket_extras field), 400 parameter_unknown (images.gallery_urls or marketplace_settings.royalty_rate), 404 resource_missing (not your event).


POST /v1/events/{id}/tiers

Add a ticket tier to an event. Returns the full updated event with the new tier appended to ticketPools. The tier's issued count always starts at 0.

Scope: events:write

Path params

ParamTypeRequiredDescription
idstringYesEvent id.

Request body

FieldTypeRequiredDescription
namestringYesTier name, e.g. VIP. Used as the tier identifier when issuing.
pricenumberYesTier price in the event currency. Must be >= 0.
totalSupplyintegerYesNumber of tickets available. Must be a positive integer.
perksobjectNoFree-form perks map, e.g. { "lounge": true }.
upgradesEnabledbooleanNoWhether tickets may be upgraded into this tier. Defaults to false.
price_stepsarrayNoPublished price schedule: [{ "starts_at": "<ISO 8601>", "price": 39.99, "label": "Early bird" }]. Steps are validated against the event's other tiers; the tier's effective price then follows the schedule.
release_atstringNoKeep the tier locked (visible but not purchasable) until this time.
release_after_sold_outstringNoKeep the tier locked until the named sibling tier sells out. May be combined with release_at.
valid_daysinteger[]NoOn an event with admission_days: the day indexes (starting at 0) this tier is valid on, e.g. [0] for a first-day ticket. Omitted, the tier is a pass valid every day. An index outside the event's days returns 400 parameter_invalid.

Accepts an Idempotency-Key header.

Errors: 404 resource_missing if the event does not exist or is not yours; 400 parameter_invalid if the schedule or release configuration is invalid (e.g. a release_after_sold_out cycle or an unknown sibling tier).

Example request

Terminal
curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/tiers \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "VIP",
    "price": 149.00,
    "totalSupply": 200,
    "perks": { "lounge": true },
    "upgradesEnabled": true
  }'

Example response

JSON
{
  "id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
  "name": "Summer Festival 2026",
  "status": "draft",
  "ticketPools": [
    { "name": "General Admission", "price": 49.99, "totalSupply": 5000, "issued": 0, "upgradesEnabled": false },
    { "name": "VIP", "price": 149.00, "totalSupply": 200, "issued": 0, "upgradesEnabled": true, "perks": { "lounge": true } }
  ],
  "created_at": "2026-06-05T12:00:00.000Z"
}

GET /v1/organizations

List your organizations — read-only sub-accounts grouped with your events.

Scope: events:read

Query params

ParamTypeRequiredDescription
limitintegerNoPage size, 1–100 (default 20).
starting_afterstringNoCursor — an organization id to page after.

Example request

Terminal
curl https://api.ticketconnect.example/v1/organizations \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "object": "list",
  "has_more": false,
  "data": [
    { "id": "665f1e2d3c4b5a6d7e8f9a0b", "name": "Acme Live", "status": "active", "created_at": "2026-01-10T09:00:00.000Z" }
  ]
}

GET /v1/organizations/{id}

Retrieve one of your organizations by id.

Scope: events:read

Path params

ParamTypeRequiredDescription
idstringYesOrganization id.

Example request

Terminal
curl https://api.ticketconnect.example/v1/organizations/665f1e2d3c4b5a6d7e8f9a0b \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "id": "665f1e2d3c4b5a6d7e8f9a0b",
  "name": "Acme Live",
  "status": "active",
  "created_at": "2026-01-10T09:00:00.000Z"
}

Waiting room (queue)

High-demand events can run an on-sale waiting room. When an event has one, its serialized queue field is non-null and ticket issuance is gated: a customer must hold a live admission window before POST /v1/events/{id}/tickets will succeed (otherwise it returns 403 with code not_admitted).

The waiting room is a server-side concept — call these endpoints from your backend and proxy queue state to your own buyer-facing UI. Identity is your customer's externalId, passed as customer_id.

The room moves through three phases:

PhaseMeaning
pre_saleBefore sales_start_at. Customers may pre-join; the pre-sale block is shuffled into a fair random order when the sale starts.
queue_activeThe sale is open and the queue is draining. Customers are admitted in batches, each with a short access window.
closedThe queue has fully drained (or the room is off). Purchases proceed without queueing.

A customer's queue status is waiting, admitted, or completed (bought at least once), or null when they are not in the queue.

Polling is the contract. Poll GET /v1/events/{id}/queue?customer_id=… every few seconds during the pre_sale and queue_active phases. A status of admitted with a non-null, future access_expires_at is the signal to open checkout. No webhook is emitted for admission today.


GET /v1/events/{id}/queue

Return the room phase and aggregate waiting_count, plus — when customer_id is supplied — that customer's position, people_ahead, admission status, and access window. Also includes queue-time price transparency: the purchasable price range and per-tier prices, locked tiers with their unlock conditions, and the next published price change.

Scope: tickets:issue

Path params

ParamTypeRequiredDescription
idstringYesEvent id.

Query params

ParamTypeRequiredDescription
customer_idstringNoA customer's externalId; include it to get that customer's queue entry.

Example request

Terminal
curl "https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/queue?customer_id=cus_ext_42" \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
  "queue_enabled": true,
  "phase": "queue_active",
  "sales_start_at": "2026-08-01T10:00:00.000Z",
  "waiting_count": 1841,
  "position": 212,
  "people_ahead": 187,
  "status": "waiting",
  "access_expires_at": null,
  "pricing": {
    "min_price": 49.99,
    "max_price": 149.00,
    "tiers": [
      { "name": "General Admission", "price": 49.99, "available": 3120, "locked": false },
      { "name": "VIP", "price": 149.00, "available": 0, "locked": true, "awaiting_sold_out_of": "General Admission" }
    ]
  }
}
FieldTypeDescription
queue_enabledbooleanWhether the event currently runs a waiting room. When false, phase is closed and the entry fields are null.
phasestringpre_sale, queue_active, or closed.
sales_start_atstring | nullWhen the sale opens and the queue starts draining.
waiting_countintegerNumber of customers currently waiting.
positioninteger | nullThe customer's queue position (only with customer_id).
people_aheadinteger | nullWaiting customers ahead of this one.
statusstring | nullwaiting, admitted, or completed.
access_expires_atstring | nullEnd of the customer's admission window; non-null only once admitted.
pricingobjectmin_price, max_price, and per-tier { name, price, available, locked, release_at?, awaiting_sold_out_of?, next_price_change? }.

Errors: 404 resource_missing if the event, or the supplied customer_id, does not exist.


POST /v1/events/{id}/queue/join

Put a customer in the waiting room. Returns 201 with the customer's entry. Idempotent — re-joining while holding a live spot returns the existing entry. Customers who join during pre_sale are shuffled into a fair random order at sales_start_at; a customer who joins after the shuffle is appended behind the randomized block in arrival order.

Scope: tickets:issue

Path params

ParamTypeRequiredDescription
idstringYesEvent id.

Request body

FieldTypeRequiredDescription
customer_idstringYesThe customer's externalId.

Example request

Terminal
curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/queue/join \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "cus_ext_42" }'

Example response

JSON
{
  "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
  "customer_id": "cus_ext_42",
  "phase": "queue_active",
  "position": 1854,
  "people_ahead": 1841,
  "status": "waiting",
  "access_expires_at": null
}

Errors: 400 queue_not_enabled if the event does not use a waiting room; 400 queue_join_failed if the room rejected the join; 404 resource_missing if the event or customer does not exist.


POST /v1/events/{id}/queue/leave

Remove a customer from the queue, giving up any live admission window. Re-joining afterwards starts at the back of the queue.

Scope: tickets:issue

Path params

ParamTypeRequiredDescription
idstringYesEvent id.

Request body

FieldTypeRequiredDescription
customer_idstringYesThe customer's externalId.

Example request

Terminal
curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/queue/leave \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "cus_ext_42" }'

Example response

JSON
{
  "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
  "customer_id": "cus_ext_42",
  "left": true
}

Errors: 404 not_in_queue if the customer has no active entry; 404 resource_missing if the event or customer does not exist.

Tickets

Tickets are issued to a customer for a specific event tier. Every ticket carries its delivery data in a qr object — an opaque scannable code and its format.

A serialized ticket object:

JSON
{
  "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c",
  "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
  "event_name": "Summer Festival 2026",
  "status": "valid",
  "tier": "VIP",
  "seat": null,
  "price": 149.00,
  "currency": "USD",
  "perks": { "lounge": true },
  "qr": { "data": "a1b2c3...opaque", "format": "QR" },
  "revoked": false,
  "created_at": "2026-06-05T12:30:00.000Z"
}

The status field is one of: valid, used, transferred, refunded, or expired.

Revocation is an independent axis from status. Every ticket carries a revoked boolean, and a revoked ticket additionally carries revoked_at (an ISO timestamp). A revoked ticket keeps its lifecycle status but fails every scan — see POST /v1/tickets/{id}/revoke below.


POST /v1/events/{id}/tickets

Issue a ticket to a customer for the event. Returns 201 and emits a ticket.issued webhook. The price is computed server-side from the matching tier and is schedule-effective — if the tier runs a published price schedule, the currently active step price is charged. Paid tiers require a confirmed payment_intent_id; free or comp tiers (price 0) do not. A given payment intent may fund only one ticket. See the Issue a ticket guide.

On events with a published seat map, seated tiers are issued by the seat: hold seats first with POST /v1/events/{id}/seats/hold, take the payment (which must cover the tier price × number of held seats), then pass the returned hold_id here. One ticket is issued per held seat and the response becomes a batch { "tickets": [...] }. Issuing on a seated tier without a hold is rejected with 400 seat_required.

For events with an on-sale waiting room, the customer must hold a live admission window before issuance succeeds — see Waiting room (queue). Events may also enforce a per-person purchase cap (max_tickets_per_identity).

Scope: tickets:issue

Path params

ParamTypeRequiredDescription
idstringYesEvent id.

Request body

FieldTypeRequiredDescription
customer_idstringYesThe customer's externalId (see Customers).
tierstringYesTier name to issue from, matching a tier's name.
payment_intent_idstringConditionalRequired for paid tiers; the id from POST /v1/payment_intents. Omit for free tiers.
hold_idstringNoReserved seating: a live seat hold from POST /v1/events/{id}/seats/hold. Issues one ticket per held seat and switches the response to { "tickets": [...] }.
quantityintegerNoOptional safety check alongside hold_id: must equal the number of held seats. Any value other than 1 requires hold_id.
attributionobjectNoAd attribution for this sale — see Attribution object below.

Accepts an Idempotency-Key header.

Example request

Terminal
curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/tickets \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: issue-001" \
  -d '{
    "customer_id": "cus_ext_42",
    "tier": "VIP",
    "payment_intent_id": "pi_3Q1abc..."
  }'

Example response

JSON
{
  "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c",
  "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
  "event_name": "Summer Festival 2026",
  "status": "valid",
  "tier": "VIP",
  "price": 149.00,
  "currency": "USD",
  "qr": { "data": "a1b2c3...opaque", "format": "QR" },
  "created_at": "2026-06-05T12:30:00.000Z"
}

Common errors: 404 event_not_found, 404 customer_not_provisioned, 409 tier_not_found, 403 tier_not_released (tier not auto-released yet), 409 sold_out, 402 payment_required (paid tier without a payment intent), 402 payment_failed (payment could not be verified), 409 payment_already_used, 403 not_admitted (waiting-room event, customer not yet admitted), 403 purchase_limit_reached (per-person cap). Reserved seating adds: 400 seat_required (seated tier without a hold_id), 409 hold_not_found, 409 hold_expired (re-hold and retry), 409 seat_tier_mismatch (a held seat belongs to another tier), and 400 quantity_mismatch (quantity ≠ number of held seats).

Attribution object

Optional on every issuance call. Pass what you have — every field is optional and malformed input is silently dropped, never rejected:

FieldTypeDescription
utm_source, utm_medium, utm_campaign, utm_term, utm_contentstringStandard UTM parameters, stored with the ticket for the Sales sources report.
fbclid, gclidstringAd-platform click IDs, kept only with marketing consent.
referrerstringThe buyer's referrer, if you have it.
fbp, fbcstringMeta's _fbp/_fbc browser cookie ids, for CAPI match quality. Only kept when marketingConsent is true.
eventIdstringA uuid you generate and also pass to your own browser pixel event (e.g. fbq('track', 'Purchase', {...}, {eventID: eventId})) — lets Meta deduplicate your browser event against our server event. Only kept when marketingConsent is true.
sourceUrlstringHTTP(S) page where the purchase happened. Consent-gated; credentials, query parameters and fragments are removed. Sent as CAPI event_source_url.
userAgentstringBuyer browser user agent, consent-gated. Use this when your server forwards issuance requests on behalf of the buyer.
marketingConsentbooleanYour declaration that this buyer consented to marketing tracking on your pages. Only when true do we forward fbp/fbc/eventId and fire a Meta Conversions API Purchase to your tenant pixel (configured on the Ad tracking panel card); UTM/referrer fields are stored either way; advertising click IDs require consent. Omit or false to attribute the sale without any pixel dispatch.

Test-mode purchases never dispatch CAPI. The CAPI dispatch is fire-and-forget and best-effort: a failure here never fails the ticket issuance. See the Ad tracking guide.


GET /v1/tickets

List issued tickets, newest first.

Scope: tickets:read

Query params

ParamTypeRequiredDescription
limitintegerNoPage size, 1–100 (default 20).
starting_afterstringNoCursor — a ticket id to page after.
event_idstringNoFilter to a single event.

Example request

Terminal
curl "https://api.ticketconnect.example/v1/tickets?event_id=evt_a1b2c3d4e5f6a7b8c9d0e1f2" \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "object": "list",
  "has_more": false,
  "data": [
    { "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c", "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "status": "valid", "tier": "VIP" }
  ]
}

GET /v1/tickets/{id}

Retrieve a single ticket, including its qr delivery data.

Scope: tickets:read

Path params

ParamTypeRequiredDescription
idstringYesTicket id.

Example request

Terminal
curl https://api.ticketconnect.example/v1/tickets/tkt_4d5e6f7a8b9c0d1e2f3a4b5c \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c",
  "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
  "event_name": "Summer Festival 2026",
  "status": "valid",
  "tier": "VIP",
  "price": 149.00,
  "currency": "USD",
  "qr": { "data": "a1b2c3...opaque", "format": "QR" },
  "created_at": "2026-06-05T12:30:00.000Z"
}

POST /v1/tickets/{id}/delivery_link

Mint a signed, expiring URL to a hosted mobile ticket page — the event, holder name, a live status badge, and the entry QR, rendered on a page you can hand straight to the customer. The link needs no login: the signed URL is the auth. The page always reflects the ticket's current state — a refund or revocation flips the badge immediately — and is served with Cache-Control: no-store.

Scope: tickets:read

Path params

ParamTypeRequiredDescription
idstringYesTicket id.

Request body

FieldTypeRequiredDescription
expires_in_daysintegerNoLink lifetime, 1–90 days (default 30).

Example request

Terminal
curl https://api.ticketconnect.example/v1/tickets/tkt_4d5e6f7a8b9c0d1e2f3a4b5c/delivery_link \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "expires_in_days": 60 }'

Example response

JSON
{
  "object": "delivery_link",
  "url": "https://api.ticketconnect.example/v1/t/dGt0XzRkNWU2ZjdhLi4u...",
  "expires_at": "2026-09-09T12:30:00.000Z"
}

Links are stateless: they expire on schedule, and re-minting does not invalidate earlier links. An expired, tampered, or unknown link renders one identical 404 page. The holder's email is always shown masked (j***@example.com). Errors: 400 parameter_invalid (expires_in_days out of range), 404 resource_missing.

See the Deliver tickets guide for where a hosted page fits your delivery options.


POST /v1/tickets/{id}/transfer

Transfer a ticket to another of your customers. The new holder must already exist and be provisioned. The ticket keeps the same id, but its QR code rotates: the response and the ticket.transferred webhook carry a fresh qr.data, the previous code stops scanning immediately, and delivery links minted before the transfer stop resolving.

Scope: tickets:manage

Path params

ParamTypeRequiredDescription
idstringYesTicket id.

Request body

FieldTypeRequiredDescription
to_customer_idstringYesThe recipient customer's externalId.

Accepts an Idempotency-Key header.

Example request

Terminal
curl https://api.ticketconnect.example/v1/tickets/tkt_4d5e6f7a8b9c0d1e2f3a4b5c/transfer \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "to_customer_id": "cus_ext_99" }'

Example response

JSON
{
  "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c",
  "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
  "status": "valid",
  "tier": "VIP"
}

Errors: 409 ticket_not_transferable if the ticket is refunded or used, or once the holder has opened it in their ticket wallet (it is then locked to them); 409 ticket_revoked if the ticket has been revoked; 404 customer_not_found if the recipient is missing or not provisioned. Emits a ticket.transferred webhook.


POST /v1/tickets/{id}/refund

Refund the original purchase and paid upgrade allocations in their original native currency, provider, and mode. The server reserves ledger reversals and revokes entry before provider effects. Free and comp tickets skip provider effects.

Only confirmed refunds complete the ticket. Pending Stripe and PayPal receipts are durably retained and recovered by GET using their existing IDs. Failed, canceled, mismatched, unsupported, or unproven outcomes remain held for review. A refund does not use the current tier price as a substitute for payment history.

Scope: tickets:manage

Path params

ParamTypeRequiredDescription
idstringYesTicket id.

This endpoint takes no request body. Accepts an Idempotency-Key header.

Example request

Terminal
curl https://api.ticketconnect.example/v1/tickets/tkt_4d5e6f7a8b9c0d1e2f3a4b5c/refund \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Idempotency-Key: refund-001" \
  -X POST

Example response

JSON
{
  "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c",
  "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
  "status": "refunded",
  "tier": "VIP"
}

A completed replay with the original idempotency key returns its original response; a new refund request for a completed ticket can return 409 already_refunded. Pending outcomes return 202 and retain the original reservation. Review-required outcomes must be reconciled; do not submit a new payment/refund operation under another key. Provider configuration and unsupported rails can return provider errors. ticket.refunded is emitted only after all required money legs complete.


POST /v1/tickets/{id}/revoke

Invalidate a ticket without refunding it — fraud, a chargeback, a comp clawback. Revocation sets revoked: true and revoked_at on the ticket; the lifecycle status is intentionally unchanged (revocation is an independent axis). From that moment the ticket fails every scan and can no longer be transferred.

Revoking is idempotent: revoking an already-revoked ticket returns the same terminal state with 200 and emits no second webhook. Revoke does not refund — call POST /v1/tickets/{id}/refund as well when you want both.

Scope: tickets:manage

Path params

ParamTypeRequiredDescription
idstringYesTicket id.

Request body

FieldTypeRequiredDescription
reasonstringNoFree-text reason, up to 500 characters. Kept for your audit trail.

Accepts an Idempotency-Key header.

Example request

Terminal
curl https://api.ticketconnect.example/v1/tickets/tkt_4d5e6f7a8b9c0d1e2f3a4b5c/revoke \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "chargeback received" }'

Example response

JSON
{
  "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c",
  "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
  "status": "valid",
  "tier": "VIP",
  "revoked": true,
  "revoked_at": "2026-07-11T09:15:00.000Z",
  "created_at": "2026-06-05T12:30:00.000Z"
}

Errors: 409 ticket_already_used — a checked-in ticket cannot be revoked; 400 parameter_invalid if reason is empty or longer than 500 characters. Emits a ticket.revoked webhook.


GET /v1/tickets/{id}/upgrade-options

List the higher tiers this ticket can move into, each with the fiat price difference (delta) and remaining availability. Only tiers with upgradesEnabled priced above the current tier and still in stock are returned.

Scope: tickets:manage

Path params

ParamTypeRequiredDescription
idstringYesTicket id.

Example request

Terminal
curl https://api.ticketconnect.example/v1/tickets/tkt_4d5e6f7a8b9c0d1e2f3a4b5c/upgrade-options \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "current_tier": "General Admission",
  "options": [
    {
      "name": "VIP",
      "price": 149.00,
      "delta": 99.01,
      "available": 188,
      "perks": { "lounge": true }
    }
  ]
}

POST /v1/tickets/{id}/upgrade

Upgrade a ticket to a higher tier in place (same ticket, same QR) — allowed even after the ticket has been scanned (used). If the upgrade has a positive price difference, a confirmed payment_intent_id covering the delta is required; a given delta payment intent may fund only one upgrade.

Scope: tickets:manage

Path params

ParamTypeRequiredDescription
idstringYesTicket id.

Request body

FieldTypeRequiredDescription
target_tierstringYesThe tier name to upgrade into.
payment_intent_idstringConditionalRequired when the upgrade has a positive price difference.

Accepts an Idempotency-Key header.

Example request

Terminal
curl https://api.ticketconnect.example/v1/tickets/tkt_4d5e6f7a8b9c0d1e2f3a4b5c/upgrade \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "target_tier": "VIP",
    "payment_intent_id": "pi_3Q1xyz..."
  }'

Example response

JSON
{
  "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c",
  "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
  "status": "valid",
  "tier": "VIP",
  "price": 149.00,
  "currency": "USD",
  "perks": { "lounge": true }
}

Errors: 404 tier_not_found, 409 sold_out, 400 upgrades_disabled, 400 not_an_upgrade, 402 payment_required, 402 payment_failed, 409 payment_already_used. Emits a ticket.upgraded webhook.


POST /v1/tickets/{id}/attendance

Mark a ticket as attended (check-in) by ticket id, without a full scan payload. This belongs to the scanning surface — see Scanning.

Customers

A customer is a person who holds tickets, identified by your own externalId. You create customers from an email and name only. See the Customers concept page for the full mental model.

A serialized customer object:

JSON
{
  "id": "cus_ext_42",
  "email": "fan@example.com",
  "name": "Jordan Fan",
  "status": "active",
  "created_at": "2026-06-05T11:00:00.000Z"
}

status is active once the customer is fully provisioned, otherwise pending.


POST /v1/customers

Create (or idempotently return) a customer. The id you supply as externalId is your own stable identifier — reuse it everywhere you reference this customer (issuing, transferring tickets).

Scope: customers:write

Request body

FieldTypeRequiredDescription
externalIdstringYesYour stable identifier for the customer. Becomes the customer id.
emailstringYesCustomer email; tickets are delivered here.
namestringNoCustomer display name.

Accepts an Idempotency-Key header.

Example request

Terminal
curl https://api.ticketconnect.example/v1/customers \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: cus-001" \
  -d '{
    "externalId": "cus_ext_42",
    "email": "fan@example.com",
    "name": "Jordan Fan"
  }'

Example response

JSON
{
  "id": "cus_ext_42",
  "email": "fan@example.com",
  "name": "Jordan Fan",
  "status": "active",
  "created_at": "2026-06-05T11:00:00.000Z"
}

GET /v1/customers/{id}

Retrieve a customer by the externalId you assigned.

Scope: customers:write

Path params

ParamTypeRequiredDescription
idstringYesThe customer's externalId.

Example request

Terminal
curl https://api.ticketconnect.example/v1/customers/cus_ext_42 \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "id": "cus_ext_42",
  "email": "fan@example.com",
  "name": "Jordan Fan",
  "status": "active",
  "created_at": "2026-06-05T11:00:00.000Z"
}

Perks

Perks are event add-ons — a welcome drink, lounge access, a backstage meet — that become redeemable grants a customer presents at the door. You define a perk on an event, grants are issued (now, to named customers or a section, or automatically as tickets in a tier sell), and your scanner redeems each grant by its opaque code.

A grant's code is the only handle you ever see or pass around — there are no wallets, signatures, or on-chain concepts in these responses.

A serialized perk definition:

JSON
{
  "id": "def_9f2c1a7b",
  "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
  "name": "Welcome drink",
  "redeemable": true,
  "max_uses": 1,
  "distribution": "manual",
  "active": true,
  "created_at": "2026-07-11T12:00:00.000Z"
}

Distribution

ModeHow grants are handed out
tierAuto-granted to a holder the moment they're issued a ticket in the named tier (including comps). Nothing to trigger — define it once.
manualIssued immediately to recipients: every current holder (all), a section, or named customers.

loyalty distribution (airdrop by attendance history) is a first-party-only channel and returns 400 not_supported on this API.

POST /v1/events/{id}/perks

Create a perk definition for one of your events. Scope: events:write.

Request body

FieldTypeRequiredDescription
namestringYesDisplay name of the perk.
descriptionstringNoLonger description shown to staff/holder.
distributionstringYestier or manual.
redeemablebooleanNoWhether the perk is redeemed at the door (default true). A non-redeemable perk is informational only and issues no grants.
max_usesinteger | nullNoRedemptions per grant (positive integer, or null for unlimited). Default 1.
tierstringConditionalRequired when distribution is tier — the ticket tier that earns the perk.
sectionstringNoOptional section qualifier.
scan_stationstringNoHint for your scanner UI (e.g. bar, backstage).
recipientsobjectConditionalRequired when distribution is manual: { "type": "all" }, { "type": "section", "section": "VIP" }, or { "type": "customers", "customer_ids": ["cus_1", ...] } (1–500 ids).

For a redeemable manual perk the response also carries issued, skipped, and recipients counts. tier perks issue no grants now — they materialize as matching tickets sell.

Example request

Terminal
curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/perks \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Welcome drink",
    "distribution": "manual",
    "redeemable": true,
    "max_uses": 1,
    "recipients": { "type": "customers", "customer_ids": ["cus_9f2c1a7b"] }
  }'

Errors: 400 parameter_missing (no name/recipients), 400 parameter_invalid (bad distribution/max_uses/tier), 400 not_supported (distribution: "loyalty"), 404 resource_missing (not your event).

GET /v1/events/{id}/perks

List the event's perk definitions with grant rollups. Scope: events:read. Each row adds grants_issued and grants_redeemed.

GET /v1/customers/{id}/perks

List a customer's perk grants. Scope: customers:write. Each grant carries the opaque code your scanner reads, plus perk_name, status, and uses_remaining (null = unlimited).

JSON
{
  "object": "list",
  "data": [
    {
      "code": "grant_3f7a9c0d1e2f",
      "perk_name": "Welcome drink",
      "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
      "status": "active",
      "uses_remaining": 1
    }
  ],
  "has_more": false
}

POST /v1/perks/redeem

Redeem a grant by its code at the door. Scope: scanning:write. Online and atomic — a single-use grant is consumed exactly once; an unlimited grant stays valid and each scan logs.

Request body

FieldTypeRequiredDescription
codestringYesThe grant code from the customer.

Example response

JSON
{
  "valid": true,
  "perk": { "name": "Welcome drink", "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2" },
  "uses_remaining": 0
}

A verdict is always returned with 200. valid is false with a reason of already_redeemed, revoked, expired, or not_found — an unknown code, or a code belonging to another account, is reported as not_found and never distinguished.

Ticket extras

Ticket extras are the optional things a ticket does beyond entry. Each one is off until you turn it on, and an event with none of them behaves exactly as before.

ExtraWhat it does
VaultMedia a ticket unlocks — photos, video, audio, artwork, files. An item with a future unlock_at is a time capsule.
StubAfter check-in the ticket becomes a keepsake stub with its door number, arrival time, artwork and an audio loop.
StampsStations a checked-in holder collects (bar, second stage, a sponsor booth).
PollsVotes only checked-in tickets can cast — one per ticket.
CraftingA customer trades checked-in tickets from your past events for a reward perk.

"Checked in" always means the ticket was admitted through scanning. A holder is a ticket_id or a customer_id — there are no wallets or on-chain concepts in these responses.

Configuration

The stub, stamp stations and crafting rule live on the event. Send ticket_extras on POST /v1/events or PATCH /v1/events/{id}; every key is optional.

JSON
{
  "ticket_extras": {
    "stub": {
      "enabled": true,
      "image_url": "https://acme.example/img/stub.jpg",
      "audio_url": "https://acme.example/audio/loop.mp3"
    },
    "stamps": [
      { "name": "Bar", "sponsor": "Acme Brewing" },
      { "name": "Stage 2" }
    ],
    "crafting": {
      "stubs_required": 5,
      "reward_name": "Season pass",
      "reward_unlimited": true
    }
  }
}
FieldTypeDescription
stub.enabledbooleanTurns the stub on.
stub.image_urlstringStub artwork, shown after check-in. https://, at most 2048 characters. Before check-in the event image is shown.
stub.audio_urlstringAudio loop, returned only after check-in. Same URL rules.
stampsarrayUp to 20 stations, each { "name", "sponsor"? } (60 characters each). The response adds an id per station.
crafting.stubs_requiredintegerHow many checked-in tickets the reward costs, 1–50.
crafting.reward_namestringName of the reward perk, up to 80 characters.
crafting.reward_descriptionstringOptional, up to 300 characters.
crafting.reward_unlimitedbooleantrue makes the reward redeemable any number of times (a pass). Default: single use.

On PATCH, each of stub, stamps and crafting is replaced when you send it, kept when you omit it and removed when you send null. To keep a station across an edit, re-send it with its id — a station sent without an id is a new station, and stamps collected at the old one no longer count.

The event object echoes the configuration back as ticket_extras. The key is absent on events that have none.

Errors: 400 parameter_invalid with param naming the field, e.g. ticket_extras.stub.image_url or ticket_extras.crafting.stubs_required.

Vault

POST /v1/events/{id}/vault

Add a vault item. Scope: events:write.

FieldTypeRequiredDescription
kindstringYesphoto, video, audio, artwork or file.
titlestringYesUp to 120 characters.
urlstringYesWhere the media lives. https://, at most 2048 characters.
tierstringNoOnly tickets in this tier can open the item.
attended_onlybooleanNoOnly checked-in tickets can open it.
requires_all_stampsbooleanNoOnly tickets that collected every stamp can open it.
unlock_atstringNoISO 8601 time. The item stays locked until then.

Gates combine: an item with tier and attended_only needs both.

Terminal
curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/vault \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "photo",
    "title": "Night one gallery",
    "url": "https://acme.example/galleries/night-one",
    "attended_only": true
  }'
JSON
{
  "id": "vi_5b0c7a52-0c1e-4f0a-9a57-2f3c1d9e8b11",
  "kind": "photo",
  "title": "Night one gallery",
  "url": "https://acme.example/galleries/night-one",
  "tier": null,
  "attended_only": true,
  "requires_all_stamps": false,
  "unlock_at": null,
  "created_at": "2026-10-04T12:00:00.000Z"
}

This API takes a link, not a file: host the media yourself and pass its url. To have TicketConnect store the file instead, upload it in the organizer panel (Add vault item on the event page) — the item then appears here like any other.

The url is handed to a holder as-is once their ticket unlocks the item, so anyone they share it with can open it too. Host private media behind your own expiring links if that matters.

GET /v1/events/{id}/vault

List the event's vault items, oldest first. Scope: events:read.

DELETE /v1/events/{id}/vault/{item_id}

Remove an item. Scope: events:write. Returns { "id": "...", "deleted": true }.

GET /v1/tickets/{id}/extras

What the holder of this ticket sees. Scope: tickets:read.

JSON
{
  "object": "ticket_extras",
  "ticket_id": "tkt_7d1e2f3a4b5c",
  "stub": {
    "state": "stub",
    "image_url": "https://acme.example/img/stub.jpg",
    "audio_url": "https://acme.example/audio/loop.mp3",
    "door_number": 17,
    "scanned_at": "2026-10-04T21:41:07.000Z"
  },
  "stamps": [
    { "id": "st_4c1f9a2b7d3e", "name": "Bar", "sponsor": "Acme Brewing", "collected_at": "2026-10-04T22:05:31.000Z" },
    { "id": "st_8e2a6b1c9f04", "name": "Stage 2", "sponsor": null, "collected_at": null }
  ],
  "stamps_complete": false,
  "vault": [
    { "id": "vi_5b0c…", "kind": "photo", "title": "Night one gallery", "locked": false, "lock_reason": null, "unlock_at": null, "url": "https://acme.example/galleries/night-one", "download_url": "https://acme.example/galleries/night-one" },
    { "id": "vi_9d4e…", "kind": "audio", "title": "Bonus track", "locked": true, "lock_reason": "stamps_incomplete", "unlock_at": null, "url": null, "download_url": null }
  ],
  "polls": []
}
FieldNotes
stubnull when the event has no stub. state is ticket before check-in and stub after. door_number is the ticket's arrival order by scan time; it and scanned_at and audio_url are null before check-in.
stampsEvery station of the event, with collected_at set once this ticket collected it.
vault[].lock_reasontier, not_attended, stamps_incomplete or not_yet (a time capsule — see unlock_at). A locked item never carries a url.
vault[].download_urlA link that saves the file instead of opening it. Differs from url only for images and videos uploaded through the organizer panel; for your own links it is the same address. null while locked.
pollsThe event's polls with live results and this ticket's my_vote (an option index, or null).

Errors: 404 resource_missing (not your ticket), 409 ticket_not_valid (the ticket was revoked or refunded).

POST /v1/tickets/{id}/stamps

Collect a stamp for a ticket. Scope: scanning:write.

FieldTypeRequiredDescription
station_idstringYesA station id from the event's ticket_extras.stamps.
JSON
{ "collected": true, "already_collected": false, "stamps_complete": true }

Collecting the same stamp twice is a no-op (already_collected: true).

Errors: 404 unknown_station, 409 not_attended (the ticket is not checked in yet), 409 ticket_not_valid.

Polls

POST /v1/events/{id}/polls

Open a poll. Scope: events:write.

FieldTypeRequiredDescription
questionstringYesUp to 200 characters.
optionsstring[]Yes2–6 options, up to 60 characters each.
JSON
{
  "id": "poll_1c2d3e4f-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
  "question": "Encore?",
  "options": [
    { "label": "Track A", "votes": 12 },
    { "label": "Track B", "votes": 30 }
  ],
  "total_votes": 42,
  "by_section": [
    { "section": "VIP", "votes": [2, 9] },
    { "section": "GA", "votes": [10, 21] }
  ],
  "closed": false,
  "created_at": "2026-10-04T22:10:00.000Z"
}

by_section splits the same counts by ticket tier, in option order — use it to drive per-section effects.

GET /v1/events/{id}/polls

List the event's polls with live results, newest first. Scope: events:read.

POST /v1/events/{id}/polls/{poll_id}/close

Stop accepting votes and return the poll with its final results. Scope: events:write. Closing a closed poll is a no-op.

POST /v1/events/{id}/polls/{poll_id}/votes

Cast a ticket's vote. Scope: tickets:manage.

FieldTypeRequiredDescription
ticket_idstringYesA ticket for this event.
optionintegerYesIndex into the poll's options, starting at 0.

Returns { "voted": true }.

Errors: 400 invalid_option, 404 resource_missing (not your ticket), 404 poll_not_found (unknown poll, or the ticket belongs to another event), 409 not_attended, 409 already_voted, 409 poll_closed.

Crafting

A customer's stubs are their checked-in tickets across all of your events that have not been spent yet. Crafting spends stubs_required of them — oldest first — for one grant of the event's reward. The reward is an ordinary perk grant: it appears in GET /v1/customers/{id}/perks and is redeemed with POST /v1/perks/redeem.

GET /v1/events/{id}/crafting

A customer's progress. Scope: tickets:read. Query: customer_id (required).

JSON
{
  "stubs_required": 5,
  "stubs_available": 3,
  "reward_name": "Season pass",
  "reward_description": null,
  "crafted": false
}

POST /v1/events/{id}/craft

Spend the stubs and issue the reward. Scope: tickets:manage.

FieldTypeRequiredDescription
customer_idstringYesThe customer crafting the reward.
JSON
{ "crafted": true, "perk_code": "grant_3f7a9c0d-1e2f-4a5b-8c6d-7e8f9a0b1c2d" }

One reward per customer per event. A spent ticket stays a valid stub in every other respect — it only stops counting toward crafting.

Errors: 409 not_enabled (the event has no crafting rule), 409 already_crafted, 409 not_enough_stubs (the body also carries stubs_required and stubs_available), 409 conflict (the customer's tickets changed mid-request — retry), 404 resource_missing (not your event or customer).

Payments

A payment intent represents the configured provider's collection for a ticket issuance or upgrade. The amount is always computed server-side from the event tier — the client cannot supply or override it. After the customer completes the payment on the client, pass the payment intent id back to the issue/upgrade endpoint. See the Take a payment guide.

A serialized payment intent object:

JSON
{
  "object": "payment_intent",
  "id": "pi_3Q1abc...",
  "provider": "stripe",
  "client_payload": { "client_secret": "pi_3Q1abc..._secret_..." },
  "amount": 149.00,
  "currency": "USD",
  "status": "requires_payment"
}

status is a provider-normalized value you can act on. With a card provider it is one of requires_payment (awaiting the customer's payment or action), processing, succeeded, failed, or canceled. Providers that collect money out-of-band report requires_external until you confirm the payment.


POST /v1/payment_intents

Create a payment intent with your mode-specific configured provider for an event tier. Use client_payload to complete the charge in your client (for example, Stripe's client_secret).

Scope: payments:write

Request body

FieldTypeRequiredDescription
event_idstringYesThe event to charge for.
tierstringYesThe tier name; the price is read from this tier.
quantityintegerNoTickets/seats this intent funds (1–10, default 1). The amount is tier price × quantity — set it to the seat-hold size for reserved seating.
customer_idstringNoThe customer's externalId, stored on the intent for reference.
currencystringNoMust match the server's event/account currency; it cannot override that currency.

Send an Idempotency-Key for every creation. The amount must use whole native minor units, including zero-decimal currencies such as JPY. Unsupported provider/currency combinations are rejected.

Example request

Terminal
curl https://api.ticketconnect.example/v1/payment_intents \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: pi-001" \
  -d '{
    "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
    "tier": "VIP",
    "customer_id": "cus_ext_42"
  }'

Example response

JSON
{
  "object": "payment_intent",
  "id": "pi_3Q1abc...",
  "provider": "stripe",
  "client_payload": { "client_secret": "pi_3Q1abc..._secret_..." },
  "amount": 149.00,
  "currency": "USD",
  "status": "requires_payment"
}

Errors: 404 resource_missing (unknown event), 400 parameter_invalid (unknown tier), 502 payment_provider_error.


GET /v1/payments/{id}

Retrieve a payment intent and its current status. You can only read payments created under your own account.

Scope: payments:read (or payments:write)

Path params

ParamTypeRequiredDescription
idstringYesPayment intent id.

Example request

Terminal
curl https://api.ticketconnect.example/v1/payments/pi_3Q1abc... \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "object": "payment_intent",
  "id": "pi_3Q1abc...",
  "amount": 149.00,
  "currency": "USD",
  "status": "succeeded"
}

A payment that does not belong to your account is reported as 404 resource_missing so existence is never leaked across accounts.

Payments opened by an out-of-band provider have extpay_... ids; retrieving one additionally includes "provider": "external".


POST /v1/payment_intents/{id}/confirm

Mark an out-of-band ("external") payment as collected. Only accounts whose payment provider confirms server-side support this — with a card provider such as Stripe, the charge is confirmed on the client instead and this endpoint returns 400 confirm_not_supported.

Scope: payments:write

Path params

ParamTypeRequiredDescription
idstringYesPayment intent id (extpay_...).

Example request

Terminal
curl https://api.ticketconnect.example/v1/payment_intents/extpay_9f8e7d6c5b4a3210fedcba98/confirm \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -X POST

Example response

JSON
{
  "object": "payment_intent",
  "id": "extpay_9f8e7d6c5b4a3210fedcba98",
  "status": "succeeded",
  "provider": "external"
}

Errors: 400 confirm_not_supported (your provider confirms on the client), 400 payment_not_found (no such payment under your account).

Confirmation records your assertion that money was collected elsewhere. It does not prove collection in the platform's Stripe treasury and does not fund /v1/balance. Refunds stay on the original provider rail; unsupported refunds require review.


Hosted checkout

The hosted checkout lets you sell tickets with no frontend of your own: hand a buyer a single URL and the platform serves a branded purchase page. All hosted checkout endpoints authenticate with a publishable key (pk_...) — a browser-safe key restricted to this surface; secret (sk_) keys are rejected here, and publishable keys are rejected everywhere else. See Authentication.

The purchase flow is: load the page (or fetch /info from your own embed) → POST .../intent to open the payment → complete the charge with client_payload → POST .../complete to issue the ticket.


GET /v1/checkout/{event_id}

The public checkout page (HTML). Give this URL to a buyer directly; everything else in the flow is handled by the page itself.

Auth: publishable key, passed as the key query parameter.

Query params

ParamTypeRequiredDescription
keystringYesYour publishable (pk_...) key.

Example

https://api.ticketconnect.example/v1/checkout/evt_a1b2c3d4e5f6a7b8c9d0e1f2?key=pk_test_your_key_here

An invalid or missing key renders a 401 HTML error page; an unknown event a 404 HTML page.


GET /v1/checkout/{event_id}/info

Event details as JSON — the same serialized event object as GET /v1/events/{id}, for building your own embedded checkout with just a publishable key.

Auth: publishable key (x-api-key header).

Example request

Terminal
curl https://api.ticketconnect.example/v1/checkout/evt_a1b2c3d4e5f6a7b8c9d0e1f2/info \
  -H "x-api-key: pk_test_your_key_here"

Example response

JSON
{ "event": { "id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "name": "Summer Fest", "ticketPools": [ { "name": "VIP", "price": 149.00 } ] } }

POST /v1/checkout/{event_id}/intent

Open a payment for a tier, identified by the buyer's email. Mirrors POST /v1/payment_intents: the amount is recomputed server-side from the tier price and cannot be supplied by the browser.

Auth: publishable key (x-api-key header).

Request body

FieldTypeRequiredDescription
tierstringYesThe tier to buy; the price is read from this tier.
emailstringYesThe buyer's email — the ticket is issued to this identity.

Example request

Terminal
curl https://api.ticketconnect.example/v1/checkout/evt_a1b2c3d4e5f6a7b8c9d0e1f2/intent \
  -H "x-api-key: pk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "tier": "VIP", "email": "buyer@example.com" }'

Example response

JSON
{
  "object": "payment_intent",
  "id": "pi_3Q1abc...",
  "provider": "stripe",
  "client_payload": { "client_secret": "pi_3Q1abc..._secret_..." },
  "amount": 149.00,
  "currency": "USD",
  "status": "requires_payment"
}

Errors: 404 event_not_found, 400 tier_not_found, 409 sold_out, 403 tier_not_released (the tier is visible but not yet on sale), 403 purchase_limit_reached (the buyer is at the event's per-person purchase cap), 502 payment_provider_error.


POST /v1/checkout/{event_id}/complete

Verify the payment and issue the ticket. The payment is re-verified server-side against the authoritative amount, event, and account; free tiers (price 0) skip payment verification. The payment's allocation is committed atomically with stock; the combined native allocations across issuance and upgrades cannot exceed the original confirmed collection. A retry recovers the original ticket instead of consuming that budget twice.

Auth: publishable key (x-api-key header).

Request body

FieldTypeRequiredDescription
intent_idstringYesThe payment intent id from /intent.
tierstringYesThe tier that was paid for.
emailstringYesThe buyer's email (same as on /intent).
attributionobjectNoAd attribution for this sale — see Attribution object below.

Example request

Terminal
curl https://api.ticketconnect.example/v1/checkout/evt_a1b2c3d4e5f6a7b8c9d0e1f2/complete \
  -H "x-api-key: pk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "intent_id": "pi_3Q1abc...", "tier": "VIP", "email": "buyer@example.com" }'

Example response

Returns 201 with the issued ticket and authoritative purchase details. Use purchase.value, purchase.currency and purchase.event_id for your browser Purchase event:

JSON
{
  "ticket": {
    "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c",
    "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
    "event_name": "Summer Fest",
    "status": "valid",
    "tier": "VIP",
    "price": 149.00,
    "currency": "USD",
    "qr": { "data": "a1b2c3...opaque", "format": "QR" },
    "created_at": "2026-06-05T12:30:00.000Z"
  },
  "purchase": { "value": 149.00, "currency": "USD", "event_id": "your-browser-event-id" }
}

Errors: 402 with a payment code (payment_not_found, payment_not_completed, payment_amount_insufficient, payment_currency_mismatch, ...) when verification fails; 409 payment_already_used (this payment already funded a ticket); 409 sold_out; 403 not_admitted (the event runs an on-sale queue and the buyer does not hold a live admission window yet); 403 purchase_limit_reached — the payment is not consumed, refund it via your payment provider. Emits a ticket.issued webhook on success.


Attribution object

Optional on /complete — same shape as the ticket issuance endpoint's; see Attribution object for the full field-by-field reference. In short: pass UTM/click-id fields plus marketingConsent: true and your own fbp/fbc/eventId (read from _fbp/_fbc and your own pixel call) to get a server-side Meta Conversions API Purchase fired to your tenant pixel, deduplicated against your own browser pixel event by that eventId. Include sourceUrl and userAgent for the buyer's page context. Test-mode purchases never dispatch CAPI. Every field is optional and malformed input is silently dropped, never rejected. Omit attribution (or leave marketingConsent false/absent) and the sale is still recorded with whatever UTM/referrer fields you send, with no pixel dispatch.

If you hand buyers the TicketConnect-hosted checkout page instead of calling /complete yourself, none of this applies to you — the hosted page captures attribution and dispatches CAPI on its own. See the Ad tracking guide.

Marketplace

The marketplace holds secondary-market resale listings. Prices are in fiat, and each event's resale royalty and anti-scalping bounds are applied automatically per its marketplace_settings (see Events). See the Marketplace and resale guide.

A listing's sale or ownership state does not prove that every financial leg settled. The platform freezes the original source, mode, currency conversion, and required seller/royalty/fee splits before effects. Ambiguous settlement remains pending and retries the same signed transaction bytes or original provider operation. Unsupported legacy provenance and fiat-resale buyer refunds without a proven seller clawback require review.

A serialized listing object:

JSON
{
  "id": "list_9f8e7d6c5b4a43210fedcba9876543aa",
  "ticket_id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c",
  "price": 160.00,
  "currency": "USD",
  "status": "active",
  "created_at": "2026-06-05T13:00:00.000Z"
}

GET /v1/marketplace/listings

List your resale listings, newest first. Each listing carries its status (for example active); the list is not filtered to active listings.

Scope: marketplace:read

Query params

ParamTypeRequiredDescription
limitintegerNoPage size, 1–100 (default 20).
starting_afterstringNoCursor — a listing id to page after.

Example request

Terminal
curl "https://api.ticketconnect.example/v1/marketplace/listings?limit=2" \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "list_9f8e7d6c5b4a43210fedcba9876543aa",
      "ticket_id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c",
      "price": 160.00,
      "currency": "USD",
      "status": "active",
      "created_at": "2026-06-05T13:00:00.000Z"
    }
  ]
}

GET /v1/marketplace/listings/{id}

Retrieve a single resale listing with its fiat price.

Scope: marketplace:read

Path params

ParamTypeRequiredDescription
idstringYesListing id.

Example request

Terminal
curl https://api.ticketconnect.example/v1/marketplace/listings/list_9f8e7d6c5b4a43210fedcba9876543aa \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "id": "list_9f8e7d6c5b4a43210fedcba9876543aa",
  "ticket_id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c",
  "price": 160.00,
  "currency": "USD",
  "status": "active",
  "created_at": "2026-06-05T13:00:00.000Z"
}

Reports

Per-tier rollups of sales and attendance, computed on demand and returned as JSON — or as a CSV download with format=csv. Amounts are in major units of your account currency (49.99, not 4999).

Both endpoints require the reports:read scope.

Counting semantics. tickets_issued counts all tickets created in the window — including ones later refunded or revoked. tickets_refunded counts tickets whose refund completed (the money moved); tickets_revoked counts tickets with a revocation — the two are independent, so one ticket can appear in both. gross is the pre-refund gross of all issued tickets.


GET /v1/reports/sales

Per-tier sales rollup — issued / refunded / revoked counts and gross value — over a date window on ticket creation time, optionally narrowed to one event. Tiers sort alphabetically (untiered tickets group under null, first).

Scope: reports:read

Query params

ParamTypeRequiredDescription
event_idstringNoNarrow the report to one event; omit for the whole account.
fromstringNoWindow start, ISO 8601 (date-only means midnight UTC). Default: 30 days before to.
tostringNoWindow end, ISO 8601, inclusive. Default: now. Max window: 366 days.
formatstringNojson (default) or csv.

Date-only to excludes that day's activity after 00:00 UTC. The match is inclusive on both ends, but "to": "2026-07-11" means 2026-07-11T00:00:00Z — pass a full timestamp (or the next day) for end-of-day semantics.

Example request

Terminal
curl "https://api.ticketconnect.example/v1/reports/sales?event_id=evt_a1b2c3d4e5f6a7b8c9d0e1f2&from=2026-06-01&to=2026-07-01T00:00:00Z" \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "object": "report",
  "data": {
    "report": "sales",
    "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
    "from": "2026-06-01T00:00:00.000Z",
    "to": "2026-07-01T00:00:00.000Z",
    "currency": "USD",
    "tiers": [
      { "tier": "General Admission", "tickets_issued": 1180, "tickets_refunded": 22, "tickets_revoked": 3, "gross": 57810.20 },
      { "tier": "VIP", "tickets_issued": 60, "tickets_refunded": 1, "tickets_revoked": 0, "gross": 8940.00 }
    ],
    "totals": { "tickets_issued": 1240, "tickets_refunded": 23, "tickets_revoked": 3, "gross": 66750.20 }
  },
  "generated_at": "2026-07-11T09:00:00.000Z"
}

With format=csv the response is text/csv (with a Content-Disposition: attachment header and a dated filename): one row per tier plus a TOTAL row, columns Tier, Tickets Issued, Tickets Refunded, Tickets Revoked, Gross, Currency. Column order is fixed — safe to consume positionally — and cell values are sanitized against spreadsheet formula injection.

Errors: 400 invalid_date_window (unparsable from/to, from after to, or a window over 366 days), 400 invalid_format, 404 not_found (unknown or foreign event_id).


GET /v1/reports/attendance

Issued vs checked-in counts per tier, no-show count, and an hourly UTC check-in timeline — for one event (event_id is required).

no_show counts tickets that are still valid (not refunded, not revoked) and were never checked in, so checked_in + no_show ≤ tickets_issued.

Scope: reports:read

Query params

ParamTypeRequiredDescription
event_idstringYesThe event to report on.
formatstringNojson (default) or csv.

Example request

Terminal
curl "https://api.ticketconnect.example/v1/reports/attendance?event_id=evt_a1b2c3d4e5f6a7b8c9d0e1f2" \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "object": "report",
  "data": {
    "report": "attendance",
    "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
    "tiers": [
      { "tier": "General Admission", "tickets_issued": 1180, "checked_in": 934, "no_show": 221 },
      { "tier": "VIP", "tickets_issued": 60, "checked_in": 46, "no_show": 13 }
    ],
    "totals": { "tickets_issued": 1240, "checked_in": 980, "no_show": 234 },
    "timeline": [
      { "hour": "2026-08-15T17:00:00.000Z", "checkins": 412 },
      { "hour": "2026-08-15T18:00:00.000Z", "checkins": 486 }
    ]
  },
  "generated_at": "2026-07-11T09:00:00.000Z"
}

The timeline buckets check-ins by UTC hour, sorted ascending, and is sparse — hours with zero check-ins are omitted.

With format=csv the response is one flat table (Row Type, Tier, Tickets Issued, Checked In, No Show, Hour, Check-ins): the tier rows, a TOTAL row, then the hourly timeline rows.

Errors: 400 parameter_missing (no event_id), 400 invalid_format, 404 not_found (unknown or foreign event_id).

Scanning

Validate tickets at the door and record check-ins online. Validation reads the QR string from a ticket: either the opaque qr.data you delivered, or the live, refreshing code shown for a ticket held in a holder's ticket wallet (verified cryptographically on the platform side). Attendance marks a ticket as used. On these endpoints, your API key with the scanning:write scope authorizes scanning directly. The offline door protocol is separate: devices use a short-lived scanner session token minted by your backend. See the Scanner protocol reference and the Scanning and check-in and Offline scanning guides.

A scan verdict looks like:

JSON
{ "valid": true, "status": "valid", "tier": "VIP", "ticket_id": "tkt_4d5e6f..." }

When a ticket is not admissible, valid is false and reason is one of not_found, refunded, revoked, already_used — or, for live wallet codes: code_expired (real but stale; have the holder refresh and re-scan), live_code_required (a static copy of a wallet code where the live one is required), or invalid_qr (failed cryptographic verification). Whenever the ticket was identified, the verdict includes its ticket_id; an already_used verdict also carries the tier so door staff can see what was scanned.

On an event with admission_days (see Multi-day events), already_used means the ticket was already admitted on the current admission day, and wrong_day means its tier is not valid today. POST /v1/tickets/{id}/attendance then checks a ticket in once per day and answers 409 wrong_day for a tier limited to other days.


POST /v1/scan/validate

Validate a single ticket for entry by its QR string. This does not mark the ticket used — call attendance to check in.

Scope: scanning:write

Request body

FieldTypeRequiredDescription
qrstringYesThe QR string read from the ticket, verbatim — your opaque qr.data or a live wallet code (a JSON-looking blob; never parse or re-encode it).

Example request

Terminal
curl https://api.ticketconnect.example/v1/scan/validate \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "qr": "a1b2c3...opaque" }'

Example response

JSON
{
  "valid": true,
  "status": "valid",
  "tier": "VIP",
  "ticket_id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c"
}

A failed verdict:

JSON
{ "valid": false, "reason": "already_used", "tier": "VIP", "ticket_id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c" }

POST /v1/scan/batch

Validate up to 200 tickets in a single call while online. Live codes must still be fresh when the request reaches the server. None are marked used. Returns a per-QR verdict array. This is not an offline queue: for scanning without a connection, see Offline scanning.

Scope: scanning:write

Request body

FieldTypeRequiredDescription
qrsstring[]YesArray of QR strings, verbatim (max 200). Each entry may be either code shape.

Example request

Terminal
curl https://api.ticketconnect.example/v1/scan/batch \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "qrs": ["a1b2c3...", "d4e5f6...", "unknown-code"] }'

Example response

JSON
{
  "results": [
    { "qr": "a1b2c3...", "valid": true, "status": "valid", "tier": "VIP", "ticket_id": "tkt_4d5e6f..." },
    { "qr": "d4e5f6...", "valid": false, "reason": "already_used", "tier": "GA", "ticket_id": "tkt_77aa..." },
    { "qr": "unknown-code", "valid": false, "reason": "not_found" }
  ]
}

Errors: 400 parameter_missing if qrs is not an array; 400 parameter_invalid if it contains more than 200 entries.


POST /v1/tickets/{id}/attendance

Mark a ticket as attended (check-in) by ticket id. This is the action that actually consumes the ticket. It is safe to retry: an already-used ticket returns 200 with status: "already_used" rather than an error.

Scope: scanning:write

Path params

ParamTypeRequiredDescription
idstringYesTicket id.

This endpoint takes no required body. Accepts an Idempotency-Key header.

Example request

Terminal
curl https://api.ticketconnect.example/v1/tickets/tkt_4d5e6f7a8b9c0d1e2f3a4b5c/attendance \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -X POST

Example response

JSON
{
  "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c",
  "status": "used"
}

A repeat call returns:

JSON
{
  "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c",
  "status": "already_used"
}

Errors: 404 resource_missing for an unknown ticket. Emits an attendance.verified webhook on the first successful check-in.

Scanner protocol

The offline door protocol: arm a scanner device, feed it the event's tickets, revocations and perk grants, and reconcile its device-signed scans. The Offline scanning guide explains the trust model and the @ticketconnect/offline-scanner SDK, which calls every device endpoint below for you.

Two credentials, never interchangeable

EndpointCalled byAuth
POST /v1/scanner/sessionsYour backendSecret key (sk_…), scope scanning:write
GET /v1/staff/{id}/devices, POST /v1/staff/{id}/devices/{device_id}/revokeYour backendSecret key (sk_…), scope staff:manage
Every other /v1/scanner/* endpointThe scanner deviceScanner session token: Authorization: Bearer <token> from POST /v1/scanner/sessions
  • A secret key sent to a device route returns 401. A session token sent to a partner route, or to any route outside the /v1/scanner device endpoints, also returns 401.
  • Device routes take no event id. The event comes from the session token, so a session for one event can never read or write another.
  • A session carries only the door capabilities: scan_tickets and redeem_perks. On every request its effective permissions are the token's AND the staff member's current flags, so turning a flag off takes effect within about 60 seconds.
  • A device route returns 401 with type: "authentication_error", code: "unauthorized" and a message starting with Invalid scanner session: when the token is missing, malformed, expired or tampered with, when the staff member has been deactivated, suspended or unassigned from the event, when your account is no longer active, or when the API key that minted the session has been revoked. Mint a new session and retry.

Errors use the standard envelope { "error": { "type", "code", "message", "param" } } (see Errors).

Signed strings

Pushed rows are Ed25519-signed by the device over these exact UTF-8 strings. <eventId> is the session's event id. sig is the lowercase hex signature.

scan: v1|<eventId>|<ticket_id>|<scanned_at>|<scanner_staff_id>|<device_id>
perk: v1|perk|<eventId>|<grant_id>|<redeemed_at>|<scanner_staff_id>|<device_id>
cert: v3|device|<device_id>|<event_id>|<public_key lowercase>|<capabilities>|<issued_at_ms>|<expires_at_ms>

The platform signs the cert string when it issues a device certificate. <capabilities> is the sorted, comma-joined list: perk, scan or perk,scan. Certificates in the older v2 format are no longer issued or accepted.

Reconciliation outcomes

Each row you push comes back with an outcome:

outcomeMeaningreason
acceptedThe first valid claim on the ticket or grant.—
supersededAnother claim within 10 seconds was earlier, and that one wins.lost-to-earlier-scan / lost-to-earlier-redemption
suspect_duplicateAnother claim exists more than 10 seconds away. Both are flagged for review.outside-gossip-window
rejectedNot admissible.Scans: staff_mismatch, device_unknown, device_revoked, staff_lacks_permission, bad_signature, event_ended, unknown_ticket, refunded, revoked. Perks: staff_mismatch, device_unknown, device_revoked, staff_lacks_permission, bad_signature, event_ended, unknown_grant, revoked, already_used.
  • staff_mismatch: the row's scanner_staff_id isn't the session's staff member, or its device_id belongs to someone else. Nothing is stored. The SDK only pushes the session staff member's own rows, so it treats this as a bug: the row stays queued on the device.
  • Revocation is not retroactive. A row from a device revoked at time T (everywhere, or for this event) is still processed normally if it was signed at or before T + 60 seconds; the server flags it for organizer review. Rows signed later are device_revoked.
  • staff_lacks_permission: the session no longer has scan_tickets (for scans) or redeem_perks (for perks). This is a per-row verdict in a normal 200 response, not an error for the whole request, and nothing is stored. Rows the device signed before the permission was removed (up to 60 seconds after the resulting device revocation, and only if the device was certified for that capability) are still accepted.
  • unknown_ticket / unknown_grant: the id doesn't exist in the session's event. A ticket or grant from another event is reported the same way. Nothing is stored.
  • Replays are safe. Rows are unique per device, ticket or grant, and signed timestamp. Each row is stored as pending, its effect (ticket marked used, perk use consumed, staff credit) is applied, and only then is it final. Pushing an identical row again returns its stored outcome, reason and server_record_id; if an earlier attempt was cut off while the row was still pending, the replay finishes it. A replay never marks a ticket twice, consumes another perk use or credits the staff member again.

POST /v1/scanner/sessions

Mint a scanner session for one of your staff members on one of your events. Call this from your backend and pass the token to the device. Never pass the sk_ key to a device.

Scope: scanning:write (secret key)

The token expires after 12 hours, or at the event's grace cutoff (event end + 24 hours) if that comes first. The minimum lifetime is 60 seconds. It carries only the staff member's can_scan_tickets and can_redeem_perks flags; selling, staff management and stats are always off in a scanner session.

Request body

FieldTypeRequiredDescription
staff_idstringYesA staff id from POST /v1/staff.
event_idstringYesAn event you own.

Example request

Terminal
curl https://api.ticketconnect.example/v1/scanner/sessions \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "staff_id": "staff_1727_ab12", "event_id": "evt_42" }'

Example response

201 Created

JSON
{
  "object": "scanner_session",
  "token": "eyJhbGciOiJIUzI1NiJ9...",
  "expires_at": "2026-09-30T23:10:00.000Z",
  "event_id": "evt_42",
  "staff_id": "staff_1727_ab12"
}

Errors, checked in this order: 400 parameter_missing (param is staff_id or event_id); 404 resource_missing for an unknown or unowned staff member (param: staff_id), then event (param: event_id); 403 staff_inactive; 409 staff_not_assigned; 403 staff_lacks_permission (the staff member has neither can_scan_tickets nor can_redeem_perks); 410 event_ended (more than 24 hours past the event end).


GET /v1/scanner/manifest

Everything a device needs to arm that isn't paged. It never contains the live-code master key.

Auth: scanner session token

Example request

Terminal
curl https://api.ticketconnect.example/v1/scanner/manifest \
  -H "Authorization: Bearer $SCANNER_SESSION_TOKEN"

Example response

JSON
{
  "object": "scanner_manifest",
  "event_id": "evt_42",
  "event_public_key": "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29",
  "platform_cert_public_key": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
  "event_end_at": "2026-10-01T02:00:00.000Z",
  "grace_cutoff_at": "2026-10-02T02:00:00.000Z",
  "qr_rotation": { "v": 2, "enforced": true, "period_s": 30 },
  "staff": {
    "id": "staff_1727_ab12",
    "first_name": "Gate",
    "last_name": "Keeper",
    "permissions": {
      "scan_tickets": true,
      "redeem_perks": false,
      "sell_onsite": false,
      "view_stats": false,
      "manage_staff": false
    }
  }
}
FieldDescription
event_public_keyHex Ed25519 key that ticket and perk codes are signed with. null if the event has no key; the SDK refuses to arm without one.
platform_cert_public_keyHex Ed25519 key that device certificates are signed with. Pin it once. Can be null if the platform key isn't configured.
event_end_at, grace_cutoff_atEvent end, and end + 24 hours. null for an undated event.
qr_rotationLive-code settings, or null when the event doesn't enforce live codes.
staff.permissionsThe session's effective permissions: token AND the staff member's current flags. In a scanner session, sell_onsite, view_stats and manage_staff are always false. The SDK refuses to scan (permission_denied) without scan_tickets, and to redeem without redeem_perks.

Errors: 401 for a bad session; 404 resource_missing if the session's event no longer exists.


POST /v1/scanner/devices

Register the device's Ed25519 public key for the session's staff member and receive a device certificate scoped to the session's event. The call is idempotent: re-registering the same device_id with the same key returns 201 again with already_registered: true and a fresh certificate. Registering records the event on the device (see event_ids in GET /v1/staff/{id}/devices). A staff member can hold at most 10 active devices per event. Access is checked fresh before and after the device is written: if the staff member was deactivated, unassigned or lost a capability in the meantime, the new device is revoked on the spot and no certificate is returned. Certificates are not stored. They expire at event end + 24 hours, capped at 7 days (24 hours for an undated event), and list the gossip capabilities the session's effective permissions allow.

Auth: scanner session token

Request body

FieldTypeRequiredDescription
device_idstringYes8–128 characters, [A-Za-z0-9_-].
public_keystringYesExactly 64 hex characters (a raw Ed25519 public key). Stored lowercase.
platformstringNoios, android, or web.
app_versionstringNoUp to 32 characters.

Example request

Terminal
curl https://api.ticketconnect.example/v1/scanner/devices \
  -H "Authorization: Bearer $SCANNER_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "dev_abc12345",
    "public_key": "8f1e0c5b6a2d4e7f9a3b1c0d2e4f6a8b0c1d3e5f7a9b2c4d6e8f0a1b3c5d7e9f",
    "platform": "ios",
    "app_version": "1.4.0"
  }'

Example response

201 Created

JSON
{
  "object": "scanner_device",
  "id": "dev_abc12345",
  "device_cert": {
    "device_id": "dev_abc12345",
    "event_id": "evt_42",
    "public_key": "8f1e0c5b6a2d4e7f9a3b1c0d2e4f6a8b0c1d3e5f7a9b2c4d6e8f0a1b3c5d7e9f",
    "capabilities": ["scan"],
    "issued_at_ms": 1790776717559,
    "expires_at_ms": 1790863117559,
    "sig": "<128 hex characters>"
  },
  "already_registered": false
}

device_cert is null if the platform certificate key isn't configured.

Errors: 400 parameter_invalid (param is device_id, public_key, platform or app_version); 401 unauthorized (the staff member is no longer active or assigned: mint a new session); 403 staff_lacks_permission (the session can neither scan nor redeem); 409 device_conflict (the device_id is taken, or registered with a different key); 403 device_revoked (the device is revoked, globally or for this event: revoked device ids never come back); 409 device_limit_reached (revoke one of the staff member's devices first). On device_conflict and device_revoked, generate a new device key and device_id and register again; the SDK does this once automatically.


GET /v1/scanner/devices/revoked

The distrust feed: devices whose scans and gossip must no longer be trusted. A revoked phone keeps a valid-looking certificate until it expires, so armed devices need this list. It covers every device ever registered for the session's event that is revoked globally or for this event, including those of staff who were since deactivated or unassigned, and no devices from other events.

Auth: scanner session token

Query params

ParamTypeRequiredDescription
sincestringNoISO-8601 timestamp: the now from your previous call. Omit it for the full list.
limitintegerNoDefault 1000, max 5000 (larger values are clamped).

Example response

JSON
{
  "object": "revocation_feed",
  "now": "2026-09-30T14:00:37.521Z",
  "has_more": false,
  "device_ids": ["dev_x"]
}

When has_more is true, now is the last returned row's revocation time. Call again with since=<now>. The boundary row may repeat.

Errors: 400 parameter_invalid for a non-ISO since or a limit that isn't a positive integer.


GET /v1/scanner/tickets

The event's valid (non-revoked) tickets, used to arm the gate. Ordered by ticket_id.

Auth: scanner session token

Query params

ParamTypeRequiredDescription
cursorstringNoThe next_cursor from the previous page.
limitintegerNoDefault 1000, max 5000 (larger values are clamped).

Example request

Terminal
curl "https://api.ticketconnect.example/v1/scanner/tickets?limit=1000" \
  -H "Authorization: Bearer $SCANNER_SESSION_TOKEN"

Example response

JSON
{
  "object": "list",
  "data": [
    {
      "ticket_id": "tkt_1",
      "is_used": false,
      "used_at": null,
      "used_by": null,
      "seat_number": null,
      "section": "GA",
      "row": null,
      "rot_pub_key": "5c1d8e0f2a4b6c8d0e1f3a5b7c9d2e4f6a8b0c1d3e5f7a9b2c4d6e8f0a1b3c5d",
      "barcode_sha256": "9f2b6c1e4d7a0b3c5e8f1a2d4b6c9e0f3a5d7b1c4e6f8a0b2d5c7e9f1a3b5c7d"
    }
  ],
  "next_cursor": null
}
FieldDescription
rot_pub_keyHex public key that verifies this ticket's live code. null when the event doesn't enforce live codes.
barcode_sha256Lowercase hex SHA-256 of the ticket's opaque qr.data, so a device can match your delivered code offline. The plaintext barcode is never sent.
next_cursorThe last ticket_id when the page is full; otherwise null.

Errors: 400 parameter_invalid (param: limit).


GET /v1/scanner/tickets/revoked

Revoked tickets for the event. Without since you get the full current list, which you need at arming. With since you get only revocations after that time. Revocations that are later undone aren't replayed; re-arm to pick them up.

Auth: scanner session token

Query params

ParamTypeRequiredDescription
sincestringNoISO-8601 timestamp: the now from an earlier call.
cursorstringNoThe next_cursor from the previous page.
limitintegerNoDefault 1000, max 5000 (larger values are clamped).

Example response

JSON
{
  "object": "revocation_feed",
  "now": "2026-09-30T14:00:37.521Z",
  "ticket_ids": ["tkt_9"],
  "next_cursor": null
}

now is captured before the query runs. Use the first page's now as your next since, so a revocation that lands mid-read is sent again rather than missed.

Errors: 400 parameter_invalid (param is since or limit).


GET /v1/scanner/perk-grants

The event's active perk grants, labelled so the device can show what it is redeeming. Ordered by grant_id.

Auth: scanner session token

Query params

ParamTypeRequiredDescription
cursorstringNoThe next_cursor from the previous page.
limitintegerNoDefault 1000, max 5000 (larger values are clamped).

Example response

JSON
{
  "object": "list",
  "data": [
    {
      "grant_id": "grant_a",
      "perk_def_id": "pdef_1",
      "status": "active",
      "uses_remaining": 1,
      "unlimited": false,
      "name": "Free Drink",
      "icon": "cup",
      "color": "#ff0000"
    }
  ],
  "next_cursor": null
}

Errors: 400 parameter_invalid (param: limit).


POST /v1/scanner/scans

Push device-signed scans for reconciliation: one row right after an online scan, or many when a device drains its offline backlog. Every row must be the session staff member's own, signed by one of their devices. Each newly accepted scan marks the ticket used and adds one to the staff member's scanned_tickets; a replayed row adds nothing.

Auth: scanner session token

Request body

FieldTypeRequiredDescription
scansobject[]YesAt most 1000 rows.
scans[].local_idstringNoYour local row id, up to 128 characters. It is echoed back in processed.
scans[].ticket_idstringYesThe admitted ticket. Up to 128 characters, no |.
scans[].scanned_atstringYesISO-8601 time of the scan with Z or an offset (what Date#toISOString() produces), exactly as it appears in the signed string.
scans[].scanner_staff_idstringYesMust be the session's staff member. Up to 128 characters, [A-Za-z0-9_-].
scans[].device_idstringYesA registered, non-revoked device of that staff member. Up to 128 characters, [A-Za-z0-9_-].
scans[].sigstringYesEd25519 signature over the scan string above: exactly 128 lowercase hex characters.

Example request

Terminal
curl https://api.ticketconnect.example/v1/scanner/scans \
  -H "Authorization: Bearer $SCANNER_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "scans": [
      {
        "local_id": "L1",
        "ticket_id": "tkt_1",
        "scanned_at": "2026-09-30T13:55:00.000Z",
        "scanner_staff_id": "staff_1727_ab12",
        "device_id": "dev_abc12345",
        "sig": "<hex signature over v1|evt_42|tkt_1|2026-09-30T13:55:00.000Z|staff_1727_ab12|dev_abc12345>"
      }
    ]
  }'

Example response

JSON
{
  "object": "scan_batch_result",
  "cursor": "2026-09-30T13:58:37.571Z",
  "processed": [
    {
      "ticket_id": "tkt_1",
      "local_id": "L1",
      "outcome": "accepted",
      "server_record_id": "6abd..."
    }
  ]
}

processed[] has one entry per row, with ticket_id, the optional local_id, outcome, and where they apply, reason and server_record_id (see Reconciliation outcomes).

Errors: 400 parameter_missing (param: scans); 413 batch_too_large (more than 1000 rows); 400 parameter_invalid naming the row and field (for example param: scans[1].sig).


GET /v1/scanner/scans

Pull the server's scan records for the event, including other gates' admissions, to update the local used-set. Only active rows consume a ticket.

Auth: scanner session token

Query params

ParamTypeRequiredDescription
cursorstringNoISO-8601 server_received_at watermark: the previous next_cursor. Defaults to the Unix epoch.
limitintegerNoDefault 500, max 2000 (larger values are clamped).

Example response

JSON
{
  "object": "list",
  "data": [
    {
      "ticket_id": "tkt_1",
      "scanned_at": "2026-09-30T13:55:00.000Z",
      "server_received_at": "2026-09-30T13:58:37.571Z",
      "scanner_staff_id": "staff_1727_ab12",
      "device_id": "dev_abc12345",
      "sig": "<hex>",
      "status": "active"
    }
  ],
  "next_cursor": "2026-09-30T13:58:37.571Z"
}

status is active, superseded, suspect_duplicate, or rejected. Rows still being processed (pending) are never returned. When a row's status changes later (for example an active scan is superseded by an earlier one), its server_received_at moves forward, so it re-appears after your cursor and you see the new status. next_cursor is never null: it is the last row's server_received_at, or it echoes cursor when there is nothing new. Stop paging when a page has fewer rows than limit.

Errors: 400 parameter_invalid (param is cursor or limit).


POST /v1/scanner/perk-redemptions

Push device-signed perk redemptions. This mirrors POST /v1/scanner/scans.

Auth: scanner session token

Request body

FieldTypeRequiredDescription
redemptionsobject[]YesAt most 1000 rows.
redemptions[].local_idstringNoUp to 128 characters. Echoed back in processed.
redemptions[].grant_idstringYesThe redeemed grant. Up to 128 characters, no |.
redemptions[].redeemed_atstringYesISO-8601 time of the redemption with Z or an offset.
redemptions[].scanner_staff_idstringYesMust be the session's staff member. Same rules as for scans.
redemptions[].device_idstringYesA registered, non-revoked device of that staff member.
redemptions[].sigstringYesEd25519 signature over the perk string above: exactly 128 lowercase hex characters.

Example request

Terminal
curl https://api.ticketconnect.example/v1/scanner/perk-redemptions \
  -H "Authorization: Bearer $SCANNER_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "redemptions": [
      {
        "local_id": "P1",
        "grant_id": "grant_a",
        "redeemed_at": "2026-09-30T13:57:00.000Z",
        "scanner_staff_id": "staff_1727_ab12",
        "device_id": "dev_abc12345",
        "sig": "<hex signature over v1|perk|evt_42|grant_a|2026-09-30T13:57:00.000Z|staff_1727_ab12|dev_abc12345>"
      }
    ]
  }'

Example response

JSON
{
  "object": "perk_redemption_batch_result",
  "cursor": "2026-09-30T13:58:40.000Z",
  "accepted_count": 1,
  "processed": [
    {
      "grant_id": "grant_a",
      "local_id": "P1",
      "outcome": "accepted",
      "server_record_id": "6abd..."
    }
  ]
}

accepted_count counts only the rows this request newly accepted; a replayed row reports accepted again but isn't counted.

Errors: 400 parameter_missing (param: redemptions); 413 batch_too_large; 400 parameter_invalid (for example param: redemptions[0].grant_id).


GET /v1/scanner/perk-redemptions

Pull the server's perk redemption records. This mirrors GET /v1/scanner/scans: the same cursor and limit rules (default 500, max 2000), and a next_cursor that is never null.

Auth: scanner session token

Example response

JSON
{
  "object": "list",
  "data": [
    {
      "grant_id": "grant_a",
      "redeemed_at": "2026-09-30T13:57:00.000Z",
      "server_received_at": "2026-09-30T13:58:40.000Z",
      "scanner_staff_id": "staff_1727_ab12",
      "device_id": "dev_abc12345",
      "sig": "<hex>",
      "status": "active"
    }
  ],
  "next_cursor": "2026-09-30T13:58:40.000Z"
}

Errors: 400 parameter_invalid (param is cursor or limit).


GET /v1/staff/{id}/devices

List a staff member's registered scanner devices, newest first, with the standard list pagination. Public keys stay on the server.

Scope: staff:manage (secret key)

Path params

ParamTypeRequiredDescription
idstringYesStaff id. An unknown id, or one from another account, returns 404 resource_missing.

Query params

ParamTypeRequiredDescription
limitintegerNoPage size, 1–100 (default 20).
starting_afterstringNoCursor: the last device id you saw on the previous page. An unknown id returns 400 parameter_invalid.

Example request

Terminal
curl https://api.ticketconnect.example/v1/staff/staff_1727_ab12/devices \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "object": "list",
  "data": [
    {
      "object": "scanner_device",
      "id": "dev_abc12345",
      "staff_id": "staff_1727_ab12",
      "event_ids": ["evt_17", "evt_42"],
      "platform": "android",
      "app_version": "9.9",
      "registered_at": "2026-09-30T13:50:00.000Z",
      "last_seen_at": "2026-09-30T13:58:37.000Z",
      "revoked_at": null,
      "revoked_events": [
        { "event_id": "evt_17", "revoked_at": "2026-09-29T22:00:00.000Z" }
      ]
    }
  ],
  "has_more": false
}

event_ids lists the events the device has registered for. revoked_at is set when the device is revoked everywhere; revoked_events lists events it is revoked for only (after an unassignment). platform, app_version and last_seen_at can be null.


POST /v1/staff/{id}/devices/{device_id}/revoke

Revoke one of a staff member's scanner devices, for example a lost phone. Its scans and perk redemptions signed more than 60 seconds after the revocation are rejected at reconciliation (device_revoked); earlier ones are still processed and flagged for review. It also appears in GET /v1/scanner/devices/revoked for its events, so armed peers drop its gossip, and it can never register again: the phone needs a new device_id. The call is idempotent: an already-revoked device keeps its original revoked_at and reason.

Devices are also revoked automatically:

  • Everywhere, for all of a staff member's devices, when you deactivate or suspend them (DELETE /v1/staff/{id}, or PATCH with a non-active status), delete them in the organizer panel, or switch off can_scan_tickets or can_redeem_perks (their certificates still list the old capabilities; the phones register again with fewer).
  • For one event only when you unassign them from it (DELETE /v1/staff/{id}/assignments/{eventId}). The event is added to revoked_events, and the devices keep working at the staff member's other events.

Scope: staff:manage (secret key)

Path params

ParamTypeRequiredDescription
idstringYesStaff id.
device_idstringYesA device registered to that staff member.

Request body

FieldTypeRequiredDescription
reasonstringNoStored with the revocation. Defaults to admin-revoke.

Example request

Terminal
curl https://api.ticketconnect.example/v1/staff/staff_1727_ab12/devices/dev_abc12345/revoke \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "lost phone" }'

Example response

JSON
{
  "object": "scanner_device",
  "id": "dev_abc12345",
  "staff_id": "staff_1727_ab12",
  "event_ids": ["evt_42"],
  "platform": "android",
  "app_version": "9.9",
  "registered_at": "2026-09-30T13:50:00.000Z",
  "last_seen_at": "2026-09-30T13:58:37.000Z",
  "revoked_at": "2026-09-30T14:01:00.000Z",
  "revoked_events": []
}

Errors: 404 resource_missing for an unknown or unowned staff member, or for a device that doesn't belong to that staff member.

Discounts

Discount codes applied at checkout, either a percentage off or a fixed amount off. All discount endpoints require the events:write scope.

A serialized discount object:

JSON
{
  "id": "664f0a1b2c3d4e5f60718293",
  "code": "SUMMER20",
  "type": "percentage",
  "value": 20,
  "max_uses": 500,
  "used_count": 42,
  "expires_at": "2026-09-01T00:00:00.000Z",
  "active": true,
  "created_at": "2026-06-05T10:00:00.000Z"
}

For type: "percentage", value is a percent (0–100). For type: "fixed", value is an amount in your currency.


GET /v1/discounts

List your discount codes, newest first.

Scope: events:write

Query params

ParamTypeRequiredDescription
limitintegerNoPage size, 1–100 (default 20).
starting_afterstringNoCursor — a discount id to page after.

Example request

Terminal
curl https://api.ticketconnect.example/v1/discounts \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "object": "list",
  "has_more": false,
  "data": [
    { "id": "664f0a1b2c3d4e5f60718293", "code": "SUMMER20", "type": "percentage", "value": 20, "max_uses": 500, "used_count": 42, "expires_at": "2026-09-01T00:00:00.000Z", "active": true, "created_at": "2026-06-05T10:00:00.000Z" }
  ]
}

POST /v1/discounts

Create a discount code. If you omit code, a random one is generated.

Scope: events:write

Request body

FieldTypeRequiredDescription
typestringYespercentage or fixed.
valuenumberYesNon-negative amount. For percentage, must be 0–100.
codestringNoThe code customers enter. Generated if omitted.
max_usesintegerNoMaximum redemptions.
expires_atstringNoExpiry timestamp (ISO 8601).
event_idsstring[]NoRestrict the code to specific events.

Accepts an Idempotency-Key header.

Example request

Terminal
curl https://api.ticketconnect.example/v1/discounts \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "code": "SUMMER20",
    "type": "percentage",
    "value": 20,
    "max_uses": 500,
    "expires_at": "2026-09-01T00:00:00Z"
  }'

Example response

JSON
{
  "id": "664f0a1b2c3d4e5f60718293",
  "code": "SUMMER20",
  "type": "percentage",
  "value": 20,
  "max_uses": 500,
  "used_count": 0,
  "expires_at": "2026-09-01T00:00:00.000Z",
  "active": true,
  "created_at": "2026-06-05T10:00:00.000Z"
}

GET /v1/discounts/{id}

Retrieve a discount code by id.

Scope: events:write

Path params

ParamTypeRequiredDescription
idstringYesDiscount id.

Example request

Terminal
curl https://api.ticketconnect.example/v1/discounts/664f0a1b2c3d4e5f60718293 \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "id": "664f0a1b2c3d4e5f60718293",
  "code": "SUMMER20",
  "type": "percentage",
  "value": 20,
  "max_uses": 500,
  "used_count": 42,
  "expires_at": "2026-09-01T00:00:00.000Z",
  "active": true,
  "created_at": "2026-06-05T10:00:00.000Z"
}

PATCH /v1/discounts/{id}

Update mutable fields on a discount. value is written against the discount's existing type (percentage values are still capped at 100).

Scope: events:write

Path params

ParamTypeRequiredDescription
idstringYesDiscount id.

Request body

All fields optional; send only what you want to change.

FieldTypeDescription
valuenumberNew non-negative value.
max_usesintegerNew maximum redemptions.
expires_atstring | nullNew expiry, or null to clear it.
activebooleanEnable or disable the code.

Example request

Terminal
curl https://api.ticketconnect.example/v1/discounts/664f0a1b2c3d4e5f60718293 \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -X PATCH \
  -d '{ "active": false, "max_uses": 1000 }'

Example response

JSON
{
  "id": "664f0a1b2c3d4e5f60718293",
  "code": "SUMMER20",
  "type": "percentage",
  "value": 20,
  "max_uses": 1000,
  "used_count": 42,
  "expires_at": "2026-09-01T00:00:00.000Z",
  "active": false,
  "created_at": "2026-06-05T10:00:00.000Z"
}

DELETE /v1/discounts/{id}

Delete a discount code.

Scope: events:write

Path params

ParamTypeRequiredDescription
idstringYesDiscount id.

Example request

Terminal
curl https://api.ticketconnect.example/v1/discounts/664f0a1b2c3d4e5f60718293 \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -X DELETE

Example response

JSON
{ "id": "664f0a1b2c3d4e5f60718293", "deleted": true }

Webhooks

Register URLs to receive signed event notifications, then inspect and replay deliveries when debugging your endpoint. All webhook endpoints require the webhooks:manage scope.

Webhooks are signed with a Webhook-Signature header (Stripe-compatible) using the signing secret returned once at endpoint creation. See the Webhooks guide for verification code.

The payout route emits payout.transferred after a confirmed Stripe transfer to the connected account. The legacy payout.paid subscription remains accepted for compatibility; it is not bank-paid confirmation emitted by this route. Pending or review-held transfers do not emit completion.

Event types

enabled_events accepts any of these types, or "*" for all:

event.created, event.updated, ticket.issued, ticket.confirmed, ticket.confirmation_failed, ticket.transferred, ticket.refunded, ticket.revoked, ticket.upgraded, ticket.redeemed, payment.succeeded, payment.failed, marketplace.sale.completed, attendance.verified, payout.transferred

Delivery format and retries

Every delivery is a POST with headers Webhook-Signature (t=<unix-seconds>,v1=<hex> — HMAC-SHA256 of <timestamp>.<raw body> with your whsec_ secret), Webhook-Id (the event id, for deduping), and Content-Type: application/json. The body envelope is:

JSON
{ "id": "evt_9f8e7d...", "type": "ticket.issued", "created": 1749124800, "data": { "...": "the object" } }

A delivery succeeds on any 2xx within 10 seconds. Failures are retried on a fixed backoff — 1m, 5m, 30m, 2h, 10h, 24h after each failed attempt (7 attempts total) — then marked failed. An endpoint that accumulates 20 consecutive failures is automatically set to disabled and stops receiving events.


GET /v1/webhook_endpoints

List your registered webhook endpoints. The signing secret is never returned here — it is shown only once, at creation.

Scope: webhooks:manage

Example request

Terminal
curl https://api.ticketconnect.example/v1/webhook_endpoints \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "664f0a1b2c3d4e5f60718293",
      "url": "https://acme.example/hooks/ticketconnect",
      "enabled_events": ["*"],
      "status": "active"
    }
  ]
}

POST /v1/webhook_endpoints

Register a webhook endpoint. The response includes the secret exactly once — store it to verify incoming signatures.

Scope: webhooks:manage

Request body

FieldTypeRequiredDescription
urlstringYesDestination URL. Must be http(s); https is required in production. Must resolve to a public address — private/internal addresses are rejected.
enabled_eventsstring[]NoEvent types to receive. Defaults to ["*"] (all events).

Example request

Terminal
curl https://api.ticketconnect.example/v1/webhook_endpoints \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://acme.example/hooks/ticketconnect",
    "enabled_events": ["ticket.issued", "payout.transferred"]
  }'

Example response

JSON
{
  "id": "664f0a1b2c3d4e5f60718293",
  "url": "https://acme.example/hooks/ticketconnect",
  "enabled_events": ["ticket.issued", "payout.transferred"],
  "status": "active",
  "secret": "whsec_9f8e7d6c5b4a..."
}

Errors: 400 parameter_missing (no url), 400 parameter_invalid (non-http(s) URL, or plain http in production), 400 invalid_webhook_url (the URL points at a private or internal address).


DELETE /v1/webhook_endpoints/{id}

Delete a webhook endpoint so it stops receiving events.

Scope: webhooks:manage

Path params

ParamTypeRequiredDescription
idstringYesWebhook endpoint id.

Example request

Terminal
curl https://api.ticketconnect.example/v1/webhook_endpoints/664f0a1b2c3d4e5f60718293 \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -X DELETE

Example response

JSON
{ "id": "664f0a1b2c3d4e5f60718293", "deleted": true }

POST /v1/webhook_endpoints/{id}/ping

Send a signed test ping event to the endpoint and get the attempt result back synchronously. The ping goes through the normal delivery machinery — same Webhook-Signature / Webhook-Id headers, same SSRF checks, same 10-second timeout — so it validates your signature verification end-to-end. Pings are never retried in the background; a failed ping counts toward the endpoint's auto-disable failure streak and is logged as a normal delivery.

Scope: webhooks:manage

Path params

ParamTypeRequiredDescription
idstringYesWebhook endpoint id.

Request body

FieldTypeRequiredDescription
messagestringNoText echoed in the ping's data.message, up to 256 characters. A default is supplied when omitted.

Example request

Terminal
curl https://api.ticketconnect.example/v1/webhook_endpoints/664f0a1b2c3d4e5f60718293/ping \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "message": "hello from staging" }'

Example response

JSON
{
  "id": "evt_8c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
  "endpoint_id": "664f0a1b2c3d4e5f60718293",
  "type": "ping",
  "delivered": true,
  "response_code": 200,
  "error": null
}

delivered is false on any failure; response_code is your endpoint's HTTP status (or null on a network error, with error populated). Errors: 400 parameter_invalid (message too long), 404 resource_missing.


GET /v1/events/deliveries

List recent webhook delivery attempts, with status and the last response code, to debug your endpoint.

Scope: webhooks:manage

Query params

ParamTypeRequiredDescription
limitintegerNoPage size, 1–100 (default 20).
starting_afterstringNoCursor — a delivery id to page after.

Example request

Terminal
curl "https://api.ticketconnect.example/v1/events/deliveries?limit=2" \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "6650bb1c2d3e4f5a60718293",
      "event_id": "evt_9f8e7d6c5b4a43210fedcba98765432",
      "type": "ticket.issued",
      "status": "delivered",
      "attempts": 1,
      "last_response_code": 200,
      "created_at": "2026-06-05T12:30:05.000Z"
    }
  ]
}

POST /v1/events/deliveries/{id}/replay

Re-queue a previous delivery for dispatch to your endpoint — handy after fixing an outage on your side.

Scope: webhooks:manage

Path params

ParamTypeRequiredDescription
idstringYesDelivery id.

Example request

Terminal
curl https://api.ticketconnect.example/v1/events/deliveries/6650bb1c2d3e4f5a60718293/replay \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -X POST

Example response

JSON
{ "id": "6650bb1c2d3e4f5a60718293", "status": "pending", "replayed": true }

Payouts

These endpoints onboard a mode-specific Stripe Connect account, report funded balance, and transfer available platform-held Stripe funds to that account. transferred is not bank-paid confirmation. Stripe's connected-account bank payout has its own lifecycle.

POST /v1/connect/account

Scope: payouts:claim. No request body. Creates or resumes the current mode's connected account and returns a fresh onboarding link.

Terminal
curl https://api.ticketconnect.example/v1/connect/account \
  -H "Authorization: Bearer sk_test_your_key_here" -X POST
JSON
{
  "account_id": "acct_1NxYz...",
  "onboarding_url": "https://connect.stripe.com/setup/e/acct_1NxYz.../abc123"
}

Errors include 502 api_error when provider configuration or onboarding is unavailable. Test and live account IDs are separate; an unverified legacy ID is not reused across modes.

GET /v1/connect/account

Scope: payouts:claim. Returns capability flags, not bank details or proof of a deposit.

JSON
{
  "connected": true,
  "charges_enabled": true,
  "payouts_enabled": true,
  "details_submitted": true
}

Before onboarding: { "connected": false }.

GET /v1/balance

Scope: payouts:read or payouts:claim.

JSON
{ "object": "balance", "available": 12840.50, "currency": "USD" }

available is a major-unit amount of proven platform-held Stripe funds after fees, refund debits, and payout reservations. External, PayPal, crypto, already transferred destination charges, and legacy credits without funding proof are excluded. This endpoint does not expose a separate pending field.

GET /v1/payouts

Scope: payouts:read or payouts:claim. Lists durable transfer operations, newest first.

ParamTypeRequiredDescription
limitintegerNoPage size, 1–100, default 20.
starting_afterstringNoCursor identifying a prior payout operation.
JSON
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "object": "payout",
      "id": "po_opaque_operation_id",
      "amount": 5000.00,
      "currency": "USD",
      "status": "transferred",
      "reference": "tr_1NxYz...",
      "created_at": "2026-09-30T08:00:00.000Z"
    }
  ]
}

reference is present once a Stripe transfer is known. Pending operations remain visible in history.

The ledger balance is not Stripe’s net available-cash report. Processor costs are a platform expense requiring separate liquidity; a provider can refuse a transfer when its actual available balance is insufficient.

POST /v1/payouts

Scope: payouts:claim. Required header: Idempotency-Key, 1–255 characters. The server reserves funded balance before the provider effect, including across simultaneous requests with different keys.

FieldTypeRequiredDescription
amountnumberNoPositive major-unit amount using whole native minor units. Omit for the full funded available balance.
currencystringNoThree-letter currency, defaulting to account settlement currency; funds must exist in this currency.
Terminal
curl https://api.ticketconnect.example/v1/payouts \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: payout-001" \
  -d '{ "amount": 5000.00 }'

A confirmed transfer returns 201 with the payout object above and emits payout.transferred. The legacy payout.paid subscription remains accepted for compatibility; this route does not emit bank-paid confirmation.

HTTPOutcome
201Stripe transfer confirmed; status: transferred.
202Original operation is pending or held for review; keep the same key.
400Invalid/missing key or amount, insufficient_balance, or connect_account_required.
409Request-body mismatch or an operation claim still being prepared.
502Provider unavailable or definitive transfer rejection. A definitive failed transfer releases its reservation.

Unknown outcomes keep their reservation. Recovery reuses the original provider key and destination; automatic retries stop after 23 hours from the first attempt and require reconciliation. See Idempotency.

Comps & guest lists

Complimentary (comp) tickets are free tickets issued to a named recipient — press, artists' guests, sponsors. Comps are real tickets: they claim tier supply atomically (just like paid issuance), carry the standard qr delivery data, emit ticket.issued, and count toward per-person purchase caps. Each comp records who issued it and an optional note, and the event's comps together form its guest list.


POST /v1/events/{id}/comps

Issue up to 20 comp tickets to one recipient. Returns 201 with the issued tickets as a list. The recipient is provisioned as a customer automatically, keyed on their email.

Scope: tickets:issue

Path params

ParamTypeRequiredDescription
idstringYesEvent id.

Request body

FieldTypeRequiredDescription
tierstringYesTier to issue from — supply is claimed like a paid sale.
recipientobjectYes{ "email": "...", "name": "..." } — email required, name optional (up to 120 chars).
notestringNoFree-form note, up to 500 chars — "press", "artist +1", …
quantityintegerNo1–20 tickets for this recipient (default 1). The whole batch is claimed atomically — all or nothing.

Accepts an Idempotency-Key header.

Example request

Terminal
curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/comps \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: comp-press-001" \
  -d '{
    "tier": "VIP",
    "recipient": { "email": "reviewer@musicweekly.example", "name": "Alex Reyes" },
    "note": "press",
    "quantity": 2
  }'

Example response

JSON
{
  "object": "list",
  "data": [
    {
      "id": "tkt_7a8b9c0d1e2f3a4b5c6d7e8f",
      "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
      "event_name": "Summer Festival 2026",
      "status": "valid",
      "tier": "VIP",
      "price": 0,
      "currency": "USD",
      "qr": { "data": "d4e5f6...opaque", "format": "QR" },
      "revoked": false,
      "created_at": "2026-07-11T09:00:00.000Z"
    }
  ],
  "has_more": false
}

Errors: 400 parameter_missing / parameter_invalid (bad tier, recipient.email, note, or quantity), 404 event_not_found, 409 tier_not_found, 409 sold_out (the message names how many tickets remain when the batch doesn't fit). Emits one ticket.issued webhook per ticket.

Comps and money. A comp has price: 0 and no payment attached — a refund has nothing to reverse. To pull a comp back (a clawback), use POST /v1/tickets/{id}/revoke.


GET /v1/events/{id}/comps

The event's guest list — its comp tickets, newest first, cursor-paginated.

Scope: tickets:read

Path params

ParamTypeRequiredDescription
idstringYesEvent id.

Query params

ParamTypeRequiredDescription
limitintegerNoPage size, 1–100 (default 20).
starting_afterstringNoCursor — a ticket id to page after.

Example request

Terminal
curl "https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/comps?limit=2" \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "object": "list",
  "data": [
    {
      "id": "tkt_7a8b9c0d1e2f3a4b5c6d7e8f",
      "tier": "VIP",
      "recipient": { "name": "Alex Reyes" },
      "note": "press",
      "issued_by": "Backend (staging)",
      "checked_in": false,
      "checked_in_at": null,
      "revoked": false,
      "revoked_at": null,
      "created_at": "2026-07-11T09:00:00.000Z"
    }
  ],
  "has_more": true
}

issued_by is the label of the API key that issued the comp (or the key's id when no label is set) — your audit trail for who put whom on the list. The checked_in / revoked fields track each guest's live state at the door.

Errors: 404 event_not_found if the event does not exist or is not yours.

Reserved seating

Events with a published seat map sell seated tiers by the seat. The flow is: read the map, atomically hold the seats your customer picked, take the payment, then issue with the hold_id — one ticket per held seat (see Issue a ticket for the worked flow).

Holds last 10 minutes, cover up to 10 seats, and can be extended while a card entry runs long. Once an event's map is published, issuing on a seated tier without a hold is rejected with 400 seat_required — the map stays truthful.


GET /v1/events/{id}/seatmap

The published seat map: layout (sections, rows, stage/text objects), live availability, and schedule-effective per-tier pricing — everything you need to render a seat picker.

Scope: events:read

Path params

ParamTypeRequiredDescription
idstringYesEvent id.

Example request

Terminal
curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/seatmap \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "id": "smap_1f2e3d4c",
  "name": "Main Hall",
  "canvas": { "width": 1200, "height": 800 },
  "sections": [
    {
      "id": "S1",
      "name": "Stalls",
      "kind": "seated",
      "tier": "Stalls",
      "x": 100, "y": 220, "rotation": 0,
      "rows": 12, "cols": 20,
      "row_label_scheme": "alpha",
      "capacity": 240
    }
  ],
  "objects": [
    { "id": "O1", "type": "stage", "label": "STAGE", "x": 300, "y": 40, "width": 600, "height": 120 }
  ],
  "version": 3,
  "total_seats": 240,
  "availability": {
    "sold": ["S1-A-1", "S1-A-2"],
    "held": ["S1-B-4"],
    "blocked": ["S1-L-20"]
  },
  "tiers": {
    "Stalls": { "price": 79.00, "currency": "USD" }
  }
}

availability lists seat ids that are not currently sellable — everything else in the layout is free. tiers maps each tier to its schedule-effective price (what issuance will actually charge right now).

Errors: 404 event_not_found, 404 seatmap_not_found (the event has no published seat map).


POST /v1/events/{id}/seats/hold

Atomically claim specific seats for 10 minutes. Either every requested seat is held, or none are — a conflict returns 409 seats_unavailable naming the contested seats. Duplicate seat ids in the request collapse to one claim.

Scope: tickets:issue

Path params

ParamTypeRequiredDescription
idstringYesEvent id.

Request body

FieldTypeRequiredDescription
seat_idsstring[]Yes1–10 seat ids from the seat map.

Example request

Terminal
curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/seats/hold \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "seat_ids": ["S1-C-7", "S1-C-8"] }'

Example response

JSON
{
  "hold_id": "hold_9c8b7a6d5e4f",
  "expires_at": "2026-07-11T09:10:00.000Z",
  "ttl_seconds": 600,
  "seats": [
    { "seat_id": "S1-C-7", "tier": "Stalls", "section": "Stalls", "row": "C", "seat": "7" },
    { "seat_id": "S1-C-8", "tier": "Stalls", "section": "Stalls", "row": "C", "seat": "8" }
  ]
}

Pass hold_id to POST /v1/events/{id}/tickets before expires_at — one ticket is issued per held seat. A hold that lapses simply releases its seats; issuing against it returns 409 hold_expired.

Errors: 400 parameter_missing / parameter_invalid (seat_ids empty, non-strings, or more than 10), 404 event_not_found, 404 seatmap_not_found, 409 seats_unavailable.


POST /v1/events/{id}/seats/hold/{holdId}/extend

Reset the hold's expiry to 10 minutes from now — use it when a card entry or 3-D Secure challenge runs long.

Scope: tickets:issue

Path params

ParamTypeRequiredDescription
idstringYesEvent id.
holdIdstringYesThe hold to extend.

Example request

Terminal
curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/seats/hold/hold_9c8b7a6d5e4f/extend \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -X POST

Example response

JSON
{
  "hold_id": "hold_9c8b7a6d5e4f",
  "expires_at": "2026-07-11T09:18:00.000Z",
  "seats_extended": 2
}

Errors: 404 event_not_found, 404 hold_not_found (the hold is missing, already released, expired, or already used to issue).


DELETE /v1/events/{id}/seats/hold/{holdId}

Release a hold, returning its seats to availability — when the customer walks away. Idempotent: releasing an expired or unknown hold succeeds with released: 0.

Scope: tickets:issue

Path params

ParamTypeRequiredDescription
idstringYesEvent id.
holdIdstringYesThe hold to release.

Example request

Terminal
curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/seats/hold/hold_9c8b7a6d5e4f \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -X DELETE

Example response

JSON
{ "hold_id": "hold_9c8b7a6d5e4f", "released": 2 }

released is the number of seats returned to availability.

Errors: 404 event_not_found if the event does not exist or is not yours.

Similar events

"You might also like" for your catalog: given one of your events, list the events from your own catalog most similar to it — for cross-sell modules, post-purchase recommendations, or "more like this" rails.

Results only ever come from your own catalog. Similarity is computed within your account, and another account's events can never appear in your results, in any direction. Only published, upcoming events are returned.


GET /v1/events/{id}/similar

List the events most similar to this one, best match first. Each item is the standard event object plus a similarity score in (0, 1] — higher is more similar.

Scope: events:read

Path params

ParamTypeRequiredDescription
idstringYesThe reference event id.

Query params

ParamTypeRequiredDescription
limitintegerNoMax results, 1–20 (default 5).

Example request

Terminal
curl "https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/similar?limit=3" \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "object": "list",
  "data": [
    {
      "id": "evt_b2c3d4e5f6a7b8c9d0e1f2a3",
      "name": "Riverside Sessions: Indie Night",
      "status": "published",
      "date": "2026-08-22T19:00:00.000Z",
      "venue": "Riverside Park",
      "currency": "USD",
      "similarity": 0.912
    },
    {
      "id": "evt_c3d4e5f6a7b8c9d0e1f2a3b4",
      "name": "Late Summer Open Air",
      "status": "published",
      "date": "2026-09-05T17:30:00.000Z",
      "venue": "East Meadow",
      "currency": "USD",
      "similarity": 0.847
    }
  ],
  "has_more": false
}

The list is not paginated (has_more is always false) — raise limit up to 20 if you want more candidates. A reference event with no similar upcoming events returns an empty list, not an error.

Errors: 404 resource_missing if the reference event does not exist or is not yours.

Customer segments

Behavioral audiences computed live from your ticket history — who buys repeatedly, who shows up, who doesn't. Segments are computed on every request and nothing is stored: membership can change between calls, and the platform never contacts anyone — the audience is yours to message through your own channels.

SegmentWho's in it
repeat_buyersCustomers with at least min_purchases non-refunded tickets (default 2). Revoked tickets still count as purchases.
attendeesCustomers checked in at at least min_events distinct events (default 1).
no_showsCustomers holding at least one never-checked-in, non-refunded, non-revoked ticket for a past event.

"Past" is decided by the event's own date — events with no date recorded never produce no-shows. A refunded ticket never counts anywhere.


GET /v1/customers/segments/{kind}

List one segment, newest customer first, cursor-paginated.

Scope: customers:write

Path params

ParamTypeRequiredDescription
kindstringYesrepeat_buyers, attendees, or no_shows. Anything else returns 400 parameter_invalid.

Query params

ParamTypeRequiredDescription
min_purchasesintegerNorepeat_buyers only: minimum non-refunded tickets, >= 1 (default 2). Silently ignored on other kinds.
min_eventsintegerNoattendees only: minimum distinct attended events, >= 1 (default 1). Silently ignored on other kinds.
event_idstringNoScope the segment to one event. An unknown event_id isn't an error — it yields an empty list.
limitintegerNoPage size, 1–100 (default 20).
starting_afterstringNoCursor — pass the last customer_id you saw on the previous page.

Example request

Terminal
curl "https://api.ticketconnect.example/v1/customers/segments/repeat_buyers?min_purchases=3&limit=2" \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "object": "list",
  "data": [
    {
      "customer_id": "cus_ext_42",
      "email": "dana@example.com",
      "purchase_count": 5,
      "last_activity_at": "2026-07-02T21:14:00.000Z"
    },
    {
      "customer_id": "cus_ext_17",
      "email": "sam@example.com",
      "purchase_count": 3,
      "last_activity_at": "2026-06-28T19:40:00.000Z"
    }
  ],
  "has_more": true
}

Each row carries exactly: customer_id, email, the one kind-relevant count (purchase_count | attended_event_count | no_show_count), and last_activity_at (ISO datetime or null). Customers with no qualifying tickets simply never appear — there is no zero-count row.

Errors: 400 parameter_invalid — unknown kind, a non-positive threshold, or an unknown starting_after cursor (param names the offender).


Saved segments

The endpoint above computes an audience on the fly and forgets it. When you want to name an audience, keep it, and reuse it — re-evaluate it later, or wire it into a discount trigger — save it with /v1/segments.

The distinction:

GET /v1/customers/segments/{kind}/v1/segments
What it isA one-off computationA persisted, named definition
Stored?No — nothing is keptYes — name + kind + threshold + optional event_id
Reusable?Re-pass the params each timeReference by id; re-evaluate any time
Wires into triggers?NoYes — link it to a discount code

Both run through the same audience engine, so a saved segment's members always agree with the equivalent on-the-fly computation. Saved-segment membership is still evaluated fresh on every read — the definition is stored, the members are not.

All saved-segment endpoints require the customers:write scope.

A serialized saved segment:

JSON
{
  "id": "seg_1f2e3d4c5b6a",
  "name": "Loyal fans",
  "description": "Bought 3 or more times",
  "kind": "repeat_buyers",
  "min_count": 3,
  "event_id": "evt_1a2b3c",
  "created_at": "2026-07-11T09:00:00.000Z"
}

description and event_id are only present when set.

POST /v1/segments

Save a named audience definition.

Scope: customers:write

Request body

FieldTypeRequiredDescription
namestringYes1–80 characters, unique per account. A duplicate returns 409 segment_name_taken.
kindstringYesrepeat_buyers, attendees, or no_shows. Anything else returns 400 parameter_invalid.
min_countintegerNoThreshold, >= 1. Defaults to 2 for repeat_buyers, 1 otherwise.
event_idstringNoScope the segment to a single event.
descriptionstringNoFree-text note for your own reference.

Example request

Terminal
curl https://api.ticketconnect.example/v1/segments \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Loyal fans",
    "kind": "repeat_buyers",
    "min_count": 3,
    "description": "Bought 3 or more times"
  }'

Example response

201 Created — returns the serialized saved segment shown above.

Errors: 400 parameter_invalid — bad name, kind, or min_count (param names the offender). 409 segment_name_taken — a segment with that name already exists for your account.

GET /v1/segments

List your saved segments, newest first, cursor-paginated.

Scope: customers:write

Query params

ParamTypeRequiredDescription
limitintegerNoPage size, 1–100 (default 20).
starting_afterstringNoCursor — the last segment id you saw.

Example request

Terminal
curl https://api.ticketconnect.example/v1/segments \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "object": "list",
  "has_more": false,
  "data": [
    { "id": "seg_1f2e3d4c5b6a", "name": "Loyal fans", "description": "Bought 3 or more times", "kind": "repeat_buyers", "min_count": 3, "created_at": "2026-07-11T09:00:00.000Z" }
  ]
}

GET /v1/segments/{id}

Retrieve one saved segment definition.

Scope: customers:write

Path params

ParamTypeRequiredDescription
idstringYesSegment id. Unknown id returns 404 resource_missing.

Example request

Terminal
curl https://api.ticketconnect.example/v1/segments/seg_1f2e3d4c5b6a \
  -H "Authorization: Bearer sk_test_your_key_here"

DELETE /v1/segments/{id}

Delete a saved segment definition. Any discount trigger still pointing at it simply stops matching anyone (its matches list goes empty).

Scope: customers:write

Path params

ParamTypeRequiredDescription
idstringYesSegment id. Unknown id returns 404 resource_missing.

Example request

Terminal
curl https://api.ticketconnect.example/v1/segments/seg_1f2e3d4c5b6a \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -X DELETE

Example response

JSON
{ "id": "seg_1f2e3d4c5b6a", "deleted": true }

GET /v1/segments/{id}/members

Evaluate the saved segment now and return its qualifying customers, cursor-paginated. Membership is computed fresh — nothing is cached — so the list always reflects your current ticket history.

Scope: customers:write

Path params

ParamTypeRequiredDescription
idstringYesSegment id. Unknown id returns 404 resource_missing.

Query params

ParamTypeRequiredDescription
limitintegerNoPage size, 1–100 (default 20).
starting_afterstringNoCursor — the last customer_id you saw.

Example request

Terminal
curl "https://api.ticketconnect.example/v1/segments/seg_1f2e3d4c5b6a/members?limit=2" \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

Rows have the same shape as the on-the-fly segment above — customer_id, email, the one kind-relevant count, and last_activity_at:

JSON
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "customer_id": "cus_ext_42",
      "email": "dana@example.com",
      "purchase_count": 5,
      "last_activity_at": "2026-07-02T21:14:00.000Z"
    }
  ]
}

Staff

Manage your door staff: the people who scan tickets, sell on-site, and redeem perks at your events. Create staff, tune their permissions, assign them to specific events, and read their scan totals. All staff endpoints require the staff:manage scope.

The platform provisions and manages the internal credentials a staff member needs to work the door for you, and none of that is ever exposed through the API. The only handle you ever get is the opaque id. Everything below is plain business language: names, permissions, event assignments, and scan counts. To put a staff member on an offline scanner, your backend mints a scanner session for them with POST /v1/scanner/sessions. You can list and revoke their scanner devices with GET /v1/staff/{id}/devices and POST /v1/staff/{id}/devices/{device_id}/revoke. All three are documented in the Scanner protocol reference.

A serialized staff object:

JSON
{
  "id": "staff_1720900000000_a1b2c3d4",
  "first_name": "Dana",
  "last_name": "Reeves",
  "email": "dana@example.com",
  "phone": "+1-555-0142",
  "status": "active",
  "permissions": {
    "can_scan_tickets": true,
    "can_sell_onsite": true,
    "can_redeem_perks": false,
    "can_manage_staff": false,
    "can_view_stats": true
  },
  "assigned_event_ids": ["evt_1a2b3c"],
  "scanned_tickets": 128,
  "created_at": "2026-07-10T18:30:00.000Z"
}
FieldTypeDescription
idstringOpaque staff handle — use it in every /v1/staff/{id} path.
first_namestringStaff member's first name.
last_namestringStaff member's last name.
emailstringContact email.
phonestringOptional contact phone. Omitted when not set.
statusstringactive, inactive, or suspended.
permissionsobjectThe five capability flags below.
assigned_event_idsstring[]Events this staff member is assigned to work.
scanned_ticketsintegerLifetime tickets this staff member has scanned.
created_atstringCreation timestamp (ISO 8601).

The permissions flags:

FlagDefaultGrants
can_scan_ticketstrueValidate and check in tickets at the door.
can_sell_onsitetrueSell tickets on-site at the event.
can_redeem_perksfalseRedeem attendee perks (drinks, lounge, backstage).
can_manage_stafffalseManage other staff members.
can_view_statstrueView scan and sales stats.

How on-site selling works. Staff with can_sell_onsite take walk-up payments in the Gettix app's staff mode (Gettix is our flagship licensee, built on TicketConnect) — Stripe Tap to Pay on the staff member's phone. Gate payments are configured per event in the organizer panel's one-time "Set up event" step. There are no public /v1 POS endpoints. The API surface for the door is online scanning and attendance (see Scanning & Check-in) and the offline door protocol (see Offline scanning).


POST /v1/staff

Create a door-staff member.

Scope: staff:manage

Request body

FieldTypeRequiredDescription
first_namestringYesMissing returns 400 parameter_missing.
last_namestringYesMissing returns 400 parameter_missing.
emailstringYesMissing returns 400 parameter_missing.
phonestringNoOptional contact phone.
permissionsobjectNoAny of the five flags. Unset flags fall back to their defaults above.

Example request

Terminal
curl https://api.ticketconnect.example/v1/staff \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "first_name": "Dana",
    "last_name": "Reeves",
    "email": "dana@example.com",
    "phone": "+1-555-0142",
    "permissions": { "can_redeem_perks": true }
  }'

Example response

201 Created

JSON
{
  "id": "staff_1720900000000_a1b2c3d4",
  "first_name": "Dana",
  "last_name": "Reeves",
  "email": "dana@example.com",
  "phone": "+1-555-0142",
  "status": "active",
  "permissions": {
    "can_scan_tickets": true,
    "can_sell_onsite": true,
    "can_redeem_perks": true,
    "can_manage_staff": false,
    "can_view_stats": true
  },
  "assigned_event_ids": [],
  "scanned_tickets": 0,
  "created_at": "2026-07-10T18:30:00.000Z"
}

GET /v1/staff

List your door-staff roster, newest first.

Scope: staff:manage

Query params

ParamTypeRequiredDescription
limitintegerNoPage size, 1–100 (default 20).
starting_afterstringNoCursor — the last staff id you saw on the previous page.

Example request

Terminal
curl https://api.ticketconnect.example/v1/staff \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "object": "list",
  "has_more": false,
  "data": [
    { "id": "staff_1720900000000_a1b2c3d4", "first_name": "Dana", "last_name": "Reeves", "email": "dana@example.com", "status": "active", "permissions": { "can_scan_tickets": true, "can_sell_onsite": true, "can_redeem_perks": true, "can_manage_staff": false, "can_view_stats": true }, "assigned_event_ids": ["evt_1a2b3c"], "scanned_tickets": 128, "created_at": "2026-07-10T18:30:00.000Z" }
  ]
}

GET /v1/staff/{id}

Retrieve one staff member.

Scope: staff:manage

Path params

ParamTypeRequiredDescription
idstringYesStaff id. Unknown id returns 404 resource_missing.

Example request

Terminal
curl https://api.ticketconnect.example/v1/staff/staff_1720900000000_a1b2c3d4 \
  -H "Authorization: Bearer sk_test_your_key_here"

PATCH /v1/staff/{id}

Update a staff member's permissions and/or status. Only the fields you send change; everything else is left untouched.

Scope: staff:manage

Request body

All fields optional; send only what you want to change.

FieldTypeDescription
statusstringactive, inactive, or suspended. Anything else returns 400 parameter_invalid.
permissionsobjectAny of the five capability flags; supplied flags are overwritten.

Example request

Terminal
curl https://api.ticketconnect.example/v1/staff/staff_1720900000000_a1b2c3d4 \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -X PATCH \
  -d '{ "status": "suspended", "permissions": { "can_sell_onsite": false } }'

The full updated staff object is returned.

Offline scanners. Changes reach active scanner sessions within about 60 seconds. Switching off can_scan_tickets or can_redeem_perks stops that action on the device and revokes all of the staff member's scanner devices (their certificates still list the old capability); the phones register again with fewer capabilities. Setting status to inactive or suspended also revokes all of their devices, permanently. If you reactivate them, their phones register new device ids. See Scanner protocol.


POST /v1/staff/{id}/assignments

Assign a staff member to one of your events. Assigning provisions the internal access that staff member needs for that event behind the scenes.

Scope: staff:manage

Assignments are idempotent — re-assigning the same event is a no-op and returns the unchanged staff object.

Request body

FieldTypeRequiredDescription
event_idstringYesAn event you own. Missing returns 400 parameter_missing.

Example request

Terminal
curl https://api.ticketconnect.example/v1/staff/staff_1720900000000_a1b2c3d4/assignments \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "event_id": "evt_1a2b3c" }'

The full staff object is returned with evt_1a2b3c now in assigned_event_ids.

Note: The event must belong to your account. An unknown staff id or an unknown / unowned event_id returns 404 resource_missing — you can only assign staff to events you own.


DELETE /v1/staff/{id}/assignments/{eventId}

Unassign a staff member from an event.

Scope: staff:manage

Idempotent — unassigning an event the staff member isn't on is a no-op and still returns the current staff object.

Path params

ParamTypeRequiredDescription
idstringYesStaff id.
eventIdstringYesThe event to remove from the assignment list.

Example request

Terminal
curl https://api.ticketconnect.example/v1/staff/staff_1720900000000_a1b2c3d4/assignments/evt_1a2b3c \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -X DELETE

The full staff object is returned with the event removed from assigned_event_ids. The staff member's scanner sessions for that event stop working, and their scanner devices are revoked for that event only (see revoked_events on the device list); the same phones keep working at the staff member's other events.


GET /v1/staff/{id}/stats

Read a staff member's scan totals, with a per-event breakdown. The counts reflect tickets actually scanned by this staff member.

Scope: staff:manage

Path params

ParamTypeRequiredDescription
idstringYesStaff id. Unknown id returns 404 resource_missing.

Example request

Terminal
curl https://api.ticketconnect.example/v1/staff/staff_1720900000000_a1b2c3d4/stats \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "staff_id": "staff_1720900000000_a1b2c3d4",
  "scanned_tickets": 128,
  "events": [
    { "event_id": "evt_1a2b3c", "scanned": 96 },
    { "event_id": "evt_4d5e6f", "scanned": 32 }
  ]
}

DELETE /v1/staff/{id}

Soft-deactivate a staff member: their status is set to inactive and the record is retained (so their historical scan stats stay intact). Idempotent.

Scope: staff:manage

Path params

ParamTypeRequiredDescription
idstringYesStaff id. Unknown id returns 404 resource_missing.

Example request

Terminal
curl https://api.ticketconnect.example/v1/staff/staff_1720900000000_a1b2c3d4 \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -X DELETE

Example response

The full staff object is returned with status now inactive.

Note: This is a deactivation, not a hard delete — the staff member stops working the door but stays on the roster and keeps their scan history. It also revokes all of their scanner devices. Flip them back on with PATCH /v1/staff/{id} and { "status": "active" }; their phones then register new device ids.

Discount triggers

A discount trigger links one of your saved segments to a discount code, then lets you read exactly who qualifies for that code right now. Use it to run targeted offers: "20% off for repeat buyers", "a comeback code for no-shows". All trigger endpoints require the events:write scope.

This is a read model, not an automation. A trigger never emails anyone and never applies the code on its own. Unlike the first-party organizer panel — which can auto-send a code to a matching audience on a schedule — the API hands you the current match list and you deliver the code through your own channels (email, SMS, push, in-app). You stay in control of the message and the timing.

Because matches are evaluated fresh on every call against your live ticket history, the list you read is always current — add a trigger once, then poll GET .../matches whenever you're ready to send.

A serialized trigger object:

JSON
{
  "id": "664f0a1b2c3d4e5f60718293",
  "discount_id": "664f0a1b2c3d4e5f60700001",
  "segment_id": "seg_1f2e3d4c5b6a",
  "active": true,
  "created_at": "2026-07-11T10:00:00.000Z"
}
FieldTypeDescription
idstringTrigger rule id — use it in every .../triggers/{ruleId} path.
discount_idstringThe discount code this trigger is attached to.
segment_idstringThe saved segment whose members qualify.
activebooleanWhether the trigger is live.
created_atstringCreation timestamp (ISO 8601).

The endpoints:

Method & pathDoes
POST /v1/discounts/{id}/triggersLink a saved segment to the discount code.
GET /v1/discounts/{id}/triggersList the code's triggers.
DELETE /v1/discounts/{id}/triggers/{ruleId}Remove a trigger.
GET /v1/discounts/{id}/triggers/{ruleId}/matchesList the customers who qualify now.

POST /v1/discounts/{id}/triggers

Link a saved segment to a discount code.

Scope: events:write

Path params

ParamTypeRequiredDescription
idstringYesDiscount id. Unknown id returns 404 resource_missing.

Request body

FieldTypeRequiredDescription
segment_idstringYesA saved segment you own. Missing returns 400 parameter_missing; an id that doesn't resolve to one of your segments returns 400 parameter_invalid.

Example request

Terminal
curl https://api.ticketconnect.example/v1/discounts/664f0a1b2c3d4e5f60700001/triggers \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "segment_id": "seg_1f2e3d4c5b6a" }'

Example response

201 Created

JSON
{
  "id": "664f0a1b2c3d4e5f60718293",
  "discount_id": "664f0a1b2c3d4e5f60700001",
  "segment_id": "seg_1f2e3d4c5b6a",
  "active": true,
  "created_at": "2026-07-11T10:00:00.000Z"
}

GET /v1/discounts/{id}/triggers

List the trigger rules attached to a discount code, newest first.

Scope: events:write

Path params

ParamTypeRequiredDescription
idstringYesDiscount id. Unknown id returns 404 resource_missing.

Example request

Terminal
curl https://api.ticketconnect.example/v1/discounts/664f0a1b2c3d4e5f60700001/triggers \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "object": "list",
  "has_more": false,
  "data": [
    { "id": "664f0a1b2c3d4e5f60718293", "discount_id": "664f0a1b2c3d4e5f60700001", "segment_id": "seg_1f2e3d4c5b6a", "active": true, "created_at": "2026-07-11T10:00:00.000Z" }
  ]
}

DELETE /v1/discounts/{id}/triggers/{ruleId}

Remove a trigger from a discount code. The discount code itself is untouched.

Scope: events:write

Path params

ParamTypeRequiredDescription
idstringYesDiscount id.
ruleIdstringYesTrigger rule id. Unknown id returns 404 resource_missing.

Example request

Terminal
curl https://api.ticketconnect.example/v1/discounts/664f0a1b2c3d4e5f60700001/triggers/664f0a1b2c3d4e5f60718293 \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -X DELETE

Example response

JSON
{ "id": "664f0a1b2c3d4e5f60718293", "deleted": true }

GET /v1/discounts/{id}/triggers/{ruleId}/matches

Return the customers who qualify for this code right now. The trigger's linked segment is re-evaluated against your live ticket history on every call — so this list is exactly who you'd send the code to today. Deliver the code to them through your own channels.

Scope: events:write

Each row is a segment member: customer_id, email, the one kind-relevant count (purchase_count | attended_event_count | no_show_count), and last_activity_at. Results are identical to what GET /v1/segments/{id}/members returns for the linked segment — the two share the same audience engine.

Path params

ParamTypeRequiredDescription
idstringYesDiscount id.
ruleIdstringYesTrigger rule id. Unknown id returns 404 resource_missing.

Query params

ParamTypeRequiredDescription
limitintegerNoPage size, 1–100 (default 20).
starting_afterstringNoCursor — the last customer_id you saw on the previous page.

Example request

Terminal
curl "https://api.ticketconnect.example/v1/discounts/664f0a1b2c3d4e5f60700001/triggers/664f0a1b2c3d4e5f60718293/matches?limit=2" \
  -H "Authorization: Bearer sk_test_your_key_here"

Example response

JSON
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "customer_id": "cus_ext_42",
      "email": "dana@example.com",
      "purchase_count": 5,
      "last_activity_at": "2026-07-02T21:14:00.000Z"
    }
  ]
}

Note: If the linked segment has since been deleted, matches returns an empty list rather than an error — the trigger simply has no one to match.