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.
  • Scan and check in attendees online or offline.
  • Receive signed webhooks for every important event.
  • Get paid out directly to your bank account.

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's safe to retry and reports already_used if the ticket was already checked in. For offline scanners draining a queue, use POST /v1/scan/batch to validate up to 200 codes in one call.


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 ticketsPOST /v1/scan/validate, POST /v1/scan/batch, POST /v1/tickets/:id/attendance
staff:manageManage door staff: roster, permissions, event assignments, scan statsPOST/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
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_…A sandbox that mirrors production behaviour with completely isolated data and simulated payments.
Livesk_live_…Production. Real events, real customers, real card charges, real payouts.

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 endpoints and the same logic as a live key — so if your integration works in test, it works in production. The difference is isolation:

  • 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.
  • Flip to live by swapping the key. When you're confident, change the key from sk_test_ to sk_live_ — no code changes required. Double-check GET /v1/account reports "mode": "live" before going live for real.

Tip: Because test and live share the same code and contract, "it works in test" is a real guarantee about the API surface. The only things you can't fully exercise in the sandbox are real-money settlement and bank payouts — validate those carefully on your first live transactions.

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

All money in the API is fiat, in your account's settlement currency. There is one currency per account, set when your account is created, and every price, charge, refund, balance, and payout is expressed in it. You never deal with exchange rates or any other unit.

Amounts are server-authoritative. The platform computes what a customer owes from the tier price (and any discount) on the server. A client can never set or override the amount it pays — values you send as a "price to charge" are ignored. This is your protection against tampered checkouts.

How pricing flows

Money moves through three steps, and issuance only happens once payment is verified:

1. Tier price          You set a fiat price on each tier when you create it.
        │
        ▼
2. Payment intent      You create a payment intent. The platform computes the
        │              amount server-side from the event/tier (and any discount)
        │              — the client-supplied amount, if any, is never trusted.
        ▼
3. Issuance gated      The ticket is only issued after a verified, matching,
   on verified payment  unused payment. No payment, no ticket.

This means a partner cannot accidentally (or maliciously) issue a paid ticket without a real, confirmed charge behind it. The same gate applies to upgrades: moving a ticket to a higher tier charges the fiat price difference, again computed on the server.

See Take a payment for the end-to-end flow and Issue a ticket for issuance.

Amount format

Amounts in the API — tier prices, payment amounts, balances, payouts — are plain decimal numbers in your currency's major unit: 49 means $49.00, 49.5 means $49.50. You never deal in cents. The platform converts to the processor's minor units internally (including the zero-decimal handling for currencies like JPY or KRW), so there is nothing to scale on your side.

