Introduction
TicketConnect White-Label API lets you launch a fully branded ticketing product — events, paid tickets, QR check-in, resale, payouts — without building any of the hard parts yourself. You integrate the same way you'd integrate Stripe or SendGrid: REST + an API key + signed webhooks, all in plain business language.
The hard parts are handled for you. Every ticket is tamper-proof and anti-scalping is on by default (configurable per event). You work with events, tickets, fiat prices, customer emails, QR codes, and bank payouts — the platform does all the rest behind the scenes.
Running your own events instead? These docs are for licensees — teams embedding TicketConnect behind their own product. If you're an event organizer selling on the TicketConnect platform itself, switch to the For organizers docs.
What you can build
- Sell tickets for events in your own currency, paid by card.
- Issue, transfer, refund, and upgrade tickets programmatically.
- Deliver tickets as an opaque QR string your app renders however it likes.
- Sell high-demand on-sales fairly with a built-in waiting-room queue and published price schedules.
- Run a secondary marketplace with automatic royalties and anti-scalping.
- Scan and check in attendees online or offline.
- Receive signed webhooks for every important event.
- Get paid out directly to your bank account.
Everything you call lives under the /v1 prefix and is automatically scoped
to your account — you can only ever see and touch your own data.
Base URL
https://api.ticketconnect.example/v1
Replace the host with the environment you were given. Health, the OpenAPI document, and the interactive docs are public; everything else needs an API key.
Your first call
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's safe to retry and reportsalready_usedif the ticket was already checked in. For offline scanners draining a queue, usePOST /v1/scan/batchto validate up to 200 codes in one call.
Step 10 (optional) — Receive a webhook
Instead of polling, register a webhook endpoint and let TicketConnect push events
to you — ticket.issued when a ticket is created, attendance.verified on
check-in, and more. Webhooks are signed so you can verify them with existing SDKs.
Manage endpoints with POST /v1/webhook_endpoints (requires the
webhooks:manage scope). See the full walkthrough in the
Webhooks guide.
You did it
You just sold and scanned a ticket end-to-end:
- 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 | POST /v1/scan/validate, POST /v1/scan/batch, POST /v1/tickets/:id/attendance |
staff:manage | Manage door staff: roster, permissions, event assignments, scan stats | 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 |
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_… | A sandbox that mirrors production behaviour with completely isolated data and simulated payments. |
| Live | sk_live_… | Production. Real events, real customers, real card charges, real payouts. |
Note: Live mode must be activated on your account before it can be used. Until it is,
sk_live_keys cannot be created, and any live key presented to the API is rejected with 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 endpoints and the same logic as a live key — so if your integration works in test, it works in production. The difference is isolation:
- Test events, tiers, customers, tickets, payments, and payouts live in a separate sandbox and never appear in your live data (and vice versa).
- Payments in test mode are simulated — use the standard test card numbers to trigger success or failure. No real money ever moves.
- Webhooks, reports, balances, and scanning all work the same way in test, scoped to your test data only.
Warning: The two worlds never cross. A
sk_test_key cannot read or modify live data, and 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.
- Flip to live by swapping the key. When you're confident, change the key
from
sk_test_tosk_live_— no code changes required. Double-checkGET /v1/accountreports"mode": "live"before going live for real.
Tip: Because test and live share the same code and contract, "it works in test" is a real guarantee about the API surface. The only things you can't fully exercise in the sandbox are real-money settlement and bank payouts — validate those carefully on your first live transactions.
Next steps
- 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
All money in the API is fiat, in your account's settlement currency. There is one currency per account, set when your account is created, and every price, charge, refund, balance, and payout is expressed in it. You never deal with exchange rates or any other unit.
Amounts are server-authoritative. The platform computes what a customer owes from the tier price (and any discount) on the server. A client can never set or override the amount it pays — values you send as a "price to charge" are ignored. This is your protection against tampered checkouts.
How pricing flows
Money moves through three steps, and issuance only happens once payment is verified:
1. Tier price You set a fiat price on each tier when you create it.
│
▼
2. Payment intent You create a payment intent. The platform computes the
│ amount server-side from the event/tier (and any discount)
│ — the client-supplied amount, if any, is never trusted.
▼
3. Issuance gated The ticket is only issued after a verified, matching,
on verified payment unused payment. No payment, no ticket.
This means a partner cannot accidentally (or maliciously) issue a paid ticket without a real, confirmed charge behind it. The same gate applies to upgrades: moving a ticket to a higher tier charges the fiat price difference, again computed on the server.
See Take a payment for the end-to-end flow and Issue a ticket for issuance.
Amount format
Amounts in the API — tier prices, payment amounts, balances, payouts — are
plain decimal numbers in your currency's major unit: 49 means $49.00,
49.5 means $49.50. You never deal in cents. The platform converts to the
processor's minor units internally (including the zero-decimal handling for
currencies like JPY or KRW), so there is nothing to scale on your side.
A payment also carries a normalized status so you always know where a charge
stands: requires_payment (or another requires_* state, depending on your
account's payment provider), processing, succeeded, failed, or
canceled. Issuance proceeds only once the payment verifies as completed.
Refunds
Refunding a ticket releases the ticket and reverses the sale on your balance — the sale amount is debited from what you've accrued, in the same currency as the original charge; you don't calculate anything. See Transfer, refund & upgrade.
Getting paid
Your share of every sale accrues to your account balance in your settlement currency. You move that balance to your bank account as a fiat payout. The platform handles the underlying settlement invisibly — you only ever see one number in your own currency.
See the Payouts guide to set up payouts and withdraw your balance.
Idempotency
Networks fail, requests time out, and clients retry. Idempotency lets you retry a mutating request without risking a duplicate — a second ticket issued, a second charge created. You send a unique key with the request; if you send the same key again, the platform returns the original response instead of doing the work twice.
How it works
Add an Idempotency-Key header to a mutating POST:
- The first request with a given key runs normally, and its response is stored against that key.
- A retry with the same key and the same request returns the stored response verbatim — same status code, same body. The operation runs only once.
- A retry with the same key but a different request body is rejected with
409and anidempotency_error(codeidempotency_key_reuse). Reusing a key for a different request is always a bug.
Stored responses are kept for 24 hours. After that, the same key is treated as new.
Note: Scope an
Idempotency-Keyto a single logical operation. Generate a fresh key for each new request you make, and reuse that same key only when retrying that exact request.
Recommended key format
Use a random UUID (v4) per operation. It's collision-resistant and easy to generate in any language.
KEY=$(uuidgen) # e.g. 3f9b1c2e-7a4d-4e1b-9c2a-1d6f0b8e5a21
Which endpoints support it
The Idempotency-Key header is honored on mutating POST endpoints, most
importantly:
POST /v1/customers— create a customerPOST /v1/events— create an eventPOST /v1/events/:id/tiers— add a ticket tierPOST /v1/events/:id/tickets— issue a ticketPOST /v1/payment_intents— create a payment intentPOST /v1/tickets/:id/refund— refund a ticketPOST /v1/tickets/:id/upgrade— upgrade a ticketPOST /v1/tickets/:id/attendance— record a check-inPOST /v1/payouts— request a payout
Sending the header on an endpoint where it isn't needed is harmless — it's simply ignored. Omitting it runs the request normally with no replay protection.
Example: a retried request
The same key is sent twice. The first call issues one ticket; the retry returns that same ticket without issuing a second one.
KEY=$(uuidgen)
# First attempt — times out on your side, but the platform processed it.
curl https://api.ticketconnect.example/v1/events/evt_123/tickets \
-H "Authorization: Bearer sk_test_your_key_here" \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{ "customer_id": "user_42", "tier": "General Admission" }'
# Safe retry — identical key + body → returns the original response, no duplicate.
curl https://api.ticketconnect.example/v1/events/evt_123/tickets \
-H "Authorization: Bearer sk_test_your_key_here" \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{ "customer_id": "user_42", "tier": "General Admission" }'
Both calls return the same ticket with the same id. See
Errors for the idempotency_error envelope.
Errors
Every error response uses a single, predictable envelope — the same shape across
the entire API. Branch your handling on the machine-readable type and code;
show or log the message; quote the request_id to support.
The error envelope
{
"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
Every paid ticket is funded by a card payment. You create a payment intent for a specific event and tier, collect the card on the client, and the resulting payment becomes the proof of funds you attach when you issue or upgrade a ticket.
If you've integrated Stripe before, this will feel familiar: a server-created intent, a client-side confirmation, and a status you can poll. The key difference is that TicketConnect computes the amount for you — you never send a price, so a client can't tamper with what gets charged.
Before you start
You'll need:
- An API key with the
payments: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 your account's currency. This is what stops a manipulated client from underpaying.
The client_payload carries the details your client needs to confirm the card
(for the Stripe provider, a client_secret). Pass it to the TicketConnect
client integration the same way you'd hand a client secret to Stripe's SDK, and
confirm the card in the browser or app.
Step 2 — Confirm the card
Card confirmation happens on the client, with the customer's card details — they
never touch your server. After the customer submits their card, the payment moves
toward succeeded.
Test cards
In test mode (sk_test_... keys), use your payment provider's standard test
cards to simulate outcomes — a card that always succeeds, one that always
declines, one that triggers authentication, and so on. Test-mode payments are
fully isolated from live data, so you can run a full issuance end-to-end without
charging anyone.
Step 3 — Retrieve a payment
Check a payment's current status at any time.
Endpoint: GET /v1/payments/:id · Scope: payments:read or payments:write
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 and currency, and not have funded another ticket already. See Issue a ticket. - Upgrades. When a customer moves to a more expensive tier, you create a payment for the price difference and attach it to the upgrade call. See Transfer, refund & upgrade.
Waiting-room events: charge after admission. If the event runs an on-sale queue (its event object has a non-null
queue), admission is enforced when the ticket is issued — not when the intent is created. Join the queue and 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.
The money flow is entirely card-in, fiat-out: the customer pays by card in your currency, and your proceeds settle to your bank account as a fiat payout. Your account always shows a single number — your balance in your currency.
Use an
Idempotency-Keyon every create. Retrying a payment-intent creation with the same key returns the original intent instead of creating a duplicate charge. Keys are honored for 24 hours.
Common errors
| 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 TicketConnect ticket wallet 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 TicketConnect ticket wallet. A customer who keeps their ticket in a TicketConnect-powered wallet (the TicketConnect app, or any holder experience built on the same rails) shows a live code that refreshes every few seconds instead of your static one. Your scanning endpoints validate both — send whatever was scanned, verbatim, and read the verdict (see Scanning & check-in). Your
qr.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 TicketConnect ticket wallet can't be transferred. Once the holder has opened the ticket in a TicketConnect-powered wallet (the TicketConnect app, or any holder experience built on the same rails), it is locked to them and
POST /v1/tickets/:id/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 ticket's status becomes refunded, and the sale amount is debited back
off your account balance. A refunded ticket is no longer valid for entry.
Returning the money to your customer's card is handled between you and your
payment provider — the refund call doesn't reverse the card charge itself.
Refunds aren't reversible and can't be repeated. Attempting to refund a ticket that's already refunded returns
409 already_refunded.
Upgrade a ticket to a higher tier
Move a ticket up to a more expensive tier, charging the customer the fiat difference. The ticket keeps its id and its QR code — only the tier, perks, and price change.
Step 1 — List the available upgrade options
Endpoint: GET /v1/tickets/:id/upgrade-options · Scope: tickets:manage
This returns the higher tiers the ticket can move into — those that are priced
above the current tier, have upgrades enabled, and still have supply — each with
its price, the fiat price difference (delta), remaining supply, and perks.
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. A given payment can fund one
upgrade, ever — reusing it returns 409 payment_already_used.
Step 3 — Upgrade
Endpoint: POST /v1/tickets/:id/upgrade · Scope: tickets:manage
Pass the target_tier and the payment that covers the difference.
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 TicketConnect ticket wallet — the platform verifies the latter cryptographically on your behalf. None of that machinery is visible to you: you send the scanned string, you get a verdict.
Scopes
All three endpoints on this page require the scanning:write scope.
| Endpoint | Method | Scope |
|---|---|---|
/v1/scan/validate | POST | scanning:write |
/v1/scan/batch | POST | scanning:write |
/v1/tickets/{id}/attendance | POST | scanning:write |
Online vs. offline check-in
There are two ways to check someone in, and they map to two different endpoints:
- Validate (online):
POST /v1/scan/validate(and its batch sibling) looks a ticket up by its QR string and returns a verdict. It is the right call when your scanner has a live connection. It does not mark the ticket as used — it only reports whether the ticket is currently valid. - Mark attendance:
POST /v1/tickets/{id}/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.
For an offline scanner (spotty WiFi at the gate, a remote field, etc.), let
the device validate against a cached ticket list locally, queue every scan, and
then drain the queue with POST /v1/scan/batch once it's back online. Batch
validation tells you, per QR, which scans were good and which weren't, so you can
reconcile after the fact.
Validate a single ticket at the door
Send the QR string exactly as your scanner read it. Don't parse, trim, or re-encode it — a live wallet code is longer and looks like a JSON blob; it must arrive verbatim.
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 TicketConnect ticket wallet — any holder experience built on the TicketConnect rails, the TicketConnect app being the reference one — instead of (or besides) whatever you delivered. The wallet shows a live code that refreshes every few seconds — an anti-screenshot measure — and your scanning endpoints validate it exactly like any other code: send the scanned string verbatim, get a verdict. The platform checks the code's authenticity and freshness cryptographically before the usual lifecycle verdict.
Three extra reason values exist only for these codes, and each maps to a
clear instruction for door staff:
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 / offline draining
When an offline scanner reconnects, send everything it collected in one call.
Pass an array of QR strings in qrs (up to 200 per request).
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.
See also
- Webhooks — receive
attendance.verifiedin real time. - Marketplace & resale — secondary listings.
- API reference — full endpoint details.
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.
- Automatic royalties. When a listing sells, your configured royalty is taken out and credited to you automatically. You don't compute or collect it; it simply shows up in your balance.
- Anti-scalping, on by default. Every event ships with anti-scalping
enforced and a resale ceiling of 150% of face value. Both the bounds
(floor/ceiling), the resale-open window, and whether resale or anti-scalping
is on at all are per-event settings you control when you create or update
the event (
marketplace_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 — resale royalties land in your balance.
- API reference — full endpoint details.
Discounts
Discount codes let you run promotions: a flat amount off, or a percentage off, redeemable at checkout. This guide covers the full lifecycle — create, list, retrieve, update, and delete — and how a code is applied when a customer buys a ticket.
Scopes
Every discount endpoint requires the events:write scope.
| 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.
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.paid"]
}'
{
"id": "663b1c9e2a7d654321fedcba",
"url": "https://hooks.yourapp.com/ticketconnect",
"enabled_events": ["ticket.issued", "payment.succeeded", "payout.paid"],
"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.paid | A payout to your bank account is sent. |
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.paid",
"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.paid",
"status": "delivered",
"attempts": 1,
"last_response_code": 200,
"created_at": "2026-06-05T12:00:00.000Z"
}
],
"has_more": false
}
status is one of pending, delivered, or failed. last_response_code is
the HTTP status your endpoint returned on the most recent attempt.
8. Replay a delivery
Fixed an outage on your side? Re-send a past delivery to your registered endpoint:
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.paid. - Marketplace & resale — emits
marketplace.sale.completed. - API reference — full endpoint details.
Payouts
This is how money reaches your bank account. You sell tickets in your currency, your earnings (sales proceeds plus resale royalties, net of platform fees) accumulate as a single balance, and you pay that balance out to your bank account.
One balance, your currency, fiat in and fiat out. Customers pay by card; you get paid to your bank. Everything you see here is a plain amount in your settlement currency — no settlement internals, no conversions for you to manage. The platform reconciles all of that behind the scenes.
The fiat-in / fiat-out model
- Fiat in: customers buy tickets with a card. The platform collects the payment and credits your share to your balance.
- Your balance: a single spendable number in your currency. It already reflects platform fees and any resale royalties owed to you — there is nothing to net out yourself.
- Fiat out: you request a payout, and the platform transfers the money to your connected bank account.
You never see more than one balance, and it's always in your own currency.
Scopes
| Endpoint | Method | Scope |
|---|---|---|
/v1/connect/account | POST | payouts:claim |
/v1/connect/account | GET | payouts:claim |
/v1/balance | GET | payouts:read (or payouts:claim) |
/v1/payouts | GET | payouts:read (or payouts:claim) |
/v1/payouts | POST | payouts:claim |
Note:
payouts:readis read-only (balance and payout history).payouts:claimcan do everythingpayouts:readcan and onboard the account and request payouts. Give back-office dashboards a read-only key; reservepayouts:claimfor the service that moves money.
1. Onboard with Stripe Connect
Before you can be paid, you complete a one-time Stripe Connect onboarding (bank
details, identity verification). Start it by creating an onboarding link, then
redirect your user to the returned onboarding_url.
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.../..."
}
Send the user to onboarding_url to finish setup. If they don't complete it in
one sitting, call this endpoint again to get a fresh link — it resumes the same
connected account.
2. Check onboarding status
Confirm whether the account is ready to receive payouts:
curl https://api.ticketconnect.example/v1/connect/account \
-H "Authorization: Bearer sk_test_..."
Before onboarding has started:
{ "connected": false }
Once a connected account exists:
{
"connected": true,
"charges_enabled": true,
"payouts_enabled": true,
"details_submitted": true
}
You're ready to receive money when payouts_enabled is true. (No personal or
bank details are ever returned — only these capability flags.)
3. Check your balance
curl https://api.ticketconnect.example/v1/balance \
-H "Authorization: Bearer sk_test_..."
{
"object": "balance",
"available": 1284.50,
"currency": "USD"
}
available is what you can pay out right now, in currency (your settlement
currency).
Refunds move the balance, not your bank account
Earnings land in your balance when a sale settles, and they leave it again if that sale is reversed — a refunded ticket or a cancelled event removes your share of it. Nothing is ever pulled back out of your bank account or your connected Stripe account: a reversal is a debit against the balance, so it nets off against what you earn next.
That also means the balance can go negative, if refunds land after you've
already paid out. available reports the real figure — including a negative one
— so you can see how far under you are:
{ "object": "balance", "available": -128.40, "currency": "USD" }
Payouts are refused while you're under: requesting a specific amount returns
400 insufficient_balance, and requesting the full balance returns
400 parameter_invalid (there is no positive amount to pay out). New earnings
net against the shortfall until the balance is positive again.
Because reversals settle against your balance, request payouts on a rhythm (weekly, monthly) rather than draining to zero after every sale — that keeps a buffer for refunds and avoids a stretch where you can't pay out at all.
4. Request a payout
Request a payout to your connected bank account. Pass an amount (in your
currency) to pay out part of your balance, or omit it to pay out the full
available balance.
curl https://api.ticketconnect.example/v1/payouts \
-X POST \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"amount": 500.00,
"currency": "USD"
}'
{
"object": "payout",
"id": "tr_1Q9...",
"amount": 500.00,
"currency": "USD",
"status": "paid"
}
A successful payout also emits a payout.paid webhook — see
Webhooks.
Warning: A payout can never exceed your available balance — an over-request returns
400with aninsufficient_balanceerror. You must have finished Connect onboarding first; otherwise you'll get aconnect_account_requirederror.
Use an
Idempotency-Keyheader when requesting a payout, so a network retry can't accidentally pay you out twice. A replay with the same key returns the original response.Terminalcurl https://api.ticketconnect.example/v1/payouts \ -X POST \ -H "Authorization: Bearer sk_test_..." \ -H "Idempotency-Key: payout-2026-06-05-001" \ -H "Content-Type: application/json" \ -d '{ "amount": 500.00 }'
5. List past payouts
Review your payout history, newest first, cursor-paginated via limit (and
starting_after).
curl "https://api.ticketconnect.example/v1/payouts?limit=20" \
-H "Authorization: Bearer sk_test_..."
{
"object": "list",
"data": [
{
"id": "668a2f1c9e7d654321fedcba",
"amount": 500.00,
"currency": "USD",
"reference": "tr_1Q9...",
"created_at": "2026-06-05T12:00:00.000Z"
}
],
"has_more": false
}
reference ties each entry back to the underlying transfer.
See also
- Webhooks — listen for
payout.paid. - Marketplace & resale — resale royalties feed your balance.
- API reference — full endpoint details.
Ad tracking & attribution
You own your buyer-facing product, so browser-side tracking is yours to do — drop your Meta Pixel or GTM snippet into your own pages exactly like on any site you run; nothing platform-side gets in your way. What you can't build alone is the platform side of the picture, and that's what the Ad tracking card in your panel adds.
What the Ad tracking card does today
Open your panel → white-label portal → Ad tracking and save your Meta Pixel ID and a Conversions API access token (Events Manager → your pixel → Settings → Conversions API → Generate access token).
- Server-side purchase events to your pixel. When tickets for your
attributed organizers' events sell through the TicketConnect storefront,
the platform delivers each
Purchase(value, currency) from its servers straight to your pixel via the Meta Conversions API — immune to ad blockers and iOS pixel loss, and only for buyers who accepted marketing cookies (the storefront's consent banner is handled for you). If a browser pixel event with the same event id exists, Meta deduplicates the pair automatically. - First-party attribution on every sale. UTM parameters that brought a buyer to the TicketConnect storefront are stored with the ticket and power the Sales sources report — tickets and revenue per source / medium / campaign, independent of any ad platform.
The CAPI token is write-only: stored encrypted, never shown again, replace or remove it any time.
Honest scope
| You sell via | Browser-side tracking | Server-side conversions |
|---|---|---|
| Your own frontend on the /v1 API | Yours — your pages, your pixel, your consent flow | ✔ — pass an attribution object (UTM fields, fbp/fbc, your browser eventId, marketingConsent: true) on POST /v1/events/{id}/tickets or checkout POST /v1/checkout/{event_id}/complete; we fire the CAPI Purchase to your pixel and dedupe it against your browser event by that id. marketingConsent is your declaration that the buyer consented on your pages — we take it at face value. |
TicketConnect-hosted checkout (GET /v1/checkout/{event_id}) | ✔ end-to-end — your pixel runs on the page behind a built-in consent bar; nothing loads until the buyer accepts | ✔ — the page's browser Purchase and our server Purchase share one event id, so Meta deduplicates the pair automatically |
| TicketConnect storefront (your attributed organizers) | Platform-managed, consent-gated | ✔ delivered to your pixel |
Both attribution on the /v1 API and the hosted checkout's pixel dispatch to
your Meta Pixel ID / CAPI access token from this same Ad tracking card —
the platform pixel never sees your buyers.
The GTM container ID field is stored for upcoming TicketConnect-hosted pages and has no effect yet — you don't need to fill it today; v1.1 lights up the Meta pixel on the hosted checkout, not GTM.
Verify in minutes. Open Meta Events Manager → Test Events and make a test purchase of one of your organizers' events on the TicketConnect storefront (accept the consent banner) — the server
Purchaseshould appear within seconds. Selling on /v1 instead? Complete a test-mode ticket withattribution.marketingConsent: true(or a hosted checkout purchase with the consent bar accepted) and check your pixel's recent activity in Events Manager → Overview, where the dedup status is shown per event. Note the Test Events tab lists server events only when a test event code rides along — that's a platform-side setting, so ask us to coordinate one if you want your verification to show up there.
API reference
This is the complete reference for the TicketConnect White-Label API. Every
endpoint lives under the /v1 prefix and is automatically scoped to your
account. The API is a plain REST + JSON service modeled closely on Stripe, so if
you have integrated Stripe before this will feel familiar.
Base URL
https://api.ticketconnect.example/v1
Replace the host with the environment you were issued. All paths below include
the /v1 prefix.
Authentication
Send your secret key as a bearer token on every request except the public meta
endpoints (/v1/health, /v1/openapi.json, /v1/docs):
Authorization: Bearer sk_test_your_key_here
The key is also accepted in an x-api-key header as a fallback. Use
sk_test_… keys against the sandbox and sk_live_… keys against
production; the two are fully isolated. Keys are scoped — each endpoint
documents the scope it requires (for example events:read). A wildcard *
scope satisfies any check. Calling an endpoint without the required scope
returns a 403 with type authorization_error and code forbidden. See
Authentication for how to manage keys
and scopes. Hosted-checkout endpoints are the one exception to the secret-key
rule: they authenticate with browser-safe publishable pk_… keys instead (see
Payments).
Response & error envelope
Successful responses return the resource object directly. Most resources carry
an object discriminator (for example "object": "payout").
Errors always use one consistent envelope:
{
"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. |
| 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 TicketConnect ticket wallet
(the TicketConnect app, or any holder experience built on the same rails)
show a live, refreshing entry code for this event (an anti-screenshot
measure — see Scanning & check-in). It is true
by default, settable at creation and togglable with
PATCH /v1/events/{id}. It never affects the static
qr.data you deliver yourself. It also gates phone passes: a wallet-held
ticket can be saved to Apple/Google Wallet only when live codes are off
(a phone pass is a static barcode, so the platform refuses one where a live
code is enforced).
imageUrl (card/list image) and bannerUrl (wide hero) are the event's two
media slots — set them at creation via images or later with
PATCH /v1/events/{id}. Events with a published seat map sell
seated tiers by the seat (see Reserved seating), and every
event can list its closest catalog neighbours via
GET /v1/events/{id}/similar.
GET /v1/events
List your events, newest first.
Scope: events:read
Query params
| 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. |
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. |
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 and/or live-code setting.
Accepts images, marketplace_settings and/or live_codes — at least one is
required. Partial: only the keys you send change. Pass null to unset a media
slot; omitted keys are untouched. Naturally idempotent (no Idempotency-Key
needed). Emits an event.updated webhook.
Scope: events:write
Path params
| 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. |
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),400 parameter_invalid(bad media URL, out-of-range resale bound, or non-booleanlive_codes),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. |
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, if present on the buyer's landing URL. |
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. |
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/click-id fields are stored either way. Omit or false to attribute the sale without any pixel dispatch. |
The CAPI dispatch is fire-and-forget and best-effort: a failure here never fails the ticket issuance. See the Ad tracking guide.
GET /v1/tickets
List issued tickets, newest first.
Scope: tickets:read
Query params
| 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 a TicketConnect 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 a ticket and return the buyer's money. The payment provider is
charged first — the refund is executed against the original payment (for the
Stripe provider, a PaymentIntent refund) before any ticket state changes. Only
after the provider accepts does the ticket move to refunded and stop being
valid for entry. If the provider refuses or fails, you get an error back and
the ticket is untouched. Free and comp tickets (no charge to reverse) skip the
provider entirely.
Scope: tickets:manage
Path params
| 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" \
-X POST
Example response
{
"id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c",
"event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
"status": "refunded",
"tier": "VIP"
}
Errors:
409 already_refundedif the ticket was already refunded;400 provider_not_configuredif no payment provider is configured for your account;400 refund_not_supportedif the provider cannot refund this payment;502 payment_provider_errorif the provider failed — the ticket is unchanged, retry later. Emits aticket.refundedwebhook (only after the money has moved).
POST /v1/tickets/{id}/revoke
Invalidate a ticket without refunding it — fraud, a chargeback, a comp
clawback. Revocation sets revoked: true and revoked_at on the ticket; the
lifecycle status is intentionally unchanged (revocation is an independent
axis). From that moment the ticket fails every scan and can no longer be
transferred.
Revoking is idempotent: revoking an already-revoked ticket returns the
same terminal state with 200 and emits no second webhook. Revoke does
not refund — call POST /v1/tickets/{id}/refund as well
when you want both.
Scope: tickets:manage
Path params
| 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.
Payments
A payment intent represents the card charge that funds a ticket issuance or upgrade. The amount is always computed server-side from the event tier — the client cannot supply or override it. After the customer completes the payment on the client, pass the payment intent id back to the issue/upgrade endpoint. See the Take a payment guide.
A serialized payment intent object:
{
"object": "payment_intent",
"id": "pi_3Q1abc...",
"provider": "stripe",
"client_payload": { "client_secret": "pi_3Q1abc..._secret_..." },
"amount": 149.00,
"currency": "USD",
"status": "requires_payment"
}
status is a provider-normalized value you can act on. With a card provider it
is one of requires_payment (awaiting the customer's payment or action),
processing, succeeded, failed, or canceled. Providers that collect money
out-of-band report requires_external until you
confirm the payment.
POST /v1/payment_intents
Create a card payment intent for one of your event tiers. Use client_payload
to complete the charge in your client (for example, Stripe's client_secret).
Scope: payments:write
Request body
| 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 | Override currency; defaults to the event/account currency. |
Accepts an Idempotency-Key header.
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).
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. One payment intent funds at most one ticket, and
supply is claimed atomically, so retries can never double-issue or oversell.
Auth: publishable key (x-api-key header).
Request body
| 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:
{
"ticket": {
"id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c",
"event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
"event_name": "Summer Fest",
"status": "valid",
"tier": "VIP",
"price": 149.00,
"currency": "USD",
"qr": { "data": "a1b2c3...opaque", "format": "QR" },
"created_at": "2026-06-05T12:30:00.000Z"
}
}
Errors:
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. Every
field is optional and malformed input is silently dropped, never rejected.
Omit attribution (or leave marketingConsent false/absent) and the sale is
still recorded with whatever UTM/referrer fields you send, with no pixel
dispatch.
If you hand buyers the TicketConnect-hosted checkout page instead of calling
/complete yourself, none of this applies to you — the hosted page captures
attribution and dispatches CAPI on its own. See the
Ad tracking guide.
Marketplace
The marketplace holds secondary-market resale listings. Prices are in
fiat, and each event's resale royalty and anti-scalping bounds are applied
automatically per its marketplace_settings (see Events). See
the Marketplace and resale guide.
A serialized listing object:
{
"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. Validation reads the QR
string from a ticket — either the opaque qr.data you delivered, or the live,
refreshing code shown for a ticket held in a TicketConnect ticket wallet
(verified cryptographically on the platform side). Attendance marks a ticket
as used. Your API key with the scanning:write scope authorizes scanning —
there is no separate scanner token. See the
Scanning and check-in guide.
A scan verdict looks like:
{ "valid": true, "status": "valid", "tier": "VIP", "ticket_id": "tkt_4d5e6f..." }
When a ticket is not admissible, valid is false and reason is one of
not_found, refunded, revoked, already_used — or, for live wallet
codes: code_expired (real but stale; have the holder refresh and re-scan),
live_code_required (a static copy of a wallet code where the live one is
required), or invalid_qr (failed cryptographic verification). Whenever the
ticket was identified, the verdict includes its ticket_id; an already_used
verdict also carries the tier so door staff can see what was scanned.
POST /v1/scan/validate
Validate a single ticket for entry by its QR string. This does not mark the ticket used — call attendance to check in.
Scope: scanning:write
Request body
| 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 — ideal for an offline scanner draining its queue once back online. None are marked used. Returns a per-QR verdict array.
Scope: scanning:write
Request body
| 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.
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.
Event types
enabled_events accepts any of these types, or "*" for all:
event.created, event.updated, ticket.issued, ticket.confirmed,
ticket.confirmation_failed, ticket.transferred, ticket.refunded,
ticket.revoked, ticket.upgraded, ticket.redeemed,
payment.succeeded, payment.failed, marketplace.sale.completed,
attendance.verified, payout.paid
Delivery format and retries
Every delivery is a POST with headers Webhook-Signature
(t=<unix-seconds>,v1=<hex> — HMAC-SHA256 of <timestamp>.<raw body> with your
whsec_ secret), Webhook-Id (the event id, for deduping), and
Content-Type: application/json. The body envelope is:
{ "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.paid"]
}'
Example response
{
"id": "664f0a1b2c3d4e5f60718293",
"url": "https://acme.example/hooks/ticketconnect",
"enabled_events": ["ticket.issued", "payout.paid"],
"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
Onboard for payouts, check your balance, and move money out to your bank account. Onboarding runs through Stripe Connect; everything you see here is in your settlement currency (fiat).
Scopes: balance and payout reads accept either payouts:read or
payouts:claim; claiming a payout and both Connect onboarding endpoints
(including the status read) need payouts:claim.
POST /v1/connect/account
Begin or resume payout onboarding. Creates a connected account on first call and returns a fresh onboarding link to redirect your user to.
Scope: payouts:claim
This endpoint takes no request body.
Example request
curl https://api.ticketconnect.example/v1/connect/account \
-H "Authorization: Bearer sk_test_your_key_here" \
-X POST
Example response
{
"account_id": "acct_1NxYz...",
"onboarding_url": "https://connect.stripe.com/setup/e/acct_1NxYz.../abc123"
}
Errors:
502 api_errorif the onboarding link cannot be created.
GET /v1/connect/account
Retrieve your onboarding status — whether you can receive payouts yet. Returns only capability booleans, never any personal information.
Scope: payouts:claim
Example request
curl https://api.ticketconnect.example/v1/connect/account \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"connected": true,
"charges_enabled": true,
"payouts_enabled": true,
"details_submitted": true
}
Before onboarding has started:
{ "connected": false }
GET /v1/balance
Retrieve your single spendable balance in your settlement currency.
Scope: payouts:read (or payouts:claim)
Example request
curl https://api.ticketconnect.example/v1/balance \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"object": "balance",
"available": 12840.50,
"currency": "USD"
}
GET /v1/payouts
List payouts made to your bank account, newest first.
Scope: payouts:read (or payouts:claim)
Query params
| Param | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size, 1–100 (default 20). |
starting_after | string | No | Cursor — a payout id to page after. |
Example request
curl "https://api.ticketconnect.example/v1/payouts?limit=2" \
-H "Authorization: Bearer sk_test_your_key_here"
Example response
{
"object": "list",
"has_more": false,
"data": [
{
"id": "6651aa...",
"amount": 5000.00,
"currency": "USD",
"reference": "tr_1NxYz...",
"created_at": "2026-06-04T08:00:00.000Z"
}
]
}
POST /v1/payouts
Request a payout of your available balance to your connected bank account. If
you omit amount, the full available balance is paid out. The amount can never
exceed your balance.
Scope: payouts:claim
Request body
| Field | Type | Required | Description |
|---|---|---|---|
amount | number | No | Amount to pay out. Defaults to the full available balance. Must be > 0. |
currency | string | No | Currency; defaults to your settlement currency. |
Accepts an Idempotency-Key header.
Example request
curl https://api.ticketconnect.example/v1/payouts \
-H "Authorization: Bearer sk_test_your_key_here" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: payout-001" \
-d '{ "amount": 5000.00 }'
Example response
{
"object": "payout",
"id": "tr_1NxYz...",
"amount": 5000.00,
"currency": "USD",
"status": "paid"
}
Errors:
400 parameter_invalid(non-positive amount),400 insufficient_balance,400 connect_account_required(onboarding not complete),502 api_error. Emits apayout.paidwebhook.
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; none of that is ever exposed through the
API — the only handle you ever get is the opaque id. Everything below is
plain business language: names, permissions, event assignments, and scan counts.
A serialized staff object:
{
"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 TicketConnect staff app — Stripe Tap to Pay on the staff member's phone. Gate payments and wristbands are configured per event in the organizer panel's one-time "Set up event" step. There are no public/v1POS endpoints; the API surface for the door is scanning and attendance (see Scanning & Check-in).
POST /v1/staff
Create a door-staff member.
Scope: staff:manage
Request body
| 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.
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.
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. Flip them back on with
PATCH /v1/staff/{id}and{ "status": "active" }.
Discount triggers
A discount trigger links one of your saved segments to a
discount code, then lets you read exactly who qualifies for that code right
now. Use it to run targeted offers: "20% off for repeat buyers", "a comeback
code for no-shows". All trigger endpoints require the events:write scope.
This is a read model, not an automation. A trigger never emails anyone and never applies the code on its own. Unlike the first-party organizer panel — which can auto-send a code to a matching audience on a schedule — the API hands you the current match list and you deliver the code through your own channels (email, SMS, push, in-app). You stay in control of the message and the timing.
Because matches are evaluated fresh on every call against your live ticket
history, the list you read is always current — add a trigger once, then poll
GET .../matches whenever you're ready to send.
A serialized trigger object:
{
"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.
