Introduction
TicketConnect White-Label API lets you launch a fully branded ticketing product — events, paid tickets, QR check-in, resale, payouts — without building any of the hard parts yourself. You integrate the same way you'd integrate Stripe or SendGrid: REST + an API key + signed webhooks, all in plain business language.
The hard parts are handled for you. Every ticket is tamper-proof and anti-scalping is on by default (configurable per event). You work with events, tickets, fiat prices, customer emails, QR codes, and bank payouts — the platform does all the rest behind the scenes.
Running your own events instead? These docs are for licensees — teams embedding TicketConnect behind their own product. If you're an event organizer selling on the TicketConnect platform itself, switch to the For organizers docs.
What you can build
- Sell tickets for events in your own currency, paid by card.
- Issue, transfer, refund, and upgrade tickets programmatically.
- Deliver tickets as an opaque QR string your app renders however it likes.
- Sell high-demand on-sales fairly with a built-in waiting-room queue and published price schedules.
- Run a secondary marketplace with automatic royalties and anti-scalping.
- Validate tickets at the door online, in requests of up to 200 codes, and
record check-ins through the attendance endpoint. Or scan with no network
at all through the offline door protocol (
/v1/scanner) and its SDK. - Receive signed webhooks for every important event.
- Transfer proven platform-held Stripe funds to your connected account; bank payouts follow Stripe’s separate lifecycle.
Everything you call lives under the /v1 prefix and is automatically scoped
to your account — you can only ever see and touch your own data.
Base URL
https://api.ticketconnect.example/v1
Replace the host with the environment you were given. Health, the OpenAPI document, and the interactive docs are public; everything else needs an API key.
Your first call
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-end | Quickstart |
| Understand keys, scopes, and modes | Authentication |
| Learn the mental model | How it works |
| Receive real-time events | Webhooks |
| Look up a specific endpoint | API 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 optionalIdempotency-Keyheader 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
curl https://api.ticketconnect.example/v1/account \
-H "Authorization: Bearer sk_test_your_key_here"
Node (fetch)
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:
{
"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
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)
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):
{
"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
draftstatus with no ticket tiers yet. Thedatefield 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
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)
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):
{
"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
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)
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:
{
"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
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)
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):
{
"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
queuefield is non-null), the customer must be admitted by the queue before this call succeeds — otherwise it returns a403with aqueue_error(not_admitted). Join and poll withPOST /v1/events/:id/queue/joinandGET /v1/events/:id/queue?customer_id=…(scopetickets:issue), then issue oncestatusisadmitted. Events without a queue are unaffected.
cURL
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)
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):
{
"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.issuedevent, 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
curl https://api.ticketconnect.example/v1/tickets/tkt_5b8e1c2d3f4a... \
-H "Authorization: Bearer sk_test_your_key_here"
Node (fetch)
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):
{
"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
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)
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):
{
"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/validateonly checks validity. To mark a ticket as attended, callPOST /v1/tickets/:id/attendance(alsoscanning:write). It is safe to retry and reportsalready_usedif the ticket was already checked in. To validate many codes at once while online, usePOST /v1/scan/batch, which takes up to 200 codes in one call. For gates without a reliable connection, see Offline scanning.
Step 10 (optional) — Receive a webhook
Instead of polling, register a webhook endpoint and let TicketConnect push events
to you — ticket.issued when a ticket is created, attendance.verified on
check-in, and more. Webhooks are signed so you can verify them with existing SDKs.
Manage endpoints with POST /v1/webhook_endpoints (requires the
webhooks:manage scope). See the full walkthrough in the
Webhooks guide.
You did it
You just sold and scanned a ticket end-to-end:
- Confirmed your key with
GET /v1/account. - Created an event and a ticket tier.
- Registered a customer.
- Took a card payment and issued a tamper-proof ticket.
- Fetched its QR pass and validated it at the door.
Next steps
- Authentication — keys, the
Bearertransport, and the full scopes table.- Test & live modes — go live safely after testing in the sandbox.
- How it works — the mental model behind events, tickets, and payments.
- Webhooks guide — receive real-time, signed events.
- API reference — every endpoint, parameter, and response.
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:
| Prefix | Mode | Use 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/keysmints 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 withmode_unavailable.GET /v1/account/keyslists 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:readandcheckout:writescopes, regardless of what is stored on the key; - only works on the
/v1/checkout/…endpoints — every secret endpoint rejects a publishable key with a401; - 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
curl https://api.ticketconnect.example/v1/account \
-H "Authorization: Bearer sk_test_your_key_here"
Node (fetch)
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), andGET /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 withDELETE /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:writeand 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:
{
"error": {
"type": "authorization_error",
"code": "forbidden",
"message": "Missing required scope: events:write",
"request_id": "8f2c1a4e-…"
}
}
The full set of scopes and what each unlocks:
| Scope | Meaning | Unlocks |
|---|---|---|
events:read | Read events, organizations, and the catalog | GET /v1/events, GET /v1/events/:id, GET /v1/events/:id/seatmap, GET /v1/events/:id/similar, GET /v1/organizations, GET /v1/organizations/:id |
events:write | Create and update events, tiers, and discounts | POST /v1/events, PATCH /v1/events/:id, POST /v1/events/:id/tiers, all /v1/discounts operations (including discount triggers under /v1/discounts/:id/triggers…) |
tickets:read | Read issued tickets and guest lists, mint delivery links | GET /v1/tickets, GET /v1/tickets/:id, POST /v1/tickets/:id/delivery_link, GET /v1/events/:id/comps |
tickets:issue | Issue tickets (paid, comp, and seated) and manage the on-sale waiting room | POST /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:manage | Manage a ticket's lifecycle | POST /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:write | Create and read customers, compute and save segments | POST /v1/customers, GET /v1/customers/:id, GET /v1/customers/segments/:kind, all /v1/segments saved-segment operations |
payments:write | Create payment intents (also grants read-back) | POST /v1/payment_intents, POST /v1/payment_intents/:id/confirm, GET /v1/payments/:id |
marketplace:read | Read secondary-market resale listings | GET /v1/marketplace/listings, GET /v1/marketplace/listings/:id |
reports:read | Read sales and attendance reports | GET /v1/reports/sales, GET /v1/reports/attendance |
scanning:write | Validate and check in tickets; mint offline scanner sessions | POST /v1/scan/validate, POST /v1/scan/batch, POST /v1/tickets/:id/attendance, POST /v1/scanner/sessions |
staff:manage | Manage door staff: roster, permissions, event assignments, scan stats, scanner devices | POST/GET /v1/staff, GET/PATCH/DELETE /v1/staff/:id, POST /v1/staff/:id/assignments, DELETE /v1/staff/:id/assignments/:eventId, GET /v1/staff/:id/stats, GET /v1/staff/:id/devices, POST /v1/staff/:id/devices/:deviceId/revoke |
webhooks:manage | Manage webhook endpoints and deliveries | all /v1/webhook_endpoints operations (including POST …/:id/ping), GET /v1/events/deliveries, POST /v1/events/deliveries/:id/replay |
keys:manage | Mint, list, and revoke API keys | GET/POST /v1/account/keys, DELETE /v1/account/keys/:prefix |
payouts:read | Read balance and payout history | GET /v1/balance, GET /v1/payouts |
payouts:claim | Set up payouts and request a payout | GET/POST /v1/connect/account, POST /v1/payouts (and the read operations above) |
Read access is implied by write where it makes sense. A
payments:writekey can read back the payments it created, andpayouts:claimincludes everythingpayouts:readcan do. When in doubt, the per-endpoint documentation in the API reference states the exact scope required.
Next steps
- Test & live modes — integrate in the sandbox first, then go live.
- Quickstart — sell and scan a ticket end-to-end.
- API reference — every endpoint and its required scope.
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.
| Mode | Key prefix | What it is |
|---|---|---|
| Test | sk_test_… | Isolated data and mode-specific sandbox providers. |
| Live | sk_live_… | Production data and explicitly configured live payment providers. |
Note: Live mode must be activated on your account before it can be used. Until it is,
sk_live_keys cannot be created, and any live key presented to the API is rejected with a403— never silently downgraded to test. Test mode is always available.
https://api.ticketconnect.example/v1
The base URL is the same for both. Send a test key and you're in the sandbox; send a live key and you're in production.
Same code paths, isolated data
A test key exercises the same API contract as a live key. Provider credentials, Connect accounts, chain configuration, and original payment proof must also match the selected mode:
- Test events, tiers, customers, tickets, payments, and payouts live in a separate sandbox and never appear in your live data (and vice versa).
- Payments in test mode are simulated — use the standard test card numbers to trigger success or failure. No real money ever moves.
- Webhooks, reports, balances, and scanning all work the same way in test, scoped to your test data only.
Warning: The two worlds never cross. A
sk_test_key cannot read or modify live data, and ask_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:
- The prefix.
sk_test_…is test;sk_live_…is live. - Ask the API.
GET /v1/accountreturns amodefield:
cURL
curl https://api.ticketconnect.example/v1/account \
-H "Authorization: Bearer sk_test_your_key_here"
Node (fetch)
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"
{
"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_andsk_live_keys in separate environment variables and select the right one per deployment. Never hardcode a key or let a live key leak into a staging build. - Test the unhappy paths too. Use test cards to simulate declined payments, and exercise refunds, already-used scans, and missing-scope errors so your error handling is solid before launch.
- Verify webhooks in test. Register a test webhook endpoint and confirm you receive and verify signed events before relying on them in production. See the Webhooks guide.
- Activate and configure live mode. Enable live access, configure live provider credentials, and complete live Connect onboarding. Then select the live key and check
GET /v1/accountreports"mode": "live". Missing or mismatched provider configuration is rejected; the API does not borrow live credentials for a test request.
Provider credentials and Connect account IDs are mode-specific. PayPal uses its sandbox endpoint in test and production endpoint in live. Crypto proof binds the original chain, token, recipient, and successful receipt to the requested mode. An old account ID or payment without provable mode cannot authorize a live money operation.
A successful sandbox test checks your integration contract. It does not confirm live provider capabilities, funded balances, or bank settlement.
Next steps
- Quickstart — run the full flow in test mode.
- Authentication — key formats, the
Bearertransport, and scopes. - API reference — every endpoint in detail.
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
| Object | What it is | Reference |
|---|---|---|
| Account | Your partner account — branding, mode, settlement currency. | Account |
| Organization | An event organizer your account can read from the catalog. | Events |
| Event | A single event you sell tickets for. | Events |
| Tier | A price level on an event (name, price, supply, perks). | Events |
| Ticket | An issued, tamper-proof ticket held by a customer. | Tickets |
| Customer | A person who holds tickets, identified by email. | Customers |
| Payment | A card charge, computed and verified server-side. | Payments |
| Queue | The waiting room gating issuance for a high-demand on-sale. | Events |
| Listing | A secondary-market resale of a ticket. | Marketplace |
| Payout | A 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
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
{
"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
externalIdagain 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_idat issuance returns a404.
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:
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 & path | Scope | Purpose |
|---|---|---|
POST /v1/customers | customers:write | Create a customer from { externalId, email, name? }. |
GET /v1/customers/:id | customers:write | Retrieve a customer by the externalId you supplied. |
See the full Customers API reference for field details.
Money & currencies
Public prices, payment amounts, balances, and payouts use the currency's major unit. 49.50 USD means $49.50; 1000 JPY means ¥1000. Internally, payments and allocations use whole native minor units: USD 49.50 is 4950 cents, while JPY 1000 is 1000 yen.
The server derives the amount and currency from the event, tier, quantity, and applicable discount. A request cannot replace that price or change the currency: a supplied currency must match the server's currency. Fractional minor units, non-finite amounts, and unsupported payment rails are rejected.
Payment proof and allocations
A paid ticket requires a completed payment with the correct account, mode, currency, and sufficient amount. The platform reserves each allocation atomically with ticket stock. A payment can fund supported partial or batch allocations, but their combined amount cannot exceed the original confirmed collection. Issuance and upgrades share this budget.
Your account's payment provider supplies the client payment details. Stripe uses a client secret, PayPal uses an approval URL, and an external provider records your assertion that you collected payment elsewhere. The v1 crypto adapter supports its verified USD/USDC rail; it does not convert arbitrary fiat currencies into USDC.
A completed external, PayPal, or crypto payment can prove a purchase without funding the platform's Stripe payout balance. Payment completion and money available for a Stripe transfer are separate facts.
Refunds
Refunds use the original payment allocations, currency, and mode. The original purchase and each paid upgrade are separate sources; changing a tier price later does not change the refund budget. The total refunded from a source cannot exceed its confirmed paid amount.
A refund reservation reverses the booked seller net and fee history before provider effects. Only a confirmed refund completes the allocation. Pending receipts stay held for recovery; missing provenance, failed or canceled receipts, depleted refundable balances, and unsupported resale clawbacks require review. See Transfer, refund & upgrade.
Available balance and transfers
GET /v1/balance reports available, proven platform-held Stripe funds after fees, refunds, and payout reservations. It excludes externally collected funds, PayPal and crypto collections, unknown legacy credits, and money already routed to a connected account.
POST /v1/payouts transfers that balance to your mode-specific Stripe Connect account. transferred confirms the Stripe transfer; it does not confirm a deposit in your bank account. Stripe's connected-account bank payout follows its own lifecycle. See Payouts.
The ledger balance is not Stripe’s net available-cash report. Processor costs are a platform expense requiring separate liquidity; a provider can refuse a transfer when its actual available balance is insufficient.
Idempotency
Send one Idempotency-Key for each logical mutation and reuse it with the same request whenever you retry. Use it on every payment, issuance, upgrade, refund, and payout integration call. POST /v1/payouts requires a non-empty key of at most 255 characters; endpoints that retain optional headers should still receive a key from your integration.
How it works
The platform atomically claims the key before an effect starts. The claim is scoped to your account and mode and includes the request method, path, and body hash.
- A completed retry returns the stored status and response.
- The same key with a different request returns
409 idempotency_key_reuse. - An operation already running can return
409 idempotency_in_progress; money operations with a durable reservation can instead return their pending state. - A crash or ambiguous provider response keeps the original operation held for recovery. Sending a fresh key does not resolve that uncertainty.
Completed response snapshots expire after approximately 24 hours. Unresolved claims do not expire as completed responses do. Durable financial operation records and unique payment allocations remain authoritative after the response cache expires.
For an unknown Stripe transfer or refund with no receipt ID, automatic retries stop before the provider's idempotency retention can expire: the platform uses a 23-hour ceiling from the original attempt and holds the operation for review. A known Stripe or PayPal refund receipt is recovered by GET, including after that ceiling, without creating another refund.
Choose one key per operation
A random UUID is a convenient choice. Store it with your order or refund record before sending the request.
KEY=$(uuidgen)
curl https://api.ticketconnect.example/v1/events/evt_123/tickets \
-H "Authorization: Bearer sk_test_your_key_here" \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{ "customer_id": "user_42", "tier": "General Admission" }'
If the request times out, retry this exact request with the stored key. Do not generate a replacement key for the retry.
Supported endpoints
The header is honored on mutating POST endpoints including customers, events, tiers, ticket issuance, payment intents, ticket refunds and upgrades, attendance, and payouts. Unsupported endpoints may ignore it; the header does not add replay protection to an endpoint that has no idempotent handler.
A provider receipt marked failed, canceled, or pending is not a completed refund. Preserve the original key and follow the returned state. See Errors, Payments, and Payouts.
Errors
Every error response uses a single, predictable envelope — the same shape across
the entire API. Branch your handling on the machine-readable type and code;
show or log the message; quote the request_id to support.
The error envelope
{
"error": {
"type": "invalid_request_error",
"code": "parameter_missing",
"message": "tier is required.",
"param": "tier"
}
}
| Field | Always present | Description |
|---|---|---|
type | yes | High-level category — branch your error handling on this. |
code | yes | Specific machine-readable code within the type. |
message | yes | Human-readable description. Safe to log; don't parse it. |
param | no | The offending request parameter, when one applies. Omitted otherwise. |
request_id | no | Correlation 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
type | Meaning | Typical HTTP status |
|---|---|---|
invalid_request_error | The request was malformed, missing a field, or violated a business rule (e.g. sold-out tier, unverified payment). | 400, 402, 404, 409 |
authentication_error | The API key is missing, invalid, revoked, or of the wrong kind for the endpoint. | 401 |
authorization_error | The key is valid but not allowed to do this — usually a missing scope. | 403 |
idempotency_error | An Idempotency-Key was reused with a different request. | 409 |
queue_error | The customer has not (yet) been admitted by the event's on-sale waiting room. | 403 |
rate_limit_error | You've made too many requests too quickly. | 429 |
api_error | An unexpected error on the platform side. Safe to retry. | 500, 502 |
Common codes
code | type | When you'll see it |
|---|---|---|
unauthorized | authentication_error | The key is unknown, revoked, malformed, or the wrong kind for the endpoint. |
forbidden | authorization_error | The key is missing the scope this operation requires. |
idempotency_key_reuse | idempotency_error | Same Idempotency-Key, different request body. |
parameter_missing / parameter_invalid | invalid_request_error | A required field is absent or a field has a bad value — param names it. |
resource_missing | invalid_request_error | The resource doesn't exist, or isn't yours. |
sold_out | invalid_request_error | Issuing from a tier with no remaining supply. |
payment_required | invalid_request_error | A paid ticket or upgrade was requested without a payment_intent_id. |
not_admitted | queue_error | The customer must be admitted by the waiting room before issuance. |
Note: New
codevalues can be added over time within an existingtype. Always handle thetypeyou recognize and treat unknowncodes within it gracefully rather than failing hard.
HTTP status mapping
The HTTP status and the error.type agree, so you can react to either:
| Status | Meaning |
|---|---|
400 | Invalid request — fix the payload or the business condition. |
401 | Authentication failed — check your key, its kind, and mode. |
402 | Payment required — a paid ticket or upgrade lacks a verified payment. |
403 | Not allowed — missing scope, a not-yet-released tier, or a customer not admitted by the waiting room. |
404 | The resource doesn't exist, or isn't yours. |
409 | Conflict — idempotency-key reuse, sold-out supply, or a payment already used. |
429 | Rate limited — back off and retry. |
500 / 502 | Platform 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.
# 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
{
"error": {
"type": "authentication_error",
"code": "unauthorized",
"message": "Invalid API key",
"request_id": "1b9d33a0-…"
}
}
409 Idempotency
{
"error": {
"type": "idempotency_error",
"code": "idempotency_key_reuse",
"message": "This Idempotency-Key was used with a different request."
}
}
429 Rate limit
{
"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:
{
"object": "list",
"data": [
{ "id": "evt_300", "...": "..." },
{ "id": "evt_299", "...": "..." }
],
"has_more": true
}
| Field | Description |
|---|---|
object | Always "list". |
data | The items on this page, newest first. |
has_more | true if more items exist after this page. |
Query parameters
| Parameter | Default | Description |
|---|---|---|
limit | 20 | Items per page. Clamped to the range 1–100. |
starting_after | — | An item id. Returns the page of items after it. |
Results are ordered newest-first. To fetch the next page, take the id of the
last item in data and pass it as starting_after. Keep going while
has_more is true.
Which endpoints support it
Cursor pagination applies to every list endpoint, including:
GET /v1/eventsGET /v1/ticketsGET /v1/marketplace/listingsGET /v1/discountsGET /v1/payoutsGET /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
#!/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
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 smallerlimitfor 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:issuescope (andpayments:writeif 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 bepending; 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.
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"
}'
{
"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 apayment_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 withPOST /v1/events/:id/queue/join, pollGET /v1/events/:id/queue?customer_id=...untilstatusisadmitted(with a liveaccess_expires_at), then issue. Issuing before admission returns403with error typequeue_errorand codenot_admitted. Events without a waiting room are unaffected.
Endpoint: POST /v1/events/:id/tickets · Scope: tickets:issue
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"
}'
{
"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
| Field | What it is |
|---|---|
id | The ticket id (tkt_...). Use it for delivery, transfer, refund, and upgrade. |
status | valid for a freshly issued ticket. |
tier | The tier the customer bought. |
price / currency | The fiat amount charged, in your account's currency. |
perks | Whatever perks the tier carries. |
qr.data | The opaque QR string — the guaranteed minimum way to deliver the ticket. |
qr.format | The 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:
- Read the map.
GET /v1/events/:id/seatmap(scopeevents:read) returns the layout, live availability (sold/held/blockedseat ids), and per-tier pricing — render it and let the customer pick seats. - Hold the seats.
POST /v1/events/:id/seats/holdwith{ "seat_ids": ["S1-A-1", "S1-A-2"] }(scopetickets:issue, up to 10 seats) atomically claims them for 10 minutes and returns ahold_id. If someone got there first you get409 seats_unavailablenaming the contested seats. - 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/extendresets the clock to 10 minutes from now. - Issue with the hold. Call
POST /v1/events/:id/ticketsas usual, plus"hold_id": "..."— one ticket is issued per held seat, each bound to its seat, and the response becomes a batch:
{
"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_idyou 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_outand 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 — returns403 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
| Status | code | Meaning |
|---|---|---|
400 | parameter_missing | customer_id or tier was not supplied. |
404 | event_not_found | No such event under your account. |
404 | customer_not_provisioned | The customer isn't ready yet (pending). |
403 | tier_not_released | The tier isn't on sale yet — it opens at a scheduled time or when another tier sells out. |
403 | not_admitted | The event runs a waiting room and the customer doesn't hold a live admission window (error type queue_error). |
403 | purchase_limit_reached | The customer hit the event's per-person ticket limit. |
402 | payment_required | Paid tier with no payment_intent_id. |
402 | payment_failed | The payment couldn't be verified or didn't match. |
409 | payment_already_used | That payment already funded a ticket. |
409 | tier_not_found | No tier with that name exists on the event. |
409 | sold_out | The tier has no remaining supply. |
400 | seat_required | The tier is reserved seating — hold seats first and pass hold_id. |
409 | hold_not_found | The hold is missing, released, or already used. |
409 | hold_expired | The hold lapsed — hold the seats again and retry. |
409 | seat_tier_mismatch | A held seat belongs to a different tier than the one being issued. |
400 | quantity_mismatch | quantity doesn't equal the number of held seats. |
See also
- Take a payment — create and confirm the card payment.
- Deliver tickets — get the QR into your customer's hands.
- Transfer, refund & upgrade — lifecycle operations.
- API reference: issue a ticket
Take a payment
A paid ticket needs a verified payment from your account's configured provider. Create a payment intent for an event and tier, complete the provider's payment flow, and attach that reference when issuing or upgrading a ticket. Stripe returns a client secret, PayPal an approval URL, and external payments require your server-side confirmation.
If you've integrated Stripe before, this will feel familiar: a server-created intent, a client-side confirmation, and a status you can poll. The key difference is that TicketConnect computes the amount for you — you never send a price, so a client can't tamper with what gets charged.
Before you start
You'll need:
- An API key with the
payments:writescope 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.
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"
}'
{
"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
amountfield in the request body, and any extra keys are ignored. The charge always equals the tier price on your event, in the server's event/account currency; a supplied currency must match. This is what stops a manipulated client from underpaying.
The client_payload carries the details your client needs to confirm the card
(for the Stripe provider, a client_secret). Pass it to the TicketConnect
client integration the same way you'd hand a client secret to Stripe's SDK, and
confirm the card in the browser or app.
Step 2 — Confirm the card
Card confirmation happens on the client, with the customer's card details — they
never touch your server. After the customer submits their card, the payment moves
toward succeeded.
Test cards
In test mode (sk_test_... keys), use your payment provider's standard test
cards to simulate outcomes — a card that always succeeds, one that always
declines, one that triggers authentication, and so on. Test-mode payments are
fully isolated from live data, so you can run a full issuance end-to-end without
charging anyone.
Step 3 — Retrieve a payment
Check a payment's current status at any time.
Endpoint: GET /v1/payments/:id · Scope: payments:read or payments:write
curl https://api.ticketconnect.example/v1/payments/pi_3PxyzABC \
-H "Authorization: Bearer sk_test_..."
{
"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_idtoPOST /v1/events/:id/tickets. The payment must besucceeded, match the tier amount, currency, account, and mode. Its combined allocations across tickets and upgrades cannot exceed the original confirmed collection. See Issue a ticket. - Upgrades. When a customer moves to a more expensive tier, you create a payment for the price difference and attach it to the upgrade call. See Transfer, refund & upgrade.
Waiting-room events: charge after admission. If the event runs an on-sale queue (its event object has a non-null
queue), admission is enforced when the ticket is issued — not when the intent is created. Join the queue and pollGET /v1/events/:id/queue?customer_id=...untilstatusisadmittedbefore you take the payment; otherwise the charge can succeed and issuance still return403 not_admitteduntil 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 returnedhold_id. Passquantity: <held seats>when creating the payment intent — the server charges tier price × quantity, matching what issuance will verify. If card entry runs long, extend the hold (POST /v1/events/:id/seats/hold/:holdId/extend) so the seats don't lapse mid-payment. See Issue a ticket for the full flow.
Payment completion does not always fund a Stripe payout. External, PayPal, and crypto collections remain on their original rail; only proven platform-held Stripe funds contribute to the v1 payout balance. A payout transfers to your Stripe Connect account and does not confirm a bank deposit. See Money & currencies.
Use an
Idempotency-Keyon every create. Retrying a payment-intent creation with the same key returns the original intent instead of creating a duplicate charge. Completed response snapshots last about 24 hours; unresolved financial operations retain their original claim for recovery. See Idempotency.
Common errors
| Status | code | Meaning |
|---|---|---|
400 | parameter_missing | event_id or tier was not supplied. |
400 | parameter_invalid | The named tier doesn't exist on that event. |
404 | resource_missing | No such event under your account (or payment not found on retrieve). |
502 | payment_provider_error | The provider couldn't create the payment; retry. |
See also
- Issue a ticket — turn a successful payment into a ticket.
- Transfer, refund & upgrade — charge the difference on an upgrade.
- API reference: payments
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
| Option | What you get | When to use it |
|---|---|---|
| Raw QR string | qr.data — an opaque scannable string | You render the code yourself, or embed it in your own app. The guaranteed minimum — always present. |
| Hosted ticket page | A signed, expiring link (POST /v1/tickets/:id/delivery_link) to a mobile page with the event, holder, live status badge, and the QR | You 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 checkout | The branded checkout page shows the buyer their QR on-screen right after purchase | You 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:readscope. - 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
curl https://api.ticketconnect.example/v1/tickets/tkt_4f8a9c0d1e2f \
-H "Authorization: Bearer sk_test_..."
{
"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.datais 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:
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 }'
{
"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.
/v1does not expose an Apple/Google pass file for a ticket — the hosted ticket page is the ready-made delivery surface, andqr.dataremains the delivery contract for everything you build yourself. Holders who keep the ticket in a ticket wallet on the TicketConnect rails can save it to Apple/Google Wallet from there — but only for events withlive_codesoff: a wallet pass is a static barcode, and the platform refuses one where a live, refreshing code is enforced (that refusal is what makes the anti-screenshot layer real).
At the door
Whichever way the customer receives it, your scanners validate it through the
same endpoints. The ticket's status reflects its lifecycle — a freshly issued
ticket is valid, and it becomes used after check-in. A refunded ticket
surfaces as refunded here (a transfer keeps the ticket valid — same ticket,
new owner), and a revoked ticket keeps its status but carries revoked: true
and fails every scan. A quick GET /v1/tickets/:id always tells you whether a
ticket is good to admit.
Holders may also present the ticket from a ticket wallet. A customer who keeps their ticket in a wallet on the TicketConnect rails (Gettix, our flagship licensee, is the reference holder experience) shows a live code that refreshes every few seconds instead of your static one. Your scanning endpoints validate both — send whatever was scanned, verbatim, and read the verdict (see Scanning & check-in). Your
qr.datastays valid alongside it. One consequence to know about: once the holder has opened the ticket in a wallet, the ticket is locked to them —POST /v1/tickets/:id/transferthen returns409 ticket_not_transferable. Live codes are on by default and togglable per event (live_codeson the events API).
See also
- Issue a ticket — create the ticket you're delivering.
- Transfer, refund & upgrade — what happens to delivery after a lifecycle change.
- API reference: retrieve a ticket
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:managescope — 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.
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" }'
{
"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_iddoesn't match a provisioned customer, you get404 customer_not_found. Create the customer before transferring.
A ticket opened in a holder's ticket wallet can't be transferred. Once the holder has opened the ticket in a wallet built on the TicketConnect rails (any holder experience built on those rails — Gettix, our flagship licensee, is the reference one), it is locked to them and
POST /v1/tickets/:id/transferreturns409 ticket_not_transferable(the same code ausedorrefundedticket 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
curl https://api.ticketconnect.example/v1/tickets/tkt_4f8a9c0d1e2f/refund \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: refund_88231" \
-X POST
{
"id": "tkt_4f8a9c0d1e2f",
"event_name": "Summer Fest 2026",
"status": "refunded",
"tier": "General Admission",
"qr": { "data": "8f2c1a...e7", "format": "QR" },
"created_at": "2026-06-05T14:22:00.000Z"
}
The platform reserves the refund and blocks entry before asking the provider to return money. It refunds the original purchase and each paid upgrade as separate allocations in their original currency and mode. It reverses the original booked seller net and fees rather than the ticket's current displayed price.
The ticket becomes refunded and emits ticket.refunded only when every required refund is confirmed. A pending Stripe or PayPal refund keeps its receipt ID and is recovered by GET, without another refund POST. A failed or canceled receipt, missing original proof, unsupported external refund, or depleted refundable funds stays held for review; it is not reported as refunded.
Retry with the original Idempotency-Key and identical request. A completed replay returns the original result. A separate request for an already refunded ticket can return 409 already_refunded.
Upgrade a ticket to a higher tier
Move a ticket up to a more expensive tier, charging the customer the fiat difference. The ticket keeps its id and its QR code — only the tier, perks, and price change.
Step 1 — List the available upgrade options
Endpoint: GET /v1/tickets/:id/upgrade-options · Scope: tickets:manage
This returns the higher tiers the ticket can move into — those that are priced
above the current tier, have upgrades enabled, and still have supply — each with
its price, the fiat price difference (delta), remaining supply, and perks.
curl https://api.ticketconnect.example/v1/tickets/tkt_4f8a9c0d1e2f/upgrade-options \
-H "Authorization: Bearer sk_test_..."
{
"current_tier": "General Admission",
"options": [
{
"name": "VIP",
"price": 149.00,
"delta": 100.00,
"available": 42,
"perks": { "lounge": true }
}
]
}
Step 2 — Pay the difference
If the upgrade has a positive price difference (the option's delta), take a
payment for that amount first (see Take a payment), and
keep the resulting payment_intent_id. The upgrade reserves its exact native-unit difference against the same original payment budget used for issuance. Allocations cannot exceed the verified collection, and an already consumed allocation cannot be claimed twice.
Step 3 — Upgrade
Endpoint: POST /v1/tickets/:id/upgrade · Scope: tickets:manage
Pass the target_tier and the payment that covers the difference.
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"
}'
{
"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
| Status | code | Meaning |
|---|---|---|
400 | parameter_missing | target_tier was not supplied. |
400 | not_an_upgrade | The target tier isn't priced above the current one. |
400 | upgrades_disabled | The target tier doesn't allow upgrades. |
402 | payment_required | The upgrade costs money but no payment_intent_id was attached. |
402 | payment_failed | The payment couldn't be verified or didn't match the difference. |
404 | tier_not_found | No such tier on the event. |
409 | payment_already_used | That payment already funded an upgrade. |
409 | sold_out | The target tier has no remaining supply. |
See also
- Issue a ticket — create the ticket you're managing.
- Take a payment — fund the difference on an upgrade.
- Deliver tickets — delivering the (freshly rotated) QR after a transfer.
- API reference: ticket lifecycle
Scanning & Check-in
When the doors open you need a fast, reliable way to decide one thing for every person walking up: let them in, or not? The scanning endpoints answer exactly that. You send the QR string read off a ticket and get back a clear verdict — valid, already used, refunded, or not found — plus the ticket's tier so your staff know which section it's for.
Every ticket is tamper-proof, so you can trust the verdict without any extra checks of your own. That covers both shapes of code a holder can present: the opaque QR string you deliver yourself, and the live, refreshing code shown for tickets held in a holder's ticket wallet — the platform verifies the latter cryptographically on your behalf. None of that machinery is visible to you: you send the scanned string, you get a verdict.
Scopes
All three endpoints on this page require the scanning:write scope.
| Endpoint | Method | Scope |
|---|---|---|
/v1/scan/validate | POST | scanning:write |
/v1/scan/batch | POST | scanning:write |
/v1/tickets/{id}/attendance | POST | scanning:write |
Validate vs. mark attendance
There are two ways to check someone in, and they map to two different endpoints:
- Validate (online):
POST /v1/scan/validate(and its batch sibling) looks a ticket up by its QR string and returns a verdict. It is the right call when your scanner has a live connection. It does not mark the ticket as used — it only reports whether the ticket is currently valid. - Mark attendance:
POST /v1/tickets/{id}/attendanceis what actually records the check-in and flips the ticket toused. This is the state-changing step.
For a venue with a steady connection, the simplest flow is: read the QR, call
attendance by ticket id, and admit on a used result.
If your scanners have spotty connectivity (patchy Wi-Fi at the gate, a
remote field), don't queue codes for later validation. POST /v1/scan/batch
judges codes by their current validity, so it can't tell you whether a
queued live code was valid at the moment it was scanned. Use the offline
door protocol instead: a scanner device armed through
/v1/scanner decides at the gate with no network,
verifying signed wallet codes cryptographically and matching your opaque
qr.data against its armed ticket list. It uploads device-signed scans when it
reconnects. The @ticketconnect/offline-scanner SDK implements the device
side, and Gettix's staff mode (Gettix is our flagship licensee) runs on the
same protocol. See Offline scanning.
Validate a single ticket at the door
Send the QR string exactly as your scanner read it. Don't parse, trim, or re-encode it — a live wallet code is longer and looks like a JSON blob; it must arrive verbatim.
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:
{
"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:
{
"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/validateis read-only — it never changes the ticket. Use it to preview a verdict, then call the attendance endpoint to actually check the person in.
Live codes from wallet-held tickets
Some holders keep their ticket in a ticket wallet — any holder experience built on the TicketConnect rails, Gettix, our flagship licensee, being the reference one — instead of (or besides) whatever you delivered. The wallet shows a live code that refreshes every few seconds — an anti-screenshot measure — and your scanning endpoints validate it exactly like any other code: send the scanned string verbatim, get a verdict. The platform checks the code's authenticity and freshness cryptographically before the usual lifecycle verdict.
Three extra reason values exist only for these codes, and each maps to a
clear instruction for door staff:
reason | What happened | What staff should do |
|---|---|---|
code_expired | The 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_required | A 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_qr | The code failed cryptographic verification — tampered or forged. | Refuse entry. |
Freshness comes before lifecycle: a stale code on an already-used ticket reads
code_expired, not already_used. Have the holder refresh, re-scan, and trust
the second verdict.
The static QR string you deliver (qr.data) is unaffected by any of this — it
keeps validating by itself, side by side with the wallet's live code.
Live codes are on by default for every event you create and togglable per
event: set live_codes: false at creation or flip it later with
PATCH /v1/events/{id} (see the
events API reference). With live codes off, wallet-held tickets
show a static code and none of the extra reason values above can occur.
Batch validation
To check many codes at once while online, send them in one call. Pass an
array of QR strings in qrs (up to 200 per request). Each code is judged
on its current validity, so live codes must still be fresh when the
request arrives. Batch validation is not an offline mode. To scan without a
connection, use the offline door protocol, where
devices push up to 1000 signed scans per request when they reconnect.
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:
{
"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.
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:
{ "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
200withstatus: "already_used":JSON{ "id": "tkt_8f2c...", "status": "already_used" } -
For exactly-once semantics on a flaky connection, send an
Idempotency-Keyheader. A replay with the same key returns the original response rather than processing the check-in twice.Terminalcurl 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
404with aresource_missingerror. A refunded ticket is reported as invalid byvalidate; don't mark it attended.
Multi-day events
By default a ticket admits once: after it is marked attended, every later scan
is already_used. For a festival or any event that runs over several days,
give the event admission days — the instants at which each day starts —
and a ticket admits once per day:
curl https://api.ticketconnect.example/v1/events \
-H "Authorization: Bearer sk_test_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"name": "Three Days",
"date": "2026-11-13T17:00:00Z",
"end_date": "2026-11-15T22:00:00Z",
"admission_days": ["2026-11-13T05:00:00Z", "2026-11-14T05:00:00Z", "2026-11-15T05:00:00Z"]
}'
- The day of a scan is decided by its time alone: it belongs to the last admission day that has started. A scan before the first one counts as the first day.
- Put the boundaries where your event is quiet.
05:00Zabove is 06:00 in Warsaw, so a night that runs past midnight stays one day. - A tier is a pass valid every day unless you give it
valid_dayswhen you add it —[0]makes a first-day ticket. validateanswersalready_usedonly for a ticket admitted today, andwrong_dayfor a tier that is not valid today. Attendance records one check-in per day; theattendance.verifiedwebhook carriesadmission_day.- Transfers, resale and refunds follow the first check-in, as for any ticket: a pass that has been used on day one can no longer be passed on.
Without admission_days nothing changes, even when the event has an
end_date: the end date only keeps scanning open for the whole run.
See also
- Offline scanning: scan with no network, using the
/v1/scannerprotocol and SDK. - Webhooks — receive
attendance.verifiedin real time. - Marketplace & resale — secondary listings.
- API reference — full endpoint details.
Offline scanning
Venue Wi-Fi fails at the worst moment: thousands of phones in one room, a basement club, a field with one bar of signal. An online gate (Scanning & Check-in) stops when the network does. TicketConnect's door protocol keeps it running: a scanner device downloads what it needs before doors open, decides at the gate with no network at all, and uploads a signed log of every admission when it reconnects.
The protocol is public on /v1 under /v1/scanner/*, and the
@ticketconnect/offline-scanner SDK implements the device side for you.
Gettix's staff mode (Gettix is our flagship licensee, built on TicketConnect)
runs on this same protocol. Every endpoint is documented in the
Scanner protocol reference.
Which codes can be scanned offline
The offline gate accepts both shapes of code a holder can present:
- TicketConnect-signed wallet codes. This is the signed code shown by a holder's ticket wallet on the TicketConnect rails (Gettix is the reference wallet), including its live, refreshing variant. The device verifies it cryptographically with the event key, and checks live codes for freshness.
- Your opaque
qr.data. This is the code you deliver through/v1. It carries no signature. Instead, the ticket feed includes abarcode_sha256for each ticket: the SHA-256 of the barcode, never the barcode itself. Any scanned string that doesn't start with{is treated as an opaque code: the device hashes it exactly as read and looks the hash up in its armed ticket list. An unknown hash isticket_not_found. Opaque codes skip the live-code check, just as they do on the online gate.
Caveat: an opaque code is only as fresh as the last ticket sync. While online, the device refreshes its ticket list every 60 seconds. If a transfer rotates a ticket's barcode after the device's last refresh, the new code is unknown to that gate, and the old one still matches, until the next refresh. A signed wallet code doesn't have this gap because it is verified on its own merits. That makes wallet codes the stronger option at an offline door.
Trust model in plain language
No single piece of the gate relies on the network or on trusting the phone:
- Session tokens, minted server-side. Your backend calls
POST /v1/scanner/sessionswith its secretsk_key and passes the resulting scanner session token to the device. Never ship ansk_key to a device. This is the same pattern as Stripe Terminal connection tokens. A session covers one staff member and one event, and the event comes from the token itself, so no device route takes an event id. A session for event A cannot read or write event B. The token lasts at most 12 hours and never outlives the event's grace cutoff. The SDK keeps it in your secure key-value store, never in the scan database. - Sessions carry only door capabilities. A scanner session can scan
tickets and redeem perks, nothing else. Selling, staff management and stats
are always off in it, and it is refused (
401) everywhere except the/v1/scannerdevice endpoints. - Live permissions. On every request, a session's effective permissions
are what the token grants and what the staff member currently has.
Turning off
can_scan_ticketsorcan_redeem_perks, deactivating or suspending the staff member, unassigning them from the event, suspending your account, or revoking the API key that minted the session takes effect within about 60 seconds. The device then gets401, or its pushed rows come backrejectedwith reasonstaff_lacks_permission. - Per-event signing keys. Every event has its own Ed25519 key pair. Ticket codes are signed with the private key, which never leaves the server. Devices verify them with the event's public key from the manifest.
- Device keys and device certificates. The device generates an Ed25519
key for each staff member who signs in on it and registers the public half.
Arming fails until registration succeeds. The platform returns a
platform-signed device certificate scoped to that event, listing the
device's capabilities (
scan,perkor both, following the staff member's permissions). It expires at the event's grace cutoff or after 7 days, whichever comes first. Nearby scanners check each other's certificates against the platform key (which the SDK pins on first sight and never lets change) and drop packet types the certificate doesn't grant, so a phone can't pose as a gate and a perk-only phone can't mark tickets used. A staff member can hold at most 10 active devices per event. - A local used-set that only moves forward. Admitting a ticket marks it used and appends the device-signed scan record in one transaction, so a crash can't admit someone without leaving the signed record that reconciles it. A server refresh can mark a ticket used but never makes it unused again, because the server may not have heard about a local or gossiped admission yet.
- Signed reconciliation, earliest wins. When a device syncs, the server
checks that each row belongs to the session's staff member and one of their
own devices, then verifies its Ed25519 signature against that device's
registered key. Each row is stored as pending, its effect is applied, and
only then is it final. Resending an identical row returns its stored outcome
(or finishes it, if an earlier attempt was cut off) and changes nothing
else, so retries are safe. If two gates admitted the same ticket within the
10-second gossip window, the earlier scan wins and the later one is
superseded. Scans of the same ticket more than 10 seconds apart are both flaggedsuspect_duplicatefor your review. - Revocation of tickets and devices. Revoke a ticket
(
POST /v1/tickets/{id}/revoke) and it leaves the valid-ticket feed and appears in the revoked-ticket feed. Revoke a lost phone (POST /v1/staff/{id}/devices/{device_id}/revoke) and its signed scans are rejected at reconciliation, while armed peers drop its gossip. Deactivating, suspending or deleting a staff member, or removing theircan_scan_ticketsorcan_redeem_perks, revokes all of their devices. Unassigning them from an event revokes their devices for that event only; the devices keep working at their other events. Revocation is permanent but not retroactive: rows the device signed up to 60 seconds after the revocation are still processed (and flagged for your review), and only later rows are rejected asdevice_revoked. The same applies to scans and redemptions made before a permission was removed. Ondevice_revokedordevice_conflictduring registration, the SDK generates a new device identity and registers again. - A grace cutoff. The scanning window closes at event end + 24 hours.
After that, new sessions return
410 event_ended, the server rejects late scans withevent_ended, and the SDK refuses to scan. - Live rotating codes. On events with live codes, a holder's code
refreshes every 30 seconds and is signed with a key only that ticket has.
The device verifies it with the per-ticket public key delivered in the
ticket feed (
rot_pub_key). The rotation master key never leaves the server, so an armed phone can't forge a live code.
Arming sequence
Arm each device while it is still online, before doors open. arm() in the
SDK does all of this; a custom client must follow the same order:
- Session: get a token from your backend (which called
POST /v1/scanner/sessions). - Manifest:
GET /v1/scanner/manifestreturns the event id (check that it matches), the event public key, the platform certificate key, the event end and grace cutoff, the live-code settings, the session's permissions and — on a multi-day event with daily admission — anadmissionobject:day_starts(when each admission day starts) andtiers(lowercased tier name → the day indexes it is valid on). - Device: load the device key for this staff member (or generate one),
then
POST /v1/scanner/devicesto register it and receive a device certificate. Pin the platform certificate key once. Registration is required: if it fails,arm()rejects and the gate staysnot_armed. On409 device_conflictor403 device_revokedthe SDK creates a new identity and retries once. - Revoked devices: read the full list for this event from
GET /v1/scanner/devices/revoked. - Valid tickets: page through all of
GET /v1/scanner/tickets. - Revoked tickets: page through the full list from
GET /v1/scanner/tickets/revoked. - Perk grants: page through
GET /v1/scanner/perk-grants. - Used-set: pull what other gates already admitted, using
GET /v1/scanner/scansandGET /v1/scanner/perk-redemptions.
Scan decision order
scanTicket(qr) runs these checks in order and stops at the first failure.
Everything here runs on the device.
| # | Check | Rejection reason |
|---|---|---|
| 1 | The device is armed | not_armed |
| 2 | The session grants scan_tickets (no recorded permissions counts as no) | permission_denied |
| 3 | Now is before the grace cutoff (event end + 24 h) | event_ended |
| 4 | QR envelope: JSON, required fields, a valid Ed25519 signature from the event key, not past validUntil | malformed_qr, missing_required_fields, verify_threw, bad_signature, expired |
| 5 | The QR's event is this event | wrong_event |
| 6 | The ticket is known locally | ticket_not_found |
| 7 | On events that enforce live codes: the per-ticket key is present and the live code is fresh (current 30-second step ±1) | rot_key_unavailable, rot_missing, rot_malformed, rot_stale, rot_mismatch |
| 8 | The ticket is not revoked (revocation outranks "used") | revoked |
| 9 | Daily admission only: the ticket's tier is valid on the current admission day | wrong_day |
| 10 | Sign the scan with the device key | device_key_error |
| 11 | Atomically mark the ticket used and append the signed scan | already_used, unknown_ticket |
An opaque code (anything that doesn't start with {) replaces checks 4 to 7
with the barcode_sha256 lookup: an unknown hash is ticket_not_found, and a
known one continues at check 8.
On an event with daily admission, "used" means used today. When a new admission day starts the device re-opens the tickets admitted on earlier days (once, and durably), and from then on ignores earlier days' entries from the ticket feed, the scan pull and gossip. The server reconciles the same way: two scans of one ticket collide only when they fall on the same admission day.
Success returns { ok: true, ticketId, seatNumber, … } (qr is null for an
opaque code). After an admission the device broadcasts a gossip packet and
starts a best-effort sync. redeemPerk(qr) follows the same pattern, with
permission_denied when the session lacks redeem_perks.
Sync order
startSync() runs a sync immediately and then every 15 seconds. Each run goes
in this order, so local admissions reach the server before any server state is
applied:
- Push pending scans:
POST /v1/scanner/scans. - Push pending perk redemptions:
POST /v1/scanner/perk-redemptions. - Refresh valid tickets (at most once every 60 seconds).
- Pull the revoked-ticket delta (every run).
- Pull the revoked-device delta (every run).
- Refresh perk grants (at most once every 60 seconds).
- Pull other gates' scans, then their perk redemptions.
If either push fails with a network error, 408, 429 or a 5xx, the rows
stay pending and that run skips both refreshes (steps 3 and 6), because the
server's view would lag the unpushed rows. The revocation pulls still run.
Any other whole-batch 4xx (for example 400) is final: the batch's rows are
marked rejected with the error code, local used state is kept, and a
push_rejected alert with final: true is raised through onAlert. Per-row
outcomes are final too, except staff_mismatch, which should never happen:
that row stays pending and raises a push_rejected alert with final: false.
A row rejected with staff_lacks_permission also makes the SDK re-read the
manifest, so the gate starts answering permission_denied. Refreshes can only
add "used" state, never remove it. While a perk grant has unconfirmed local or
gossiped redemptions, the device keeps the lower of the server's and its own
remaining uses.
When the token has 5 minutes or less left, or a request returns 401, the SDK
asks your SessionProvider for a new session. The gate disarms until the
next arm if the manifest names a different staff member, or the device shows
up as revoked.
Shared phones. Each row belongs to the staff member who signed it, and a sync pushes only the current staff member's rows. If someone else signs in on the same phone, the previous staff member's unsynced scans and redemptions stay queued on the device until that person signs in on it again while online (
countPendingOfOtherStaff()reports how many; Gettix shows a notice on its scanner screens). If a staff member loses every scanner permission, no session can be minted for them, so their queued rows wait until a permission is restored.
Peer gossip (optional)
Gates that can reach each other can share admissions within seconds, with no server involved. After an admission, a device sends a small signed packet that carries the scan, its signature and the sender's device certificate. A receiver accepts it only after these checks, in order:
- The packet's size and event are right, and it is a
v: 2packet with a certificate. Anything else is dropped. - The sender is not on the event's revoked-device list.
- Flood control, keyed on the sender's network source when the transport reports one (else on the claimed device id). Senders without a verified certificate get 20 packets/s per key before any cryptography, within a 200/s total. Senders with a verified certificate get 20 packets/s per (device id, source), within a 200 verifications/s CPU guard. One spoofed key can use at most 20 of those 200, so junk traffic can't crowd out real gates.
- The certificate matches the sender, is for this event, is unexpired,
grants the packet type (
scanorperk), and is signed by the pinned platform key. - The row signature verifies. Only then does the packet count against the sender's 20/s budget and mark itself as seen, so a forged copy can't block the genuine one.
The receiver then records the scan in its own used-set, atomically. Nothing about peers is persisted: each gossip session verifies a peer's certificate on its first packet. A device without a certificate doesn't broadcast.
Gossip is transport-agnostic. You supply one or more PeerTransports: UDP on
the venue LAN, an iOS/Android phone-to-phone mesh, BLE, or anything else that
moves strings. Pass the packet's source (for example the UDP sender address
or the mesh peer name) so flood budgets can't be exhausted by someone spoofing
a gate's id. The packet format (gossip v2 packets, v3 certificates) and the
canonical signed strings are specified in the SDK README (sections Gossip
protocol and Canonical strings). The canonical strings are also listed in
the Scanner protocol reference.
Limits
| Limit | Value |
|---|---|
Rows per push (scans or redemptions) | 1000; more returns 413 batch_too_large (the SDK pushes 200 at a time) |
| Push row fields | ids up to 128 characters; sig exactly 128 lowercase hex; timestamps ISO-8601 with Z or an offset |
GET /v1/scanner/tickets, /tickets/revoked, /perk-grants, /devices/revoked page size | default 1000, max 5000 (larger values are clamped) |
GET /v1/scanner/scans, /perk-redemptions page size | default 500, max 2000 |
| Active devices | 10 per staff member per event (409 device_limit_reached) |
| Session token lifetime | up to 12 h, never past event end + 24 h (minimum 60 s) |
| Permission, staff and account changes | effective within about 60 s |
| Device certificate lifetime | until event end + 24 h, capped at 7 days (24 h for an undated event) |
| Gossip reconciliation window | 10 s: earliest wins inside it; suspect_duplicate outside it |
| Gossip packet size | 2048 characters |
| Gossip rate caps (per receiver) | per key (source, else device id): 20 packets/s; unverified senders 200/s in total; verified senders 200 verifications/s CPU guard |
| Gossip memory bounds | 4000 seen packets, 512 tracked peers, 1000 rejected certificates |
| Live code | 30 s steps, ±1 step tolerance |
SDK quick start
1. Install
The SDK is the package @ticketconnect/offline-scanner (v0.2.0). It is
distributed as the tarball ticketconnect-offline-scanner-0.2.0.tgz:
npm install ./ticketconnect-offline-scanner-0.2.0.tgz
v0.2.0 adds daily admission for multi-day events. A gate on v0.1.0 keeps working at such an event but treats a ticket as used for good, so it refuses re-entry on later days.
It ships ESM, CommonJS and TypeScript declarations. Its runtime dependencies
are @noble/curves and @noble/hashes, and it doesn't use React, React
Native, Expo or Node-only APIs, so it runs in React Native, the browser or
Node.
2. Mint sessions on your server
Add an endpoint to your backend that your staff app calls after the staff
member signs in. It is the only place your sk_ key is used. The key needs the
scanning:write scope.
// Your backend (Node / Express). The sk_ key never leaves this server.
app.post("/scanner-session", requireStaffLogin, async (req, res) => {
const r = await fetch("https://api.ticketconnect.example/v1/scanner/sessions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TICKETCONNECT_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
staff_id: req.user.ticketconnectStaffId, // from POST /v1/staff
event_id: req.body.event_id,
}),
});
const body = await r.json();
if (!r.ok) return res.status(r.status).json(body); // 404 / 403 / 409 / 410
// body: { object: "scanner_session", token, expires_at, event_id, staff_id }
res.json({ token: body.token, expires_at: body.expires_at });
});
The staff member must be active, assigned to the event
(POST /v1/staff/{id}/assignments), and have
can_scan_tickets or can_redeem_perks.
3. Implement the ports
The engine does not do its own I/O. The host app supplies:
| Port | What you provide |
|---|---|
SessionProvider | mint(): Promise<{ token, expiresAt }>, which calls the endpoint from step 2. |
ScannerStore | Durable storage, one instance per event (for example SQLite): meta, tickets (with an indexed getTicketByBarcodeHash lookup), revocation flags, perk grants, and the signed scan and perk outboxes. Methods marked ATOMIC (upsertTickets, markTicketsRevoked, commitLocalScan, recordPeerScan, upsertPerkGrants, commitLocalPerkRedemption, recordPeerPerkRedemption) must save all of their writes or none, without interleaving. On SQLite, use a single BEGIN IMMEDIATE … COMMIT. upsertTickets must never turn a used ticket back to unused. For daily admission also implement the optional, atomic reopenTicketsUsedBefore(cutoffIso): every used ticket whose usedAt is before the cutoff becomes unused again (the engine calls it once when a new admission day starts); without it the gate refuses re-entry on later days. The store keeps no peer table. |
KeyValueStore | Secure string storage (Keychain / Keystore) for the device identity (private key, public key and device id, one set per staff member), the scanner session token, device certificates and the pinned platform key. |
RandomSource | bytes(n) from a cryptographically secure generator. |
PeerTransport (optional) | A lossy string channel for gossip: start(onPacket, self), stop(), send(json). Call onPacket(json, source) with the sender's address or peer name when you know it. |
Clock, Logger (optional) | Default to Date.now() and no logging. |
MemoryScannerStore and MemoryKeyValueStore are included for tests and
prototypes. Replace them with durable, secure implementations in production.
4. Arm, scan, sync, gossip
import {
createOfflineScanner,
MemoryScannerStore,
MemoryKeyValueStore,
type SessionProvider,
} from "@ticketconnect/offline-scanner";
const session: SessionProvider = {
async mint() {
const res = await fetch("https://your-backend.example/scanner-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event_id: "evt_42" }),
});
if (!res.ok) throw new Error(`session mint failed: ${res.status}`);
const body = await res.json(); // { token, expires_at }
return { token: body.token, expiresAt: body.expires_at };
},
};
// One engine per event, shared by your ticket and perk screens.
const scanner = createOfflineScanner({
eventId: "evt_42",
baseUrl: "https://api.ticketconnect.example", // paths are appended as /v1/scanner/...
session,
store: new MemoryScannerStore(), // production: durable, transactional store
keyValue: new MemoryKeyValueStore(), // production: Keychain / Keystore
random: { bytes: (n) => crypto.getRandomValues(new Uint8Array(n)) },
transports: [], // add your PeerTransport(s) for gossip
device: { platform: "ios", appVersion: "1.0.0" },
});
// Arm while online. ensureArmed() reuses a stored session when it is still fresh.
await scanner.arm();
// Keep reconciling in the background: runs now, then every 15 s.
scanner.startSync();
const offAlerts = scanner.onAlert((alert) => {
if (alert.outcome === "push_rejected") {
console.error("server refused rows", alert.code, alert.final ? "rejected for good" : "kept queued");
return;
}
console.warn("possible duplicate entry", alert.ticketId);
});
// Optional phone-to-phone gossip. Returns false if the device isn't armed.
await scanner.startGossip();
// At the gate. Works with no network.
const result = await scanner.scanTicket(qrText);
if (result.ok) {
admit(result.ticketId, result.seatNumber);
} else {
refuse(result.reason); // "already_used" | "revoked" | "permission_denied" | "rot_stale" | …
}
// Perks use the same engine.
const perk = await scanner.redeemPerk(perkQrText);
// Teardown.
await scanner.stopGossip();
scanner.stopSync();
offAlerts();
Debouncing belongs in your camera handler, not the engine. The Gettix reference ignores the same payload for 10 seconds and any read within 200 ms of the previous one.
What the SDK does not include
- Camera and scanning UI. You decode the QR and pass the string to
scanTicket, then design your own result screens. - A native mesh transport. The SDK handles packet format, flood control and all cryptography, while the host supplies the transports. Gettix's staff mode is the reference implementation of the ports: an SQLite store, secure storage, UDP on the LAN, and a phone-to-phone mesh over MultipeerConnectivity on iOS and Nearby Connections on Android. That code is part of the Gettix app and is not included in the SDK.
- Durable storage. The in-memory stores are for tests and prototypes.
- Session minting and staff sign-in. These live in your backend and app (step 2).
- Gate sales and wristbands. There are no
/v1POS endpoints.
See also
- Scanner protocol reference: every
/v1/scannerendpoint with requests, responses and errors. - Scanning & Check-in: the online gate.
- Staff reference: create staff, assign events, and manage their devices.
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.
| Endpoint | Method | Scope |
|---|---|---|
/v1/marketplace/listings | GET | marketplace:read |
/v1/marketplace/listings/{id} | GET | marketplace:read |
How resale works (at a glance)
- Fiat pricing. Every listing is priced in your settlement currency. You see one number — the resale price — just like any other amount in the API.
- Verified royalty settlement. The configured royalty is part of the frozen resale split. Each required leg must have original payment proof and a confirmed settlement on its rail; unknown legs remain pending. A resale royalty does not automatically become platform-held Stripe funds in your v1 payout balance.
- Anti-scalping, on by default. Every event ships with anti-scalping
enforced and a resale ceiling of 150% of face value. Both the bounds
(floor/ceiling), the resale-open window, and whether resale or anti-scalping
is on at all are per-event settings you control when you create or update
the event (
marketplace_settingson 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.
curl "https://api.ticketconnect.example/v1/marketplace/listings?limit=20" \
-H "Authorization: Bearer sk_test_..."
{
"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:
curl "https://api.ticketconnect.example/v1/marketplace/listings?limit=20&starting_after=list_9a3f1c2e4b6d8f0a1b2c3d4e5f60718" \
-H "Authorization: Bearer sk_test_..."
Note: A listing's
statustells you where it is in its life. Once a listing sells, it stops beingactiveand you'll receive amarketplace.sale.completedwebhook — see Webhooks. Useticket_idto tie a listing back to the ticket (GET /v1/tickets/:id) and, through it, the event.
Retrieve a single listing
curl https://api.ticketconnect.example/v1/marketplace/listings/list_9a3f1c2e4b6d8f0a1b2c3d4e5f60718 \
-H "Authorization: Bearer sk_test_..."
{
"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.completedandticket.transferred. - Payouts — which proven funds are available for Stripe transfers.
- API reference — full endpoint details.
Discounts
Discount codes let you run promotions: a flat amount off, or a percentage off, redeemable at checkout. This guide covers the full lifecycle — create, list, retrieve, update, and delete — and how a code is applied when a customer buys a ticket.
Scopes
Every discount endpoint requires the events:write scope.
| Endpoint | Method | Scope |
|---|---|---|
/v1/discounts | POST | events:write |
/v1/discounts | GET | events:write |
/v1/discounts/{id} | GET | events:write |
/v1/discounts/{id} | PATCH | events:write |
/v1/discounts/{id} | DELETE | events:write |
The discount object
{
"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"
}
| Field | Meaning |
|---|---|
code | The string the customer types at checkout. If you omit it on create, one is generated for you. |
type | "fixed" or "percentage". |
value | For fixed, the amount off in your currency. For percentage, a number from 0–100. |
max_uses | Total redemptions allowed across all customers (null = unlimited). |
used_count | How many times it's been redeemed so far. |
expires_at | When it stops working (null = no expiry). |
active | Whether 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.
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"
}'
{
"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:
curl https://api.ticketconnect.example/v1/discounts \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"code": "WELCOME15",
"type": "fixed",
"value": 15
}'
{
"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_idsarray on create (only the first entry is used — one code, one event). Omit it for an account-wide code. If you don't pass acode, 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
codeat 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
percentagecode subtracts that percentage from the order total.fixedcodes 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_countis 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).
curl "https://api.ticketconnect.example/v1/discounts?limit=20" \
-H "Authorization: Bearer sk_test_..."
Retrieve a discount
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.
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
curl https://api.ticketconnect.example/v1/discounts/64fa1b2c9e7d654321fedcba \
-X DELETE \
-H "Authorization: Bearer sk_test_..."
{ "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 —
payment.succeededfires on a completed checkout. - API reference — full endpoint details.
Webhooks
Webhooks push real-time notifications to your server whenever something happens
in your account — a ticket is issued, a payment succeeds, a resale completes, a
payout is sent. Instead of polling the API, you register a URL once and we
POST a signed JSON event to it every time.
If you've used Stripe webhooks, this will feel identical: signed payloads you can verify with an HMAC, automatic retries with backoff, and a delivery log you can inspect and replay.
The payout route emits payout.transferred after a confirmed Stripe transfer to the connected account. The legacy payout.paid subscription remains accepted for compatibility; it is not bank-paid confirmation emitted by this route. Pending or review-held transfers do not emit completion.
Scopes
All webhook-management endpoints require the webhooks:manage scope.
| Endpoint | Method | Scope |
|---|---|---|
/v1/webhook_endpoints | POST | webhooks:manage |
/v1/webhook_endpoints | GET | webhooks:manage |
/v1/webhook_endpoints/{id} | DELETE | webhooks:manage |
/v1/webhook_endpoints/{id}/ping | POST | webhooks:manage |
/v1/events/deliveries | GET | webhooks:manage |
/v1/events/deliveries/{id}/replay | POST | webhooks: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.
curl https://api.ticketconnect.example/v1/webhook_endpoints \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.yourapp.com/ticketconnect",
"enabled_events": ["ticket.issued", "payment.succeeded", "payout.transferred"]
}'
{
"id": "663b1c9e2a7d654321fedcba",
"url": "https://hooks.yourapp.com/ticketconnect",
"enabled_events": ["ticket.issued", "payment.succeeded", "payout.transferred"],
"status": "active",
"secret": "whsec_Hk9...redacted..."
}
The signing
secretis 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 a400 invalid_webhook_urlerror. The same check re-runs on every delivery, so a URL that later starts resolving to an internal address has its deliveries markedfailedrather than retried.
List endpoints
curl https://api.ticketconnect.example/v1/webhook_endpoints \
-H "Authorization: Bearer sk_test_..."
The secret is not included in list responses.
Delete an endpoint
curl https://api.ticketconnect.example/v1/webhook_endpoints/663b1c9e2a7d654321fedcba \
-X DELETE \
-H "Authorization: Bearer sk_test_..."
{ "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:
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" }'
{
"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 type | When it fires |
|---|---|
event.created | A new event is created. |
event.updated | An event's details change. |
ticket.issued | A ticket is issued to a customer. |
ticket.confirmed | A purchased ticket's tamper-proof record is finalized shortly after issuance. Informational — nothing to act on. |
ticket.confirmation_failed | Finalization did not complete after issuance. The ticket stays valid and scannable — treat this as an operational alert. |
ticket.transferred | A ticket changes hands to another holder. |
ticket.refunded | A ticket is refunded. |
ticket.revoked | A ticket is invalidated without a refund (fraud, chargeback, comp clawback). It fails scans from that moment. |
ticket.upgraded | A ticket is moved to a higher tier. |
ticket.redeemed | A ticket is checked in / used at the door. |
payment.succeeded | A card payment completes successfully. |
payment.failed | A card payment fails. |
marketplace.sale.completed | A secondary-market resale completes. |
attendance.verified | A check-in is recorded for a ticket. |
payout.transferred | Stripe confirms a transfer to the connected account; this is not bank-paid confirmation. |
Note: Every issuance produces
ticket.issuedimmediately, followed shortly by exactly one ofticket.confirmedorticket.confirmation_failed. Onticket.confirmation_failedthe 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:
{
"id": "evt_4f8a2c1e9b7d6543210fedcba9876543",
"type": "payout.transferred",
"created": 1749124800,
"data": {
"amount": 250.00,
"currency": "USD",
"reference": "tr_1Q9..."
}
}
id— unique event id (prefixedevt_). 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:
| Header | Description |
|---|---|
Webhook-Signature | The signature to verify, format t=<timestamp>,v1=<hex>. |
Webhook-Id | The event id (matches id in the body) — handy for logging. |
Content-Type | Always 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:
- Parse
t(timestamp) andv1(signature hex) from the header. - Build the signed payload string:
`${t}.${rawBody}`— the timestamp, a literal., then the raw, unparsed request body. - Compute
HMAC-SHA256(secret, signedPayload)and hex-encode it. - Compare it to
v1using a constant-time comparison. - Optionally, reject the request if
tis 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)
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:
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):
Retry Delay after previous attempt 1 1 minute 2 5 minutes 3 30 minutes 4 2 hours 5 10 hours 6 24 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
2xxquickly — 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(theevt_...value, also in theWebhook-Idheader) as a dedupe key. - On receipt, check whether you've already processed that
id; if so, return200and 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:
curl "https://api.ticketconnect.example/v1/events/deliveries?limit=20" \
-H "Authorization: Bearer sk_test_..."
{
"object": "list",
"data": [
{
"id": "665c2a1f9e7d654321fedcba",
"event_id": "evt_4f8a...",
"type": "payout.transferred",
"status": "delivered",
"attempts": 1,
"last_response_code": 200,
"created_at": "2026-06-05T12:00:00.000Z"
}
],
"has_more": false
}
status is one of pending, delivered, or failed. last_response_code is
the HTTP status your endpoint returned on the most recent attempt.
8. Replay a delivery
Fixed an outage on your side? Re-send a past delivery to your registered endpoint:
curl https://api.ticketconnect.example/v1/events/deliveries/665c2a1f9e7d654321fedcba/replay \
-X POST \
-H "Authorization: Bearer sk_test_..."
{ "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
- Scanning & check-in — emits
attendance.verified. - Payouts — emits
payout.transferred. - Marketplace & resale — emits
marketplace.sale.completed. - API reference — full endpoint details.
Payouts
The payout API transfers available platform-held Stripe funds to your Stripe Connect account. It reports transferred once Stripe confirms that transfer. A deposit in your bank account is a separate Stripe connected-account payout and is not confirmed by this API.
Scopes
| Endpoint | Method | Scope |
|---|---|---|
/v1/connect/account | POST, GET | payouts:claim |
/v1/balance | GET | payouts:read or payouts:claim |
/v1/payouts | GET | payouts:read or payouts:claim |
/v1/payouts | POST | payouts:claim |
Use a read-only key for dashboards and a payouts:claim key for the service that moves funds.
1. Complete Connect onboarding
curl https://api.ticketconnect.example/v1/connect/account \
-X POST -H "Authorization: Bearer sk_test_..."
{
"account_id": "acct_1Q...",
"onboarding_url": "https://connect.stripe.com/setup/e/acct_1Q.../..."
}
Redirect the user to onboarding_url. A later call resumes the same account with a fresh link. Test and live Connect accounts are stored separately; a test account or an unverified legacy account ID cannot receive a live transfer.
Read GET /v1/connect/account to check capabilities:
{
"connected": true,
"charges_enabled": true,
"payouts_enabled": true,
"details_submitted": true
}
Before onboarding, the response is { "connected": false }. Capability flags describe account readiness, not a completed bank payout.
2. Read the available balance
curl https://api.ticketconnect.example/v1/balance \
-H "Authorization: Bearer sk_test_..."
{ "object": "balance", "available": 1284.50, "currency": "USD" }
This balance includes only proven platform-held Stripe credits after booked fees, refund debits, and outstanding payout reservations. External, PayPal, crypto, unknown legacy credits, and funds already sent to a destination account do not authorize a transfer from the platform Stripe treasury.
Refunds reverse the original booked net. If refundable funds have already been paid out, the refund may require review and remain held; the API does not guarantee that a debit alone returns money to the buyer. Keep a refund buffer and follow refund completion states.
The ledger balance is not Stripe’s net available-cash report. Processor costs are a platform expense requiring separate liquidity; a provider can refuse a transfer when its actual available balance is insufficient.
3. Request a transfer
A payout requires an Idempotency-Key. Send a positive amount in major units that converts to whole minor units, or omit it to request the full available balance. The currency defaults to the account's settlement currency.
curl https://api.ticketconnect.example/v1/payouts \
-X POST \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: payout-2026-09-30-001" \
-H "Content-Type: application/json" \
-d '{ "amount": 500.00, "currency": "USD" }'
A confirmed transfer returns 201:
{
"object": "payout",
"id": "po_opaque_operation_id",
"amount": 500.00,
"currency": "USD",
"status": "transferred",
"reference": "tr_1Q9...",
"created_at": "2026-09-30T14:22:00.000Z"
}
The platform reserves the funded balance atomically before calling Stripe. Concurrent requests with different keys cannot spend the same balance. A confirmed operation emits payout.transferred. The legacy payout.paid subscription name remains accepted for compatibility; it is not a bank-paid confirmation emitted by this route.
Pending operations and retries
An ambiguous response returns 202 with pending or review_required status. Funds remain reserved. Retry the same request with the original key; recovery uses the original provider operation and destination. Never request a replacement transfer under a fresh key to resolve an unknown result.
Automatic retries stop after 23 hours from the original attempt when there is no definitive outcome, before Stripe can discard the provider key. The operation then stays held for reconciliation. A definitive provider rejection marks it failed and releases its reservation; the route returns 502.
Invalid or missing keys, non-positive or fractional-minor amounts, insufficient funded balance, and missing Connect accounts are rejected before a transfer.
4. Read transfer history
GET /v1/payouts?limit=20 lists durable operations, newest first. Each row includes the operation ID, amount, currency, state, optional Stripe transfer reference, and creation time. Use starting_after for pagination and inspect the state: a pending row is not a completed transfer.
See API reference: payouts and Webhooks.
Ad tracking & attribution
Configure Ad tracking in the white-label portal. Only the organization owner can change the Meta Pixel ID, GTM container ID or write-only CAPI token. The token is encrypted at rest and is never returned by the API.
Browser and server events
The hosted checkout loads your Meta Pixel and published GTM container only after the buyer accepts optional cookies. Cookie settings lets the buyer withdraw that choice. Consent is stored separately for each pixel/container. Test-mode checkouts load neither tracker and test purchases never dispatch CAPI.
| Action | Meta browser event | GTM dataLayer event |
|---|---|---|
| Page loaded after consent | PageView | page_view |
| Ticket/event viewed | ViewContent | view_item |
| Checkout started | InitiateCheckout | begin_checkout |
| Paid ticket confirmed | Purchase | purchase |
purchase includes an ecommerce object with transaction_id, value,
currency and items. Amount and currency come from the completed server
purchase, including scheduled tier prices. Free tickets emit no Purchase.
The server sends a consented Meta CAPI Purchase after ticket issuance.
Browser and server use the same event ID for deduplication. A CAPI failure
never prevents a ticket from being issued.
GTM setup
Saving a GTM ID loads the container; you must publish the container and
configure tags for the events above yourself. Tags must honor the buyer's
Consent Mode v2 settings. All four storage/advertising settings start denied.
If you also send Meta through GTM, preserve event_id as Meta's event ID;
otherwise remove that duplicate Meta tag and use the built-in pixel.
Available surfaces
| Surface | Browser tracking | Server tracking |
|---|---|---|
| TicketConnect-hosted checkout | Meta + GTM, with a built-in consent banner | Tenant CAPI Purchase, with shared browser/server ID |
Your own frontend using /v1 | Implement your own pixel/GTM and consent UI | Pass optional attribution on issuance or checkout completion; tenant CAPI only |
| Gettix site | Consent-gated Meta + GTM; a dedicated white-label deployment resolves IDs from your panel | Funnel relay; confirmed card resale Purchase. External seller purchases emit browser Purchase after confirmed completion |
A dedicated Gettix deployment must have TICKETCONNECT_TENANT_SLUG set on
its backend. Failed tenant lookup disables tracking instead of falling back
to the platform pixel. The standard Gettix deployment uses TicketConnect's
platform tracking IDs. Public configuration never contains the CAPI token.
Attribution
UTM fields and referrer accompany the order for the Sales sources report.
Advertising click IDs, Meta cookie IDs, browser event ID, source page and user
agent require marketingConsent: true. On your own frontend, that flag is
your declaration that the buyer consented.
Pass the browser page as sourceUrl and the browser's userAgent in the
attribution object. Only HTTP(S) source URLs are accepted; credentials,
query parameters and fragments are removed. See ticket attribution.
Verify the integration
First check that neither Meta nor GTM requests occur before consent, then
accept and check the network requests and dataLayer. Complete a real,
paid live-mode purchase and compare the amount, currency and event ID in the
browser Purchase and CAPI payload. Verify reception and deduplication in
Meta Events Manager using your own pixel and token. Test-mode ticket
purchases deliberately do not reach the production pixel.
META_CAPI_TEST_EVENT_CODE is an optional server setting for a coordinated
Meta Test Events check; do not leave it set during normal production tracking.
API reference
This is the complete reference for the TicketConnect White-Label API. Every
endpoint lives under the /v1 prefix and is automatically scoped to your
account. The API is a plain REST + JSON service modeled closely on Stripe, so if
you have integrated Stripe before this will feel familiar.
Base URL
https://api.ticketconnect.example/v1
Replace the host with the environment you were issued. All paths below include
the /v1 prefix.
Authentication
Send your secret key as a bearer token on every request except the public meta
endpoints (/v1/health, /v1/openapi.json, /v1/docs):
Authorization: Bearer sk_test_your_key_here
The key is also accepted in an x-api-key header as a fallback. Use
sk_test_… keys against the sandbox and sk_live_… keys against
production; the two are fully isolated. Keys are scoped — each endpoint
documents the scope it requires (for example events:read). A wildcard *
scope satisfies any check. Calling an endpoint without the required scope
returns a 403 with type authorization_error and code forbidden. See
Authentication for how to manage keys
and scopes. Hosted-checkout endpoints are the one exception to the secret-key
rule: they authenticate with browser-safe publishable pk_… keys instead (see
Payments).
Response & error envelope
Successful responses return the resource object directly. Most resources carry
an object discriminator (for example "object": "payout").
Errors always use one consistent envelope:
{
"error": {
"type": "invalid_request_error",
"code": "parameter_missing",
"message": "name is required.",
"param": "name"
}
}
| Field | Description |
|---|---|
type | High-level category: invalid_request_error, authentication_error, authorization_error, rate_limit_error, idempotency_error, or api_error. |
code | Machine-readable code, e.g. parameter_missing, resource_missing, insufficient_balance. |
message | Human-readable explanation. |
param | The offending request field, when applicable. |
request_id | Request 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:
{
"object": "list",
"data": [ { "...": "..." } ],
"has_more": true
}
| Query param | Type | Description |
|---|---|---|
limit | integer | Page size, 1–100. Defaults to 20. |
starting_after | string | An 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
| Group | Endpoints |
|---|---|
| Meta | Health probe, OpenAPI document (public). |
| Account | Retrieve the authenticated account; mint, list, and revoke API keys. |
| Events | Create, list, and update events, tiers, media, and organizations; on-sale waiting room (queue). |
| Tickets | Issue, list, transfer, refund, revoke, and upgrade tickets; hosted delivery links. |
| Customers | Create and retrieve customers. |
| Segments | Behavioral customer segments — repeat buyers, attendees, no-shows. |
| Comps | Complimentary tickets and per-event guest lists. |
| Ticket extras | Media vault, check-in stub, stamps, polls, and crafting. |
| Reserved seating | Seat maps, atomic seat holds, seat-bound issuance. |
| Similar events | Closest matches from your own catalog, scored. |
| Payments | Create and retrieve payment intents. |
| Marketplace | List and retrieve resale listings. |
| Reports | Per-tier sales and attendance reports, with CSV download. |
| Scanning | Validate tickets and record check-in. |
| Discounts | Manage discount codes. |
| Webhooks | Manage endpoints, ping them, and inspect deliveries. |
| Payouts | Connect 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
curl https://api.ticketconnect.example/v1/health
Example response
{
"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
curl https://api.ticketconnect.example/v1/openapi.json
Example response
{
"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
curl https://api.ticketconnect.example/v1/account \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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"
}
| Field | Type | Description |
|---|---|---|
id | string | Your account id. |
name | string | Account display name. |
mode | string | live or test — which environment this key operates in. |
status | string | Account status, e.g. active. |
branding | object | Branding configuration for hosted pages — logoUrl, supportEmail, primaryColor, emailDomain. |
currency | string | Your 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
curl https://api.ticketconnect.example/v1/account/keys \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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
| Field | Type | Required | Description |
|---|---|---|---|
label | string | No | A display label, up to 80 characters. |
mode | string | No | live or test. Defaults to the calling key's mode. A test key cannot mint a live key. |
scopes | string[] | No | Scopes for the new key — must be a subset of the calling key's. Defaults to the caller's scopes. |
kind | string | No | secret (default) or publishable. |
Example request
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
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
prefix | string | Yes | The key prefix, as shown in GET /v1/account/keys. |
Example request
curl https://api.ticketconnect.example/v1/account/keys/sk_test_9f8e7d \
-H "Authorization: Bearer sk_test_your_key_here" \
-X DELETE
Example response
{ "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:
{
"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
issuedfield on a tier is the number of tickets already issued from that tier;totalSupply - issuedis 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:
| Field | Type | Description |
|---|---|---|
base_price | number | The tier's base price before scheduled steps. |
price_schedule | array | Every published step: { starts_at, price, label?, status } with status one of past, active, upcoming. |
next_price_change | object | null | The next step, { at, price, label? }, or null when no further change is published. |
Every tier carries a release object describing its auto-release state:
{ "released": true } for tiers on sale, or { "released": false } plus
release_at (unlock time) and/or awaiting_sold_out_of (the sibling tier that
must sell out first). Locked tiers are visible but cannot be issued from.
queue is non-null only when the event uses an on-sale waiting room (see
Waiting room (queue)): { "enabled": true, "sales_start_at": "2026-08-01T10:00:00.000Z" }.
live_codes says whether tickets held in a holder's ticket wallet (any
holder experience built on the TicketConnect rails — Gettix, our flagship
licensee, is the reference one)
show a live, refreshing entry code for this event (an anti-screenshot
measure — see Scanning & check-in). It is true
by default, settable at creation and togglable with
PATCH /v1/events/{id}. It never affects the static
qr.data you deliver yourself. It also gates phone passes: a wallet-held
ticket can be saved to Apple/Google Wallet only when live codes are off
(a phone pass is a static barcode, so the platform refuses one where a live
code is enforced).
imageUrl (card/list image) and bannerUrl (wide hero) are the event's two
media slots — set them at creation via images or later with
PATCH /v1/events/{id}. Events with a published seat map sell
seated tiers by the seat (see Reserved seating), and every
event can list its closest catalog neighbours via
GET /v1/events/{id}/similar.
GET /v1/events
List your events, newest first.
Scope: events:read
Query params
| Param | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–100 (default 20). |
starting_after | string | No | Cursor — an event id to page after. |
Example request
curl "https://api.ticketconnect.example/v1/events?limit=2" \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Event name. |
date | string | number | Yes | Event start, any value the date parser accepts (ISO 8601 recommended). |
venue | string | No | Venue name. |
location | string | No | Free-form location/address. |
city | string | No | City. |
country | string | No | Country code. |
category | string | No | Category, e.g. music. |
description | string | No | Event description. |
currency | string | No | Currency for this event; defaults to your account currency. |
end_date | string | No | When the event ends (ISO 8601), for an event that runs longer than one day. Must not be before date. Scanning stays open until the end date plus 24 hours. |
admission_days | string[] | No | Daily admission: the ISO 8601 instants at which each admission day starts, ascending (1–31). With it a ticket admits once per day instead of once for the whole event. |
max_tickets_per_identity | integer | No | Per-person purchase cap for this event, 1–50. The platform default applies when unset. |
images | object | No | Event 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_settings | object | No | Per-event resale / anti-scalping policy (see below). Platform defaults apply when unset. |
live_codes | boolean | No | Whether wallet-held tickets show a live, refreshing entry code (default true). Your own qr.data delivery is unaffected. |
ticket_extras | object | No | Optional ticket extras — stub, stamps and crafting. Omit it and the event has none. |
Accepts an Idempotency-Key header.
The marketplace_settings object (all fields optional; supplied keys override the platform defaults):
| Field | Type | Description |
|---|---|---|
resale_enabled | boolean | Master switch for the secondary market. Default true. When false, tickets for this event can't be relisted at all. |
anti_scalping_enabled | boolean | Enforce the resale price floor/ceiling. Default true. When false, resale price is uncapped. |
max_resale_price_percent | integer | Resale ceiling as % of face value, 100–1000. Default 150. |
min_resale_price_percent | integer | Resale floor as % of face value, 0–100. Default 0. |
resale_start_mode | string | When resale opens: immediate, hours_before_event, or days_before_event. Default immediate. |
resale_start_value | integer | Hours 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
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
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Event id. |
Example request
curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2 \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
"name": "Summer Festival 2026",
"status": "draft",
"date": "2026-08-15T18:00:00.000Z",
"ticketPools": [
{ "name": "General Admission", "price": 49.99, "totalSupply": 5000, "issued": 12 }
],
"created_at": "2026-06-05T12:00:00.000Z"
}
PATCH /v1/events/{id}
Update an event's media, resale policy, live-code setting and/or
ticket extras, end date and/or admission days. Accepts images,
marketplace_settings, live_codes, ticket_extras, end_date and/or
admission_days — at least one is required. Partial: only the keys you send change. Pass null to unset a media
slot; omitted keys are untouched. Naturally idempotent (no Idempotency-Key
needed). Emits an event.updated webhook.
Scope: events:write
Path params
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Event id. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
images | object | Conditional | { "image_url": string | null, "banner_url": string | null }. URLs must be https://, at most 2048 characters. null unsets the slot. |
marketplace_settings | object | Conditional | Same fields as on create — send only the keys you want to change. |
live_codes | boolean | Conditional | Toggle live, refreshing entry codes for wallet-held tickets. Turning it off and back on is safe: codes already delivered to holders' wallets keep working. Turning it on for an event whose holders already opened their ticket in a wallet requires them to reopen it online once before the gate. |
end_date | string | null | Conditional | The event's end (ISO 8601); null removes it. |
admission_days | string[] | null | Conditional | Replaces the list of admission-day starts; null or [] switches daily admission off. Changing it while the event is running moves the day boundaries for scans that follow. |
ticket_extras | object | null | Conditional | Ticket extras. Each of stub, stamps and crafting is replaced when sent, kept when omitted and removed when null; null for the whole object removes all three. |
Example request
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
{
"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 ofimages,marketplace_settings,live_codes,ticket_extras),400 parameter_invalid(bad media URL, out-of-range resale bound, non-booleanlive_codes, or an invalidticket_extrasfield),400 parameter_unknown(images.gallery_urlsormarketplace_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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Event id. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Tier name, e.g. VIP. Used as the tier identifier when issuing. |
price | number | Yes | Tier price in the event currency. Must be >= 0. |
totalSupply | integer | Yes | Number of tickets available. Must be a positive integer. |
perks | object | No | Free-form perks map, e.g. { "lounge": true }. |
upgradesEnabled | boolean | No | Whether tickets may be upgraded into this tier. Defaults to false. |
price_steps | array | No | Published 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_at | string | No | Keep the tier locked (visible but not purchasable) until this time. |
release_after_sold_out | string | No | Keep the tier locked until the named sibling tier sells out. May be combined with release_at. |
valid_days | integer[] | No | On an event with admission_days: the day indexes (starting at 0) this tier is valid on, e.g. [0] for a first-day ticket. Omitted, the tier is a pass valid every day. An index outside the event's days returns 400 parameter_invalid. |
Accepts an Idempotency-Key header.
Errors:
404 resource_missingif the event does not exist or is not yours;400 parameter_invalidif the schedule or release configuration is invalid (e.g. arelease_after_sold_outcycle or an unknown sibling tier).
Example request
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
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–100 (default 20). |
starting_after | string | No | Cursor — an organization id to page after. |
Example request
curl https://api.ticketconnect.example/v1/organizations \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Organization id. |
Example request
curl https://api.ticketconnect.example/v1/organizations/665f1e2d3c4b5a6d7e8f9a0b \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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:
| Phase | Meaning |
|---|---|
pre_sale | Before sales_start_at. Customers may pre-join; the pre-sale block is shuffled into a fair random order when the sale starts. |
queue_active | The sale is open and the queue is draining. Customers are admitted in batches, each with a short access window. |
closed | The 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 thepre_saleandqueue_activephases. Astatusofadmittedwith a non-null, futureaccess_expires_atis 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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Event id. |
Query params
| Param | Type | Required | Description |
|---|---|---|---|
customer_id | string | No | A customer's externalId; include it to get that customer's queue entry. |
Example request
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
{
"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" }
]
}
}
| Field | Type | Description |
|---|---|---|
queue_enabled | boolean | Whether the event currently runs a waiting room. When false, phase is closed and the entry fields are null. |
phase | string | pre_sale, queue_active, or closed. |
sales_start_at | string | null | When the sale opens and the queue starts draining. |
waiting_count | integer | Number of customers currently waiting. |
position | integer | null | The customer's queue position (only with customer_id). |
people_ahead | integer | null | Waiting customers ahead of this one. |
status | string | null | waiting, admitted, or completed. |
access_expires_at | string | null | End of the customer's admission window; non-null only once admitted. |
pricing | object | min_price, max_price, and per-tier { name, price, available, locked, release_at?, awaiting_sold_out_of?, next_price_change? }. |
Errors:
404 resource_missingif the event, or the suppliedcustomer_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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Event id. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
customer_id | string | Yes | The customer's externalId. |
Example request
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
{
"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_enabledif the event does not use a waiting room;400 queue_join_failedif the room rejected the join;404 resource_missingif 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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Event id. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
customer_id | string | Yes | The customer's externalId. |
Example request
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
{
"event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
"customer_id": "cus_ext_42",
"left": true
}
Errors:
404 not_in_queueif the customer has no active entry;404 resource_missingif 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:
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Event id. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
customer_id | string | Yes | The customer's externalId (see Customers). |
tier | string | Yes | Tier name to issue from, matching a tier's name. |
payment_intent_id | string | Conditional | Required for paid tiers; the id from POST /v1/payment_intents. Omit for free tiers. |
hold_id | string | No | Reserved seating: a live seat hold from POST /v1/events/{id}/seats/hold. Issues one ticket per held seat and switches the response to { "tickets": [...] }. |
quantity | integer | No | Optional safety check alongside hold_id: must equal the number of held seats. Any value other than 1 requires hold_id. |
attribution | object | No | Ad attribution for this sale — see Attribution object below. |
Accepts an Idempotency-Key header.
Example request
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
{
"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 ahold_id),409 hold_not_found,409 hold_expired(re-hold and retry),409 seat_tier_mismatch(a held seat belongs to another tier), and400 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:
| Field | Type | Description |
|---|---|---|
utm_source, utm_medium, utm_campaign, utm_term, utm_content | string | Standard UTM parameters, stored with the ticket for the Sales sources report. |
fbclid, gclid | string | Ad-platform click IDs, kept only with marketing consent. |
referrer | string | The buyer's referrer, if you have it. |
fbp, fbc | string | Meta's _fbp/_fbc browser cookie ids, for CAPI match quality. Only kept when marketingConsent is true. |
eventId | string | A 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. |
sourceUrl | string | HTTP(S) page where the purchase happened. Consent-gated; credentials, query parameters and fragments are removed. Sent as CAPI event_source_url. |
userAgent | string | Buyer browser user agent, consent-gated. Use this when your server forwards issuance requests on behalf of the buyer. |
marketingConsent | boolean | Your declaration that this buyer consented to marketing tracking on your pages. Only when true do we forward fbp/fbc/eventId and fire a Meta Conversions API Purchase to your tenant pixel (configured on the Ad tracking panel card); UTM/referrer fields are stored either way; advertising click IDs require consent. Omit or false to attribute the sale without any pixel dispatch. |
Test-mode purchases never dispatch CAPI. The CAPI dispatch is fire-and-forget and best-effort: a failure here never fails the ticket issuance. See the Ad tracking guide.
GET /v1/tickets
List issued tickets, newest first.
Scope: tickets:read
Query params
| Param | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–100 (default 20). |
starting_after | string | No | Cursor — a ticket id to page after. |
event_id | string | No | Filter to a single event. |
Example request
curl "https://api.ticketconnect.example/v1/tickets?event_id=evt_a1b2c3d4e5f6a7b8c9d0e1f2" \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Ticket id. |
Example request
curl https://api.ticketconnect.example/v1/tickets/tkt_4d5e6f7a8b9c0d1e2f3a4b5c \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Ticket id. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
expires_in_days | integer | No | Link lifetime, 1–90 days (default 30). |
Example request
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
{
"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_daysout 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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Ticket id. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
to_customer_id | string | Yes | The recipient customer's externalId. |
Accepts an Idempotency-Key header.
Example request
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
{
"id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c",
"event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
"status": "valid",
"tier": "VIP"
}
Errors:
409 ticket_not_transferableif the ticket isrefundedorused, or once the holder has opened it in their ticket wallet (it is then locked to them);409 ticket_revokedif the ticket has been revoked;404 customer_not_foundif the recipient is missing or not provisioned. Emits aticket.transferredwebhook.
POST /v1/tickets/{id}/refund
Refund the original purchase and paid upgrade allocations in their original native currency, provider, and mode. The server reserves ledger reversals and revokes entry before provider effects. Free and comp tickets skip provider effects.
Only confirmed refunds complete the ticket. Pending Stripe and PayPal receipts are durably retained and recovered by GET using their existing IDs. Failed, canceled, mismatched, unsupported, or unproven outcomes remain held for review. A refund does not use the current tier price as a substitute for payment history.
Scope: tickets:manage
Path params
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Ticket id. |
This endpoint takes no request body. Accepts an Idempotency-Key header.
Example request
curl https://api.ticketconnect.example/v1/tickets/tkt_4d5e6f7a8b9c0d1e2f3a4b5c/refund \
-H "Authorization: Bearer sk_test_your_key_here" \
-H "Idempotency-Key: refund-001" \
-X POST
Example response
{
"id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c",
"event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
"status": "refunded",
"tier": "VIP"
}
A completed replay with the original idempotency key returns its original response; a new refund request for a completed ticket can return
409 already_refunded. Pending outcomes return202and retain the original reservation. Review-required outcomes must be reconciled; do not submit a new payment/refund operation under another key. Provider configuration and unsupported rails can return provider errors.ticket.refundedis emitted only after all required money legs complete.
POST /v1/tickets/{id}/revoke
Invalidate a ticket without refunding it — fraud, a chargeback, a comp
clawback. Revocation sets revoked: true and revoked_at on the ticket; the
lifecycle status is intentionally unchanged (revocation is an independent
axis). From that moment the ticket fails every scan and can no longer be
transferred.
Revoking is idempotent: revoking an already-revoked ticket returns the
same terminal state with 200 and emits no second webhook. Revoke does
not refund — call POST /v1/tickets/{id}/refund as well
when you want both.
Scope: tickets:manage
Path params
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Ticket id. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
reason | string | No | Free-text reason, up to 500 characters. Kept for your audit trail. |
Accepts an Idempotency-Key header.
Example request
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
{
"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_invalidifreasonis empty or longer than 500 characters. Emits aticket.revokedwebhook.
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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Ticket id. |
Example request
curl https://api.ticketconnect.example/v1/tickets/tkt_4d5e6f7a8b9c0d1e2f3a4b5c/upgrade-options \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Ticket id. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
target_tier | string | Yes | The tier name to upgrade into. |
payment_intent_id | string | Conditional | Required when the upgrade has a positive price difference. |
Accepts an Idempotency-Key header.
Example request
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
{
"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 aticket.upgradedwebhook.
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:
{
"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
| Field | Type | Required | Description |
|---|---|---|---|
externalId | string | Yes | Your stable identifier for the customer. Becomes the customer id. |
email | string | Yes | Customer email; tickets are delivered here. |
name | string | No | Customer display name. |
Accepts an Idempotency-Key header.
Example request
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
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The customer's externalId. |
Example request
curl https://api.ticketconnect.example/v1/customers/cus_ext_42 \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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:
{
"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
| Mode | How grants are handed out |
|---|---|
tier | Auto-granted to a holder the moment they're issued a ticket in the named tier (including comps). Nothing to trigger — define it once. |
manual | Issued 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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Display name of the perk. |
description | string | No | Longer description shown to staff/holder. |
distribution | string | Yes | tier or manual. |
redeemable | boolean | No | Whether the perk is redeemed at the door (default true). A non-redeemable perk is informational only and issues no grants. |
max_uses | integer | null | No | Redemptions per grant (positive integer, or null for unlimited). Default 1. |
tier | string | Conditional | Required when distribution is tier — the ticket tier that earns the perk. |
section | string | No | Optional section qualifier. |
scan_station | string | No | Hint for your scanner UI (e.g. bar, backstage). |
recipients | object | Conditional | Required 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
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(noname/recipients),400 parameter_invalid(baddistribution/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).
{
"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
| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | The grant code from the customer. |
Example response
{
"valid": true,
"perk": { "name": "Welcome drink", "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2" },
"uses_remaining": 0
}
A verdict is always returned with 200. valid is false with a reason of
already_redeemed, revoked, expired, or not_found — an unknown code, or a
code belonging to another account, is reported as not_found and never
distinguished.
Ticket extras
Ticket extras are the optional things a ticket does beyond entry. Each one is off until you turn it on, and an event with none of them behaves exactly as before.
| Extra | What it does |
|---|---|
| Vault | Media a ticket unlocks — photos, video, audio, artwork, files. An item with a future unlock_at is a time capsule. |
| Stub | After check-in the ticket becomes a keepsake stub with its door number, arrival time, artwork and an audio loop. |
| Stamps | Stations a checked-in holder collects (bar, second stage, a sponsor booth). |
| Polls | Votes only checked-in tickets can cast — one per ticket. |
| Crafting | A customer trades checked-in tickets from your past events for a reward perk. |
"Checked in" always means the ticket was admitted through
scanning. A holder is a ticket_id or a customer_id — there
are no wallets or on-chain concepts in these responses.
Configuration
The stub, stamp stations and crafting rule live on the event. Send
ticket_extras on POST /v1/events or
PATCH /v1/events/{id}; every key is optional.
{
"ticket_extras": {
"stub": {
"enabled": true,
"image_url": "https://acme.example/img/stub.jpg",
"audio_url": "https://acme.example/audio/loop.mp3"
},
"stamps": [
{ "name": "Bar", "sponsor": "Acme Brewing" },
{ "name": "Stage 2" }
],
"crafting": {
"stubs_required": 5,
"reward_name": "Season pass",
"reward_unlimited": true
}
}
}
| Field | Type | Description |
|---|---|---|
stub.enabled | boolean | Turns the stub on. |
stub.image_url | string | Stub artwork, shown after check-in. https://, at most 2048 characters. Before check-in the event image is shown. |
stub.audio_url | string | Audio loop, returned only after check-in. Same URL rules. |
stamps | array | Up to 20 stations, each { "name", "sponsor"? } (60 characters each). The response adds an id per station. |
crafting.stubs_required | integer | How many checked-in tickets the reward costs, 1–50. |
crafting.reward_name | string | Name of the reward perk, up to 80 characters. |
crafting.reward_description | string | Optional, up to 300 characters. |
crafting.reward_unlimited | boolean | true makes the reward redeemable any number of times (a pass). Default: single use. |
On PATCH, each of stub, stamps and crafting is replaced when you
send it, kept when you omit it and removed when you send null. To
keep a station across an edit, re-send it with its id — a station sent
without an id is a new station, and stamps collected at the old one no longer
count.
The event object echoes the configuration back as ticket_extras. The key is
absent on events that have none.
Errors:
400 parameter_invalidwithparamnaming the field, e.g.ticket_extras.stub.image_urlorticket_extras.crafting.stubs_required.
Vault
POST /v1/events/{id}/vault
Add a vault item. Scope: events:write.
| Field | Type | Required | Description |
|---|---|---|---|
kind | string | Yes | photo, video, audio, artwork or file. |
title | string | Yes | Up to 120 characters. |
url | string | Yes | Where the media lives. https://, at most 2048 characters. |
tier | string | No | Only tickets in this tier can open the item. |
attended_only | boolean | No | Only checked-in tickets can open it. |
requires_all_stamps | boolean | No | Only tickets that collected every stamp can open it. |
unlock_at | string | No | ISO 8601 time. The item stays locked until then. |
Gates combine: an item with tier and attended_only needs both.
curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/vault \
-H "Authorization: Bearer sk_test_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"kind": "photo",
"title": "Night one gallery",
"url": "https://acme.example/galleries/night-one",
"attended_only": true
}'
{
"id": "vi_5b0c7a52-0c1e-4f0a-9a57-2f3c1d9e8b11",
"kind": "photo",
"title": "Night one gallery",
"url": "https://acme.example/galleries/night-one",
"tier": null,
"attended_only": true,
"requires_all_stamps": false,
"unlock_at": null,
"created_at": "2026-10-04T12:00:00.000Z"
}
This API takes a link, not a file: host the media yourself and pass its url.
To have TicketConnect store the file instead, upload it in the organizer panel
(Add vault item on the event page) — the item then appears here like any
other.
The
urlis handed to a holder as-is once their ticket unlocks the item, so anyone they share it with can open it too. Host private media behind your own expiring links if that matters.
GET /v1/events/{id}/vault
List the event's vault items, oldest first. Scope: events:read.
DELETE /v1/events/{id}/vault/{item_id}
Remove an item. Scope: events:write. Returns { "id": "...", "deleted": true }.
GET /v1/tickets/{id}/extras
What the holder of this ticket sees. Scope: tickets:read.
{
"object": "ticket_extras",
"ticket_id": "tkt_7d1e2f3a4b5c",
"stub": {
"state": "stub",
"image_url": "https://acme.example/img/stub.jpg",
"audio_url": "https://acme.example/audio/loop.mp3",
"door_number": 17,
"scanned_at": "2026-10-04T21:41:07.000Z"
},
"stamps": [
{ "id": "st_4c1f9a2b7d3e", "name": "Bar", "sponsor": "Acme Brewing", "collected_at": "2026-10-04T22:05:31.000Z" },
{ "id": "st_8e2a6b1c9f04", "name": "Stage 2", "sponsor": null, "collected_at": null }
],
"stamps_complete": false,
"vault": [
{ "id": "vi_5b0c…", "kind": "photo", "title": "Night one gallery", "locked": false, "lock_reason": null, "unlock_at": null, "url": "https://acme.example/galleries/night-one", "download_url": "https://acme.example/galleries/night-one" },
{ "id": "vi_9d4e…", "kind": "audio", "title": "Bonus track", "locked": true, "lock_reason": "stamps_incomplete", "unlock_at": null, "url": null, "download_url": null }
],
"polls": []
}
| Field | Notes |
|---|---|
stub | null when the event has no stub. state is ticket before check-in and stub after. door_number is the ticket's arrival order by scan time; it and scanned_at and audio_url are null before check-in. |
stamps | Every station of the event, with collected_at set once this ticket collected it. |
vault[].lock_reason | tier, not_attended, stamps_incomplete or not_yet (a time capsule — see unlock_at). A locked item never carries a url. |
vault[].download_url | A link that saves the file instead of opening it. Differs from url only for images and videos uploaded through the organizer panel; for your own links it is the same address. null while locked. |
polls | The event's polls with live results and this ticket's my_vote (an option index, or null). |
Errors:
404 resource_missing(not your ticket),409 ticket_not_valid(the ticket was revoked or refunded).
POST /v1/tickets/{id}/stamps
Collect a stamp for a ticket. Scope: scanning:write.
| Field | Type | Required | Description |
|---|---|---|---|
station_id | string | Yes | A station id from the event's ticket_extras.stamps. |
{ "collected": true, "already_collected": false, "stamps_complete": true }
Collecting the same stamp twice is a no-op (already_collected: true).
Errors:
404 unknown_station,409 not_attended(the ticket is not checked in yet),409 ticket_not_valid.
Polls
POST /v1/events/{id}/polls
Open a poll. Scope: events:write.
| Field | Type | Required | Description |
|---|---|---|---|
question | string | Yes | Up to 200 characters. |
options | string[] | Yes | 2–6 options, up to 60 characters each. |
{
"id": "poll_1c2d3e4f-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
"question": "Encore?",
"options": [
{ "label": "Track A", "votes": 12 },
{ "label": "Track B", "votes": 30 }
],
"total_votes": 42,
"by_section": [
{ "section": "VIP", "votes": [2, 9] },
{ "section": "GA", "votes": [10, 21] }
],
"closed": false,
"created_at": "2026-10-04T22:10:00.000Z"
}
by_section splits the same counts by ticket tier, in option order — use it to
drive per-section effects.
GET /v1/events/{id}/polls
List the event's polls with live results, newest first. Scope: events:read.
POST /v1/events/{id}/polls/{poll_id}/close
Stop accepting votes and return the poll with its final results.
Scope: events:write. Closing a closed poll is a no-op.
POST /v1/events/{id}/polls/{poll_id}/votes
Cast a ticket's vote. Scope: tickets:manage.
| Field | Type | Required | Description |
|---|---|---|---|
ticket_id | string | Yes | A ticket for this event. |
option | integer | Yes | Index into the poll's options, starting at 0. |
Returns { "voted": true }.
Errors:
400 invalid_option,404 resource_missing(not your ticket),404 poll_not_found(unknown poll, or the ticket belongs to another event),409 not_attended,409 already_voted,409 poll_closed.
Crafting
A customer's stubs are their checked-in tickets across all of your events
that have not been spent yet. Crafting spends stubs_required of them — oldest
first — for one grant of the event's reward. The reward is an ordinary
perk grant: it appears in GET /v1/customers/{id}/perks and is
redeemed with POST /v1/perks/redeem.
GET /v1/events/{id}/crafting
A customer's progress. Scope: tickets:read. Query: customer_id (required).
{
"stubs_required": 5,
"stubs_available": 3,
"reward_name": "Season pass",
"reward_description": null,
"crafted": false
}
POST /v1/events/{id}/craft
Spend the stubs and issue the reward. Scope: tickets:manage.
| Field | Type | Required | Description |
|---|---|---|---|
customer_id | string | Yes | The customer crafting the reward. |
{ "crafted": true, "perk_code": "grant_3f7a9c0d-1e2f-4a5b-8c6d-7e8f9a0b1c2d" }
One reward per customer per event. A spent ticket stays a valid stub in every other respect — it only stops counting toward crafting.
Errors:
409 not_enabled(the event has no crafting rule),409 already_crafted,409 not_enough_stubs(the body also carriesstubs_requiredandstubs_available),409 conflict(the customer's tickets changed mid-request — retry),404 resource_missing(not your event or customer).
Payments
A payment intent represents the configured provider's collection for a ticket issuance or upgrade. The amount is always computed server-side from the event tier — the client cannot supply or override it. After the customer completes the payment on the client, pass the payment intent id back to the issue/upgrade endpoint. See the Take a payment guide.
A serialized payment intent object:
{
"object": "payment_intent",
"id": "pi_3Q1abc...",
"provider": "stripe",
"client_payload": { "client_secret": "pi_3Q1abc..._secret_..." },
"amount": 149.00,
"currency": "USD",
"status": "requires_payment"
}
status is a provider-normalized value you can act on. With a card provider it
is one of requires_payment (awaiting the customer's payment or action),
processing, succeeded, failed, or canceled. Providers that collect money
out-of-band report requires_external until you
confirm the payment.
POST /v1/payment_intents
Create a payment intent with your mode-specific configured provider for an event tier. Use client_payload
to complete the charge in your client (for example, Stripe's client_secret).
Scope: payments:write
Request body
| Field | Type | Required | Description |
|---|---|---|---|
event_id | string | Yes | The event to charge for. |
tier | string | Yes | The tier name; the price is read from this tier. |
quantity | integer | No | Tickets/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_id | string | No | The customer's externalId, stored on the intent for reference. |
currency | string | No | Must match the server's event/account currency; it cannot override that currency. |
Send an Idempotency-Key for every creation. The amount must use whole native minor units, including zero-decimal currencies such as JPY. Unsupported provider/currency combinations are rejected.
Example request
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
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Payment intent id. |
Example request
curl https://api.ticketconnect.example/v1/payments/pi_3Q1abc... \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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_missingso 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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Payment intent id (extpay_...). |
Example request
curl https://api.ticketconnect.example/v1/payment_intents/extpay_9f8e7d6c5b4a3210fedcba98/confirm \
-H "Authorization: Bearer sk_test_your_key_here" \
-X POST
Example response
{
"object": "payment_intent",
"id": "extpay_9f8e7d6c5b4a3210fedcba98",
"status": "succeeded",
"provider": "external"
}
Errors:
400 confirm_not_supported(your provider confirms on the client),400 payment_not_found(no such payment under your account).Confirmation records your assertion that money was collected elsewhere. It does not prove collection in the platform's Stripe treasury and does not fund
/v1/balance. Refunds stay on the original provider rail; unsupported refunds require review.
Hosted checkout
The hosted checkout lets you sell tickets with no frontend of your own: hand
a buyer a single URL and the platform serves a branded purchase page. All hosted
checkout endpoints authenticate with a publishable key (pk_...) — a
browser-safe key restricted to this surface; secret (sk_) keys are rejected
here, and publishable keys are rejected everywhere else. See
Authentication.
The purchase flow is: load the page (or fetch /info from your own embed) →
POST .../intent to open the payment → complete the charge with
client_payload → POST .../complete to issue the ticket.
GET /v1/checkout/{event_id}
The public checkout page (HTML). Give this URL to a buyer directly; everything else in the flow is handled by the page itself.
Auth: publishable key, passed as the key query parameter.
Query params
| Param | Type | Required | Description |
|---|---|---|---|
key | string | Yes | Your 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
401HTML error page; an unknown event a404HTML 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
curl https://api.ticketconnect.example/v1/checkout/evt_a1b2c3d4e5f6a7b8c9d0e1f2/info \
-H "x-api-key: pk_test_your_key_here"
Example response
{ "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
| Field | Type | Required | Description |
|---|---|---|---|
tier | string | Yes | The tier to buy; the price is read from this tier. |
email | string | Yes | The buyer's email — the ticket is issued to this identity. |
Example request
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
{
"object": "payment_intent",
"id": "pi_3Q1abc...",
"provider": "stripe",
"client_payload": { "client_secret": "pi_3Q1abc..._secret_..." },
"amount": 149.00,
"currency": "USD",
"status": "requires_payment"
}
Errors:
404 event_not_found,400 tier_not_found,409 sold_out,403 tier_not_released(the tier is visible but not yet on sale),403 purchase_limit_reached(the buyer is at the event's per-person purchase cap),502 payment_provider_error.
POST /v1/checkout/{event_id}/complete
Verify the payment and issue the ticket. The payment is re-verified server-side
against the authoritative amount, event, and account; free tiers (price 0)
skip payment verification. The payment's allocation is committed atomically with stock; the combined native allocations across issuance and upgrades cannot exceed the original confirmed collection. A retry recovers the original ticket instead of consuming that budget twice.
Auth: publishable key (x-api-key header).
Request body
| Field | Type | Required | Description |
|---|---|---|---|
intent_id | string | Yes | The payment intent id from /intent. |
tier | string | Yes | The tier that was paid for. |
email | string | Yes | The buyer's email (same as on /intent). |
attribution | object | No | Ad attribution for this sale — see Attribution object below. |
Example request
curl https://api.ticketconnect.example/v1/checkout/evt_a1b2c3d4e5f6a7b8c9d0e1f2/complete \
-H "x-api-key: pk_test_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "intent_id": "pi_3Q1abc...", "tier": "VIP", "email": "buyer@example.com" }'
Example response
Returns 201 with the issued ticket and authoritative purchase details. Use purchase.value, purchase.currency and purchase.event_id for your browser Purchase event:
{
"ticket": {
"id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c",
"event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
"event_name": "Summer Fest",
"status": "valid",
"tier": "VIP",
"price": 149.00,
"currency": "USD",
"qr": { "data": "a1b2c3...opaque", "format": "QR" },
"created_at": "2026-06-05T12:30:00.000Z"
},
"purchase": { "value": 149.00, "currency": "USD", "event_id": "your-browser-event-id" }
}
Errors:
402with 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 aticket.issuedwebhook on success.
Attribution object
Optional on /complete — same shape as the ticket issuance endpoint's; see
Attribution object for the full field-by-field reference.
In short: pass UTM/click-id fields plus marketingConsent: true and your own
fbp/fbc/eventId (read from _fbp/_fbc and your own pixel call) to get
a server-side Meta Conversions API Purchase fired to your tenant pixel,
deduplicated against your own browser pixel event by that eventId. Include sourceUrl and userAgent for the buyer's page context. Test-mode purchases never dispatch CAPI. Every
field is optional and malformed input is silently dropped, never rejected.
Omit attribution (or leave marketingConsent false/absent) and the sale is
still recorded with whatever UTM/referrer fields you send, with no pixel
dispatch.
If you hand buyers the TicketConnect-hosted checkout page instead of calling
/complete yourself, none of this applies to you — the hosted page captures
attribution and dispatches CAPI on its own. See the
Ad tracking guide.
Marketplace
The marketplace holds secondary-market resale listings. Prices are in
fiat, and each event's resale royalty and anti-scalping bounds are applied
automatically per its marketplace_settings (see Events). See
the Marketplace and resale guide.
A listing's sale or ownership state does not prove that every financial leg settled. The platform freezes the original source, mode, currency conversion, and required seller/royalty/fee splits before effects. Ambiguous settlement remains pending and retries the same signed transaction bytes or original provider operation. Unsupported legacy provenance and fiat-resale buyer refunds without a proven seller clawback require review.
A serialized listing object:
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–100 (default 20). |
starting_after | string | No | Cursor — a listing id to page after. |
Example request
curl "https://api.ticketconnect.example/v1/marketplace/listings?limit=2" \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Listing id. |
Example request
curl https://api.ticketconnect.example/v1/marketplace/listings/list_9f8e7d6c5b4a43210fedcba9876543aa \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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_issuedcounts all tickets created in the window — including ones later refunded or revoked.tickets_refundedcounts tickets whose refund completed (the money moved);tickets_revokedcounts tickets with a revocation — the two are independent, so one ticket can appear in both.grossis 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
| Param | Type | Required | Description |
|---|---|---|---|
event_id | string | No | Narrow the report to one event; omit for the whole account. |
from | string | No | Window start, ISO 8601 (date-only means midnight UTC). Default: 30 days before to. |
to | string | No | Window end, ISO 8601, inclusive. Default: now. Max window: 366 days. |
format | string | No | json (default) or csv. |
Date-only
toexcludes that day's activity after 00:00 UTC. The match is inclusive on both ends, but"to": "2026-07-11"means2026-07-11T00:00:00Z— pass a full timestamp (or the next day) for end-of-day semantics.
Example request
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
{
"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(unparsablefrom/to,fromafterto, or a window over 366 days),400 invalid_format,404 not_found(unknown or foreignevent_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
| Param | Type | Required | Description |
|---|---|---|---|
event_id | string | Yes | The event to report on. |
format | string | No | json (default) or csv. |
Example request
curl "https://api.ticketconnect.example/v1/reports/attendance?event_id=evt_a1b2c3d4e5f6a7b8c9d0e1f2" \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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(noevent_id),400 invalid_format,404 not_found(unknown or foreignevent_id).
Scanning
Validate tickets at the door and record check-ins online. Validation reads the
QR string from a ticket: either the opaque qr.data you delivered, or the
live, refreshing code shown for a ticket held in a holder's ticket wallet
(verified cryptographically on the platform side). Attendance marks a ticket
as used. On these endpoints, your API key with the scanning:write scope
authorizes scanning directly. The offline door protocol is separate: devices
use a short-lived scanner session token minted by your backend. See the
Scanner protocol reference and the
Scanning and check-in and
Offline scanning guides.
A scan verdict looks like:
{ "valid": true, "status": "valid", "tier": "VIP", "ticket_id": "tkt_4d5e6f..." }
When a ticket is not admissible, valid is false and reason is one of
not_found, refunded, revoked, already_used — or, for live wallet
codes: code_expired (real but stale; have the holder refresh and re-scan),
live_code_required (a static copy of a wallet code where the live one is
required), or invalid_qr (failed cryptographic verification). Whenever the
ticket was identified, the verdict includes its ticket_id; an already_used
verdict also carries the tier so door staff can see what was scanned.
On an event with admission_days (see Multi-day events),
already_used means the ticket was already admitted on the current
admission day, and wrong_day means its tier is not valid today.
POST /v1/tickets/{id}/attendance then checks a ticket in once per day and
answers 409 wrong_day for a tier limited to other days.
POST /v1/scan/validate
Validate a single ticket for entry by its QR string. This does not mark the ticket used — call attendance to check in.
Scope: scanning:write
Request body
| Field | Type | Required | Description |
|---|---|---|---|
qr | string | Yes | The 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
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
{
"valid": true,
"status": "valid",
"tier": "VIP",
"ticket_id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c"
}
A failed verdict:
{ "valid": false, "reason": "already_used", "tier": "VIP", "ticket_id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c" }
POST /v1/scan/batch
Validate up to 200 tickets in a single call while online. Live codes must still be fresh when the request reaches the server. None are marked used. Returns a per-QR verdict array. This is not an offline queue: for scanning without a connection, see Offline scanning.
Scope: scanning:write
Request body
| Field | Type | Required | Description |
|---|---|---|---|
qrs | string[] | Yes | Array of QR strings, verbatim (max 200). Each entry may be either code shape. |
Example request
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
{
"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_missingifqrsis not an array;400 parameter_invalidif 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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Ticket id. |
This endpoint takes no required body. Accepts an Idempotency-Key header.
Example request
curl https://api.ticketconnect.example/v1/tickets/tkt_4d5e6f7a8b9c0d1e2f3a4b5c/attendance \
-H "Authorization: Bearer sk_test_your_key_here" \
-X POST
Example response
{
"id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c",
"status": "used"
}
A repeat call returns:
{
"id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c",
"status": "already_used"
}
Errors:
404 resource_missingfor an unknown ticket. Emits anattendance.verifiedwebhook on the first successful check-in.
Scanner protocol
The offline door protocol: arm a scanner device, feed it the event's tickets,
revocations and perk grants, and reconcile its device-signed scans. The
Offline scanning guide explains the trust model and the
@ticketconnect/offline-scanner SDK, which calls every device endpoint below
for you.
Two credentials, never interchangeable
| Endpoint | Called by | Auth |
|---|---|---|
POST /v1/scanner/sessions | Your backend | Secret key (sk_…), scope scanning:write |
GET /v1/staff/{id}/devices, POST /v1/staff/{id}/devices/{device_id}/revoke | Your backend | Secret key (sk_…), scope staff:manage |
Every other /v1/scanner/* endpoint | The scanner device | Scanner session token: Authorization: Bearer <token> from POST /v1/scanner/sessions |
- A secret key sent to a device route returns
401. A session token sent to a partner route, or to any route outside the/v1/scannerdevice endpoints, also returns401. - Device routes take no event id. The event comes from the session token, so a session for one event can never read or write another.
- A session carries only the door capabilities:
scan_ticketsandredeem_perks. On every request its effective permissions are the token's AND the staff member's current flags, so turning a flag off takes effect within about 60 seconds. - A device route returns
401withtype: "authentication_error",code: "unauthorized"and a message starting withInvalid scanner session:when the token is missing, malformed, expired or tampered with, when the staff member has been deactivated, suspended or unassigned from the event, when your account is no longer active, or when the API key that minted the session has been revoked. Mint a new session and retry.
Errors use the standard envelope { "error": { "type", "code", "message", "param" } }
(see Errors).
Signed strings
Pushed rows are Ed25519-signed by the device over these exact UTF-8 strings.
<eventId> is the session's event id. sig is the lowercase hex signature.
scan: v1|<eventId>|<ticket_id>|<scanned_at>|<scanner_staff_id>|<device_id>
perk: v1|perk|<eventId>|<grant_id>|<redeemed_at>|<scanner_staff_id>|<device_id>
cert: v3|device|<device_id>|<event_id>|<public_key lowercase>|<capabilities>|<issued_at_ms>|<expires_at_ms>
The platform signs the cert string when it issues a device certificate.
<capabilities> is the sorted, comma-joined list: perk, scan or
perk,scan. Certificates in the older v2 format are no longer issued or
accepted.
Reconciliation outcomes
Each row you push comes back with an outcome:
outcome | Meaning | reason |
|---|---|---|
accepted | The first valid claim on the ticket or grant. | — |
superseded | Another claim within 10 seconds was earlier, and that one wins. | lost-to-earlier-scan / lost-to-earlier-redemption |
suspect_duplicate | Another claim exists more than 10 seconds away. Both are flagged for review. | outside-gossip-window |
rejected | Not admissible. | Scans: staff_mismatch, device_unknown, device_revoked, staff_lacks_permission, bad_signature, event_ended, unknown_ticket, refunded, revoked. Perks: staff_mismatch, device_unknown, device_revoked, staff_lacks_permission, bad_signature, event_ended, unknown_grant, revoked, already_used. |
staff_mismatch: the row'sscanner_staff_idisn't the session's staff member, or itsdevice_idbelongs to someone else. Nothing is stored. The SDK only pushes the session staff member's own rows, so it treats this as a bug: the row stays queued on the device.- Revocation is not retroactive. A row from a device revoked at time T
(everywhere, or for this event) is still processed normally if it was
signed at or before T + 60 seconds; the server flags it for organizer
review. Rows signed later are
device_revoked. staff_lacks_permission: the session no longer hasscan_tickets(for scans) orredeem_perks(for perks). This is a per-row verdict in a normal200response, not an error for the whole request, and nothing is stored. Rows the device signed before the permission was removed (up to 60 seconds after the resulting device revocation, and only if the device was certified for that capability) are still accepted.unknown_ticket/unknown_grant: the id doesn't exist in the session's event. A ticket or grant from another event is reported the same way. Nothing is stored.- Replays are safe. Rows are unique per device, ticket or grant, and
signed timestamp. Each row is stored as
pending, its effect (ticket marked used, perk use consumed, staff credit) is applied, and only then is it final. Pushing an identical row again returns its storedoutcome,reasonandserver_record_id; if an earlier attempt was cut off while the row was still pending, the replay finishes it. A replay never marks a ticket twice, consumes another perk use or credits the staff member again.
POST /v1/scanner/sessions
Mint a scanner session for one of your staff members on one of your events.
Call this from your backend and pass the token to the device. Never pass the
sk_ key to a device.
Scope: scanning:write (secret key)
The token expires after 12 hours, or at the event's grace cutoff (event end +
24 hours) if that comes first. The minimum lifetime is 60 seconds. It carries
only the staff member's can_scan_tickets and can_redeem_perks flags;
selling, staff management and stats are always off in a scanner session.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
staff_id | string | Yes | A staff id from POST /v1/staff. |
event_id | string | Yes | An event you own. |
Example request
curl https://api.ticketconnect.example/v1/scanner/sessions \
-H "Authorization: Bearer sk_test_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "staff_id": "staff_1727_ab12", "event_id": "evt_42" }'
Example response
201 Created
{
"object": "scanner_session",
"token": "eyJhbGciOiJIUzI1NiJ9...",
"expires_at": "2026-09-30T23:10:00.000Z",
"event_id": "evt_42",
"staff_id": "staff_1727_ab12"
}
Errors, checked in this order:
400 parameter_missing(paramisstaff_idorevent_id);404 resource_missingfor an unknown or unowned staff member (param: staff_id), then event (param: event_id);403 staff_inactive;409 staff_not_assigned;403 staff_lacks_permission(the staff member has neithercan_scan_ticketsnorcan_redeem_perks);410 event_ended(more than 24 hours past the event end).
GET /v1/scanner/manifest
Everything a device needs to arm that isn't paged. It never contains the live-code master key.
Auth: scanner session token
Example request
curl https://api.ticketconnect.example/v1/scanner/manifest \
-H "Authorization: Bearer $SCANNER_SESSION_TOKEN"
Example response
{
"object": "scanner_manifest",
"event_id": "evt_42",
"event_public_key": "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29",
"platform_cert_public_key": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
"event_end_at": "2026-10-01T02:00:00.000Z",
"grace_cutoff_at": "2026-10-02T02:00:00.000Z",
"qr_rotation": { "v": 2, "enforced": true, "period_s": 30 },
"staff": {
"id": "staff_1727_ab12",
"first_name": "Gate",
"last_name": "Keeper",
"permissions": {
"scan_tickets": true,
"redeem_perks": false,
"sell_onsite": false,
"view_stats": false,
"manage_staff": false
}
}
}
| Field | Description |
|---|---|
event_public_key | Hex Ed25519 key that ticket and perk codes are signed with. null if the event has no key; the SDK refuses to arm without one. |
platform_cert_public_key | Hex Ed25519 key that device certificates are signed with. Pin it once. Can be null if the platform key isn't configured. |
event_end_at, grace_cutoff_at | Event end, and end + 24 hours. null for an undated event. |
qr_rotation | Live-code settings, or null when the event doesn't enforce live codes. |
staff.permissions | The session's effective permissions: token AND the staff member's current flags. In a scanner session, sell_onsite, view_stats and manage_staff are always false. The SDK refuses to scan (permission_denied) without scan_tickets, and to redeem without redeem_perks. |
Errors:
401for a bad session;404 resource_missingif the session's event no longer exists.
POST /v1/scanner/devices
Register the device's Ed25519 public key for the session's staff member and
receive a device certificate scoped to the session's event. The call is
idempotent: re-registering the same device_id with the same key returns
201 again with already_registered: true and a fresh certificate.
Registering records the event on the device (see event_ids in
GET /v1/staff/{id}/devices). A staff member can hold
at most 10 active devices per event. Access is checked fresh before and
after the device is written: if the staff member was deactivated, unassigned
or lost a capability in the meantime, the new device is revoked on the spot
and no certificate is returned. Certificates are not stored. They
expire at event end + 24 hours, capped at 7 days (24 hours for an undated
event), and list the gossip capabilities the session's effective
permissions allow.
Auth: scanner session token
Request body
| Field | Type | Required | Description |
|---|---|---|---|
device_id | string | Yes | 8–128 characters, [A-Za-z0-9_-]. |
public_key | string | Yes | Exactly 64 hex characters (a raw Ed25519 public key). Stored lowercase. |
platform | string | No | ios, android, or web. |
app_version | string | No | Up to 32 characters. |
Example request
curl https://api.ticketconnect.example/v1/scanner/devices \
-H "Authorization: Bearer $SCANNER_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"device_id": "dev_abc12345",
"public_key": "8f1e0c5b6a2d4e7f9a3b1c0d2e4f6a8b0c1d3e5f7a9b2c4d6e8f0a1b3c5d7e9f",
"platform": "ios",
"app_version": "1.4.0"
}'
Example response
201 Created
{
"object": "scanner_device",
"id": "dev_abc12345",
"device_cert": {
"device_id": "dev_abc12345",
"event_id": "evt_42",
"public_key": "8f1e0c5b6a2d4e7f9a3b1c0d2e4f6a8b0c1d3e5f7a9b2c4d6e8f0a1b3c5d7e9f",
"capabilities": ["scan"],
"issued_at_ms": 1790776717559,
"expires_at_ms": 1790863117559,
"sig": "<128 hex characters>"
},
"already_registered": false
}
device_cert is null if the platform certificate key isn't configured.
Errors:
400 parameter_invalid(paramisdevice_id,public_key,platformorapp_version);401 unauthorized(the staff member is no longer active or assigned: mint a new session);403 staff_lacks_permission(the session can neither scan nor redeem);409 device_conflict(thedevice_idis taken, or registered with a different key);403 device_revoked(the device is revoked, globally or for this event: revoked device ids never come back);409 device_limit_reached(revoke one of the staff member's devices first). Ondevice_conflictanddevice_revoked, generate a new device key anddevice_idand register again; the SDK does this once automatically.
GET /v1/scanner/devices/revoked
The distrust feed: devices whose scans and gossip must no longer be trusted. A revoked phone keeps a valid-looking certificate until it expires, so armed devices need this list. It covers every device ever registered for the session's event that is revoked globally or for this event, including those of staff who were since deactivated or unassigned, and no devices from other events.
Auth: scanner session token
Query params
| Param | Type | Required | Description |
|---|---|---|---|
since | string | No | ISO-8601 timestamp: the now from your previous call. Omit it for the full list. |
limit | integer | No | Default 1000, max 5000 (larger values are clamped). |
Example response
{
"object": "revocation_feed",
"now": "2026-09-30T14:00:37.521Z",
"has_more": false,
"device_ids": ["dev_x"]
}
When has_more is true, now is the last returned row's revocation time.
Call again with since=<now>. The boundary row may repeat.
Errors:
400 parameter_invalidfor a non-ISOsinceor alimitthat isn't a positive integer.
GET /v1/scanner/tickets
The event's valid (non-revoked) tickets, used to arm the gate. Ordered by
ticket_id.
Auth: scanner session token
Query params
| Param | Type | Required | Description |
|---|---|---|---|
cursor | string | No | The next_cursor from the previous page. |
limit | integer | No | Default 1000, max 5000 (larger values are clamped). |
Example request
curl "https://api.ticketconnect.example/v1/scanner/tickets?limit=1000" \
-H "Authorization: Bearer $SCANNER_SESSION_TOKEN"
Example response
{
"object": "list",
"data": [
{
"ticket_id": "tkt_1",
"is_used": false,
"used_at": null,
"used_by": null,
"seat_number": null,
"section": "GA",
"row": null,
"rot_pub_key": "5c1d8e0f2a4b6c8d0e1f3a5b7c9d2e4f6a8b0c1d3e5f7a9b2c4d6e8f0a1b3c5d",
"barcode_sha256": "9f2b6c1e4d7a0b3c5e8f1a2d4b6c9e0f3a5d7b1c4e6f8a0b2d5c7e9f1a3b5c7d"
}
],
"next_cursor": null
}
| Field | Description |
|---|---|
rot_pub_key | Hex public key that verifies this ticket's live code. null when the event doesn't enforce live codes. |
barcode_sha256 | Lowercase hex SHA-256 of the ticket's opaque qr.data, so a device can match your delivered code offline. The plaintext barcode is never sent. |
next_cursor | The last ticket_id when the page is full; otherwise null. |
Errors:
400 parameter_invalid(param: limit).
GET /v1/scanner/tickets/revoked
Revoked tickets for the event. Without since you get the full current list,
which you need at arming. With since you get only revocations after that
time. Revocations that are later undone aren't replayed; re-arm to pick them
up.
Auth: scanner session token
Query params
| Param | Type | Required | Description |
|---|---|---|---|
since | string | No | ISO-8601 timestamp: the now from an earlier call. |
cursor | string | No | The next_cursor from the previous page. |
limit | integer | No | Default 1000, max 5000 (larger values are clamped). |
Example response
{
"object": "revocation_feed",
"now": "2026-09-30T14:00:37.521Z",
"ticket_ids": ["tkt_9"],
"next_cursor": null
}
now is captured before the query runs. Use the first page's now as your
next since, so a revocation that lands mid-read is sent again rather than
missed.
Errors:
400 parameter_invalid(paramissinceorlimit).
GET /v1/scanner/perk-grants
The event's active perk grants, labelled so the device can show what it is
redeeming. Ordered by grant_id.
Auth: scanner session token
Query params
| Param | Type | Required | Description |
|---|---|---|---|
cursor | string | No | The next_cursor from the previous page. |
limit | integer | No | Default 1000, max 5000 (larger values are clamped). |
Example response
{
"object": "list",
"data": [
{
"grant_id": "grant_a",
"perk_def_id": "pdef_1",
"status": "active",
"uses_remaining": 1,
"unlimited": false,
"name": "Free Drink",
"icon": "cup",
"color": "#ff0000"
}
],
"next_cursor": null
}
Errors:
400 parameter_invalid(param: limit).
POST /v1/scanner/scans
Push device-signed scans for reconciliation: one row right after an online
scan, or many when a device drains its offline backlog. Every row must be the
session staff member's own, signed by one of their devices. Each newly
accepted scan marks the ticket used and adds one to the staff member's
scanned_tickets; a replayed row adds nothing.
Auth: scanner session token
Request body
| Field | Type | Required | Description |
|---|---|---|---|
scans | object[] | Yes | At most 1000 rows. |
scans[].local_id | string | No | Your local row id, up to 128 characters. It is echoed back in processed. |
scans[].ticket_id | string | Yes | The admitted ticket. Up to 128 characters, no |. |
scans[].scanned_at | string | Yes | ISO-8601 time of the scan with Z or an offset (what Date#toISOString() produces), exactly as it appears in the signed string. |
scans[].scanner_staff_id | string | Yes | Must be the session's staff member. Up to 128 characters, [A-Za-z0-9_-]. |
scans[].device_id | string | Yes | A registered, non-revoked device of that staff member. Up to 128 characters, [A-Za-z0-9_-]. |
scans[].sig | string | Yes | Ed25519 signature over the scan string above: exactly 128 lowercase hex characters. |
Example request
curl https://api.ticketconnect.example/v1/scanner/scans \
-H "Authorization: Bearer $SCANNER_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"scans": [
{
"local_id": "L1",
"ticket_id": "tkt_1",
"scanned_at": "2026-09-30T13:55:00.000Z",
"scanner_staff_id": "staff_1727_ab12",
"device_id": "dev_abc12345",
"sig": "<hex signature over v1|evt_42|tkt_1|2026-09-30T13:55:00.000Z|staff_1727_ab12|dev_abc12345>"
}
]
}'
Example response
{
"object": "scan_batch_result",
"cursor": "2026-09-30T13:58:37.571Z",
"processed": [
{
"ticket_id": "tkt_1",
"local_id": "L1",
"outcome": "accepted",
"server_record_id": "6abd..."
}
]
}
processed[] has one entry per row, with ticket_id, the optional
local_id, outcome, and where they apply, reason and server_record_id
(see Reconciliation outcomes).
Errors:
400 parameter_missing(param: scans);413 batch_too_large(more than 1000 rows);400 parameter_invalidnaming the row and field (for exampleparam: scans[1].sig).
GET /v1/scanner/scans
Pull the server's scan records for the event, including other gates'
admissions, to update the local used-set. Only active rows consume a ticket.
Auth: scanner session token
Query params
| Param | Type | Required | Description |
|---|---|---|---|
cursor | string | No | ISO-8601 server_received_at watermark: the previous next_cursor. Defaults to the Unix epoch. |
limit | integer | No | Default 500, max 2000 (larger values are clamped). |
Example response
{
"object": "list",
"data": [
{
"ticket_id": "tkt_1",
"scanned_at": "2026-09-30T13:55:00.000Z",
"server_received_at": "2026-09-30T13:58:37.571Z",
"scanner_staff_id": "staff_1727_ab12",
"device_id": "dev_abc12345",
"sig": "<hex>",
"status": "active"
}
],
"next_cursor": "2026-09-30T13:58:37.571Z"
}
status is active, superseded, suspect_duplicate, or rejected. Rows
still being processed (pending) are never returned. When a row's status
changes later (for example an active scan is superseded by an earlier
one), its server_received_at moves forward, so it re-appears after your
cursor and you see the new status. next_cursor is never null: it is
the last row's server_received_at, or it echoes cursor when there is
nothing new. Stop paging when a page has fewer rows than limit.
Errors:
400 parameter_invalid(paramiscursororlimit).
POST /v1/scanner/perk-redemptions
Push device-signed perk redemptions. This mirrors
POST /v1/scanner/scans.
Auth: scanner session token
Request body
| Field | Type | Required | Description |
|---|---|---|---|
redemptions | object[] | Yes | At most 1000 rows. |
redemptions[].local_id | string | No | Up to 128 characters. Echoed back in processed. |
redemptions[].grant_id | string | Yes | The redeemed grant. Up to 128 characters, no |. |
redemptions[].redeemed_at | string | Yes | ISO-8601 time of the redemption with Z or an offset. |
redemptions[].scanner_staff_id | string | Yes | Must be the session's staff member. Same rules as for scans. |
redemptions[].device_id | string | Yes | A registered, non-revoked device of that staff member. |
redemptions[].sig | string | Yes | Ed25519 signature over the perk string above: exactly 128 lowercase hex characters. |
Example request
curl https://api.ticketconnect.example/v1/scanner/perk-redemptions \
-H "Authorization: Bearer $SCANNER_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"redemptions": [
{
"local_id": "P1",
"grant_id": "grant_a",
"redeemed_at": "2026-09-30T13:57:00.000Z",
"scanner_staff_id": "staff_1727_ab12",
"device_id": "dev_abc12345",
"sig": "<hex signature over v1|perk|evt_42|grant_a|2026-09-30T13:57:00.000Z|staff_1727_ab12|dev_abc12345>"
}
]
}'
Example response
{
"object": "perk_redemption_batch_result",
"cursor": "2026-09-30T13:58:40.000Z",
"accepted_count": 1,
"processed": [
{
"grant_id": "grant_a",
"local_id": "P1",
"outcome": "accepted",
"server_record_id": "6abd..."
}
]
}
accepted_count counts only the rows this request newly accepted; a replayed
row reports accepted again but isn't counted.
Errors:
400 parameter_missing(param: redemptions);413 batch_too_large;400 parameter_invalid(for exampleparam: redemptions[0].grant_id).
GET /v1/scanner/perk-redemptions
Pull the server's perk redemption records. This mirrors
GET /v1/scanner/scans: the same cursor and limit
rules (default 500, max 2000), and a next_cursor that is never null.
Auth: scanner session token
Example response
{
"object": "list",
"data": [
{
"grant_id": "grant_a",
"redeemed_at": "2026-09-30T13:57:00.000Z",
"server_received_at": "2026-09-30T13:58:40.000Z",
"scanner_staff_id": "staff_1727_ab12",
"device_id": "dev_abc12345",
"sig": "<hex>",
"status": "active"
}
],
"next_cursor": "2026-09-30T13:58:40.000Z"
}
Errors:
400 parameter_invalid(paramiscursororlimit).
GET /v1/staff/{id}/devices
List a staff member's registered scanner devices, newest first, with the standard list pagination. Public keys stay on the server.
Scope: staff:manage (secret key)
Path params
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Staff id. An unknown id, or one from another account, returns 404 resource_missing. |
Query params
| Param | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–100 (default 20). |
starting_after | string | No | Cursor: the last device id you saw on the previous page. An unknown id returns 400 parameter_invalid. |
Example request
curl https://api.ticketconnect.example/v1/staff/staff_1727_ab12/devices \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"object": "list",
"data": [
{
"object": "scanner_device",
"id": "dev_abc12345",
"staff_id": "staff_1727_ab12",
"event_ids": ["evt_17", "evt_42"],
"platform": "android",
"app_version": "9.9",
"registered_at": "2026-09-30T13:50:00.000Z",
"last_seen_at": "2026-09-30T13:58:37.000Z",
"revoked_at": null,
"revoked_events": [
{ "event_id": "evt_17", "revoked_at": "2026-09-29T22:00:00.000Z" }
]
}
],
"has_more": false
}
event_ids lists the events the device has registered for. revoked_at is
set when the device is revoked everywhere; revoked_events lists events it
is revoked for only (after an unassignment). platform, app_version and
last_seen_at can be null.
POST /v1/staff/{id}/devices/{device_id}/revoke
Revoke one of a staff member's scanner devices, for example a lost phone. Its
scans and perk redemptions signed more than 60 seconds after the revocation
are rejected at reconciliation (device_revoked); earlier ones are still
processed and flagged for review. It also appears in
GET /v1/scanner/devices/revoked for its events, so
armed peers drop its gossip, and it can never register again: the phone needs
a new device_id. The call is idempotent: an already-revoked device keeps its
original revoked_at and reason.
Devices are also revoked automatically:
- Everywhere, for all of a staff member's devices, when you deactivate or
suspend them (
DELETE /v1/staff/{id}, orPATCHwith a non-activestatus), delete them in the organizer panel, or switch offcan_scan_ticketsorcan_redeem_perks(their certificates still list the old capabilities; the phones register again with fewer). - For one event only when you unassign them from it
(
DELETE /v1/staff/{id}/assignments/{eventId}). The event is added torevoked_events, and the devices keep working at the staff member's other events.
Scope: staff:manage (secret key)
Path params
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Staff id. |
device_id | string | Yes | A device registered to that staff member. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
reason | string | No | Stored with the revocation. Defaults to admin-revoke. |
Example request
curl https://api.ticketconnect.example/v1/staff/staff_1727_ab12/devices/dev_abc12345/revoke \
-H "Authorization: Bearer sk_test_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "reason": "lost phone" }'
Example response
{
"object": "scanner_device",
"id": "dev_abc12345",
"staff_id": "staff_1727_ab12",
"event_ids": ["evt_42"],
"platform": "android",
"app_version": "9.9",
"registered_at": "2026-09-30T13:50:00.000Z",
"last_seen_at": "2026-09-30T13:58:37.000Z",
"revoked_at": "2026-09-30T14:01:00.000Z",
"revoked_events": []
}
Errors:
404 resource_missingfor an unknown or unowned staff member, or for a device that doesn't belong to that staff member.
Discounts
Discount codes applied at checkout, either a percentage off or a fixed
amount off. All discount endpoints require the events:write scope.
A serialized discount object:
{
"id": "664f0a1b2c3d4e5f60718293",
"code": "SUMMER20",
"type": "percentage",
"value": 20,
"max_uses": 500,
"used_count": 42,
"expires_at": "2026-09-01T00:00:00.000Z",
"active": true,
"created_at": "2026-06-05T10:00:00.000Z"
}
For type: "percentage", value is a percent (0–100). For
type: "fixed", value is an amount in your currency.
GET /v1/discounts
List your discount codes, newest first.
Scope: events:write
Query params
| Param | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–100 (default 20). |
starting_after | string | No | Cursor — a discount id to page after. |
Example request
curl https://api.ticketconnect.example/v1/discounts \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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
| Field | Type | Required | Description |
|---|---|---|---|
type | string | Yes | percentage or fixed. |
value | number | Yes | Non-negative amount. For percentage, must be 0–100. |
code | string | No | The code customers enter. Generated if omitted. |
max_uses | integer | No | Maximum redemptions. |
expires_at | string | No | Expiry timestamp (ISO 8601). |
event_ids | string[] | No | Restrict the code to specific events. |
Accepts an Idempotency-Key header.
Example request
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
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Discount id. |
Example request
curl https://api.ticketconnect.example/v1/discounts/664f0a1b2c3d4e5f60718293 \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Discount id. |
Request body
All fields optional; send only what you want to change.
| Field | Type | Description |
|---|---|---|
value | number | New non-negative value. |
max_uses | integer | New maximum redemptions. |
expires_at | string | null | New expiry, or null to clear it. |
active | boolean | Enable or disable the code. |
Example request
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
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Discount id. |
Example request
curl https://api.ticketconnect.example/v1/discounts/664f0a1b2c3d4e5f60718293 \
-H "Authorization: Bearer sk_test_your_key_here" \
-X DELETE
Example response
{ "id": "664f0a1b2c3d4e5f60718293", "deleted": true }
Webhooks
Register URLs to receive signed event notifications, then inspect and replay
deliveries when debugging your endpoint. All webhook endpoints require the
webhooks:manage scope.
Webhooks are signed with a Webhook-Signature header (Stripe-compatible) using
the signing secret returned once at endpoint creation. See the
Webhooks guide for verification code.
The payout route emits payout.transferred after a confirmed Stripe transfer to the connected account. The legacy payout.paid subscription remains accepted for compatibility; it is not bank-paid confirmation emitted by this route. Pending or review-held transfers do not emit completion.
Event types
enabled_events accepts any of these types, or "*" for all:
event.created, event.updated, ticket.issued, ticket.confirmed,
ticket.confirmation_failed, ticket.transferred, ticket.refunded,
ticket.revoked, ticket.upgraded, ticket.redeemed,
payment.succeeded, payment.failed, marketplace.sale.completed,
attendance.verified, payout.transferred
Delivery format and retries
Every delivery is a POST with headers Webhook-Signature
(t=<unix-seconds>,v1=<hex> — HMAC-SHA256 of <timestamp>.<raw body> with your
whsec_ secret), Webhook-Id (the event id, for deduping), and
Content-Type: application/json. The body envelope is:
{ "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
curl https://api.ticketconnect.example/v1/webhook_endpoints \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | Destination URL. Must be http(s); https is required in production. Must resolve to a public address — private/internal addresses are rejected. |
enabled_events | string[] | No | Event types to receive. Defaults to ["*"] (all events). |
Example request
curl https://api.ticketconnect.example/v1/webhook_endpoints \
-H "Authorization: Bearer sk_test_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"url": "https://acme.example/hooks/ticketconnect",
"enabled_events": ["ticket.issued", "payout.transferred"]
}'
Example response
{
"id": "664f0a1b2c3d4e5f60718293",
"url": "https://acme.example/hooks/ticketconnect",
"enabled_events": ["ticket.issued", "payout.transferred"],
"status": "active",
"secret": "whsec_9f8e7d6c5b4a..."
}
Errors:
400 parameter_missing(nourl),400 parameter_invalid(non-http(s)URL, or plainhttpin 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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Webhook endpoint id. |
Example request
curl https://api.ticketconnect.example/v1/webhook_endpoints/664f0a1b2c3d4e5f60718293 \
-H "Authorization: Bearer sk_test_your_key_here" \
-X DELETE
Example response
{ "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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Webhook endpoint id. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
message | string | No | Text echoed in the ping's data.message, up to 256 characters. A default is supplied when omitted. |
Example request
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
{
"id": "evt_8c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
"endpoint_id": "664f0a1b2c3d4e5f60718293",
"type": "ping",
"delivered": true,
"response_code": 200,
"error": null
}
deliveredisfalseon any failure;response_codeis your endpoint's HTTP status (ornullon a network error, witherrorpopulated). Errors:400 parameter_invalid(messagetoo 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
| Param | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–100 (default 20). |
starting_after | string | No | Cursor — a delivery id to page after. |
Example request
curl "https://api.ticketconnect.example/v1/events/deliveries?limit=2" \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Delivery id. |
Example request
curl https://api.ticketconnect.example/v1/events/deliveries/6650bb1c2d3e4f5a60718293/replay \
-H "Authorization: Bearer sk_test_your_key_here" \
-X POST
Example response
{ "id": "6650bb1c2d3e4f5a60718293", "status": "pending", "replayed": true }
Payouts
These endpoints onboard a mode-specific Stripe Connect account, report funded balance, and transfer available platform-held Stripe funds to that account. transferred is not bank-paid confirmation. Stripe's connected-account bank payout has its own lifecycle.
POST /v1/connect/account
Scope: payouts:claim. No request body. Creates or resumes the current mode's connected account and returns a fresh onboarding link.
curl https://api.ticketconnect.example/v1/connect/account \
-H "Authorization: Bearer sk_test_your_key_here" -X POST
{
"account_id": "acct_1NxYz...",
"onboarding_url": "https://connect.stripe.com/setup/e/acct_1NxYz.../abc123"
}
Errors include 502 api_error when provider configuration or onboarding is unavailable. Test and live account IDs are separate; an unverified legacy ID is not reused across modes.
GET /v1/connect/account
Scope: payouts:claim. Returns capability flags, not bank details or proof of a deposit.
{
"connected": true,
"charges_enabled": true,
"payouts_enabled": true,
"details_submitted": true
}
Before onboarding: { "connected": false }.
GET /v1/balance
Scope: payouts:read or payouts:claim.
{ "object": "balance", "available": 12840.50, "currency": "USD" }
available is a major-unit amount of proven platform-held Stripe funds after fees, refund debits, and payout reservations. External, PayPal, crypto, already transferred destination charges, and legacy credits without funding proof are excluded. This endpoint does not expose a separate pending field.
GET /v1/payouts
Scope: payouts:read or payouts:claim. Lists durable transfer operations, newest first.
| Param | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–100, default 20. |
starting_after | string | No | Cursor identifying a prior payout operation. |
{
"object": "list",
"has_more": false,
"data": [
{
"object": "payout",
"id": "po_opaque_operation_id",
"amount": 5000.00,
"currency": "USD",
"status": "transferred",
"reference": "tr_1NxYz...",
"created_at": "2026-09-30T08:00:00.000Z"
}
]
}
reference is present once a Stripe transfer is known. Pending operations remain visible in history.
The ledger balance is not Stripe’s net available-cash report. Processor costs are a platform expense requiring separate liquidity; a provider can refuse a transfer when its actual available balance is insufficient.
POST /v1/payouts
Scope: payouts:claim. Required header: Idempotency-Key, 1–255 characters. The server reserves funded balance before the provider effect, including across simultaneous requests with different keys.
| Field | Type | Required | Description |
|---|---|---|---|
amount | number | No | Positive major-unit amount using whole native minor units. Omit for the full funded available balance. |
currency | string | No | Three-letter currency, defaulting to account settlement currency; funds must exist in this currency. |
curl https://api.ticketconnect.example/v1/payouts \
-H "Authorization: Bearer sk_test_your_key_here" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: payout-001" \
-d '{ "amount": 5000.00 }'
A confirmed transfer returns 201 with the payout object above and emits payout.transferred. The legacy payout.paid subscription remains accepted for compatibility; this route does not emit bank-paid confirmation.
| HTTP | Outcome |
|---|---|
201 | Stripe transfer confirmed; status: transferred. |
202 | Original operation is pending or held for review; keep the same key. |
400 | Invalid/missing key or amount, insufficient_balance, or connect_account_required. |
409 | Request-body mismatch or an operation claim still being prepared. |
502 | Provider unavailable or definitive transfer rejection. A definitive failed transfer releases its reservation. |
Unknown outcomes keep their reservation. Recovery reuses the original provider key and destination; automatic retries stop after 23 hours from the first attempt and require reconciliation. See Idempotency.
Comps & guest lists
Complimentary (comp) tickets are free tickets issued to a named recipient —
press, artists' guests, sponsors. Comps are real tickets: they claim tier
supply atomically (just like paid issuance), carry the standard qr delivery
data, emit ticket.issued, and count toward per-person purchase caps. Each
comp records who issued it and an optional note, and the event's comps
together form its guest list.
POST /v1/events/{id}/comps
Issue up to 20 comp tickets to one recipient. Returns 201 with the issued
tickets as a list. The recipient is provisioned as a customer automatically,
keyed on their email.
Scope: tickets:issue
Path params
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Event id. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
tier | string | Yes | Tier to issue from — supply is claimed like a paid sale. |
recipient | object | Yes | { "email": "...", "name": "..." } — email required, name optional (up to 120 chars). |
note | string | No | Free-form note, up to 500 chars — "press", "artist +1", … |
quantity | integer | No | 1–20 tickets for this recipient (default 1). The whole batch is claimed atomically — all or nothing. |
Accepts an Idempotency-Key header.
Example request
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
{
"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(badtier,recipient.email,note, orquantity),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 oneticket.issuedwebhook per ticket.
Comps and money. A comp has
price: 0and no payment attached — a refund has nothing to reverse. To pull a comp back (a clawback), usePOST /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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Event id. |
Query params
| Param | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–100 (default 20). |
starting_after | string | No | Cursor — a ticket id to page after. |
Example request
curl "https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/comps?limit=2" \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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_foundif 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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Event id. |
Example request
curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/seatmap \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Event id. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
seat_ids | string[] | Yes | 1–10 seat ids from the seat map. |
Example request
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
{
"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_idsempty, 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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Event id. |
holdId | string | Yes | The hold to extend. |
Example request
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
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Event id. |
holdId | string | Yes | The hold to release. |
Example request
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
{ "hold_id": "hold_9c8b7a6d5e4f", "released": 2 }
released is the number of seats returned to availability.
Errors:
404 event_not_foundif 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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The reference event id. |
Query params
| Param | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Max results, 1–20 (default 5). |
Example request
curl "https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/similar?limit=3" \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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_missingif 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.
| Segment | Who's in it |
|---|---|
repeat_buyers | Customers with at least min_purchases non-refunded tickets (default 2). Revoked tickets still count as purchases. |
attendees | Customers checked in at at least min_events distinct events (default 1). |
no_shows | Customers 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
| Param | Type | Required | Description |
|---|---|---|---|
kind | string | Yes | repeat_buyers, attendees, or no_shows. Anything else returns 400 parameter_invalid. |
Query params
| Param | Type | Required | Description |
|---|---|---|---|
min_purchases | integer | No | repeat_buyers only: minimum non-refunded tickets, >= 1 (default 2). Silently ignored on other kinds. |
min_events | integer | No | attendees only: minimum distinct attended events, >= 1 (default 1). Silently ignored on other kinds. |
event_id | string | No | Scope the segment to one event. An unknown event_id isn't an error — it yields an empty list. |
limit | integer | No | Page size, 1–100 (default 20). |
starting_after | string | No | Cursor — pass the last customer_id you saw on the previous page. |
Example request
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
{
"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— unknownkind, a non-positive threshold, or an unknownstarting_aftercursor (paramnames 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 is | A one-off computation | A persisted, named definition |
| Stored? | No — nothing is kept | Yes — name + kind + threshold + optional event_id |
| Reusable? | Re-pass the params each time | Reference by id; re-evaluate any time |
| Wires into triggers? | No | Yes — 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:
{
"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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | 1–80 characters, unique per account. A duplicate returns 409 segment_name_taken. |
kind | string | Yes | repeat_buyers, attendees, or no_shows. Anything else returns 400 parameter_invalid. |
min_count | integer | No | Threshold, >= 1. Defaults to 2 for repeat_buyers, 1 otherwise. |
event_id | string | No | Scope the segment to a single event. |
description | string | No | Free-text note for your own reference. |
Example request
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— badname,kind, ormin_count(paramnames the offender).409 segment_name_taken— a segment with thatnamealready exists for your account.
GET /v1/segments
List your saved segments, newest first, cursor-paginated.
Scope: customers:write
Query params
| Param | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–100 (default 20). |
starting_after | string | No | Cursor — the last segment id you saw. |
Example request
curl https://api.ticketconnect.example/v1/segments \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Segment id. Unknown id returns 404 resource_missing. |
Example request
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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Segment id. Unknown id returns 404 resource_missing. |
Example request
curl https://api.ticketconnect.example/v1/segments/seg_1f2e3d4c5b6a \
-H "Authorization: Bearer sk_test_your_key_here" \
-X DELETE
Example response
{ "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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Segment id. Unknown id returns 404 resource_missing. |
Query params
| Param | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–100 (default 20). |
starting_after | string | No | Cursor — the last customer_id you saw. |
Example request
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:
{
"object": "list",
"has_more": false,
"data": [
{
"customer_id": "cus_ext_42",
"email": "dana@example.com",
"purchase_count": 5,
"last_activity_at": "2026-07-02T21:14:00.000Z"
}
]
}
Staff
Manage your door staff: the people who scan tickets, sell on-site, and
redeem perks at your events. Create staff, tune their permissions, assign them
to specific events, and read their scan totals. All staff endpoints require the
staff:manage scope.
The platform provisions and manages the internal credentials a staff member
needs to work the door for you, and none of that is ever exposed through the
API. The only handle you ever get is the opaque id. Everything below is
plain business language: names, permissions, event assignments, and scan counts.
To put a staff member on an offline scanner, your backend mints a scanner
session for them with POST /v1/scanner/sessions. You can list and revoke
their scanner devices with GET /v1/staff/{id}/devices and
POST /v1/staff/{id}/devices/{device_id}/revoke. All three are documented in
the Scanner protocol reference.
A serialized staff object:
{
"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"
}
| Field | Type | Description |
|---|---|---|
id | string | Opaque staff handle — use it in every /v1/staff/{id} path. |
first_name | string | Staff member's first name. |
last_name | string | Staff member's last name. |
email | string | Contact email. |
phone | string | Optional contact phone. Omitted when not set. |
status | string | active, inactive, or suspended. |
permissions | object | The five capability flags below. |
assigned_event_ids | string[] | Events this staff member is assigned to work. |
scanned_tickets | integer | Lifetime tickets this staff member has scanned. |
created_at | string | Creation timestamp (ISO 8601). |
The permissions flags:
| Flag | Default | Grants |
|---|---|---|
can_scan_tickets | true | Validate and check in tickets at the door. |
can_sell_onsite | true | Sell tickets on-site at the event. |
can_redeem_perks | false | Redeem attendee perks (drinks, lounge, backstage). |
can_manage_staff | false | Manage other staff members. |
can_view_stats | true | View scan and sales stats. |
How on-site selling works. Staff with
can_sell_onsitetake walk-up payments in the Gettix app's staff mode (Gettix is our flagship licensee, built on TicketConnect) — Stripe Tap to Pay on the staff member's phone. Gate payments are configured per event in the organizer panel's one-time "Set up event" step. There are no public/v1POS endpoints. The API surface for the door is online scanning and attendance (see Scanning & Check-in) and the offline door protocol (see Offline scanning).
POST /v1/staff
Create a door-staff member.
Scope: staff:manage
Request body
| Field | Type | Required | Description |
|---|---|---|---|
first_name | string | Yes | Missing returns 400 parameter_missing. |
last_name | string | Yes | Missing returns 400 parameter_missing. |
email | string | Yes | Missing returns 400 parameter_missing. |
phone | string | No | Optional contact phone. |
permissions | object | No | Any of the five flags. Unset flags fall back to their defaults above. |
Example request
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
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–100 (default 20). |
starting_after | string | No | Cursor — the last staff id you saw on the previous page. |
Example request
curl https://api.ticketconnect.example/v1/staff \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Staff id. Unknown id returns 404 resource_missing. |
Example request
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.
| Field | Type | Description |
|---|---|---|
status | string | active, inactive, or suspended. Anything else returns 400 parameter_invalid. |
permissions | object | Any of the five capability flags; supplied flags are overwritten. |
Example request
curl https://api.ticketconnect.example/v1/staff/staff_1720900000000_a1b2c3d4 \
-H "Authorization: Bearer sk_test_your_key_here" \
-H "Content-Type: application/json" \
-X PATCH \
-d '{ "status": "suspended", "permissions": { "can_sell_onsite": false } }'
The full updated staff object is returned.
Offline scanners. Changes reach active scanner sessions within about 60 seconds. Switching off
can_scan_ticketsorcan_redeem_perksstops that action on the device and revokes all of the staff member's scanner devices (their certificates still list the old capability); the phones register again with fewer capabilities. Settingstatustoinactiveorsuspendedalso revokes all of their devices, permanently. If you reactivate them, their phones register new device ids. See Scanner protocol.
POST /v1/staff/{id}/assignments
Assign a staff member to one of your events. Assigning provisions the internal access that staff member needs for that event behind the scenes.
Scope: staff:manage
Assignments are idempotent — re-assigning the same event is a no-op and returns the unchanged staff object.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
event_id | string | Yes | An event you own. Missing returns 400 parameter_missing. |
Example request
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_idreturns404 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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Staff id. |
eventId | string | Yes | The event to remove from the assignment list. |
Example request
curl https://api.ticketconnect.example/v1/staff/staff_1720900000000_a1b2c3d4/assignments/evt_1a2b3c \
-H "Authorization: Bearer sk_test_your_key_here" \
-X DELETE
The full staff object is returned with the event removed from
assigned_event_ids. The staff member's scanner sessions for that event stop
working, and their scanner devices are revoked for that event only (see
revoked_events on the device list); the same phones keep working at the
staff member's other events.
GET /v1/staff/{id}/stats
Read a staff member's scan totals, with a per-event breakdown. The counts reflect tickets actually scanned by this staff member.
Scope: staff:manage
Path params
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Staff id. Unknown id returns 404 resource_missing. |
Example request
curl https://api.ticketconnect.example/v1/staff/staff_1720900000000_a1b2c3d4/stats \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Staff id. Unknown id returns 404 resource_missing. |
Example request
curl https://api.ticketconnect.example/v1/staff/staff_1720900000000_a1b2c3d4 \
-H "Authorization: Bearer sk_test_your_key_here" \
-X DELETE
Example response
The full staff object is returned with status now inactive.
Note: This is a deactivation, not a hard delete — the staff member stops working the door but stays on the roster and keeps their scan history. It also revokes all of their scanner devices. Flip them back on with
PATCH /v1/staff/{id}and{ "status": "active" }; their phones then register new device ids.
Discount triggers
A discount trigger links one of your saved segments to a
discount code, then lets you read exactly who qualifies for that code right
now. Use it to run targeted offers: "20% off for repeat buyers", "a comeback
code for no-shows". All trigger endpoints require the events:write scope.
This is a read model, not an automation. A trigger never emails anyone and never applies the code on its own. Unlike the first-party organizer panel — which can auto-send a code to a matching audience on a schedule — the API hands you the current match list and you deliver the code through your own channels (email, SMS, push, in-app). You stay in control of the message and the timing.
Because matches are evaluated fresh on every call against your live ticket
history, the list you read is always current — add a trigger once, then poll
GET .../matches whenever you're ready to send.
A serialized trigger object:
{
"id": "664f0a1b2c3d4e5f60718293",
"discount_id": "664f0a1b2c3d4e5f60700001",
"segment_id": "seg_1f2e3d4c5b6a",
"active": true,
"created_at": "2026-07-11T10:00:00.000Z"
}
| Field | Type | Description |
|---|---|---|
id | string | Trigger rule id — use it in every .../triggers/{ruleId} path. |
discount_id | string | The discount code this trigger is attached to. |
segment_id | string | The saved segment whose members qualify. |
active | boolean | Whether the trigger is live. |
created_at | string | Creation timestamp (ISO 8601). |
The endpoints:
| Method & path | Does |
|---|---|
POST /v1/discounts/{id}/triggers | Link a saved segment to the discount code. |
GET /v1/discounts/{id}/triggers | List the code's triggers. |
DELETE /v1/discounts/{id}/triggers/{ruleId} | Remove a trigger. |
GET /v1/discounts/{id}/triggers/{ruleId}/matches | List the customers who qualify now. |
POST /v1/discounts/{id}/triggers
Link a saved segment to a discount code.
Scope: events:write
Path params
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Discount id. Unknown id returns 404 resource_missing. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
segment_id | string | Yes | A 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
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
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Discount id. Unknown id returns 404 resource_missing. |
Example request
curl https://api.ticketconnect.example/v1/discounts/664f0a1b2c3d4e5f60700001/triggers \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Discount id. |
ruleId | string | Yes | Trigger rule id. Unknown id returns 404 resource_missing. |
Example request
curl https://api.ticketconnect.example/v1/discounts/664f0a1b2c3d4e5f60700001/triggers/664f0a1b2c3d4e5f60718293 \
-H "Authorization: Bearer sk_test_your_key_here" \
-X DELETE
Example response
{ "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
| Param | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Discount id. |
ruleId | string | Yes | Trigger rule id. Unknown id returns 404 resource_missing. |
Query params
| Param | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–100 (default 20). |
starting_after | string | No | Cursor — the last customer_id you saw on the previous page. |
Example request
curl "https://api.ticketconnect.example/v1/discounts/664f0a1b2c3d4e5f60700001/triggers/664f0a1b2c3d4e5f60718293/matches?limit=2" \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"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,
matchesreturns an empty list rather than an error — the trigger simply has no one to match.