A payment also carries a normalized status so you always know where a charge stands: requires_payment (or another requires_* state, depending on your account's payment provider), processing, succeeded, failed, or canceled. Issuance proceeds only once the payment verifies as completed.

Refunds

Refunding a ticket releases the ticket and reverses the sale on your balance — the sale amount is debited from what you've accrued, in the same currency as the original charge; you don't calculate anything. See Transfer, refund & upgrade.

Getting paid

Your share of every sale accrues to your account balance in your settlement currency. You move that balance to your bank account as a fiat payout. The platform handles the underlying settlement invisibly — you only ever see one number in your own currency.

See the Payouts guide to set up payouts and withdraw your balance.

Idempotency

Networks fail, requests time out, and clients retry. Idempotency lets you retry a mutating request without risking a duplicate — a second ticket issued, a second charge created. You send a unique key with the request; if you send the same key again, the platform returns the original response instead of doing the work twice.

How it works

Add an Idempotency-Key header to a mutating POST:

  • The first request with a given key runs normally, and its response is stored against that key.
  • A retry with the same key and the same request returns the stored response verbatim — same status code, same body. The operation runs only once.
  • A retry with the same key but a different request body is rejected with 409 and an idempotency_error (code idempotency_key_reuse). Reusing a key for a different request is always a bug.

Stored responses are kept for 24 hours. After that, the same key is treated as new.

Note: Scope an Idempotency-Key to a single logical operation. Generate a fresh key for each new request you make, and reuse that same key only when retrying that exact request.

Recommended key format

Use a random UUID (v4) per operation. It's collision-resistant and easy to generate in any language.

Terminal
KEY=$(uuidgen)   # e.g. 3f9b1c2e-7a4d-4e1b-9c2a-1d6f0b8e5a21

Which endpoints support it

The Idempotency-Key header is honored on mutating POST endpoints, most importantly:

  • POST /v1/customers — create a customer
  • POST /v1/events — create an event
  • POST /v1/events/:id/tiers — add a ticket tier
  • POST /v1/events/:id/tickets — issue a ticket
  • POST /v1/payment_intents — create a payment intent
  • POST /v1/tickets/:id/refund — refund a ticket
  • POST /v1/tickets/:id/upgrade — upgrade a ticket
  • POST /v1/tickets/:id/attendance — record a check-in
  • POST /v1/payouts — request a payout

Sending the header on an endpoint where it isn't needed is harmless — it's simply ignored. Omitting it runs the request normally with no replay protection.

Example: a retried request

The same key is sent twice. The first call issues one ticket; the retry returns that same ticket without issuing a second one.

Terminal
KEY=$(uuidgen)

# First attempt — times out on your side, but the platform processed it.
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" }'

# Safe retry — identical key + body → returns the original response, no duplicate.
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" }'

Both calls return the same ticket with the same id. See Errors for the idempotency_error envelope.

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_afterAn 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

Every paid ticket is funded by a card payment. You create a payment intent for a specific event and tier, collect the card on the client, and the resulting payment becomes the proof of funds you attach when you issue or upgrade a ticket.

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 your account's currency. 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 and currency, and not have funded another ticket already. 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.

The money flow is entirely card-in, fiat-out: the customer pays by card in your currency, and your proceeds settle to your bank account as a fiat payout. Your account always shows a single number — your balance in your currency.

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. Keys are honored for 24 hours.

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 TicketConnect ticket wallet 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 TicketConnect ticket wallet. A customer who keeps their ticket in a TicketConnect-powered wallet (the TicketConnect app, or any holder experience built on the same rails) 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 themPOST /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 TicketConnect ticket wallet can't be transferred. Once the holder has opened the ticket in a TicketConnect-powered wallet (the TicketConnect app, or any holder experience built on the same rails), 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 ticket's status becomes refunded, and the sale amount is debited back off your account balance. A refunded ticket is no longer valid for entry. Returning the money to your customer's card is handled between you and your payment provider — the refund call doesn't reverse the card charge itself.

Refunds aren't reversible and can't be repeated. Attempting to refund a ticket that's already refunded returns 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. A given payment can fund one upgrade, ever — reusing it returns 409 payment_already_used.

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 TicketConnect 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

Online vs. offline check-in

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.

For an offline scanner (spotty WiFi at the gate, a remote field, etc.), let the device validate against a cached ticket list locally, queue every scan, and then drain the queue with POST /v1/scan/batch once it's back online. Batch validation tells you, per QR, which scans were good and which weren't, so you can reconcile after the fact.

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 TicketConnect ticket wallet — any holder experience built on the TicketConnect rails, the TicketConnect app 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 / offline draining

When an offline scanner reconnects, send everything it collected in one call. Pass an array of QR strings in qrs (up to 200 per request).

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.

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.
  • Automatic royalties. When a listing sells, your configured royalty is taken out and credited to you automatically. You don't compute or collect it; it simply shows up in your 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 — resale royalties land in your balance.
  • 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 0100.
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.

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.paid"]
  }'
JSON
{
  "id": "663b1c9e2a7d654321fedcba",
  "url": "https://hooks.yourapp.com/ticketconnect",
  "enabled_events": ["ticket.issued", "payment.succeeded", "payout.paid"],
  "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.paidA payout to your bank account is sent.

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.paid",
  "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.paid",
      "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

This is how money reaches your bank account. You sell tickets in your currency, your earnings (sales proceeds plus resale royalties, net of platform fees) accumulate as a single balance, and you pay that balance out to your bank account.

One balance, your currency, fiat in and fiat out. Customers pay by card; you get paid to your bank. Everything you see here is a plain amount in your settlement currency — no settlement internals, no conversions for you to manage. The platform reconciles all of that behind the scenes.

The fiat-in / fiat-out model

  • Fiat in: customers buy tickets with a card. The platform collects the payment and credits your share to your balance.
  • Your balance: a single spendable number in your currency. It already reflects platform fees and any resale royalties owed to you — there is nothing to net out yourself.
  • Fiat out: you request a payout, and the platform transfers the money to your connected bank account.

You never see more than one balance, and it's always in your own currency.

Scopes

EndpointMethodScope
/v1/connect/accountPOSTpayouts:claim
/v1/connect/accountGETpayouts:claim
/v1/balanceGETpayouts:read (or payouts:claim)
/v1/payoutsGETpayouts:read (or payouts:claim)
/v1/payoutsPOSTpayouts:claim

Note: payouts:read is read-only (balance and payout history). payouts:claim can do everything payouts:read can and onboard the account and request payouts. Give back-office dashboards a read-only key; reserve payouts:claim for the service that moves money.

1. Onboard with Stripe Connect

Before you can be paid, you complete a one-time Stripe Connect onboarding (bank details, identity verification). Start it by creating an onboarding link, then redirect your user to the returned onboarding_url.

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

Send the user to onboarding_url to finish setup. If they don't complete it in one sitting, call this endpoint again to get a fresh link — it resumes the same connected account.

2. Check onboarding status

Confirm whether the account is ready to receive payouts:

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

Before onboarding has started:

JSON
{ "connected": false }

Once a connected account exists:

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

You're ready to receive money when payouts_enabled is true. (No personal or bank details are ever returned — only these capability flags.)

3. Check your balance

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

available is what you can pay out right now, in currency (your settlement currency).

Refunds move the balance, not your bank account

Earnings land in your balance when a sale settles, and they leave it again if that sale is reversed — a refunded ticket or a cancelled event removes your share of it. Nothing is ever pulled back out of your bank account or your connected Stripe account: a reversal is a debit against the balance, so it nets off against what you earn next.

That also means the balance can go negative, if refunds land after you've already paid out. available reports the real figure — including a negative one — so you can see how far under you are:

JSON
{ "object": "balance", "available": -128.40, "currency": "USD" }

Payouts are refused while you're under: requesting a specific amount returns 400 insufficient_balance, and requesting the full balance returns 400 parameter_invalid (there is no positive amount to pay out). New earnings net against the shortfall until the balance is positive again.

Because reversals settle against your balance, request payouts on a rhythm (weekly, monthly) rather than draining to zero after every sale — that keeps a buffer for refunds and avoids a stretch where you can't pay out at all.

4. Request a payout

Request a payout to your connected bank account. Pass an amount (in your currency) to pay out part of your balance, or omit it to pay out the full available balance.

Terminal
curl https://api.ticketconnect.example/v1/payouts \
  -X POST \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 500.00,
    "currency": "USD"
  }'
JSON
{
  "object": "payout",
  "id": "tr_1Q9...",
  "amount": 500.00,
  "currency": "USD",
  "status": "paid"
}

A successful payout also emits a payout.paid webhook — see Webhooks.

Warning: A payout can never exceed your available balance — an over-request returns 400 with an insufficient_balance error. You must have finished Connect onboarding first; otherwise you'll get a connect_account_required error.

Use an Idempotency-Key header when requesting a payout, so a network retry can't accidentally pay you out twice. A replay with the same key returns the original response.

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

5. List past payouts

Review your payout history, newest first, cursor-paginated via limit (and starting_after).

Terminal
curl "https://api.ticketconnect.example/v1/payouts?limit=20" \
  -H "Authorization: Bearer sk_test_..."
JSON
{
  "object": "list",
  "data": [
    {
      "id": "668a2f1c9e7d654321fedcba",
      "amount": 500.00,
      "currency": "USD",
      "reference": "tr_1Q9...",
      "created_at": "2026-06-05T12:00:00.000Z"
    }
  ],
  "has_more": false
}

reference ties each entry back to the underlying transfer.

See also

Ad tracking & attribution

You own your buyer-facing product, so browser-side tracking is yours to do — drop your Meta Pixel or GTM snippet into your own pages exactly like on any site you run; nothing platform-side gets in your way. What you can't build alone is the platform side of the picture, and that's what the Ad tracking card in your panel adds.

What the Ad tracking card does today

Open your panel → white-label portal → Ad tracking and save your Meta Pixel ID and a Conversions API access token (Events Manager → your pixel → Settings → Conversions API → Generate access token).

  • Server-side purchase events to your pixel. When tickets for your attributed organizers' events sell through the TicketConnect storefront, the platform delivers each Purchase (value, currency) from its servers straight to your pixel via the Meta Conversions API — immune to ad blockers and iOS pixel loss, and only for buyers who accepted marketing cookies (the storefront's consent banner is handled for you). If a browser pixel event with the same event id exists, Meta deduplicates the pair automatically.
  • First-party attribution on every sale. UTM parameters that brought a buyer to the TicketConnect storefront are stored with the ticket and power the Sales sources report — tickets and revenue per source / medium / campaign, independent of any ad platform.

The CAPI token is write-only: stored encrypted, never shown again, replace or remove it any time.

Honest scope

You sell viaBrowser-side trackingServer-side conversions
Your own frontend on the /v1 APIYours — your pages, your pixel, your consent flow✔ — pass an attribution object (UTM fields, fbp/fbc, your browser eventId, marketingConsent: true) on POST /v1/events/{id}/tickets or checkout POST /v1/checkout/{event_id}/complete; we fire the CAPI Purchase to your pixel and dedupe it against your browser event by that id. marketingConsent is your declaration that the buyer consented on your pages — we take it at face value.
TicketConnect-hosted checkout (GET /v1/checkout/{event_id})✔ end-to-end — your pixel runs on the page behind a built-in consent bar; nothing loads until the buyer accepts✔ — the page's browser Purchase and our server Purchase share one event id, so Meta deduplicates the pair automatically
TicketConnect storefront (your attributed organizers)Platform-managed, consent-gated✔ delivered to your pixel

Both attribution on the /v1 API and the hosted checkout's pixel dispatch to your Meta Pixel ID / CAPI access token from this same Ad tracking card — the platform pixel never sees your buyers.

The GTM container ID field is stored for upcoming TicketConnect-hosted pages and has no effect yet — you don't need to fill it today; v1.1 lights up the Meta pixel on the hosted checkout, not GTM.

Verify in minutes. Open Meta Events Manager → Test Events and make a test purchase of one of your organizers' events on the TicketConnect storefront (accept the consent banner) — the server Purchase should appear within seconds. Selling on /v1 instead? Complete a test-mode ticket with attribution.marketingConsent: true (or a hosted checkout purchase with the consent bar accepted) and check your pixel's recent activity in Events Manager → Overview, where the dedup status is shown per event. Note the Test Events tab lists server events only when a test event code rides along — that's a platform-side setting, so ask us to coordinate one if you want your verification to show up there.

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, 1100. 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.
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 TicketConnect ticket wallet (the TicketConnect app, or any holder experience built on the same rails) 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, 1100 (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.
max_tickets_per_identityintegerNoPer-person purchase cap for this event, 150. 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.

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, 1001000. Default 150.
min_resale_price_percentintegerResale floor as % of face value, 0100. 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 and/or live-code setting. Accepts images, marketplace_settings and/or live_codes — 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.

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), 400 parameter_invalid (bad media URL, out-of-range resale bound, or non-boolean live_codes), 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.

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, 1100 (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, if present on the buyer's landing URL.
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.
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/click-id fields are stored either way. Omit or false to attribute the sale without any pixel dispatch.

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, 1100 (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, 190 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 a TicketConnect 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 a ticket and return the buyer's money. The payment provider is charged first — the refund is executed against the original payment (for the Stripe provider, a PaymentIntent refund) before any ticket state changes. Only after the provider accepts does the ticket move to refunded and stop being valid for entry. If the provider refuses or fails, you get an error back and the ticket is untouched. Free and comp tickets (no charge to reverse) skip the provider entirely.

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" \
  -X POST

Example response

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

Errors: 409 already_refunded if the ticket was already refunded; 400 provider_not_configured if no payment provider is configured for your account; 400 refund_not_supported if the provider cannot refund this payment; 502 payment_provider_error if the provider failed — the ticket is unchanged, retry later. Emits a ticket.refunded webhook (only after the money has moved).


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.

Payments

A payment intent represents the card charge that funds 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 card payment intent for one of your event tiers. 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.
currencystringNoOverride currency; defaults to the event/account currency.

Accepts an Idempotency-Key header.

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


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_payloadPOST .../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. One payment intent funds at most one ticket, and supply is claimed atomically, so retries can never double-issue or oversell.

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:

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

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. 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 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, 1100 (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. 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 TicketConnect ticket wallet (verified cryptographically on the platform side). Attendance marks a ticket as used. Your API key with the scanning:write scope authorizes scanning — there is no separate scanner token. See the Scanning and check-in guide.

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.


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 — ideal for an offline scanner draining its queue once back online. None are marked used. Returns a per-QR verdict array.

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.

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 (0100). 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, 1100 (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 0100.
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.

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

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.paid"]
  }'

Example response

JSON
{
  "id": "664f0a1b2c3d4e5f60718293",
  "url": "https://acme.example/hooks/ticketconnect",
  "enabled_events": ["ticket.issued", "payout.paid"],
  "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, 1100 (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

Onboard for payouts, check your balance, and move money out to your bank account. Onboarding runs through Stripe Connect; everything you see here is in your settlement currency (fiat).

Scopes: balance and payout reads accept either payouts:read or payouts:claim; claiming a payout and both Connect onboarding endpoints (including the status read) need payouts:claim.


POST /v1/connect/account

Begin or resume payout onboarding. Creates a connected account on first call and returns a fresh onboarding link to redirect your user to.

Scope: payouts:claim

This endpoint takes no request body.

Example request

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

Example response

JSON
{
  "account_id": "acct_1NxYz...",
  "onboarding_url": "https://connect.stripe.com/setup/e/acct_1NxYz.../abc123"
}

Errors: 502 api_error if the onboarding link cannot be created.


GET /v1/connect/account

Retrieve your onboarding status — whether you can receive payouts yet. Returns only capability booleans, never any personal information.

Scope: payouts:claim

Example request

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

Example response

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

Before onboarding has started:

JSON
{ "connected": false }

GET /v1/balance

Retrieve your single spendable balance in your settlement currency.

Scope: payouts:read (or payouts:claim)

Example request

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

Example response

JSON
{
  "object": "balance",
  "available": 12840.50,
  "currency": "USD"
}

GET /v1/payouts

List payouts made to your bank account, newest first.

Scope: payouts:read (or payouts:claim)

Query params

ParamTypeRequiredDescription
limitintegerNoPage size, 1100 (default 20).
starting_afterstringNoCursor — a payout id to page after.

Example request

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

Example response

JSON
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "6651aa...",
      "amount": 5000.00,
      "currency": "USD",
      "reference": "tr_1NxYz...",
      "created_at": "2026-06-04T08:00:00.000Z"
    }
  ]
}

POST /v1/payouts

Request a payout of your available balance to your connected bank account. If you omit amount, the full available balance is paid out. The amount can never exceed your balance.

Scope: payouts:claim

Request body

FieldTypeRequiredDescription
amountnumberNoAmount to pay out. Defaults to the full available balance. Must be > 0.
currencystringNoCurrency; defaults to your settlement currency.

Accepts an Idempotency-Key header.

Example request

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

Example response

JSON
{
  "object": "payout",
  "id": "tr_1NxYz...",
  "amount": 5000.00,
  "currency": "USD",
  "status": "paid"
}

Errors: 400 parameter_invalid (non-positive amount), 400 insufficient_balance, 400 connect_account_required (onboarding not complete), 502 api_error. Emits a payout.paid webhook.

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", …
quantityintegerNo120 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, 1100 (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, 120 (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, 1100 (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, 1100 (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, 1100 (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; 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.

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 TicketConnect staff app — Stripe Tap to Pay on the staff member's phone. Gate payments and wristbands 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 scanning and attendance (see Scanning & Check-in).


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, 1100 (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.


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.


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. Flip them back on with PATCH /v1/staff/{id} and { "status": "active" }.

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, 1100 (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.