# TicketConnect > TicketConnect documentation for two audiences. Licensees: the White-Label API — launch a fully branded ticketing product (events, paid tickets, QR check-in, resale, payouts) behind a clean, Stripe-style REST API. Organizers: guides for selling, reselling, scanning and getting paid for your events on the TicketConnect platform. Tamper-proof tickets and anti-scalping are built in. --- # 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**](#organizers-introduction) 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 ```text 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 ```bash 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**](#quickstart) to sell your first ticket end-to-end, or jump straight to the [**API reference**](#api-overview). ## Where to go next | If you want to… | Start here | | --- | --- | | Sell a ticket end-to-end | [Quickstart](#quickstart) | | Understand keys, scopes, and modes | [Authentication](#authentication) | | Learn the mental model | [How it works](#how-it-works) | | Receive real-time events | [Webhooks](#webhooks) | | Look up a specific endpoint | [API reference](#api-overview) | ## 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](#test-and-live-modes). **Base URL** for every call: ```text 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 `POST`s accept an optional `Idempotency-Key` header so retries are safe. * Errors come back as `{ "error": { "type", "code", "message", "param", "request_id" } }`. --- ## Step 1 — Get your API key Create a secret key in your dashboard. You'll get a key that starts with `sk_test_` (sandbox) or `sk_live_` (production). The key is shown **once** at creation, so store it somewhere safe. For this tutorial you only need a test key. For the full breakdown of key formats, the `Authorization: Bearer` transport, and the scopes each call requires, read [Authentication](#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** ```bash curl https://api.ticketconnect.example/v1/account \ -H "Authorization: Bearer sk_test_your_key_here" ``` **Node (fetch)** ```javascript const BASE = "https://api.ticketconnect.example/v1"; const KEY = "sk_test_your_key_here"; const res = await fetch(`${BASE}/account`, { headers: { Authorization: `Bearer ${KEY}` }, }); const account = await res.json(); console.log(account); ``` **Example response:** ```json { "id": "ten_8a1f...", "name": "Acme Tickets", "mode": "test", "status": "active", "currency": "USD" } ``` The `mode` field tells you whether this key is `test` or `live`. The `currency` is your account's settlement currency — every price in this guide is in that currency. --- ## Step 3 — Create an event **Endpoint:** `POST /v1/events` — requires the **`events:write`** scope. Only `name` and `date` are required. The other fields (venue, city, description, etc.) are optional. **cURL** ```bash 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)** ```javascript const res = await fetch(`${BASE}/events`, { method: "POST", headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", "Idempotency-Key": "evt-create-001", }, body: JSON.stringify({ name: "Summer Sounds 2026", date: "2026-08-15T19:00:00Z", venue: "Riverside Arena", city: "Austin", country: "US", }), }); const event = await res.json(); console.log(event.id); // evt_... ``` **Example response (trimmed):** ```json { "id": "evt_3f9c2a7b1e4d...", "name": "Summer Sounds 2026", "status": "draft", "date": "2026-08-15T19:00:00.000Z", "venue": "Riverside Arena", "city": "Austin", "country": "US", "currency": "USD", "ticketPools": [], "created_at": "2026-06-05T12:00:00.000Z" } ``` Save the `id` (e.g. `evt_3f9c2a7b1e4d...`) — you'll use it as `:id` in the next steps. > **Note:** A new event starts in `draft` status with no ticket tiers yet. The `date` field > accepts any value the standard date parser understands; an ISO 8601 timestamp is > the safest choice. --- ## Step 4 — Add a ticket tier A tier defines a name, a fiat price, and how many tickets are available. **Endpoint:** `POST /v1/events/:id/tiers` — requires the **`events:write`** scope. Required fields: `name` (string), `price` (a non-negative number in your currency), and `totalSupply` (a positive integer). You may also send `perks`, `upgradesEnabled`, and advanced sale options (a published `price_steps` schedule, `release_at` / `release_after_sold_out` auto-release) — see the [Events reference](#api-events). **cURL** ```bash 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)** ```javascript const eventId = "evt_3f9c2a7b1e4d..."; const res = await fetch(`${BASE}/events/${eventId}/tiers`, { method: "POST", headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: "General Admission", price: 49.0, totalSupply: 500, upgradesEnabled: true, }), }); const event = await res.json(); console.log(event.ticketPools); ``` **Example response (trimmed):** ```json { "id": "evt_3f9c2a7b1e4d...", "name": "Summer Sounds 2026", "status": "draft", "currency": "USD", "ticketPools": [ { "name": "General Admission", "price": 49, "totalSupply": 500, "issued": 0, "upgradesEnabled": true } ], "created_at": "2026-06-05T12:00:00.000Z" } ``` The response is the full updated event. The tier you just added appears in `ticketPools` with `issued: 0`. You'll reference the tier later by its `name` (here, `"General Admission"`). --- ## Step 5 — Create a customer A customer is just an email and a name, plus your own `externalId` so you can match the customer back to your system. **Endpoint:** `POST /v1/customers` — requires the **`customers:write`** scope. Required fields: `externalId` (your own id for this customer) and `email`. `name` is optional. **cURL** ```bash 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)** ```javascript const res = await fetch(`${BASE}/customers`, { method: "POST", headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ externalId: "user_42", email: "fan@example.com", name: "Jordan Rivera", }), }); const customer = await res.json(); console.log(customer.id); // "user_42" ``` **Example response:** ```json { "id": "user_42", "email": "fan@example.com", "name": "Jordan Rivera", "status": "active", "created_at": "2026-06-05T12:01:00.000Z" } ``` The customer's `id` is the `externalId` you supplied — use it as `customer_id` when issuing the ticket. The first time you reference a new customer the platform sets everything up behind the scenes, so the response is the same whether the customer is brand new or already on file. --- ## Step 6 — Take payment Because the tier above is priced above zero, the ticket must be paid for before it can be issued. Create a payment intent for the event and tier; the amount is computed **server-side** from the tier price, so you never send an amount. **Endpoint:** `POST /v1/payment_intents` — requires the **`payments:write`** scope. Required fields: `event_id` and `tier`. You may also pass `customer_id`. **cURL** ```bash 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)** ```javascript const res = await fetch(`${BASE}/payment_intents`, { method: "POST", headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", "Idempotency-Key": "pi-summer-001", }, body: JSON.stringify({ event_id: "evt_3f9c2a7b1e4d...", tier: "General Admission", customer_id: "user_42", }), }); const intent = await res.json(); console.log(intent.id, intent.status); ``` **Example response (trimmed):** ```json { "object": "payment_intent", "id": "pi_1QabcDEF...", "amount": 49, "currency": "USD", "status": "requires_payment", "client_payload": { "client_secret": "pi_1QabcDEF..._secret_..." } } ``` You collect the card payment on your side using `client_payload` (for example, with the standard payment SDK in your frontend). Once the payment succeeds, you'll pass the intent's `id` to the issuance call in the next step. > **Note:** In test mode, use the standard test card numbers to simulate a successful > payment — no real money moves. See [Test & live modes](#test-and-live-modes). --- ## Step 7 — Issue the ticket With a verified payment in hand, issue the ticket to your customer. **Endpoint:** `POST /v1/events/:id/tickets` — requires the **`tickets:issue`** scope. Required fields: `customer_id`, `tier`, and — for any paid tier — `payment_intent_id`. The platform re-verifies the payment, checks it matches this event and amount, and guards against using the same payment twice. > **High-demand on-sale?** If the event uses the on-sale waiting room (its > `queue` field is non-null), the customer must be **admitted by the queue** > before this call succeeds — otherwise it returns a `403` with a `queue_error` > (`not_admitted`). Join and poll with `POST /v1/events/:id/queue/join` and > `GET /v1/events/:id/queue?customer_id=…` (scope **`tickets:issue`**), then > issue once `status` is `admitted`. Events without a queue are unaffected. **cURL** ```bash 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)** ```javascript const eventId = "evt_3f9c2a7b1e4d..."; const res = await fetch(`${BASE}/events/${eventId}/tickets`, { method: "POST", headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", "Idempotency-Key": "issue-summer-001", }, body: JSON.stringify({ customer_id: "user_42", tier: "General Admission", payment_intent_id: "pi_1QabcDEF...", }), }); const ticket = await res.json(); console.log(ticket.id); // tkt_... ``` **Example response (trimmed):** ```json { "id": "tkt_5b8e1c2d3f4a...", "event_id": "evt_3f9c2a7b1e4d...", "event_name": "Summer Sounds 2026", "status": "valid", "tier": "General Admission", "price": 49, "currency": "USD", "qr": { "data": "a1b2c3d4e5f6...", "format": "QR" }, "created_at": "2026-06-05T12:02:00.000Z" } ``` Every ticket is tamper-proof and anti-scalping is on by default — all handled for you (and configurable per event; see [Marketplace and resale](#marketplace-and-resale)). The `qr.data` string is the opaque code your scanner reads at the door. > **Tip:** The same data is also delivered to your webhook as a **`ticket.issued`** event, > so your systems can react in real time. See the > [Webhooks guide](#webhooks). --- ## 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** ```bash curl https://api.ticketconnect.example/v1/tickets/tkt_5b8e1c2d3f4a... \ -H "Authorization: Bearer sk_test_your_key_here" ``` **Node (fetch)** ```javascript const ticketId = "tkt_5b8e1c2d3f4a..."; const res = await fetch(`${BASE}/tickets/${ticketId}`, { headers: { Authorization: `Bearer ${KEY}` }, }); const ticket = await res.json(); console.log(ticket.qr.data); ``` **Example response (trimmed):** ```json { "id": "tkt_5b8e1c2d3f4a...", "event_name": "Summer Sounds 2026", "status": "valid", "tier": "General Admission", "qr": { "data": "a1b2c3d4e5f6...", "format": "QR" }, "created_at": "2026-06-05T12:02:00.000Z" } ``` Render `qr.data` as a QR code and deliver it through your own app, email, or ticket page. No internal identifiers are ever returned. --- ## Step 9 — Check in at the door When the attendee arrives, validate the QR they present. Validation returns a verdict without consuming the ticket. **Endpoint:** `POST /v1/scan/validate` — requires the **`scanning:write`** scope. Required field: `qr` (the `qr.data` value from the ticket). **cURL** ```bash 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)** ```javascript const res = await fetch(`${BASE}/scan/validate`, { method: "POST", headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ qr: "a1b2c3d4e5f6..." }), }); const verdict = await res.json(); console.log(verdict.valid); // true ``` **Example response (valid ticket):** ```json { "valid": true, "status": "valid", "tier": "General Admission", "ticket_id": "tkt_5b8e1c2d3f4a..." } ``` If the ticket has already been used or refunded, `valid` is `false` and `reason` explains why (for example `"already_used"` or `"refunded"`). > **Record the actual check-in.** `POST /v1/scan/validate` only checks validity. To > mark a ticket as attended, call **`POST /v1/tickets/:id/attendance`** (also > **`scanning:write`**) — it's safe to retry and reports `already_used` if the > ticket was already checked in. For offline scanners draining a queue, use > **`POST /v1/scan/batch`** to validate up to 200 codes in one call. --- ## Step 10 (optional) — Receive a webhook Instead of polling, register a webhook endpoint and let TicketConnect push events to you — `ticket.issued` when a ticket is created, `attendance.verified` on check-in, and more. Webhooks are signed so you can verify them with existing SDKs. Manage endpoints with `POST /v1/webhook_endpoints` (requires the **`webhooks:manage`** scope). See the full walkthrough in the [Webhooks guide](#webhooks). --- ## You did it You just sold and scanned a ticket end-to-end: 1. Confirmed your key with `GET /v1/account`. 2. Created an event and a ticket tier. 3. Registered a customer. 4. Took a card payment and issued a tamper-proof ticket. 5. Fetched its QR pass and validated it at the door. > **Next steps** > > * [Authentication](#authentication) — keys, the `Bearer` transport, and the full scopes table. > * [Test & live modes](#test-and-live-modes) — go live safely after testing in the sandbox. > * [How it works](#how-it-works) — the mental model behind events, tickets, and payments. > * [Webhooks guide](#webhooks) — receive real-time, signed events. > * [API reference](#api-overview) — 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). ```text 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](#test-and-live-modes) for the full picture. > **Warning:** A secret key can create events, take payments, and issue tickets. **Treat it like > a password.** Use it only from your backend — never embed it in a mobile app, > single-page app, or anything that runs on a user's device. ### Keys are shown once When you create a key, the full value is returned **exactly once**, at creation. After that, TicketConnect stores only a hashed version and can never show you the plaintext again. Copy it immediately into a secrets manager or environment variable. If you lose a key, you can't recover it — create a new one and revoke the old. ### Self-serve key management You can mint, list, and revoke keys **through the API itself** — no dashboard round-trip needed for rotation: * `POST /v1/account/keys` mints a new key (the plaintext is in that response, once). A minted key's scopes must be a **subset of the calling key's**, and a test-mode key can never mint a live-mode key — no privilege escalation. Minting a live-mode key additionally requires live mode to be activated on the account; until then the request is rejected with `mode_unavailable`. * `GET /v1/account/keys` lists your keys (prefix, label, scopes, timestamps); revoked keys stay listed as an audit trail. Secrets are never returned. * `DELETE /v1/account/keys/{prefix}` revokes a key immediately. The calling key can never revoke itself, so a rotation always overlaps. All three require the **`keys:manage`** scope, and active keys are capped at **25 per account**. See the [Account reference](#api-account) for the full endpoint details. ### Publishable keys Alongside secret keys, you can be issued **publishable keys** with a `pk_` prefix (`pk_test_…` / `pk_live_…`). They exist for one purpose: the browser-facing **hosted checkout** surface. A publishable key: * is safe to expose in a browser — it is always confined to the `checkout:read` and `checkout:write` scopes, regardless of what is stored on the key; * only works on the `/v1/checkout/…` endpoints — every secret endpoint rejects a publishable key with a `401`; * carries the same test/live mode split in its prefix as a secret key. The reverse also holds: secret (`sk_`) keys are rejected on the publishable checkout surface, so a leaked page can never be escalated. See the [Payments reference](#api-payments) for the checkout endpoints themselves. ## The Authorization header Send your key as a bearer token in the `Authorization` header on every request: ```text Authorization: Bearer sk_test_your_key_here ``` **cURL** ```bash curl https://api.ticketconnect.example/v1/account \ -H "Authorization: Bearer sk_test_your_key_here" ``` **Node (fetch)** ```javascript const res = await fetch("https://api.ticketconnect.example/v1/account", { headers: { Authorization: "Bearer sk_test_your_key_here" }, }); const account = await res.json(); console.log(account.mode); // "test" or "live" ``` A good first call is `GET /v1/account` — it confirms the key is valid and tells you which `mode` it operates in. If your HTTP client makes the `Authorization` header awkward to set, the API also accepts the same key in an `x-api-key` header. Prefer the `Bearer` transport — it is what every example in these docs uses. > **Note:** A few endpoints are **public** and need no key at all: `GET /v1/health` > (uptime checks), `GET /v1/openapi.json` (the machine-readable spec, useful for > codegen before you have a key), and `GET /v1/docs` (the interactive Swagger > UI). Everything else requires authentication. ## Key safety & rotation * **Store keys as secrets.** Use environment variables or a secrets manager — never source control. * **Use one key per environment.** Keep test and live keys separate, and ideally scope keys per integration so you can revoke one without breaking others. * **Rotate regularly, and immediately if a key may have leaked.** Mint a new key with `POST /v1/account/keys`, deploy it, then revoke the old one with `DELETE /v1/account/keys/{prefix}`. Because keys are stored hashed, revocation is the only remediation for a compromised key. * **Grant least privilege.** Give each key only the scopes it needs (see below). A scanning app at the door, for example, needs `scanning:write` and nothing more. ## Scopes Each key carries a set of **scopes** that define exactly which operations it can perform. If a key is missing a scope an operation needs, the request is rejected with the standard error envelope and an HTTP `403`: ```json { "error": { "type": "authorization_error", "code": "forbidden", "message": "Missing required scope: events:write", "request_id": "8f2c1a4e-…" } } ``` The full set of scopes and what each unlocks: | 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:write` key > can read back the payments it created, and `payouts:claim` includes everything > `payouts:read` can do. When in doubt, the per-endpoint documentation in the > [API reference](#api-overview) states the exact scope required. ## Next steps * [Test & live modes](#test-and-live-modes) — integrate in the sandbox first, then go live. * [Quickstart](#quickstart) — sell and scan a ticket end-to-end. * [API reference](#api-overview) — 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 a `403` — never silently downgraded to test. Test mode is always > available. ```text https://api.ticketconnect.example/v1 ``` The base URL is the same for both. Send a test key and you're in the sandbox; send a live key and you're in production. ## Same code paths, isolated data A test key exercises **the same endpoints and the same logic** as a live key — so if your integration works in test, it works in production. The difference is isolation: * Test events, tiers, customers, tickets, payments, and payouts live in a **separate sandbox** and never appear in your live data (and vice versa). * Payments in test mode are **simulated** — use the standard test card numbers to trigger success or failure. No real money ever moves. * Webhooks, reports, balances, and scanning all work the same way in test, scoped to your test data only. > **Warning:** The two worlds never cross. A `sk_test_` key cannot read or modify live data, and > a `sk_live_` key cannot see anything you created in test. An id created in one > mode is meaningless in the other. ## Telling which mode a key is in There are two reliable ways: 1. **The prefix.** `sk_test_…` is test; `sk_live_…` is live. 2. **Ask the API.** `GET /v1/account` returns a `mode` field: **cURL** ```bash curl https://api.ticketconnect.example/v1/account \ -H "Authorization: Bearer sk_test_your_key_here" ``` **Node (fetch)** ```javascript const res = await fetch("https://api.ticketconnect.example/v1/account", { headers: { Authorization: "Bearer sk_test_your_key_here" }, }); const { mode } = await res.json(); console.log(mode); // "test" ``` ```json { "id": "ten_8a1f...", "name": "Acme Tickets", "mode": "test", "status": "active", "currency": "USD" } ``` ## Best practices * **Build in test first.** Wire up your entire flow — create an event, take a payment, issue a ticket, scan it — using a `sk_test_` key before you touch live. Run the [Quickstart](#quickstart) end-to-end in test. * **Key your config by mode.** Store `sk_test_` and `sk_live_` keys in separate environment variables and select the right one per deployment. Never hardcode a key or let a live key leak into a staging build. * **Test the unhappy paths too.** Use test cards to simulate declined payments, and exercise refunds, already-used scans, and missing-scope errors so your error handling is solid before launch. * **Verify webhooks in test.** Register a test webhook endpoint and confirm you receive and verify signed events before relying on them in production. See the [Webhooks guide](#webhooks). * **Flip to live by swapping the key.** When you're confident, change the key from `sk_test_` to `sk_live_` — no code changes required. Double-check `GET /v1/account` reports `"mode": "live"` before going live for real. > **Tip:** Because test and live share the same code and contract, "it works in test" is a > real guarantee about the API surface. The only things you can't fully exercise in > the sandbox are real-money settlement and bank payouts — validate those carefully > on your first live transactions. ## Next steps * [Quickstart](#quickstart) — run the full flow in test mode. * [Authentication](#authentication) — key formats, the `Bearer` transport, and scopes. * [API reference](#api-overview) — 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](#introduction) 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. ```text 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](#api-account) | | **Organization** | An event organizer your account can read from the catalog. | [Events](#api-events) | | **Event** | A single event you sell tickets for. | [Events](#api-events) | | **Tier** | A price level on an event (name, price, supply, perks). | [Events](#api-events) | | **Ticket** | An issued, tamper-proof ticket held by a customer. | [Tickets](#api-tickets) | | **Customer** | A person who holds tickets, identified by email. | [Customers](#customers) | | **Payment** | A card charge, computed and verified server-side. | [Payments](#money-and-currencies) | | **Queue** | The waiting room gating issuance for a high-demand on-sale. | [Events](#api-events) | | **Listing** | A secondary-market resale of a ticket. | [Marketplace](#api-marketplace) | | **Payout** | A fiat transfer of your balance to your bank. | [Payouts](#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](#test-and-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** ```bash curl https://api.ticketconnect.example/v1/customers \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "externalId": "user_42", "email": "ada@example.com", "name": "Ada Lovelace" }' ``` **Response** ```json { "id": "user_42", "email": "ada@example.com", "name": "Ada Lovelace", "status": "active", "created_at": "2026-06-05T12:01:00.000Z" } ``` Requires the `customers:write` scope. This endpoint accepts an [`Idempotency-Key`](#idempotency) header so retries are safe. ## Provisioning happens transparently Creating the customer is the only setup step. The moment you `POST /v1/customers`, the platform quietly does everything needed to make that customer's future tickets tamper-proof and deliverable behind the scenes: * It is **idempotent** — creating the same `externalId` again returns the existing customer unchanged; nothing is set up twice, and retries are safe. * It is **invisible** — none of the internal setup is exposed in your request or response, and it never asks the customer for anything. * It is **required before issuance** — a ticket can only be issued to a customer that already exists; referencing an unknown `customer_id` at issuance returns a `404`. In short: create the customer first, then issue tickets. The platform handles the rest. ## The relationship to tickets Every ticket belongs to exactly one customer at a time. You reference the customer's `id` as `customer_id` when issuing, and retrieve the customer record itself at any time: ```bash curl https://api.ticketconnect.example/v1/customers/user_42 \ -H "Authorization: Bearer sk_test_your_key_here" ``` When you [transfer a ticket](#transfer-refund-upgrade), 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](#api-customers) 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: ```text 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](#transfer-refund-upgrade): moving a ticket to a higher tier charges the **fiat price difference**, again computed on the server. See [Take a payment](#take-a-payment) for the end-to-end flow and [Issue a ticket](#issue-a-ticket) for issuance. ## Amount format Amounts in the API — tier prices, payment `amount`s, 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](#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](#payouts) to set up payouts and withdraw your balance. --- # Idempotency Networks fail, requests time out, and clients retry. Idempotency lets you retry a mutating request **without risking a duplicate** — a second ticket issued, a second charge created. You send a unique key with the request; if you send the same key again, the platform returns the **original response** instead of doing the work twice. ## How it works Add an `Idempotency-Key` header to a mutating `POST`: * The **first** request with a given key runs normally, and its response is stored against that key. * A **retry** with the same key **and the same request** returns the stored response verbatim — same status code, same body. The operation runs only once. * A retry with the same key but a **different request body** is rejected with `409` and an `idempotency_error` (code `idempotency_key_reuse`). Reusing a key for a different request is always a bug. Stored responses are kept for **24 hours**. After that, the same key is treated as new. > **Note:** Scope an `Idempotency-Key` to a single logical operation. Generate a fresh key > for each new request you make, and reuse that same key only when retrying that > exact request. ## Recommended key format Use a **random UUID (v4)** per operation. It's collision-resistant and easy to generate in any language. ```bash KEY=$(uuidgen) # e.g. 3f9b1c2e-7a4d-4e1b-9c2a-1d6f0b8e5a21 ``` ## Which endpoints support it The `Idempotency-Key` header is honored on mutating `POST` endpoints, most importantly: * `POST /v1/customers` — create a customer * `POST /v1/events` — create an event * `POST /v1/events/:id/tiers` — add a ticket tier * `POST /v1/events/:id/tickets` — issue a ticket * `POST /v1/payment_intents` — create a payment intent * `POST /v1/tickets/:id/refund` — refund a ticket * `POST /v1/tickets/:id/upgrade` — upgrade a ticket * `POST /v1/tickets/:id/attendance` — record a check-in * `POST /v1/payouts` — request a payout Sending the header on an endpoint where it isn't needed is harmless — it's simply ignored. Omitting it runs the request normally with no replay protection. ## Example: a retried request The same key is sent twice. The first call issues one ticket; the retry returns that same ticket without issuing a second one. ```bash 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](#errors) for the `idempotency_error` envelope. --- # Errors Every error response uses a single, predictable envelope — the same shape across the entire API. Branch your handling on the machine-readable `type` and `code`; show or log the `message`; quote the `request_id` to support. ## The error envelope ```json { "error": { "type": "invalid_request_error", "code": "parameter_missing", "message": "tier is required.", "param": "tier" } } ``` | 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 `code` values can be added over time within an existing `type`. Always handle > the `type` you recognize and treat unknown `code`s 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. ```bash # Log the correlation id from any response. curl -si https://api.ticketconnect.example/v1/events/evt_does_not_exist \ -H "Authorization: Bearer sk_test_your_key_here" \ | grep -i x-request-id ``` ## Example error responses **401 Authentication** ```json { "error": { "type": "authentication_error", "code": "unauthorized", "message": "Invalid API key", "request_id": "1b9d33a0-…" } } ``` **409 Idempotency** ```json { "error": { "type": "idempotency_error", "code": "idempotency_key_reuse", "message": "This Idempotency-Key was used with a different request." } } ``` **429 Rate limit** ```json { "error": { "type": "rate_limit_error", "code": "rate_limited", "message": "Rate limit exceeded", "request_id": "4e8a90fb-…" } } ``` See [Idempotency](#idempotency) for the `409` case in context. --- # Pagination List endpoints return results in pages using **cursor pagination**. You control the page size with `limit` and walk forward with `starting_after`, using the id of the last item you received as the cursor for the next page. ## The list envelope Every list endpoint returns the same shape: ```json { "object": "list", "data": [ { "id": "evt_300", "...": "..." }, { "id": "evt_299", "...": "..." } ], "has_more": true } ``` | 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/events` * `GET /v1/tickets` * `GET /v1/marketplace/listings` * `GET /v1/discounts` * `GET /v1/payouts` * `GET /v1/events/deliveries` (webhook deliveries) Some list endpoints also accept filters (for example `event_id` on `GET /v1/tickets`); filters compose with pagination. ## Paging through every result **curl** ```bash #!/usr/bin/env bash KEY="sk_test_your_key_here" BASE="https://api.ticketconnect.example/v1/events?limit=100" after="" while :; do url="$BASE" [ -n "$after" ] && url="$BASE&starting_after=$after" page=$(curl -s "$url" -H "Authorization: Bearer $KEY") echo "$page" | jq -c '.data[]' has_more=$(echo "$page" | jq -r '.has_more') [ "$has_more" != "true" ] && break after=$(echo "$page" | jq -r '.data[-1].id') done ``` **Node** ```js const KEY = "sk_test_your_key_here"; const BASE = "https://api.ticketconnect.example/v1/events"; async function* listAllEvents() { let startingAfter; while (true) { const url = new URL(BASE); url.searchParams.set("limit", "100"); if (startingAfter) url.searchParams.set("starting_after", startingAfter); const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` }, }); const page = await res.json(); for (const item of page.data) yield item; if (!page.has_more) break; startingAfter = page.data[page.data.length - 1].id; } } for await (const event of listAllEvents()) { console.log(event.id); } ``` > **Note:** Use the largest `limit` (100) when bulk-syncing to minimize round trips, and a > smaller `limit` for interactive, user-facing lists. --- # Issue a ticket Issuing a ticket is how you put a real, scannable ticket in a customer's hands. You tell TicketConnect which event, which tier, and which customer, and (for paid tiers) attach the payment that funds it. The platform creates a tamper-proof ticket and returns it with everything you need to deliver it. Use this guide when you've already built your event catalog and you're ready to sell. If you haven't created an event, a tier, or a customer yet, do that first — they're prerequisites for every issuance. ## Before you start You'll need: * An API key with the **`tickets:issue`** scope (and **`payments:write`** if you also create the payment yourself — see [Take a payment](#take-a-payment)). * An **event** that exists under your account. * A **ticket tier** on that event (name, fiat price, supply). * A **customer** record that is fully provisioned (`status: "active"`). A customer created moments ago may still be `pending`; issuance to a non-provisioned customer is rejected. Base URL for every call: ```text 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. ```bash curl https://api.ticketconnect.example/v1/payment_intents \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "event_id": "evt_summerfest", "tier": "General Admission", "customer_id": "user_a1b2c3" }' ``` ```json { "object": "payment_intent", "id": "pi_3PxyzABC", "provider": "stripe", "client_payload": { "client_secret": "pi_3PxyzABC_secret_..." }, "amount": 49.00, "currency": "USD", "status": "requires_payment" } ``` Collect the card details and complete the payment on the client using the returned `client_payload` (for Stripe, its `client_secret`), exactly as you would with Stripe. Once the payment status is `succeeded`, you're ready to issue. Full details, including test cards, are in [Take a payment](#take-a-payment). > **Free and comp tickets skip this step.** If the tier price is `0`, you can issue > without a `payment_intent_id`. ## Step 2 — Issue the ticket Call the issuance endpoint with the customer, the tier, and the payment that funds it. > **High-demand on-sale? Get the customer admitted first.** If the event runs an > on-sale waiting room (its event object has a non-null `queue`), issuance is > gated on queue admission: join the queue with > `POST /v1/events/:id/queue/join`, poll `GET /v1/events/:id/queue?customer_id=...` > until `status` is `admitted` (with a live `access_expires_at`), then issue. > Issuing before admission returns `403` with error type `queue_error` and code > `not_admitted`. Events without a waiting room are unaffected. **Endpoint:** `POST /v1/events/:id/tickets` · **Scope:** `tickets:issue` ```bash curl https://api.ticketconnect.example/v1/events/evt_summerfest/tickets \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: issue_order_88231" \ -d '{ "customer_id": "user_a1b2c3", "tier": "General Admission", "payment_intent_id": "pi_3PxyzABC" }' ``` ```json { "id": "tkt_4f8a9c0d1e2f", "event_id": "evt_summerfest", "event_name": "Summer Fest 2026", "status": "valid", "tier": "General Admission", "price": 49.00, "currency": "USD", "perks": { "entry": "main_gate" }, "qr": { "data": "8f2c1a...e7", "format": "QR" }, "created_at": "2026-06-05T14:22:00.000Z" } ``` A `201` response means the ticket is issued. The customer now holds it. ## What the returned ticket contains | 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](#deliver-tickets). ## Reserved seating: seatmap → hold → issue If the event has a **published seat map**, its seated tiers are sold by the seat, and issuing on such a tier without a hold is rejected with `400 seat_required`. The flow adds one step in front of payment: 1. **Read the map.** `GET /v1/events/:id/seatmap` (scope `events:read`) returns the layout, live availability (`sold` / `held` / `blocked` seat ids), and per-tier pricing — render it and let the customer pick seats. 2. **Hold the seats.** `POST /v1/events/:id/seats/hold` with `{ "seat_ids": ["S1-A-1", "S1-A-2"] }` (scope `tickets:issue`, up to 10 seats) atomically claims them for **10 minutes** and returns a `hold_id`. If someone got there first you get `409 seats_unavailable` naming the contested seats. 3. **Take the payment.** The payment must cover the tier price × the number of held seats. Card entry running long? `POST /v1/events/:id/seats/hold/:holdId/extend` resets the clock to 10 minutes from now. 4. **Issue with the hold.** Call `POST /v1/events/:id/tickets` as usual, plus `"hold_id": "..."` — one ticket is issued per held seat, each bound to its seat, and the response becomes a batch: ```json { "tickets": [ { "id": "tkt_...", "tier": "Stalls", "seat": "A-1", "qr": { "data": "...", "format": "QR" } }, { "id": "tkt_...", "tier": "Stalls", "seat": "A-2", "qr": { "data": "...", "format": "QR" } } ] } ``` Pass `"quantity": 2` alongside `hold_id` as a safety check — issuance fails with `400 quantity_mismatch` unless it equals the number of held seats (any `quantity` other than `1` requires a `hold_id`). If the customer walks away, release the hold with `DELETE /v1/events/:id/seats/hold/:holdId` (or just let it lapse). An expired hold fails issuance with `409 hold_expired` — re-hold and retry. See [Reserved seating](#api-seating) for the full endpoint contracts. ## How issuance protects you The endpoint enforces several rules so you can't accidentally oversell or double-charge: * **Payment must be verified, matching, and unused.** For a paid tier, the `payment_intent_id` you pass is checked server-side against the expected amount, currency, event, and your account. A payment that doesn't match, or that has already funded a ticket, is rejected. * **No double-spend.** A single payment can fund at most one ticket. Reusing a payment returns `409 payment_already_used`. * **No overselling.** Supply for each tier is claimed atomically. If the last seat is taken by a concurrent issue, you get `409 sold_out` and no ticket is created. * **Waiting-room admission.** On events with an on-sale queue, a ticket is only issued to a customer who currently holds a live admission window — see the hint in Step 2. * **Per-person purchase caps.** If the event sets a per-person ticket limit (`max_tickets_per_identity`), issuance past the cap — including free and comp tickets — returns `403 purchase_limit_reached`. > **Always send an `Idempotency-Key`.** If a network hiccup makes you retry an > issuance, the same key returns the original ticket instead of creating a second > one (and double-claiming supply). Keys are honored for 24 hours. ### Common errors | 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](#take-a-payment) — create and confirm the card payment. * [Deliver tickets](#deliver-tickets) — get the QR into your customer's hands. * [Transfer, refund & upgrade](#transfer-refund-upgrade) — lifecycle operations. * [API reference: issue a ticket](#api-tickets) --- # Take a payment Every paid ticket is funded by a card payment. You create a **payment intent** for a specific event and tier, collect the card on the client, and the resulting payment becomes the proof of funds you attach when you issue or upgrade a ticket. If you've integrated Stripe before, this will feel familiar: a server-created intent, a client-side confirmation, and a status you can poll. The key difference is that **TicketConnect computes the amount for you** — you never send a price, so a client can't tamper with what gets charged. ## Before you start You'll need: * An API key with the **`payments:write`** scope to create payments. * An **event** with at least one priced **tier**. Reading a payment back (`GET /v1/payments/:id`) is allowed with either `payments:write` or `payments:read`. Base URL: ```text 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. ```bash curl https://api.ticketconnect.example/v1/payment_intents \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: pay_order_88231" \ -d '{ "event_id": "evt_summerfest", "tier": "General Admission", "customer_id": "user_a1b2c3" }' ``` ```json { "object": "payment_intent", "id": "pi_3PxyzABC", "provider": "stripe", "client_payload": { "client_secret": "pi_3PxyzABC_secret_..." }, "amount": 49.00, "currency": "USD", "status": "requires_payment" } ``` > **The amount is server-authoritative.** There is no `amount` field in the request > body, and any extra keys are ignored. The charge always equals the tier price on > your event, in your account's currency. This is what stops a manipulated client > from underpaying. The `client_payload` carries the details your client needs to confirm the card (for the Stripe provider, a `client_secret`). Pass it to the TicketConnect client integration the same way you'd hand a client secret to Stripe's SDK, and confirm the card in the browser or app. ## Step 2 — Confirm the card Card confirmation happens on the client, with the customer's card details — they never touch your server. After the customer submits their card, the payment moves toward `succeeded`. ### Test cards In test mode (`sk_test_...` keys), use your payment provider's standard test cards to simulate outcomes — a card that always succeeds, one that always declines, one that triggers authentication, and so on. Test-mode payments are fully isolated from live data, so you can run a full issuance end-to-end without charging anyone. ## Step 3 — Retrieve a payment Check a payment's current status at any time. **Endpoint:** `GET /v1/payments/:id` · **Scope:** `payments:read` or `payments:write` ```bash curl https://api.ticketconnect.example/v1/payments/pi_3PxyzABC \ -H "Authorization: Bearer sk_test_..." ``` ```json { "object": "payment_intent", "id": "pi_3PxyzABC", "amount": 49.00, "currency": "USD", "status": "succeeded" } ``` A payment is only ever visible to the account that created it; requesting a payment that belongs to another account returns `404 resource_missing`. ## How a payment ties into issuance and upgrades A payment on its own doesn't put a ticket in anyone's hands — it's the funding proof you attach to the next step: * **Issuance.** Pass the payment as `payment_intent_id` to `POST /v1/events/:id/tickets`. The payment must be `succeeded`, match the tier amount and currency, and not have funded another ticket already. See [Issue a ticket](#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](#transfer-refund-upgrade). > **Waiting-room events: charge after admission.** If the event runs an on-sale > queue (its event object has a non-null `queue`), admission is enforced when the > ticket is issued — not when the intent is created. Join the queue and poll > `GET /v1/events/:id/queue?customer_id=...` until `status` is `admitted` before > you take the payment; otherwise the charge can succeed and issuance still > return `403 not_admitted` until the customer is admitted. > **Reserved seating: hold the seats before you charge.** On events with a > published seat map, hold the customer's seats first > (`POST /v1/events/:id/seats/hold` — seats stay yours for 10 minutes), then > take the payment, then issue with the returned `hold_id`. Pass > `quantity: ` 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](#issue-a-ticket) for the full flow. The money flow is entirely card-in, fiat-out: the customer pays by card in your currency, and your proceeds settle to your bank account as a fiat payout. Your account always shows a single number — your balance in your currency. > **Use an `Idempotency-Key` on every create.** Retrying a payment-intent creation > with the same key returns the original intent instead of creating a duplicate > charge. Keys are honored for 24 hours. ### Common errors | 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](#issue-a-ticket) — turn a successful payment into a ticket. * [Transfer, refund & upgrade](#transfer-refund-upgrade) — charge the difference on an upgrade. * [API reference: payments](#api-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](#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:read`** scope. * An issued ticket id (`tkt_...`). Base URL: ```text https://api.ticketconnect.example/v1 ``` ## Step 1 — Fetch the ticket **Endpoint:** `GET /v1/tickets/:id` · **Scope:** `tickets:read` ```bash curl https://api.ticketconnect.example/v1/tickets/tkt_4f8a9c0d1e2f \ -H "Authorization: Bearer sk_test_..." ``` ```json { "id": "tkt_4f8a9c0d1e2f", "event_id": "evt_summerfest", "event_name": "Summer Fest 2026", "status": "valid", "tier": "General Admission", "qr": { "data": "8f2c1a...e7", "format": "QR" }, "created_at": "2026-06-05T14:22:00.000Z" } ``` The `qr.data` field is your raw QR string, and `qr.format` tells you how to render it (e.g. `QR`). ## Step 2 — Present the ticket to your customer ### Option A — Render the QR string yourself Take `qr.data` and render it as a QR code with any standard library in your stack, then show it in your app, your email, or a printed PDF. This is the most flexible option and works everywhere. > **`qr.data` is an opaque string.** It's a random value that uniquely identifies > the ticket for scanning — it carries no personal or sensitive data, and it isn't > a price, a customer id, or anything you should parse. Just render it. ### Option B — Let the hosted checkout show it If the ticket was bought through the hosted checkout page (`GET /v1/checkout/:eventId?key=pk_...`), the buyer sees the rendered QR code on-screen the moment the purchase completes — no front-end work on your side. It's the same `qr.data` you can fetch later with `GET /v1/tickets/:id`, so you can re-deliver it through your own channels at any time. ### Option C — Send a hosted ticket page link Mint a signed link to a ready-made mobile ticket page and deliver *that* — in an email, an SMS, or a "view your ticket" button: ```bash curl https://api.ticketconnect.example/v1/tickets/tkt_4f8a9c0d1e2f/delivery_link \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "expires_in_days": 30 }' ``` ```json { "object": "delivery_link", "url": "https://api.ticketconnect.example/v1/t/dGt0XzRmOGE5YzBkLi4u...", "expires_at": "2026-08-10T14:22:00.000Z" } ``` > **Links are bound to the QR code they were minted for.** A transfer rotates > the ticket's code, so every previously minted delivery link stops working — > the old holder's link can never show the new holder's code. Mint a fresh > link after a transfer. Revoking the signing secret (`TICKET_LINK_SECRET`) > invalidates all outstanding links at once. The page shows your logo, the event details, the holder's name and masked email, a live status badge, and the rendered entry QR — self-contained, mobile first, no login. It is **live**: a refund or revocation flips the badge the moment it happens, so the page is always truthful even while the link stays valid. Links expire after `expires_in_days` (1–90, default 30); expired or tampered links all render one identical 404 page. Minting needs only the `tickets:read` scope. See the [API reference](#api-tickets) for the full contract. > **No pass file through this API.** `/v1` does not expose an Apple/Google > pass file for a ticket — the hosted ticket page is the ready-made delivery > surface, and `qr.data` remains the delivery contract for everything you > build yourself. Holders who keep the ticket in a TicketConnect ticket > wallet **can** save it to Apple/Google Wallet from there — but only for > events with `live_codes` off: a wallet pass is a static barcode, and the > platform refuses one where a live, refreshing code is enforced (that > refusal is what makes the anti-screenshot layer real). ## At the door Whichever way the customer receives it, your scanners validate it through the same endpoints. The ticket's `status` reflects its lifecycle — a freshly issued ticket is `valid`, and it becomes `used` after check-in. A refunded ticket surfaces as `refunded` here (a transfer keeps the ticket `valid` — same ticket, new owner), and a revoked ticket keeps its `status` but carries `revoked: true` and fails every scan. A quick `GET /v1/tickets/:id` always tells you whether a ticket is good to admit. > **Holders may also present the ticket from a TicketConnect ticket wallet.** > A customer who keeps their ticket in a TicketConnect-powered wallet (the > TicketConnect app, or any holder experience built on the same rails) shows a > **live code that refreshes every few seconds** instead of your static one. > Your scanning endpoints validate both — send whatever was scanned, verbatim, > and read the verdict (see > [Scanning & check-in](#scanning-and-check-in)). Your `qr.data` stays > valid alongside it. One consequence to know about: once the holder has > opened the ticket in a wallet, the ticket is **locked to them** — > `POST /v1/tickets/:id/transfer` then returns `409 ticket_not_transferable`. > Live codes are on by default and togglable per event (`live_codes` on the > [events API](#api-events)). ## See also * [Issue a ticket](#issue-a-ticket) — create the ticket you're delivering. * [Transfer, refund & upgrade](#transfer-refund-upgrade) — what happens to delivery after a lifecycle change. * [API reference: retrieve a ticket](#api-tickets) --- # Transfer, refund & upgrade Issuing a ticket isn't the end of its life. Customers change plans, want a better seat, or need their money back. This guide covers the three lifecycle operations you'll reach for most: **transferring** a ticket to another customer, **refunding** it, and **upgrading** it to a higher tier. All of these operations use the same scope and act on a ticket id (`tkt_...`). ## Before you start You'll need: * An API key with the **`tickets:manage`** scope — required for every operation in this guide. * An issued ticket id. * For upgrades that cost money, a payment for the price difference (scope `payments:write`; see [Take a payment](#take-a-payment)). Base URL: ```text 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. ```bash curl https://api.ticketconnect.example/v1/tickets/tkt_4f8a9c0d1e2f/transfer \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: transfer_88231" \ -d '{ "to_customer_id": "user_99887766" }' ``` ```json { "id": "tkt_4f8a9c0d1e2f", "event_id": "evt_summerfest", "event_name": "Summer Fest 2026", "status": "valid", "tier": "General Admission", "qr": { "data": "8f2c1a...e7", "format": "QR" }, "created_at": "2026-06-05T14:22:00.000Z" } ``` The ticket now belongs to the new customer — and **the QR code rotates**: the response (and the `ticket.transferred` webhook) carries a fresh `qr.data`, and the previous code stops scanning immediately. That kills any screenshot the previous holder kept, and it also invalidates any hosted delivery links minted before the transfer — mint a new one for the new holder. > **The recipient must exist first.** If `to_customer_id` doesn't match a > provisioned customer, you get `404 customer_not_found`. Create the customer > before transferring. > **A ticket opened in a TicketConnect ticket wallet can't be transferred.** > Once the holder has opened the ticket in a TicketConnect-powered wallet (the > TicketConnect app, or any holder experience built on the same rails), it is > locked to them and `POST /v1/tickets/:id/transfer` returns > `409 ticket_not_transferable` (the same code a `used` or `refunded` ticket > gets). Refunds are unaffected — a refunded ticket fails every scan > regardless. ## Refund a ticket Cancel a ticket and reverse the customer's payment. **Endpoint:** `POST /v1/tickets/:id/refund` · **Scope:** `tickets:manage` ```bash curl https://api.ticketconnect.example/v1/tickets/tkt_4f8a9c0d1e2f/refund \ -H "Authorization: Bearer sk_test_..." \ -H "Idempotency-Key: refund_88231" \ -X POST ``` ```json { "id": "tkt_4f8a9c0d1e2f", "event_name": "Summer Fest 2026", "status": "refunded", "tier": "General Admission", "qr": { "data": "8f2c1a...e7", "format": "QR" }, "created_at": "2026-06-05T14:22:00.000Z" } ``` The ticket's `status` becomes `refunded`, and the sale amount is debited back off your account balance. A refunded ticket is no longer valid for entry. Returning the money to your customer's card is handled between you and your payment provider — the refund call doesn't reverse the card charge itself. > **Refunds aren't reversible and can't be repeated.** Attempting to refund a > ticket that's already refunded returns `409 already_refunded`. ## Upgrade a ticket to a higher tier Move a ticket up to a more expensive tier, charging the customer the fiat difference. The ticket keeps its id and its QR code — only the tier, perks, and price change. ### Step 1 — List the available upgrade options **Endpoint:** `GET /v1/tickets/:id/upgrade-options` · **Scope:** `tickets:manage` This returns the higher tiers the ticket can move into — those that are priced above the current tier, have upgrades enabled, and still have supply — each with its price, the fiat price difference (`delta`), remaining supply, and perks. ```bash curl https://api.ticketconnect.example/v1/tickets/tkt_4f8a9c0d1e2f/upgrade-options \ -H "Authorization: Bearer sk_test_..." ``` ```json { "current_tier": "General Admission", "options": [ { "name": "VIP", "price": 149.00, "delta": 100.00, "available": 42, "perks": { "lounge": true } } ] } ``` ### Step 2 — Pay the difference If the upgrade has a positive price difference (the option's `delta`), take a payment for that amount first (see [Take a payment](#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. ```bash curl https://api.ticketconnect.example/v1/tickets/tkt_4f8a9c0d1e2f/upgrade \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: upgrade_88231" \ -d '{ "target_tier": "VIP", "payment_intent_id": "pi_9QabcDEF" }' ``` ```json { "id": "tkt_4f8a9c0d1e2f", "event_name": "Summer Fest 2026", "status": "valid", "tier": "VIP", "price": 149.00, "currency": "USD", "perks": { "lounge": true }, "qr": { "data": "8f2c1a...e7", "format": "QR" }, "created_at": "2026-06-05T14:22:00.000Z" } ``` The ticket is now on the higher tier, with the new perks and price, and the same QR code the customer already has. > **Upgrades can work even after check-in.** As long as the target tier allows > upgrades, a ticket can be moved up even if it's already been used — handy for > on-site "upgrade to VIP at the door" flows. The price difference is computed > server-side from the tiers, never trusted from the request. ### Common upgrade errors | 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](#issue-a-ticket) — create the ticket you're managing. * [Take a payment](#take-a-payment) — fund the difference on an upgrade. * [Deliver tickets](#deliver-tickets) — delivering the (freshly rotated) QR after a transfer. * [API reference: ticket lifecycle](#api-tickets) --- # 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}/attendance` is what actually records the check-in and flips the ticket to `used`. This is the state-changing step. For a venue with a steady connection, the simplest flow is: read the QR, call **attendance** by ticket id, and admit on a `used` result. For an **offline** scanner (spotty WiFi at the gate, a remote field, etc.), let the device validate against a cached ticket list locally, queue every scan, and then **drain the queue** with `POST /v1/scan/batch` once it's back online. Batch validation tells you, per QR, which scans were good and which weren't, so you can reconcile after the fact. ## Validate a single ticket at the door Send the QR string exactly as your scanner read it. Don't parse, trim, or re-encode it — a live wallet code is longer and looks like a JSON blob; it must arrive verbatim. ```bash curl https://api.ticketconnect.example/v1/scan/validate \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "qr": "QR_STRING_FROM_THE_TICKET" }' ``` A valid ticket returns: ```json { "valid": true, "status": "valid", "tier": "General Admission", "ticket_id": "tkt_8f2c..." } ``` A ticket that has already been scanned in returns a verdict you can show your staff directly: ```json { "valid": false, "reason": "already_used", "tier": "General Admission", "ticket_id": "tkt_8f2c..." } ``` The possible `reason` values for an invalid result are `not_found`, `refunded`, `revoked`, `already_used`, and — for live wallet codes — `code_expired`, `live_code_required`, and `invalid_qr` (see below). A `not_found` result carries no `ticket_id`. > **Note:** `POST /v1/scan/validate` is read-only — it never changes the ticket. Use it to > preview a verdict, then call the attendance endpoint to actually check the person > in. ## Live codes from wallet-held tickets Some holders keep their ticket in a **TicketConnect ticket wallet** — any holder experience built on the TicketConnect rails, the TicketConnect app being the reference one — instead of (or besides) whatever you delivered. The wallet shows a **live code that refreshes every few seconds** — an anti-screenshot measure — and your scanning endpoints validate it exactly like any other code: send the scanned string verbatim, get a verdict. The platform checks the code's authenticity and freshness cryptographically before the usual lifecycle verdict. Three extra `reason` values exist only for these codes, and each maps to a clear instruction for door staff: | `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](#api-events)). 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). ```bash curl https://api.ticketconnect.example/v1/scan/batch \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "qrs": [ "QR_STRING_1", "QR_STRING_2", "QR_STRING_3" ] }' ``` You get one verdict per QR, in a `results` array: ```json { "results": [ { "qr": "QR_STRING_1", "valid": true, "status": "valid", "tier": "VIP", "ticket_id": "tkt_a1..." }, { "qr": "QR_STRING_2", "valid": false, "reason": "already_used", "tier": "VIP", "ticket_id": "tkt_b2..." }, { "qr": "QR_STRING_3", "valid": false, "reason": "not_found" } ] } ``` > **Warning:** A single batch may contain at most **200** entries. Larger backlogs should be > split into multiple requests. Like `validate`, batch is a read-only check — it > reports verdicts but does not mark tickets used. ## Mark attendance by ticket id This is the call that records the check-in. Use the ticket's id (not the QR string), which you have from issuance, from a list, or from a `validate` response. ```bash curl https://api.ticketconnect.example/v1/tickets/tkt_8f2c.../attendance \ -X POST \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" ``` A first successful check-in returns: ```json { "id": "tkt_8f2c...", "status": "used" } ``` ### Idempotent re-scans Door scanning is messy — the same person gets scanned twice, a request times out and the device retries, two lanes scan the same group. The attendance endpoint is built for this: * Calling it again on a ticket that is **already** checked in is a **safe no-op**. Instead of an error, you get a `200` with `status: "already_used"`: ```json { "id": "tkt_8f2c...", "status": "already_used" } ``` * For exactly-once semantics on a flaky connection, send an `Idempotency-Key` header. A replay with the same key returns the original response rather than processing the check-in twice. ```bash curl https://api.ticketconnect.example/v1/tickets/tkt_8f2c.../attendance \ -X POST \ -H "Authorization: Bearer sk_test_..." \ -H "Idempotency-Key: door-lane-2-scan-90183" \ -H "Content-Type: application/json" ``` A successful check-in also triggers an `attendance.verified` webhook, so any back-office dashboards stay in sync automatically. > **Note:** A ticket that doesn't exist (or isn't yours) returns `404` with a > `resource_missing` error. A refunded ticket is reported as invalid by > `validate`; don't mark it attended. ## See also * [Webhooks](#webhooks) — receive `attendance.verified` in real time. * [Marketplace & resale](#marketplace-and-resale) — secondary listings. * [API reference](#api-overview) — 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](#payouts). * **Anti-scalping, on by default.** Every event ships with anti-scalping enforced and a resale ceiling of 150% of face value. Both the bounds (floor/ceiling), the resale-open window, and whether resale or anti-scalping is on at all are **per-event settings** you control when you create or update the event (`marketplace_settings` on [Events](#api-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. ```bash curl "https://api.ticketconnect.example/v1/marketplace/listings?limit=20" \ -H "Authorization: Bearer sk_test_..." ``` ```json { "object": "list", "data": [ { "id": "list_9a3f1c2e4b6d8f0a1b2c3d4e5f60718", "ticket_id": "tkt_8f2c...", "price": 75.00, "currency": "USD", "status": "active", "created_at": "2026-06-05T12:00:00.000Z" } ], "has_more": false } ``` To page through more results, use `starting_after` on the next request: ```bash curl "https://api.ticketconnect.example/v1/marketplace/listings?limit=20&starting_after=list_9a3f1c2e4b6d8f0a1b2c3d4e5f60718" \ -H "Authorization: Bearer sk_test_..." ``` > **Note:** A listing's `status` tells you where it is in its life. Once a listing sells, it > stops being `active` and you'll receive a `marketplace.sale.completed` webhook — > see [Webhooks](#webhooks). Use `ticket_id` to tie a listing back to the ticket > (`GET /v1/tickets/:id`) and, through it, the event. ## Retrieve a single listing ```bash curl https://api.ticketconnect.example/v1/marketplace/listings/list_9a3f1c2e4b6d8f0a1b2c3d4e5f60718 \ -H "Authorization: Bearer sk_test_..." ``` ```json { "id": "list_9a3f1c2e4b6d8f0a1b2c3d4e5f60718", "ticket_id": "tkt_8f2c...", "price": 75.00, "currency": "USD", "status": "active", "created_at": "2026-06-05T12:00:00.000Z" } ``` A listing id that doesn't exist (or isn't yours) returns `404` with a `resource_missing` error. ## See also * [Webhooks](#webhooks) — listen for `marketplace.sale.completed` and `ticket.transferred`. * [Payouts](#payouts) — resale royalties land in your balance. * [API reference](#api-overview) — 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 ```json { "id": "64fa1b2c9e7d654321fedcba", "code": "SUMMER10", "type": "percentage", "value": 10, "max_uses": 500, "used_count": 0, "expires_at": "2026-09-01T00:00:00.000Z", "active": true, "created_at": "2026-06-05T12:00:00.000Z" } ``` | 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. ```bash curl https://api.ticketconnect.example/v1/discounts \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "code": "SUMMER10", "type": "percentage", "value": 10, "max_uses": 500, "expires_at": "2026-09-01T00:00:00.000Z" }' ``` ```json { "id": "64fa1b2c9e7d654321fedcba", "code": "SUMMER10", "type": "percentage", "value": 10, "max_uses": 500, "used_count": 0, "expires_at": "2026-09-01T00:00:00.000Z", "active": true, "created_at": "2026-06-05T12:00:00.000Z" } ``` ## Create a fixed-amount discount For a `fixed` discount, `value` is an amount in your settlement currency. This is $15 off: ```bash curl https://api.ticketconnect.example/v1/discounts \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "code": "WELCOME15", "type": "fixed", "value": 15 }' ``` ```json { "id": "64fa77c1e9b7d654321fedcb", "code": "WELCOME15", "type": "fixed", "value": 15, "max_uses": null, "used_count": 0, "expires_at": null, "active": true, "created_at": "2026-06-05T12:05:00.000Z" } ``` > **Note:** You can scope a code to a single event by passing its id in the `event_ids` > array on create (only the first entry is used — one code, one event). Omit it > for an account-wide code. If you don't pass a `code`, the API generates a > unique one (e.g. `DISC-A1B2C3D4E5F6`) and returns it. Codes are matched > **uppercase** at redemption, so create codes in uppercase. ## How discounts apply at checkout A discount changes the amount your customer pays — nothing more: * The customer enters the `code` at checkout. * The platform validates it — active, not expired, under `max_uses`, not already redeemed by that customer (each customer can use a given code once), and applicable to the event — and applies it to the order. * A `percentage` code subtracts that percentage from the order total. `fixed` codes can be created and managed through the API, but checkout currently applies percentage codes only. * The amount is always recomputed **server-side** from the event/tier price — a client can never dictate the final price — and `used_count` is incremented on redemption. > **Warning:** Amounts are server-authoritative. The discount only adjusts the order total the > platform calculates; you never send a final price from the client. ## List discounts Cursor-paginated via `limit` (and `starting_after`). ```bash curl "https://api.ticketconnect.example/v1/discounts?limit=20" \ -H "Authorization: Bearer sk_test_..." ``` ## Retrieve a discount ```bash 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. ```bash 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 ```bash curl https://api.ticketconnect.example/v1/discounts/64fa1b2c9e7d654321fedcba \ -X DELETE \ -H "Authorization: Bearer sk_test_..." ``` ```json { "id": "64fa1b2c9e7d654321fedcba", "deleted": true } ``` A missing or non-owned id returns `404` with a `resource_missing` error on any of the retrieve/update/delete operations. ## See also * [Webhooks](#webhooks) — `payment.succeeded` fires on a completed checkout. * [API reference](#api-overview) — 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. ```bash curl https://api.ticketconnect.example/v1/webhook_endpoints \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.yourapp.com/ticketconnect", "enabled_events": ["ticket.issued", "payment.succeeded", "payout.paid"] }' ``` ```json { "id": "663b1c9e2a7d654321fedcba", "url": "https://hooks.yourapp.com/ticketconnect", "enabled_events": ["ticket.issued", "payment.succeeded", "payout.paid"], "status": "active", "secret": "whsec_Hk9...redacted..." } ``` > **The signing `secret` is returned exactly once, here at creation.** It is never > shown again in list or other responses. Store it securely — you need it to verify > every incoming webhook. If you lose it, delete the endpoint and create a new one. > **Your URL is validated at registration.** It must be `http(s)` — and **https > in production** — and it must resolve to a publicly routable address. URLs that > point at private, link-local, or otherwise internal address space are rejected > with a `400 invalid_webhook_url` error. The same check re-runs on every > delivery, so a URL that later starts resolving to an internal address has its > deliveries marked `failed` rather than retried. ### List endpoints ```bash curl https://api.ticketconnect.example/v1/webhook_endpoints \ -H "Authorization: Bearer sk_test_..." ``` The `secret` is **not** included in list responses. ### Delete an endpoint ```bash curl https://api.ticketconnect.example/v1/webhook_endpoints/663b1c9e2a7d654321fedcba \ -X DELETE \ -H "Authorization: Bearer sk_test_..." ``` ```json { "id": "663b1c9e2a7d654321fedcba", "deleted": true } ``` ### Ping your endpoint Before wiring real traffic, send yourself a **test ping**. The ping travels through the exact same delivery machinery as production events — same `Webhook-Signature` and `Webhook-Id` headers, same 10-second timeout — so it is an end-to-end check of your signature verification. The response is synchronous: ```bash curl https://api.ticketconnect.example/v1/webhook_endpoints/663b1c9e2a7d654321fedcba/ping \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "message": "hello from staging" }' ``` ```json { "id": "evt_8c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f", "endpoint_id": "663b1c9e2a7d654321fedcba", "type": "ping", "delivered": true, "response_code": 200, "error": null } ``` Your endpoint receives a normal envelope with `type: "ping"` and `data: { message }` (`message` is optional, up to 256 characters; a default is supplied when omitted). Pings are **never retried** — a failed attempt is reported immediately in the response (`delivered: false`, plus `error` or a non-2xx `response_code`). Like any real delivery, a failed ping counts toward the endpoint's auto-disable failure streak, and every ping shows up in the [deliveries log](#webhooks). ## 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.issued` immediately, followed shortly > by exactly one of `ticket.confirmed` or `ticket.confirmation_failed`. On > `ticket.confirmation_failed` the ticket itself keeps working (only refunded or > used tickets fail a scan) — no customer action is needed, but if you see these > recur, contact support. All event payloads are plain business data (ids, > amounts, currency) — no settlement or platform internals ever appear. ## 3. The event payload Every delivery is a `POST` with a JSON body in this envelope: ```json { "id": "evt_4f8a2c1e9b7d6543210fedcba9876543", "type": "payout.paid", "created": 1749124800, "data": { "amount": 250.00, "currency": "USD", "reference": "tr_1Q9..." } } ``` * `id` — unique event id (prefixed `evt_`). Use it for idempotent handling. * `type` — one of the event types above. * `created` — Unix timestamp (seconds) when the event was generated. * `data` — the event-specific payload. Each request also carries these headers: | Header | Description | | --- | --- | | `Webhook-Signature` | The signature to verify, format `t=,v1=`. | | `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: ```text Webhook-Signature: t=1749124800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` To verify, recompute the HMAC and compare: 1. Parse `t` (timestamp) and `v1` (signature hex) from the header. 2. Build the signed payload string: `` `${t}.${rawBody}` `` — the timestamp, a literal `.`, then the **raw, unparsed** request body. 3. Compute `HMAC-SHA256(secret, signedPayload)` and hex-encode it. 4. Compare it to `v1` using a **constant-time** comparison. 5. Optionally, reject the request if `t` is too far from the current time (a tolerance window) to guard against replay. > **Verify against the raw request body**, exactly as received — before any JSON > parsing or re-serialization. If your framework reparses and re-stringifies the > body, the bytes change and the signature will not match. ### Verification snippet (Node.js) ```javascript 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: ```javascript 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 `2xx` quickly — within the 10-second window — and offload heavy > processing to a background job. A slow handler looks like a failed delivery and > gets retried. ## 6. Idempotent handling on your side Retries (and manual replays) mean your endpoint **can receive the same event more than once**. Make your handler idempotent: * Use the event `id` (the `evt_...` value, also in the `Webhook-Id` header) as a dedupe key. * On receipt, check whether you've already processed that `id`; if so, return `200` and do nothing else. * Only then apply the side effect (update a record, send an email, etc.). This guarantees that a redelivered `payment.succeeded` doesn't double-fulfil an order. ## 7. Inspect deliveries Every delivery attempt is logged. List recent ones (cursor-paginated via `limit`) to debug your endpoint: ```bash curl "https://api.ticketconnect.example/v1/events/deliveries?limit=20" \ -H "Authorization: Bearer sk_test_..." ``` ```json { "object": "list", "data": [ { "id": "665c2a1f9e7d654321fedcba", "event_id": "evt_4f8a...", "type": "payout.paid", "status": "delivered", "attempts": 1, "last_response_code": 200, "created_at": "2026-06-05T12:00:00.000Z" } ], "has_more": false } ``` `status` is one of `pending`, `delivered`, or `failed`. `last_response_code` is the HTTP status your endpoint returned on the most recent attempt. ## 8. Replay a delivery Fixed an outage on your side? Re-send a past delivery to your registered endpoint: ```bash curl https://api.ticketconnect.example/v1/events/deliveries/665c2a1f9e7d654321fedcba/replay \ -X POST \ -H "Authorization: Bearer sk_test_..." ``` ```json { "id": "665c2a1f9e7d654321fedcba", "status": "pending", "replayed": true } ``` The delivery is re-queued and dispatched shortly after. (Because a replay can re-deliver an event you already handled, idempotent handling — section 6 — matters here too.) ## See also * [Scanning & check-in](#scanning-and-check-in) — emits `attendance.verified`. * [Payouts](#payouts) — emits `payout.paid`. * [Marketplace & resale](#marketplace-and-resale) — emits `marketplace.sale.completed`. * [API reference](#api-overview) — 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:read` is read-only (balance and payout history). `payouts:claim` can do > everything `payouts:read` can **and** onboard the account and request payouts. > Give back-office dashboards a read-only key; reserve `payouts:claim` for the > service that moves money. ## 1. Onboard with Stripe Connect Before you can be paid, you complete a one-time Stripe Connect onboarding (bank details, identity verification). Start it by creating an onboarding link, then redirect your user to the returned `onboarding_url`. ```bash curl https://api.ticketconnect.example/v1/connect/account \ -X POST \ -H "Authorization: Bearer sk_test_..." ``` ```json { "account_id": "acct_1Q...", "onboarding_url": "https://connect.stripe.com/setup/e/acct_1Q.../..." } ``` Send the user to `onboarding_url` to finish setup. If they don't complete it in one sitting, call this endpoint again to get a fresh link — it resumes the same connected account. ## 2. Check onboarding status Confirm whether the account is ready to receive payouts: ```bash curl https://api.ticketconnect.example/v1/connect/account \ -H "Authorization: Bearer sk_test_..." ``` Before onboarding has started: ```json { "connected": false } ``` Once a connected account exists: ```json { "connected": true, "charges_enabled": true, "payouts_enabled": true, "details_submitted": true } ``` You're ready to receive money when `payouts_enabled` is `true`. (No personal or bank details are ever returned — only these capability flags.) ## 3. Check your balance ```bash curl https://api.ticketconnect.example/v1/balance \ -H "Authorization: Bearer sk_test_..." ``` ```json { "object": "balance", "available": 1284.50, "currency": "USD" } ``` `available` is what you can pay out right now, in `currency` (your settlement currency). ### Refunds move the balance, not your bank account Earnings land in your balance when a sale settles, and they leave it again if that sale is reversed — a refunded ticket or a cancelled event removes your share of it. Nothing is ever pulled back out of your bank account or your connected Stripe account: a reversal is a debit against the balance, so it nets off against what you earn next. That also means the balance can go **negative**, if refunds land after you've already paid out. `available` reports the real figure — including a negative one — so you can see how far under you are: ```json { "object": "balance", "available": -128.40, "currency": "USD" } ``` Payouts are refused while you're under: requesting a specific amount returns `400 insufficient_balance`, and requesting the full balance returns `400 parameter_invalid` (there is no positive amount to pay out). New earnings net against the shortfall until the balance is positive again. > Because reversals settle against your balance, request payouts on a rhythm > (weekly, monthly) rather than draining to zero after every sale — that keeps a > buffer for refunds and avoids a stretch where you can't pay out at all. ## 4. Request a payout Request a payout to your connected bank account. Pass an `amount` (in your currency) to pay out part of your balance, or omit it to pay out the **full available balance**. ```bash curl https://api.ticketconnect.example/v1/payouts \ -X POST \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "amount": 500.00, "currency": "USD" }' ``` ```json { "object": "payout", "id": "tr_1Q9...", "amount": 500.00, "currency": "USD", "status": "paid" } ``` A successful payout also emits a `payout.paid` webhook — see [Webhooks](#webhooks). > **Warning:** A payout can never exceed your available balance — an over-request returns `400` > with an `insufficient_balance` error. You must have finished Connect onboarding > first; otherwise you'll get a `connect_account_required` error. > **Use an `Idempotency-Key` header** when requesting a payout, so a network retry > can't accidentally pay you out twice. A replay with the same key returns the > original response. > > ```bash > curl https://api.ticketconnect.example/v1/payouts \ > -X POST \ > -H "Authorization: Bearer sk_test_..." \ > -H "Idempotency-Key: payout-2026-06-05-001" \ > -H "Content-Type: application/json" \ > -d '{ "amount": 500.00 }' > ``` ## 5. List past payouts Review your payout history, newest first, cursor-paginated via `limit` (and `starting_after`). ```bash curl "https://api.ticketconnect.example/v1/payouts?limit=20" \ -H "Authorization: Bearer sk_test_..." ``` ```json { "object": "list", "data": [ { "id": "668a2f1c9e7d654321fedcba", "amount": 500.00, "currency": "USD", "reference": "tr_1Q9...", "created_at": "2026-06-05T12:00:00.000Z" } ], "has_more": false } ``` `reference` ties each entry back to the underlying transfer. ## See also * [Webhooks](#webhooks) — listen for `payout.paid`. * [Marketplace & resale](#marketplace-and-resale) — resale royalties feed your balance. * [API reference](#api-overview) — 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`](#api-tickets) or checkout [`POST /v1/checkout/{event_id}/complete`](#api-payments); we fire the CAPI `Purchase` to **your** pixel and dedupe it against your browser event by that id. `marketingConsent` is your declaration that the buyer consented on your pages — we take it at face value. | | TicketConnect-hosted checkout (`GET /v1/checkout/{event_id}`) | ✔ end-to-end — your pixel runs on the page behind a built-in consent bar; nothing loads until the buyer accepts | ✔ — the page's browser `Purchase` and our server `Purchase` share one event id, so Meta deduplicates the pair automatically | | TicketConnect storefront (your attributed organizers) | Platform-managed, consent-gated | ✔ delivered to your pixel | Both `attribution` on the /v1 API and the hosted checkout's pixel dispatch to **your** Meta Pixel ID / CAPI access token from this same Ad tracking card — the platform pixel never sees your buyers. The **GTM container ID** field is stored for upcoming TicketConnect-hosted pages and has no effect yet — you don't need to fill it today; v1.1 lights up the Meta pixel on the hosted checkout, not GTM. > **Verify in minutes.** Open Meta Events Manager → *Test Events* and make a > test purchase of one of your organizers' events on the TicketConnect > storefront (accept the consent banner) — the server `Purchase` should > appear within seconds. Selling on /v1 instead? Complete a test-mode ticket > with `attribution.marketingConsent: true` (or a hosted checkout purchase > with the consent bar accepted) and check your pixel's recent activity in > Events Manager → *Overview*, where the dedup status is shown per event. > Note the *Test Events* tab lists server events only when a test event code > rides along — that's a platform-side setting, so ask us to coordinate one > if you want your verification to show up there. --- # API reference This is the complete reference for the TicketConnect White-Label API. Every endpoint lives under the `/v1` prefix and is automatically scoped to your account. The API is a plain REST + JSON service modeled closely on Stripe, so if you have integrated Stripe before this will feel familiar. ## Base URL ```text 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`): ```text 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](#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](#api-payments)). ## Response & error envelope Successful responses return the resource object directly. Most resources carry an `object` discriminator (for example `"object": "payout"`). Errors always use one consistent envelope: ```json { "error": { "type": "invalid_request_error", "code": "parameter_missing", "message": "name is required.", "param": "name" } } ``` | 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](#errors) for the full catalog of codes and statuses. ## Pagination List endpoints are cursor-based and return a Stripe-style list envelope: ```json { "object": "list", "data": [ { "...": "..." } ], "has_more": true } ``` | Query 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). ```text 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](#money-and-currencies). ## Resource groups | Group | Endpoints | | --- | --- | | [Meta](#api-meta) | Health probe, OpenAPI document (public). | | [Account](#api-account) | Retrieve the authenticated account; mint, list, and revoke API keys. | | [Events](#api-events) | Create, list, and update events, tiers, media, and organizations; on-sale waiting room (queue). | | [Tickets](#api-tickets) | Issue, list, transfer, refund, revoke, and upgrade tickets; hosted delivery links. | | [Customers](#api-customers) | Create and retrieve customers. | | [Segments](#api-segments) | Behavioral customer segments — repeat buyers, attendees, no-shows. | | [Comps](#api-comps) | Complimentary tickets and per-event guest lists. | | [Reserved seating](#api-seating) | Seat maps, atomic seat holds, seat-bound issuance. | | [Similar events](#api-similar) | Closest matches from your own catalog, scored. | | [Payments](#api-payments) | Create and retrieve payment intents. | | [Marketplace](#api-marketplace) | List and retrieve resale listings. | | [Reports](#api-reports) | Per-tier sales and attendance reports, with CSV download. | | [Scanning](#api-scanning) | Validate tickets and record check-in. | | [Discounts](#api-discounts) | Manage discount codes. | | [Webhooks](#api-webhooks) | Manage endpoints, ping them, and inspect deliveries. | | [Payouts](#api-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 ```bash curl https://api.ticketconnect.example/v1/health ``` ### Example response ```json { "status": "ok", "version": "v1" } ``` --- ## GET /v1/openapi.json Returns the machine-readable OpenAPI 3.0 contract for the entire API. Fetch it for client/SDK generation or to import the API into Postman. **Scope:** Public (no auth) ### Example request ```bash curl https://api.ticketconnect.example/v1/openapi.json ``` ### Example response ```json { "openapi": "3.0.3", "info": { "title": "TicketConnect White-Label API", "version": "1.0.0" }, "paths": { "...": "..." } } ``` A live Swagger UI rendering of this document is hosted at **`/v1/docs`**. --- # Account Your partner account — branding, mode, and settlement currency — plus self-serve **API key management**: list, mint, and revoke keys without leaving the API. ## GET /v1/account Retrieve the authenticated account. A quick way to confirm an API key is valid and learn which mode (`live` or `test`) it operates in. **Scope:** Authenticated (any valid key) ### Example request ```bash curl https://api.ticketconnect.example/v1/account \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "id": "ten_8f2a1c4d5e6f7a8b9c0d1e2f", "name": "Acme Live Events", "mode": "test", "status": "active", "branding": { "logoUrl": "https://acme.example/logo.png", "supportEmail": "support@acme.example", "primaryColor": "#e11d48" }, "currency": "USD" } ``` | 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 ```bash curl https://api.ticketconnect.example/v1/account/keys \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "data": [ { "prefix": "sk_test_9f8e7d", "label": "Backend (staging)", "mode": "test", "kind": "secret", "scopes": ["events:read", "tickets:issue"], "created_at": "2026-07-01T10:00:00.000Z", "last_used_at": "2026-07-11T08:59:00.000Z", "revoked_at": null } ] } ``` --- ## POST /v1/account/keys Mint a new API key. The plaintext `secret` is returned **exactly once**, in this response — copy it straight into your secrets manager. No privilege escalation is possible: the minted key's `scopes` must be a subset of the calling key's scopes (a `*` wildcard key can mint anything, including another `*` key), and a test-mode key can never mint a live-mode key. Active keys are capped at **25 per account** (shared with keys created in the dashboard). **Scope:** `keys:manage` ### Request body | 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 ```bash curl https://api.ticketconnect.example/v1/account/keys \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "label": "Door scanner", "scopes": ["scanning:write"] }' ``` ### Example response ```json { "prefix": "sk_test_2b4c6d", "label": "Door scanner", "mode": "test", "kind": "secret", "scopes": ["scanning:write"], "created_at": "2026-07-11T09:00:00.000Z", "last_used_at": null, "revoked_at": null, "secret": "sk_test_2b4c6d...shown_only_once" } ``` > Errors: `400 parameter_invalid` (bad label/scopes/mode/kind), > `403 scope_escalation` (requested scopes exceed the caller's), > `403 mode_escalation` (test key minting live), `409 key_limit_reached` > (25 active keys — revoke unused keys first). --- ## DELETE /v1/account/keys/{prefix} Revoke a key by its prefix. Revocation is **immediate** — the next request with the revoked key gets a `401` — and **idempotent**. The calling key can never revoke itself, so a rotation always overlaps: mint the new key, deploy it, then revoke the old one with the new key (or any other key holding `keys:manage`). **Scope:** `keys:manage` ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `prefix` | string | Yes | The key prefix, as shown in `GET /v1/account/keys`. | ### Example request ```bash curl https://api.ticketconnect.example/v1/account/keys/sk_test_9f8e7d \ -H "Authorization: Bearer sk_test_your_key_here" \ -X DELETE ``` ### Example response ```json { "prefix": "sk_test_9f8e7d", "revoked": true, "revoked_at": "2026-07-11T09:05:00.000Z" } ``` > Errors: `400 cannot_revoke_self` (a key cannot revoke itself), > `404 resource_missing` (no key with that prefix on your account). --- # Events Events are the things you sell tickets to. Each event holds one or more **ticket tiers** (name, price, supply, perks). **Organizations** are read-only sub-accounts you can list and retrieve alongside your events. A serialized event object looks like this: ```json { "id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "name": "Summer Festival 2026", "status": "draft", "date": "2026-08-15T18:00:00.000Z", "venue": "Riverside Park", "city": "Austin", "country": "US", "category": "music", "description": "Two stages, twenty acts.", "currency": "USD", "imageUrl": "https://acme.example/img/summerfest-card.jpg", "bannerUrl": "https://acme.example/img/summerfest-hero.jpg", "ticketPools": [ { "name": "General Admission", "price": 49.99, "totalSupply": 5000, "issued": 0, "upgradesEnabled": false, "perks": null, "release": { "released": true } } ], "queue": null, "live_codes": true, "created_at": "2026-06-05T12:00:00.000Z" } ``` > The `issued` field on a tier is the number of tickets already issued from that > tier; `totalSupply - issued` is what remains. Tier `price` is always the **schedule-effective** price — what checkout charges right now. A tier that runs a published price schedule additionally carries: | 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)](#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](#scanning-and-check-in)). It is `true` by default, settable at creation and togglable with [`PATCH /v1/events/{id}`](#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}`](#api-events). Events with a published seat map sell seated tiers by the seat (see [Reserved seating](#api-seating)), and every event can list its closest catalog neighbours via [`GET /v1/events/{id}/similar`](#api-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 ```bash curl "https://api.ticketconnect.example/v1/events?limit=2" \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "object": "list", "has_more": false, "data": [ { "id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "name": "Summer Festival 2026", "status": "draft", "ticketPools": [] } ] } ``` --- ## POST /v1/events Create an event under your account. Returns `201` with the new event. New events start in `draft` status with no tiers — add tiers with [`POST /v1/events/{id}/tiers`](#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 ```bash curl https://api.ticketconnect.example/v1/events \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: evt-create-001" \ -d '{ "name": "Summer Festival 2026", "date": "2026-08-15T18:00:00Z", "venue": "Riverside Park", "city": "Austin", "country": "US", "currency": "USD" }' ``` ### Example response ```json { "id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "name": "Summer Festival 2026", "status": "draft", "date": "2026-08-15T18:00:00.000Z", "currency": "USD", "ticketPools": [], "queue": null, "live_codes": true, "created_at": "2026-06-05T12:00:00.000Z" } ``` --- ## GET /v1/events/{id} Retrieve a single event by id. **Scope:** `events:read` ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Event id. | ### Example request ```bash curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2 \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "name": "Summer Festival 2026", "status": "draft", "date": "2026-08-15T18:00:00.000Z", "ticketPools": [ { "name": "General Admission", "price": 49.99, "totalSupply": 5000, "issued": 12 } ], "created_at": "2026-06-05T12:00:00.000Z" } ``` --- ## PATCH /v1/events/{id} Update an event's **media**, **resale policy** and/or **live-code setting**. Accepts `images`, `marketplace_settings` and/or `live_codes` — at least one is required. Partial: only the keys you send change. Pass `null` to unset a media slot; omitted keys are untouched. Naturally idempotent (no `Idempotency-Key` needed). Emits an `event.updated` webhook. **Scope:** `events:write` ### Path params | 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](#post-v1-events) — 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 ```bash curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2 \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -X PATCH \ -d '{ "images": { "image_url": "https://acme.example/img/summerfest-card.jpg", "banner_url": null } }' ``` ### Example response ```json { "id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "name": "Summer Festival 2026", "status": "draft", "imageUrl": "https://acme.example/img/summerfest-card.jpg", "created_at": "2026-06-05T12:00:00.000Z" } ``` > Errors: `400 parameter_missing` (none of `images`, `marketplace_settings`, > `live_codes`), `400 parameter_invalid` (bad media URL, out-of-range resale > bound, or non-boolean `live_codes`), > `400 parameter_unknown` (`images.gallery_urls` or `marketplace_settings.royalty_rate`), > `404 resource_missing` (not your event). --- ## POST /v1/events/{id}/tiers Add a ticket tier to an event. Returns the full updated event with the new tier appended to `ticketPools`. The tier's `issued` count always starts at `0`. **Scope:** `events:write` ### Path params | 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": "", "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_missing` if the event does not exist or is not yours; > `400 parameter_invalid` if the schedule or release configuration is invalid > (e.g. a `release_after_sold_out` cycle or an unknown sibling tier). ### Example request ```bash curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/tiers \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "VIP", "price": 149.00, "totalSupply": 200, "perks": { "lounge": true }, "upgradesEnabled": true }' ``` ### Example response ```json { "id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "name": "Summer Festival 2026", "status": "draft", "ticketPools": [ { "name": "General Admission", "price": 49.99, "totalSupply": 5000, "issued": 0, "upgradesEnabled": false }, { "name": "VIP", "price": 149.00, "totalSupply": 200, "issued": 0, "upgradesEnabled": true, "perks": { "lounge": true } } ], "created_at": "2026-06-05T12:00:00.000Z" } ``` --- ## GET /v1/organizations List your organizations — read-only sub-accounts grouped with your events. **Scope:** `events:read` ### Query params | 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 ```bash curl https://api.ticketconnect.example/v1/organizations \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "object": "list", "has_more": false, "data": [ { "id": "665f1e2d3c4b5a6d7e8f9a0b", "name": "Acme Live", "status": "active", "created_at": "2026-01-10T09:00:00.000Z" } ] } ``` --- ## GET /v1/organizations/{id} Retrieve one of your organizations by id. **Scope:** `events:read` ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Organization id. | ### Example request ```bash curl https://api.ticketconnect.example/v1/organizations/665f1e2d3c4b5a6d7e8f9a0b \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "id": "665f1e2d3c4b5a6d7e8f9a0b", "name": "Acme Live", "status": "active", "created_at": "2026-01-10T09:00:00.000Z" } ``` --- ## Waiting room (queue) High-demand events can run an **on-sale waiting room**. When an event has one, its serialized `queue` field is non-null and ticket issuance is gated: a customer must hold a live admission window before [`POST /v1/events/{id}/tickets`](#api-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 the `pre_sale` and `queue_active` phases. A `status` > of `admitted` with a non-null, future `access_expires_at` is the signal to open > checkout. No webhook is emitted for admission today. --- ## GET /v1/events/{id}/queue Return the room phase and aggregate `waiting_count`, plus — when `customer_id` is supplied — that customer's position, `people_ahead`, admission status, and access window. Also includes queue-time price transparency: the purchasable price range and per-tier prices, locked tiers with their unlock conditions, and the next published price change. **Scope:** `tickets:issue` ### Path params | 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 ```bash curl "https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/queue?customer_id=cus_ext_42" \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "queue_enabled": true, "phase": "queue_active", "sales_start_at": "2026-08-01T10:00:00.000Z", "waiting_count": 1841, "position": 212, "people_ahead": 187, "status": "waiting", "access_expires_at": null, "pricing": { "min_price": 49.99, "max_price": 149.00, "tiers": [ { "name": "General Admission", "price": 49.99, "available": 3120, "locked": false }, { "name": "VIP", "price": 149.00, "available": 0, "locked": true, "awaiting_sold_out_of": "General Admission" } ] } } ``` | 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_missing` if the event, or the supplied `customer_id`, > does not exist. --- ## POST /v1/events/{id}/queue/join Put a customer in the waiting room. Returns `201` with the customer's entry. Idempotent — re-joining while holding a live spot returns the existing entry. Customers who join during `pre_sale` are shuffled into a fair random order at `sales_start_at`; a customer who joins after the shuffle is appended behind the randomized block in arrival order. **Scope:** `tickets:issue` ### Path params | 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 ```bash curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/queue/join \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "cus_ext_42" }' ``` ### Example response ```json { "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "customer_id": "cus_ext_42", "phase": "queue_active", "position": 1854, "people_ahead": 1841, "status": "waiting", "access_expires_at": null } ``` > Errors: `400 queue_not_enabled` if the event does not use a waiting room; > `400 queue_join_failed` if the room rejected the join; `404 resource_missing` > if the event or customer does not exist. --- ## POST /v1/events/{id}/queue/leave Remove a customer from the queue, giving up any live admission window. Re-joining afterwards starts at the back of the queue. **Scope:** `tickets:issue` ### Path params | 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 ```bash curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/queue/leave \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "cus_ext_42" }' ``` ### Example response ```json { "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "customer_id": "cus_ext_42", "left": true } ``` > Errors: `404 not_in_queue` if the customer has no active entry; > `404 resource_missing` if the event or customer does not exist. --- # Tickets Tickets are issued to a customer for a specific event tier. Every ticket carries its delivery data in a `qr` object — an opaque scannable code and its format. A serialized ticket object: ```json { "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c", "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "event_name": "Summer Festival 2026", "status": "valid", "tier": "VIP", "seat": null, "price": 149.00, "currency": "USD", "perks": { "lounge": true }, "qr": { "data": "a1b2c3...opaque", "format": "QR" }, "revoked": false, "created_at": "2026-06-05T12:30:00.000Z" } ``` The `status` field is one of: `valid`, `used`, `transferred`, `refunded`, or `expired`. **Revocation is an independent axis from `status`.** Every ticket carries a `revoked` boolean, and a revoked ticket additionally carries `revoked_at` (an ISO timestamp). A revoked ticket keeps its lifecycle `status` but fails every scan — see [`POST /v1/tickets/{id}/revoke`](#api-tickets) 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](#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`](#api-seating), 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)](#api-events). 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](#api-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`](#api-payments). Omit for free tiers. | | `hold_id` | string | No | Reserved seating: a live seat hold from [`POST /v1/events/{id}/seats/hold`](#api-seating). 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 ```bash curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/tickets \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: issue-001" \ -d '{ "customer_id": "cus_ext_42", "tier": "VIP", "payment_intent_id": "pi_3Q1abc..." }' ``` ### Example response ```json { "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c", "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "event_name": "Summer Festival 2026", "status": "valid", "tier": "VIP", "price": 149.00, "currency": "USD", "qr": { "data": "a1b2c3...opaque", "format": "QR" }, "created_at": "2026-06-05T12:30:00.000Z" } ``` > Common errors: `404 event_not_found`, `404 customer_not_provisioned`, > `409 tier_not_found`, `403 tier_not_released` (tier not auto-released yet), > `409 sold_out`, `402 payment_required` (paid tier without a payment intent), > `402 payment_failed` (payment could not be verified), > `409 payment_already_used`, `403 not_admitted` (waiting-room event, customer > not yet admitted), `403 purchase_limit_reached` (per-person cap). Reserved > seating adds: `400 seat_required` (seated tier without a `hold_id`), > `409 hold_not_found`, `409 hold_expired` (re-hold and retry), > `409 seat_tier_mismatch` (a held seat belongs to another tier), and > `400 quantity_mismatch` (`quantity` ≠ number of held seats). ### Attribution object Optional on every issuance call. Pass what you have — every field is optional and malformed input is silently dropped, never rejected: | 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](#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 ```bash curl "https://api.ticketconnect.example/v1/tickets?event_id=evt_a1b2c3d4e5f6a7b8c9d0e1f2" \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "object": "list", "has_more": false, "data": [ { "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c", "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "status": "valid", "tier": "VIP" } ] } ``` --- ## GET /v1/tickets/{id} Retrieve a single ticket, including its `qr` delivery data. **Scope:** `tickets:read` ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Ticket id. | ### Example request ```bash curl https://api.ticketconnect.example/v1/tickets/tkt_4d5e6f7a8b9c0d1e2f3a4b5c \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c", "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "event_name": "Summer Festival 2026", "status": "valid", "tier": "VIP", "price": 149.00, "currency": "USD", "qr": { "data": "a1b2c3...opaque", "format": "QR" }, "created_at": "2026-06-05T12:30:00.000Z" } ``` --- ## POST /v1/tickets/{id}/delivery_link Mint a signed, expiring URL to a **hosted mobile ticket page** — the event, holder name, a live status badge, and the entry QR, rendered on a page you can hand straight to the customer. The link needs no login: the signed URL is the auth. The page always reflects the ticket's current state — a refund or revocation flips the badge immediately — and is served with `Cache-Control: no-store`. **Scope:** `tickets:read` ### Path params | 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 ```bash curl https://api.ticketconnect.example/v1/tickets/tkt_4d5e6f7a8b9c0d1e2f3a4b5c/delivery_link \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "expires_in_days": 60 }' ``` ### Example response ```json { "object": "delivery_link", "url": "https://api.ticketconnect.example/v1/t/dGt0XzRkNWU2ZjdhLi4u...", "expires_at": "2026-09-09T12:30:00.000Z" } ``` > Links are stateless: they expire on schedule, and re-minting does not > invalidate earlier links. An expired, tampered, or unknown link renders one > identical 404 page. The holder's email is always shown masked > (`j***@example.com`). Errors: `400 parameter_invalid` (`expires_in_days` out > of range), `404 resource_missing`. See the [Deliver tickets](#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 ```bash curl https://api.ticketconnect.example/v1/tickets/tkt_4d5e6f7a8b9c0d1e2f3a4b5c/transfer \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "to_customer_id": "cus_ext_99" }' ``` ### Example response ```json { "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c", "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "status": "valid", "tier": "VIP" } ``` > Errors: `409 ticket_not_transferable` if the ticket is `refunded` or `used`, > or once the holder has opened it in a TicketConnect ticket wallet (it is > then locked to them); `409 ticket_revoked` if the ticket has been revoked; > `404 customer_not_found` if the recipient is missing or not provisioned. > Emits a `ticket.transferred` webhook. --- ## POST /v1/tickets/{id}/refund Refund a ticket and return the buyer's money. **The payment provider is charged first** — the refund is executed against the original payment (for the Stripe provider, a PaymentIntent refund) before any ticket state changes. Only after the provider accepts does the ticket move to `refunded` and stop being valid for entry. If the provider refuses or fails, you get an error back and the ticket is untouched. Free and comp tickets (no charge to reverse) skip the provider entirely. **Scope:** `tickets:manage` ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Ticket id. | This endpoint takes no request body. Accepts an `Idempotency-Key` header. ### Example request ```bash curl https://api.ticketconnect.example/v1/tickets/tkt_4d5e6f7a8b9c0d1e2f3a4b5c/refund \ -H "Authorization: Bearer sk_test_your_key_here" \ -X POST ``` ### Example response ```json { "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c", "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "status": "refunded", "tier": "VIP" } ``` > Errors: `409 already_refunded` if the ticket was already refunded; > `400 provider_not_configured` if no payment provider is configured for your > account; `400 refund_not_supported` if the provider cannot refund this > payment; `502 payment_provider_error` if the provider failed — the ticket is > unchanged, retry later. Emits a `ticket.refunded` webhook (only after the > money has moved). --- ## POST /v1/tickets/{id}/revoke Invalidate a ticket **without refunding it** — fraud, a chargeback, a comp clawback. Revocation sets `revoked: true` and `revoked_at` on the ticket; the lifecycle `status` is intentionally unchanged (revocation is an independent axis). From that moment the ticket fails every scan and can no longer be transferred. Revoking is **idempotent**: revoking an already-revoked ticket returns the same terminal state with `200` and emits no second webhook. Revoke does **not** refund — call [`POST /v1/tickets/{id}/refund`](#api-tickets) 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 ```bash curl https://api.ticketconnect.example/v1/tickets/tkt_4d5e6f7a8b9c0d1e2f3a4b5c/revoke \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "reason": "chargeback received" }' ``` ### Example response ```json { "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c", "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "status": "valid", "tier": "VIP", "revoked": true, "revoked_at": "2026-07-11T09:15:00.000Z", "created_at": "2026-06-05T12:30:00.000Z" } ``` > Errors: `409 ticket_already_used` — a checked-in ticket cannot be revoked; > `400 parameter_invalid` if `reason` is empty or longer than 500 characters. > Emits a `ticket.revoked` webhook. --- ## GET /v1/tickets/{id}/upgrade-options List the higher tiers this ticket can move into, each with the fiat price difference (`delta`) and remaining availability. Only tiers with `upgradesEnabled` priced above the current tier and still in stock are returned. **Scope:** `tickets:manage` ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Ticket id. | ### Example request ```bash curl https://api.ticketconnect.example/v1/tickets/tkt_4d5e6f7a8b9c0d1e2f3a4b5c/upgrade-options \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "current_tier": "General Admission", "options": [ { "name": "VIP", "price": 149.00, "delta": 99.01, "available": 188, "perks": { "lounge": true } } ] } ``` --- ## POST /v1/tickets/{id}/upgrade Upgrade a ticket to a higher tier in place (same ticket, same QR) — allowed even after the ticket has been scanned (`used`). If the upgrade has a positive price difference, a confirmed `payment_intent_id` covering the delta is required; a given delta payment intent may fund only one upgrade. **Scope:** `tickets:manage` ### Path params | 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 ```bash curl https://api.ticketconnect.example/v1/tickets/tkt_4d5e6f7a8b9c0d1e2f3a4b5c/upgrade \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "target_tier": "VIP", "payment_intent_id": "pi_3Q1xyz..." }' ``` ### Example response ```json { "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c", "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "status": "valid", "tier": "VIP", "price": 149.00, "currency": "USD", "perks": { "lounge": true } } ``` > Errors: `404 tier_not_found`, `409 sold_out`, `400 upgrades_disabled`, > `400 not_an_upgrade`, `402 payment_required`, `402 payment_failed`, > `409 payment_already_used`. Emits a `ticket.upgraded` webhook. --- ## POST /v1/tickets/{id}/attendance Mark a ticket as attended (check-in) by ticket id, without a full scan payload. This belongs to the scanning surface — see [Scanning](#api-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](#customers) concept page for the full mental model. A serialized customer object: ```json { "id": "cus_ext_42", "email": "fan@example.com", "name": "Jordan Fan", "status": "active", "created_at": "2026-06-05T11:00:00.000Z" } ``` `status` is `active` once the customer is fully provisioned, otherwise `pending`. --- ## POST /v1/customers Create (or idempotently return) a customer. The `id` you supply as `externalId` is your own stable identifier — reuse it everywhere you reference this customer (issuing, transferring tickets). **Scope:** `customers:write` ### Request body | 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 ```bash curl https://api.ticketconnect.example/v1/customers \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: cus-001" \ -d '{ "externalId": "cus_ext_42", "email": "fan@example.com", "name": "Jordan Fan" }' ``` ### Example response ```json { "id": "cus_ext_42", "email": "fan@example.com", "name": "Jordan Fan", "status": "active", "created_at": "2026-06-05T11:00:00.000Z" } ``` --- ## GET /v1/customers/{id} Retrieve a customer by the `externalId` you assigned. **Scope:** `customers:write` ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | The customer's `externalId`. | ### Example request ```bash curl https://api.ticketconnect.example/v1/customers/cus_ext_42 \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "id": "cus_ext_42", "email": "fan@example.com", "name": "Jordan Fan", "status": "active", "created_at": "2026-06-05T11:00:00.000Z" } ``` --- # Perks Perks are event add-ons — a welcome drink, lounge access, a backstage meet — that become **redeemable grants** a customer presents at the door. You define a perk on an event, grants are issued (now, to named customers or a section, or automatically as tickets in a tier sell), and your scanner redeems each grant by its opaque `code`. A grant's `code` is the only handle you ever see or pass around — there are no wallets, signatures, or on-chain concepts in these responses. A serialized perk definition: ```json { "id": "def_9f2c1a7b", "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "name": "Welcome drink", "redeemable": true, "max_uses": 1, "distribution": "manual", "active": true, "created_at": "2026-07-11T12:00:00.000Z" } ``` ## Distribution | 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 ```bash curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/perks \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "Welcome drink", "distribution": "manual", "redeemable": true, "max_uses": 1, "recipients": { "type": "customers", "customer_ids": ["cus_9f2c1a7b"] } }' ``` > Errors: `400 parameter_missing` (no `name`/`recipients`), `400 parameter_invalid` > (bad `distribution`/`max_uses`/`tier`), `400 not_supported` (`distribution: "loyalty"`), > `404 resource_missing` (not your event). ## GET /v1/events/{id}/perks List the event's perk definitions with grant rollups. **Scope:** `events:read`. Each row adds `grants_issued` and `grants_redeemed`. ## GET /v1/customers/{id}/perks List a customer's perk grants. **Scope:** `customers:write`. Each grant carries the opaque `code` your scanner reads, plus `perk_name`, `status`, and `uses_remaining` (`null` = unlimited). ```json { "object": "list", "data": [ { "code": "grant_3f7a9c0d1e2f", "perk_name": "Welcome drink", "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "status": "active", "uses_remaining": 1 } ], "has_more": false } ``` ## POST /v1/perks/redeem Redeem a grant by its `code` at the door. **Scope:** `scanning:write`. Online and atomic — a single-use grant is consumed exactly once; an unlimited grant stays valid and each scan logs. ### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `code` | string | Yes | The grant `code` from the customer. | ### Example response ```json { "valid": true, "perk": { "name": "Welcome drink", "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2" }, "uses_remaining": 0 } ``` A verdict is always returned with `200`. `valid` is `false` with a `reason` of `already_redeemed`, `revoked`, `expired`, or `not_found` — an unknown code, or a code belonging to another account, is reported as `not_found` and never distinguished. --- # Payments A payment intent represents the card charge that funds a ticket issuance or upgrade. The **amount is always computed server-side** from the event tier — the client cannot supply or override it. After the customer completes the payment on the client, pass the payment intent id back to the issue/upgrade endpoint. See the [Take a payment](#take-a-payment) guide. A serialized payment intent object: ```json { "object": "payment_intent", "id": "pi_3Q1abc...", "provider": "stripe", "client_payload": { "client_secret": "pi_3Q1abc..._secret_..." }, "amount": 149.00, "currency": "USD", "status": "requires_payment" } ``` `status` is a provider-normalized value you can act on. With a card provider it is one of `requires_payment` (awaiting the customer's payment or action), `processing`, `succeeded`, `failed`, or `canceled`. Providers that collect money out-of-band report `requires_external` until you [confirm the payment](#post-v1-payment_intents-id-confirm). --- ## 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 ```bash curl https://api.ticketconnect.example/v1/payment_intents \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: pi-001" \ -d '{ "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "tier": "VIP", "customer_id": "cus_ext_42" }' ``` ### Example response ```json { "object": "payment_intent", "id": "pi_3Q1abc...", "provider": "stripe", "client_payload": { "client_secret": "pi_3Q1abc..._secret_..." }, "amount": 149.00, "currency": "USD", "status": "requires_payment" } ``` > Errors: `404 resource_missing` (unknown event), `400 parameter_invalid` > (unknown tier), `502 payment_provider_error`. --- ## GET /v1/payments/{id} Retrieve a payment intent and its current status. You can only read payments created under your own account. **Scope:** `payments:read` (or `payments:write`) ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Payment intent id. | ### Example request ```bash curl https://api.ticketconnect.example/v1/payments/pi_3Q1abc... \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "object": "payment_intent", "id": "pi_3Q1abc...", "amount": 149.00, "currency": "USD", "status": "succeeded" } ``` > A payment that does not belong to your account is reported as > `404 resource_missing` so existence is never leaked across accounts. Payments opened by an out-of-band provider have `extpay_...` ids; retrieving one additionally includes `"provider": "external"`. --- ## POST /v1/payment_intents/{id}/confirm Mark an out-of-band ("external") payment as collected. Only accounts whose payment provider confirms server-side support this — with a card provider such as Stripe, the charge is confirmed on the client instead and this endpoint returns `400 confirm_not_supported`. **Scope:** `payments:write` ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Payment intent id (`extpay_...`). | ### Example request ```bash curl https://api.ticketconnect.example/v1/payment_intents/extpay_9f8e7d6c5b4a3210fedcba98/confirm \ -H "Authorization: Bearer sk_test_your_key_here" \ -X POST ``` ### Example response ```json { "object": "payment_intent", "id": "extpay_9f8e7d6c5b4a3210fedcba98", "status": "succeeded", "provider": "external" } ``` > Errors: `400 confirm_not_supported` (your provider confirms on the client), > `400 payment_not_found` (no such payment under your account). --- ## Hosted checkout The hosted checkout lets you sell tickets with **no frontend of your own**: hand a buyer a single URL and the platform serves a branded purchase page. All hosted checkout endpoints authenticate with a **publishable key** (`pk_...`) — a browser-safe key restricted to this surface; secret (`sk_`) keys are rejected here, and publishable keys are rejected everywhere else. See [Authentication](#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 ```text https://api.ticketconnect.example/v1/checkout/evt_a1b2c3d4e5f6a7b8c9d0e1f2?key=pk_test_your_key_here ``` > An invalid or missing key renders a `401` HTML error page; an unknown event a > `404` HTML page. --- ## GET /v1/checkout/{event_id}/info Event details as JSON — the same serialized event object as `GET /v1/events/{id}`, for building your own embedded checkout with just a publishable key. **Auth:** publishable key (`x-api-key` header). ### Example request ```bash curl https://api.ticketconnect.example/v1/checkout/evt_a1b2c3d4e5f6a7b8c9d0e1f2/info \ -H "x-api-key: pk_test_your_key_here" ``` ### Example response ```json { "event": { "id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "name": "Summer Fest", "ticketPools": [ { "name": "VIP", "price": 149.00 } ] } } ``` --- ## POST /v1/checkout/{event_id}/intent Open a payment for a tier, identified by the buyer's email. Mirrors `POST /v1/payment_intents`: the amount is recomputed server-side from the tier price and cannot be supplied by the browser. **Auth:** publishable key (`x-api-key` header). ### Request body | 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 ```bash curl https://api.ticketconnect.example/v1/checkout/evt_a1b2c3d4e5f6a7b8c9d0e1f2/intent \ -H "x-api-key: pk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "tier": "VIP", "email": "buyer@example.com" }' ``` ### Example response ```json { "object": "payment_intent", "id": "pi_3Q1abc...", "provider": "stripe", "client_payload": { "client_secret": "pi_3Q1abc..._secret_..." }, "amount": 149.00, "currency": "USD", "status": "requires_payment" } ``` > Errors: `404 event_not_found`, `400 tier_not_found`, `409 sold_out`, > `403 tier_not_released` (the tier is visible but not yet on sale), > `403 purchase_limit_reached` (the buyer is at the event's per-person purchase > cap), `502 payment_provider_error`. --- ## POST /v1/checkout/{event_id}/complete Verify the payment and issue the ticket. The payment is re-verified server-side against the authoritative amount, event, and account; free tiers (price `0`) skip payment verification. One payment intent funds at most one ticket, and supply is claimed atomically, so retries can never double-issue or oversell. **Auth:** publishable key (`x-api-key` header). ### Request body | 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 ```bash curl https://api.ticketconnect.example/v1/checkout/evt_a1b2c3d4e5f6a7b8c9d0e1f2/complete \ -H "x-api-key: pk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "intent_id": "pi_3Q1abc...", "tier": "VIP", "email": "buyer@example.com" }' ``` ### Example response Returns `201` with the issued ticket: ```json { "ticket": { "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c", "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "event_name": "Summer Fest", "status": "valid", "tier": "VIP", "price": 149.00, "currency": "USD", "qr": { "data": "a1b2c3...opaque", "format": "QR" }, "created_at": "2026-06-05T12:30:00.000Z" } } ``` > Errors: `402` with a payment code (`payment_not_found`, > `payment_not_completed`, `payment_amount_insufficient`, > `payment_currency_mismatch`, ...) when verification fails; > `409 payment_already_used` (this payment already funded a ticket); > `409 sold_out`; `403 not_admitted` (the event runs an on-sale queue and the > buyer does not hold a live admission window yet); `403 purchase_limit_reached` > — the payment is not consumed, refund it via your payment provider. > Emits a `ticket.issued` webhook on success. --- ### Attribution object Optional on `/complete` — same shape as the ticket issuance endpoint's; see [Attribution object](#api-tickets) 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](#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](#api-events)). See the [Marketplace and resale](#marketplace-and-resale) guide. A serialized listing object: ```json { "id": "list_9f8e7d6c5b4a43210fedcba9876543aa", "ticket_id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c", "price": 160.00, "currency": "USD", "status": "active", "created_at": "2026-06-05T13:00:00.000Z" } ``` --- ## GET /v1/marketplace/listings List your resale listings, newest first. Each listing carries its `status` (for example `active`); the list is not filtered to active listings. **Scope:** `marketplace:read` ### Query params | 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 ```bash curl "https://api.ticketconnect.example/v1/marketplace/listings?limit=2" \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "object": "list", "has_more": false, "data": [ { "id": "list_9f8e7d6c5b4a43210fedcba9876543aa", "ticket_id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c", "price": 160.00, "currency": "USD", "status": "active", "created_at": "2026-06-05T13:00:00.000Z" } ] } ``` --- ## GET /v1/marketplace/listings/{id} Retrieve a single resale listing with its fiat price. **Scope:** `marketplace:read` ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Listing id. | ### Example request ```bash curl https://api.ticketconnect.example/v1/marketplace/listings/list_9f8e7d6c5b4a43210fedcba9876543aa \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "id": "list_9f8e7d6c5b4a43210fedcba9876543aa", "ticket_id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c", "price": 160.00, "currency": "USD", "status": "active", "created_at": "2026-06-05T13:00:00.000Z" } ``` --- # Reports Per-tier rollups of sales and attendance, computed on demand and returned as JSON — or as a **CSV download** with `format=csv`. Amounts are in **major units of your account currency** (`49.99`, not `4999`). Both endpoints require the **`reports:read`** scope. > **Counting semantics.** `tickets_issued` counts **all** tickets created in > the window — including ones later refunded or revoked. `tickets_refunded` > counts tickets whose refund completed (the money moved); `tickets_revoked` > counts tickets with a revocation — the two are independent, so one ticket > can appear in both. `gross` is the pre-refund gross of all issued tickets. --- ## GET /v1/reports/sales Per-tier sales rollup — issued / refunded / revoked counts and gross value — over a date window on ticket **creation time**, optionally narrowed to one event. Tiers sort alphabetically (untiered tickets group under `null`, first). **Scope:** `reports:read` ### Query params | 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 `to` excludes that day's activity after 00:00 UTC.** The match is > inclusive on both ends, but `"to": "2026-07-11"` means `2026-07-11T00:00:00Z` > — pass a full timestamp (or the next day) for end-of-day semantics. ### Example request ```bash curl "https://api.ticketconnect.example/v1/reports/sales?event_id=evt_a1b2c3d4e5f6a7b8c9d0e1f2&from=2026-06-01&to=2026-07-01T00:00:00Z" \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "object": "report", "data": { "report": "sales", "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "from": "2026-06-01T00:00:00.000Z", "to": "2026-07-01T00:00:00.000Z", "currency": "USD", "tiers": [ { "tier": "General Admission", "tickets_issued": 1180, "tickets_refunded": 22, "tickets_revoked": 3, "gross": 57810.20 }, { "tier": "VIP", "tickets_issued": 60, "tickets_refunded": 1, "tickets_revoked": 0, "gross": 8940.00 } ], "totals": { "tickets_issued": 1240, "tickets_refunded": 23, "tickets_revoked": 3, "gross": 66750.20 } }, "generated_at": "2026-07-11T09:00:00.000Z" } ``` With `format=csv` the response is `text/csv` (with a `Content-Disposition: attachment` header and a dated filename): one row per tier plus a `TOTAL` row, columns `Tier, Tickets Issued, Tickets Refunded, Tickets Revoked, Gross, Currency`. Column order is fixed — safe to consume positionally — and cell values are sanitized against spreadsheet formula injection. > Errors: `400 invalid_date_window` (unparsable `from`/`to`, `from` after > `to`, or a window over 366 days), `400 invalid_format`, `404 not_found` > (unknown or foreign `event_id`). --- ## GET /v1/reports/attendance Issued vs checked-in counts per tier, no-show count, and an hourly UTC check-in timeline — for **one event** (`event_id` is required). `no_show` counts tickets that are still valid (not refunded, not revoked) and were never checked in, so `checked_in + no_show ≤ tickets_issued`. **Scope:** `reports:read` ### Query params | Param | Type | Required | Description | | --- | --- | --- | --- | | `event_id` | string | Yes | The event to report on. | | `format` | string | No | `json` (default) or `csv`. | ### Example request ```bash curl "https://api.ticketconnect.example/v1/reports/attendance?event_id=evt_a1b2c3d4e5f6a7b8c9d0e1f2" \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "object": "report", "data": { "report": "attendance", "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "tiers": [ { "tier": "General Admission", "tickets_issued": 1180, "checked_in": 934, "no_show": 221 }, { "tier": "VIP", "tickets_issued": 60, "checked_in": 46, "no_show": 13 } ], "totals": { "tickets_issued": 1240, "checked_in": 980, "no_show": 234 }, "timeline": [ { "hour": "2026-08-15T17:00:00.000Z", "checkins": 412 }, { "hour": "2026-08-15T18:00:00.000Z", "checkins": 486 } ] }, "generated_at": "2026-07-11T09:00:00.000Z" } ``` The `timeline` buckets check-ins by UTC hour, sorted ascending, and is **sparse** — hours with zero check-ins are omitted. With `format=csv` the response is one flat table (`Row Type, Tier, Tickets Issued, Checked In, No Show, Hour, Check-ins`): the tier rows, a `TOTAL` row, then the hourly timeline rows. > Errors: `400 parameter_missing` (no `event_id`), `400 invalid_format`, > `404 not_found` (unknown or foreign `event_id`). --- # Scanning Validate tickets at the door and record check-ins. Validation reads the QR string from a ticket — either the opaque `qr.data` you delivered, or the live, refreshing code shown for a ticket held in a TicketConnect ticket wallet (verified cryptographically on the platform side). Attendance marks a ticket as used. Your API key with the `scanning:write` scope authorizes scanning — there is no separate scanner token. See the [Scanning and check-in](#scanning-and-check-in) guide. A scan verdict looks like: ```json { "valid": true, "status": "valid", "tier": "VIP", "ticket_id": "tkt_4d5e6f..." } ``` When a ticket is not admissible, `valid` is `false` and `reason` is one of `not_found`, `refunded`, `revoked`, `already_used` — or, for live wallet codes: `code_expired` (real but stale; have the holder refresh and re-scan), `live_code_required` (a static copy of a wallet code where the live one is required), or `invalid_qr` (failed cryptographic verification). Whenever the ticket was identified, the verdict includes its `ticket_id`; an `already_used` verdict also carries the `tier` so door staff can see what was scanned. --- ## POST /v1/scan/validate Validate a single ticket for entry by its QR string. This **does not** mark the ticket used — call [attendance](#post-v1-tickets-id-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 ```bash curl https://api.ticketconnect.example/v1/scan/validate \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "qr": "a1b2c3...opaque" }' ``` ### Example response ```json { "valid": true, "status": "valid", "tier": "VIP", "ticket_id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c" } ``` A failed verdict: ```json { "valid": false, "reason": "already_used", "tier": "VIP", "ticket_id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c" } ``` --- ## POST /v1/scan/batch Validate up to **200** tickets in a single call — ideal for an offline scanner draining its queue once back online. None are marked used. Returns a per-QR verdict array. **Scope:** `scanning:write` ### Request body | Field | Type | Required | Description | | --- | --- | --- | --- | | `qrs` | string[] | Yes | Array of QR strings, verbatim (max 200). Each entry may be either code shape. | ### Example request ```bash curl https://api.ticketconnect.example/v1/scan/batch \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "qrs": ["a1b2c3...", "d4e5f6...", "unknown-code"] }' ``` ### Example response ```json { "results": [ { "qr": "a1b2c3...", "valid": true, "status": "valid", "tier": "VIP", "ticket_id": "tkt_4d5e6f..." }, { "qr": "d4e5f6...", "valid": false, "reason": "already_used", "tier": "GA", "ticket_id": "tkt_77aa..." }, { "qr": "unknown-code", "valid": false, "reason": "not_found" } ] } ``` > Errors: `400 parameter_missing` if `qrs` is not an array; `400 parameter_invalid` > if it contains more than 200 entries. --- ## POST /v1/tickets/{id}/attendance Mark a ticket as attended (check-in) by ticket id. This is the action that actually consumes the ticket. It is safe to retry: an already-used ticket returns `200` with `status: "already_used"` rather than an error. **Scope:** `scanning:write` ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Ticket id. | This endpoint takes no required body. Accepts an `Idempotency-Key` header. ### Example request ```bash curl https://api.ticketconnect.example/v1/tickets/tkt_4d5e6f7a8b9c0d1e2f3a4b5c/attendance \ -H "Authorization: Bearer sk_test_your_key_here" \ -X POST ``` ### Example response ```json { "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c", "status": "used" } ``` A repeat call returns: ```json { "id": "tkt_4d5e6f7a8b9c0d1e2f3a4b5c", "status": "already_used" } ``` > Errors: `404 resource_missing` for an unknown ticket. Emits an > `attendance.verified` webhook on the first successful check-in. --- # Discounts Discount codes applied at checkout, either a `percentage` off or a `fixed` amount off. All discount endpoints require the `events:write` scope. A serialized discount object: ```json { "id": "664f0a1b2c3d4e5f60718293", "code": "SUMMER20", "type": "percentage", "value": 20, "max_uses": 500, "used_count": 42, "expires_at": "2026-09-01T00:00:00.000Z", "active": true, "created_at": "2026-06-05T10:00:00.000Z" } ``` For `type: "percentage"`, `value` is a percent (`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 ```bash curl https://api.ticketconnect.example/v1/discounts \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "object": "list", "has_more": false, "data": [ { "id": "664f0a1b2c3d4e5f60718293", "code": "SUMMER20", "type": "percentage", "value": 20, "max_uses": 500, "used_count": 42, "expires_at": "2026-09-01T00:00:00.000Z", "active": true, "created_at": "2026-06-05T10:00:00.000Z" } ] } ``` --- ## POST /v1/discounts Create a discount code. If you omit `code`, a random one is generated. **Scope:** `events:write` ### Request body | 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 ```bash curl https://api.ticketconnect.example/v1/discounts \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "code": "SUMMER20", "type": "percentage", "value": 20, "max_uses": 500, "expires_at": "2026-09-01T00:00:00Z" }' ``` ### Example response ```json { "id": "664f0a1b2c3d4e5f60718293", "code": "SUMMER20", "type": "percentage", "value": 20, "max_uses": 500, "used_count": 0, "expires_at": "2026-09-01T00:00:00.000Z", "active": true, "created_at": "2026-06-05T10:00:00.000Z" } ``` --- ## GET /v1/discounts/{id} Retrieve a discount code by id. **Scope:** `events:write` ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Discount id. | ### Example request ```bash curl https://api.ticketconnect.example/v1/discounts/664f0a1b2c3d4e5f60718293 \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "id": "664f0a1b2c3d4e5f60718293", "code": "SUMMER20", "type": "percentage", "value": 20, "max_uses": 500, "used_count": 42, "expires_at": "2026-09-01T00:00:00.000Z", "active": true, "created_at": "2026-06-05T10:00:00.000Z" } ``` --- ## PATCH /v1/discounts/{id} Update mutable fields on a discount. `value` is written against the discount's existing type (percentage values are still capped at `100`). **Scope:** `events:write` ### Path params | 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 ```bash curl https://api.ticketconnect.example/v1/discounts/664f0a1b2c3d4e5f60718293 \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -X PATCH \ -d '{ "active": false, "max_uses": 1000 }' ``` ### Example response ```json { "id": "664f0a1b2c3d4e5f60718293", "code": "SUMMER20", "type": "percentage", "value": 20, "max_uses": 1000, "used_count": 42, "expires_at": "2026-09-01T00:00:00.000Z", "active": false, "created_at": "2026-06-05T10:00:00.000Z" } ``` --- ## DELETE /v1/discounts/{id} Delete a discount code. **Scope:** `events:write` ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Discount id. | ### Example request ```bash curl https://api.ticketconnect.example/v1/discounts/664f0a1b2c3d4e5f60718293 \ -H "Authorization: Bearer sk_test_your_key_here" \ -X DELETE ``` ### Example response ```json { "id": "664f0a1b2c3d4e5f60718293", "deleted": true } ``` --- # Webhooks Register URLs to receive signed event notifications, then inspect and replay deliveries when debugging your endpoint. All webhook endpoints require the `webhooks:manage` scope. Webhooks are signed with a `Webhook-Signature` header (Stripe-compatible) using the signing secret returned once at endpoint creation. See the [Webhooks guide](#webhooks) 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=,v1=` — HMAC-SHA256 of `.` with your `whsec_` secret), `Webhook-Id` (the event id, for deduping), and `Content-Type: application/json`. The body envelope is: ```json { "id": "evt_9f8e7d...", "type": "ticket.issued", "created": 1749124800, "data": { "...": "the object" } } ``` A delivery succeeds on any `2xx` within **10 seconds**. Failures are retried on a fixed backoff — 1m, 5m, 30m, 2h, 10h, 24h after each failed attempt (**7 attempts total**) — then marked `failed`. An endpoint that accumulates **20 consecutive failures** is automatically set to `disabled` and stops receiving events. --- ## GET /v1/webhook_endpoints List your registered webhook endpoints. The signing secret is never returned here — it is shown only once, at creation. **Scope:** `webhooks:manage` ### Example request ```bash curl https://api.ticketconnect.example/v1/webhook_endpoints \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "object": "list", "has_more": false, "data": [ { "id": "664f0a1b2c3d4e5f60718293", "url": "https://acme.example/hooks/ticketconnect", "enabled_events": ["*"], "status": "active" } ] } ``` --- ## POST /v1/webhook_endpoints Register a webhook endpoint. The response includes the `secret` **exactly once** — store it to verify incoming signatures. **Scope:** `webhooks:manage` ### Request body | 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 ```bash curl https://api.ticketconnect.example/v1/webhook_endpoints \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "url": "https://acme.example/hooks/ticketconnect", "enabled_events": ["ticket.issued", "payout.paid"] }' ``` ### Example response ```json { "id": "664f0a1b2c3d4e5f60718293", "url": "https://acme.example/hooks/ticketconnect", "enabled_events": ["ticket.issued", "payout.paid"], "status": "active", "secret": "whsec_9f8e7d6c5b4a..." } ``` > Errors: `400 parameter_missing` (no `url`), `400 parameter_invalid` > (non-`http(s)` URL, or plain `http` in production), `400 invalid_webhook_url` > (the URL points at a private or internal address). --- ## DELETE /v1/webhook_endpoints/{id} Delete a webhook endpoint so it stops receiving events. **Scope:** `webhooks:manage` ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Webhook endpoint id. | ### Example request ```bash curl https://api.ticketconnect.example/v1/webhook_endpoints/664f0a1b2c3d4e5f60718293 \ -H "Authorization: Bearer sk_test_your_key_here" \ -X DELETE ``` ### Example response ```json { "id": "664f0a1b2c3d4e5f60718293", "deleted": true } ``` --- ## POST /v1/webhook_endpoints/{id}/ping Send a signed test `ping` event to the endpoint and get the attempt result back synchronously. The ping goes through the normal delivery machinery — same `Webhook-Signature` / `Webhook-Id` headers, same SSRF checks, same 10-second timeout — so it validates your signature verification end-to-end. Pings are never retried in the background; a failed ping counts toward the endpoint's auto-disable failure streak and is logged as a normal delivery. **Scope:** `webhooks:manage` ### Path params | 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 ```bash curl https://api.ticketconnect.example/v1/webhook_endpoints/664f0a1b2c3d4e5f60718293/ping \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "message": "hello from staging" }' ``` ### Example response ```json { "id": "evt_8c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f", "endpoint_id": "664f0a1b2c3d4e5f60718293", "type": "ping", "delivered": true, "response_code": 200, "error": null } ``` > `delivered` is `false` on any failure; `response_code` is your endpoint's > HTTP status (or `null` on a network error, with `error` populated). Errors: > `400 parameter_invalid` (`message` too long), `404 resource_missing`. --- ## GET /v1/events/deliveries List recent webhook delivery attempts, with status and the last response code, to debug your endpoint. **Scope:** `webhooks:manage` ### Query params | 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 ```bash curl "https://api.ticketconnect.example/v1/events/deliveries?limit=2" \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "object": "list", "has_more": false, "data": [ { "id": "6650bb1c2d3e4f5a60718293", "event_id": "evt_9f8e7d6c5b4a43210fedcba98765432", "type": "ticket.issued", "status": "delivered", "attempts": 1, "last_response_code": 200, "created_at": "2026-06-05T12:30:05.000Z" } ] } ``` --- ## POST /v1/events/deliveries/{id}/replay Re-queue a previous delivery for dispatch to your endpoint — handy after fixing an outage on your side. **Scope:** `webhooks:manage` ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Delivery id. | ### Example request ```bash curl https://api.ticketconnect.example/v1/events/deliveries/6650bb1c2d3e4f5a60718293/replay \ -H "Authorization: Bearer sk_test_your_key_here" \ -X POST ``` ### Example response ```json { "id": "6650bb1c2d3e4f5a60718293", "status": "pending", "replayed": true } ``` --- # Payouts Onboard for payouts, check your balance, and move money out to your bank account. Onboarding runs through Stripe Connect; everything you see here is in your settlement currency (fiat). Scopes: balance and payout reads accept either `payouts:read` or `payouts:claim`; claiming a payout and both Connect onboarding endpoints (including the status read) need `payouts:claim`. --- ## POST /v1/connect/account Begin or resume payout onboarding. Creates a connected account on first call and returns a fresh onboarding link to redirect your user to. **Scope:** `payouts:claim` This endpoint takes no request body. ### Example request ```bash curl https://api.ticketconnect.example/v1/connect/account \ -H "Authorization: Bearer sk_test_your_key_here" \ -X POST ``` ### Example response ```json { "account_id": "acct_1NxYz...", "onboarding_url": "https://connect.stripe.com/setup/e/acct_1NxYz.../abc123" } ``` > Errors: `502 api_error` if the onboarding link cannot be created. --- ## GET /v1/connect/account Retrieve your onboarding status — whether you can receive payouts yet. Returns only capability booleans, never any personal information. **Scope:** `payouts:claim` ### Example request ```bash curl https://api.ticketconnect.example/v1/connect/account \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "connected": true, "charges_enabled": true, "payouts_enabled": true, "details_submitted": true } ``` Before onboarding has started: ```json { "connected": false } ``` --- ## GET /v1/balance Retrieve your single spendable balance in your settlement currency. **Scope:** `payouts:read` (or `payouts:claim`) ### Example request ```bash curl https://api.ticketconnect.example/v1/balance \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "object": "balance", "available": 12840.50, "currency": "USD" } ``` --- ## GET /v1/payouts List payouts made to your bank account, newest first. **Scope:** `payouts:read` (or `payouts:claim`) ### Query params | 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 ```bash curl "https://api.ticketconnect.example/v1/payouts?limit=2" \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "object": "list", "has_more": false, "data": [ { "id": "6651aa...", "amount": 5000.00, "currency": "USD", "reference": "tr_1NxYz...", "created_at": "2026-06-04T08:00:00.000Z" } ] } ``` --- ## POST /v1/payouts Request a payout of your available balance to your connected bank account. If you omit `amount`, the full available balance is paid out. The amount can never exceed your balance. **Scope:** `payouts:claim` ### Request body | 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 ```bash curl https://api.ticketconnect.example/v1/payouts \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: payout-001" \ -d '{ "amount": 5000.00 }' ``` ### Example response ```json { "object": "payout", "id": "tr_1NxYz...", "amount": 5000.00, "currency": "USD", "status": "paid" } ``` > Errors: `400 parameter_invalid` (non-positive amount), `400 insufficient_balance`, > `400 connect_account_required` (onboarding not complete), `502 api_error`. > Emits a `payout.paid` webhook. --- # Comps & guest lists Complimentary (comp) tickets are **free tickets issued to a named recipient** — press, artists' guests, sponsors. Comps are real tickets: they claim tier supply atomically (just like paid issuance), carry the standard `qr` delivery data, emit `ticket.issued`, and count toward per-person purchase caps. Each comp records who issued it and an optional note, and the event's comps together form its **guest list**. --- ## POST /v1/events/{id}/comps Issue up to 20 comp tickets to one recipient. Returns `201` with the issued tickets as a list. The recipient is provisioned as a customer automatically, keyed on their email. **Scope:** `tickets:issue` ### Path params | 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 ```bash curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/comps \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: comp-press-001" \ -d '{ "tier": "VIP", "recipient": { "email": "reviewer@musicweekly.example", "name": "Alex Reyes" }, "note": "press", "quantity": 2 }' ``` ### Example response ```json { "object": "list", "data": [ { "id": "tkt_7a8b9c0d1e2f3a4b5c6d7e8f", "event_id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2", "event_name": "Summer Festival 2026", "status": "valid", "tier": "VIP", "price": 0, "currency": "USD", "qr": { "data": "d4e5f6...opaque", "format": "QR" }, "revoked": false, "created_at": "2026-07-11T09:00:00.000Z" } ], "has_more": false } ``` > Errors: `400 parameter_missing` / `parameter_invalid` (bad `tier`, > `recipient.email`, `note`, or `quantity`), `404 event_not_found`, > `409 tier_not_found`, `409 sold_out` (the message names how many tickets > remain when the batch doesn't fit). Emits one `ticket.issued` webhook per > ticket. > **Comps and money.** A comp has `price: 0` and no payment attached — a > refund has nothing to reverse. To pull a comp back (a clawback), use > [`POST /v1/tickets/{id}/revoke`](#api-tickets). --- ## 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 ```bash curl "https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/comps?limit=2" \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "object": "list", "data": [ { "id": "tkt_7a8b9c0d1e2f3a4b5c6d7e8f", "tier": "VIP", "recipient": { "name": "Alex Reyes" }, "note": "press", "issued_by": "Backend (staging)", "checked_in": false, "checked_in_at": null, "revoked": false, "revoked_at": null, "created_at": "2026-07-11T09:00:00.000Z" } ], "has_more": true } ``` `issued_by` is the label of the API key that issued the comp (or the key's id when no label is set) — your audit trail for who put whom on the list. The `checked_in` / `revoked` fields track each guest's live state at the door. > Errors: `404 event_not_found` if the event does not exist or is not yours. --- # Reserved seating Events with a **published seat map** sell seated tiers **by the seat**. The flow is: read the map, atomically **hold** the seats your customer picked, take the payment, then issue with the `hold_id` — one ticket per held seat (see [Issue a ticket](#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 ```bash curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/seatmap \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "id": "smap_1f2e3d4c", "name": "Main Hall", "canvas": { "width": 1200, "height": 800 }, "sections": [ { "id": "S1", "name": "Stalls", "kind": "seated", "tier": "Stalls", "x": 100, "y": 220, "rotation": 0, "rows": 12, "cols": 20, "row_label_scheme": "alpha", "capacity": 240 } ], "objects": [ { "id": "O1", "type": "stage", "label": "STAGE", "x": 300, "y": 40, "width": 600, "height": 120 } ], "version": 3, "total_seats": 240, "availability": { "sold": ["S1-A-1", "S1-A-2"], "held": ["S1-B-4"], "blocked": ["S1-L-20"] }, "tiers": { "Stalls": { "price": 79.00, "currency": "USD" } } } ``` `availability` lists seat ids that are **not** currently sellable — everything else in the layout is free. `tiers` maps each tier to its **schedule-effective** price (what issuance will actually charge right now). > Errors: `404 event_not_found`, `404 seatmap_not_found` (the event has no > published seat map). --- ## POST /v1/events/{id}/seats/hold Atomically claim specific seats for **10 minutes**. Either every requested seat is held, or none are — a conflict returns `409 seats_unavailable` naming the contested seats. Duplicate seat ids in the request collapse to one claim. **Scope:** `tickets:issue` ### Path params | 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 ```bash curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/seats/hold \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "seat_ids": ["S1-C-7", "S1-C-8"] }' ``` ### Example response ```json { "hold_id": "hold_9c8b7a6d5e4f", "expires_at": "2026-07-11T09:10:00.000Z", "ttl_seconds": 600, "seats": [ { "seat_id": "S1-C-7", "tier": "Stalls", "section": "Stalls", "row": "C", "seat": "7" }, { "seat_id": "S1-C-8", "tier": "Stalls", "section": "Stalls", "row": "C", "seat": "8" } ] } ``` Pass `hold_id` to [`POST /v1/events/{id}/tickets`](#api-tickets) before `expires_at` — one ticket is issued per held seat. A hold that lapses simply releases its seats; issuing against it returns `409 hold_expired`. > Errors: `400 parameter_missing` / `parameter_invalid` (`seat_ids` empty, > non-strings, or more than 10), `404 event_not_found`, > `404 seatmap_not_found`, `409 seats_unavailable`. --- ## POST /v1/events/{id}/seats/hold/{holdId}/extend Reset the hold's expiry to **10 minutes from now** — use it when a card entry or 3-D Secure challenge runs long. **Scope:** `tickets:issue` ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Event id. | | `holdId` | string | Yes | The hold to extend. | ### Example request ```bash curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/seats/hold/hold_9c8b7a6d5e4f/extend \ -H "Authorization: Bearer sk_test_your_key_here" \ -X POST ``` ### Example response ```json { "hold_id": "hold_9c8b7a6d5e4f", "expires_at": "2026-07-11T09:18:00.000Z", "seats_extended": 2 } ``` > Errors: `404 event_not_found`, `404 hold_not_found` (the hold is missing, > already released, expired, or already used to issue). --- ## DELETE /v1/events/{id}/seats/hold/{holdId} Release a hold, returning its seats to availability — when the customer walks away. **Idempotent**: releasing an expired or unknown hold succeeds with `released: 0`. **Scope:** `tickets:issue` ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Event id. | | `holdId` | string | Yes | The hold to release. | ### Example request ```bash curl https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/seats/hold/hold_9c8b7a6d5e4f \ -H "Authorization: Bearer sk_test_your_key_here" \ -X DELETE ``` ### Example response ```json { "hold_id": "hold_9c8b7a6d5e4f", "released": 2 } ``` `released` is the number of seats returned to availability. > Errors: `404 event_not_found` if the event does not exist or is not yours. --- # Similar events "You might also like" for your catalog: given one of your events, list the events **from your own catalog** most similar to it — for cross-sell modules, post-purchase recommendations, or "more like this" rails. **Results only ever come from your own catalog.** Similarity is computed within your account, and another account's events can never appear in your results, in any direction. Only **published, upcoming** events are returned. --- ## GET /v1/events/{id}/similar List the events most similar to this one, best match first. Each item is the standard [event object](#api-events) 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 ```bash curl "https://api.ticketconnect.example/v1/events/evt_a1b2c3d4e5f6a7b8c9d0e1f2/similar?limit=3" \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "object": "list", "data": [ { "id": "evt_b2c3d4e5f6a7b8c9d0e1f2a3", "name": "Riverside Sessions: Indie Night", "status": "published", "date": "2026-08-22T19:00:00.000Z", "venue": "Riverside Park", "currency": "USD", "similarity": 0.912 }, { "id": "evt_c3d4e5f6a7b8c9d0e1f2a3b4", "name": "Late Summer Open Air", "status": "published", "date": "2026-09-05T17:30:00.000Z", "venue": "East Meadow", "currency": "USD", "similarity": 0.847 } ], "has_more": false } ``` The list is not paginated (`has_more` is always `false`) — raise `limit` up to `20` if you want more candidates. A reference event with no similar upcoming events returns an empty list, not an error. > Errors: `404 resource_missing` if the reference event does not exist or is > not yours. --- # Customer segments Behavioral audiences computed **live** from your ticket history — who buys repeatedly, who shows up, who doesn't. Segments are computed on every request and nothing is stored: membership can change between calls, and the platform never contacts anyone — the audience is yours to message through your own channels. | 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 ```bash curl "https://api.ticketconnect.example/v1/customers/segments/repeat_buyers?min_purchases=3&limit=2" \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "object": "list", "data": [ { "customer_id": "cus_ext_42", "email": "dana@example.com", "purchase_count": 5, "last_activity_at": "2026-07-02T21:14:00.000Z" }, { "customer_id": "cus_ext_17", "email": "sam@example.com", "purchase_count": 3, "last_activity_at": "2026-06-28T19:40:00.000Z" } ], "has_more": true } ``` Each row carries exactly: `customer_id`, `email`, the one kind-relevant count (`purchase_count` | `attended_event_count` | `no_show_count`), and `last_activity_at` (ISO datetime or `null`). Customers with no qualifying tickets simply never appear — there is no zero-count row. > Errors: `400 parameter_invalid` — unknown `kind`, a non-positive threshold, > or an unknown `starting_after` cursor (`param` names the offender). --- ## Saved segments The endpoint above computes an audience **on the fly** and forgets it. When you want to name an audience, keep it, and reuse it — re-evaluate it later, or wire it into a [discount trigger](#api-discount-triggers) — 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: ```json { "id": "seg_1f2e3d4c5b6a", "name": "Loyal fans", "description": "Bought 3 or more times", "kind": "repeat_buyers", "min_count": 3, "event_id": "evt_1a2b3c", "created_at": "2026-07-11T09:00:00.000Z" } ``` `description` and `event_id` are only present when set. ### POST /v1/segments Save a named audience definition. **Scope:** `customers:write` #### Request body | 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 ```bash curl https://api.ticketconnect.example/v1/segments \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "name": "Loyal fans", "kind": "repeat_buyers", "min_count": 3, "description": "Bought 3 or more times" }' ``` #### Example response `201 Created` — returns the serialized saved segment shown above. > Errors: `400 parameter_invalid` — bad `name`, `kind`, or `min_count` (`param` > names the offender). `409 segment_name_taken` — a segment with that `name` > already exists for your account. ### GET /v1/segments List your saved segments, newest first, cursor-paginated. **Scope:** `customers:write` #### Query params | 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 ```bash curl https://api.ticketconnect.example/v1/segments \ -H "Authorization: Bearer sk_test_your_key_here" ``` #### Example response ```json { "object": "list", "has_more": false, "data": [ { "id": "seg_1f2e3d4c5b6a", "name": "Loyal fans", "description": "Bought 3 or more times", "kind": "repeat_buyers", "min_count": 3, "created_at": "2026-07-11T09:00:00.000Z" } ] } ``` ### GET /v1/segments/{id} Retrieve one saved segment definition. **Scope:** `customers:write` #### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Segment id. Unknown id returns `404 resource_missing`. | #### Example request ```bash 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 ```bash curl https://api.ticketconnect.example/v1/segments/seg_1f2e3d4c5b6a \ -H "Authorization: Bearer sk_test_your_key_here" \ -X DELETE ``` #### Example response ```json { "id": "seg_1f2e3d4c5b6a", "deleted": true } ``` ### GET /v1/segments/{id}/members Evaluate the saved segment now and return its qualifying customers, cursor-paginated. Membership is computed fresh — nothing is cached — so the list always reflects your current ticket history. **Scope:** `customers:write` #### Path params | 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 ```bash curl "https://api.ticketconnect.example/v1/segments/seg_1f2e3d4c5b6a/members?limit=2" \ -H "Authorization: Bearer sk_test_your_key_here" ``` #### Example response Rows have the same shape as the on-the-fly segment above — `customer_id`, `email`, the one kind-relevant count, and `last_activity_at`: ```json { "object": "list", "has_more": false, "data": [ { "customer_id": "cus_ext_42", "email": "dana@example.com", "purchase_count": 5, "last_activity_at": "2026-07-02T21:14:00.000Z" } ] } ``` --- # Staff Manage your **door staff** — the people who scan tickets, sell on-site, and redeem perks at your events. Create staff, tune their permissions, assign them to specific events, and read their scan totals. All staff endpoints require the `staff:manage` scope. The platform provisions and manages the internal credentials a staff member needs to work the door for you; **none of that is ever exposed through the API** — the only handle you ever get is the opaque `id`. Everything below is plain business language: names, permissions, event assignments, and scan counts. A serialized staff object: ```json { "id": "staff_1720900000000_a1b2c3d4", "first_name": "Dana", "last_name": "Reeves", "email": "dana@example.com", "phone": "+1-555-0142", "status": "active", "permissions": { "can_scan_tickets": true, "can_sell_onsite": true, "can_redeem_perks": false, "can_manage_staff": false, "can_view_stats": true }, "assigned_event_ids": ["evt_1a2b3c"], "scanned_tickets": 128, "created_at": "2026-07-10T18:30:00.000Z" } ``` | 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_onsite` take walk-up > payments in the TicketConnect staff app — Stripe **Tap to Pay** on the staff > member's phone. Gate payments and wristbands are configured per event in the > organizer panel's one-time "Set up event" step. There are no public `/v1` > POS endpoints; the API surface for the door is scanning and attendance (see > [Scanning & Check-in](#scanning-and-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 ```bash curl https://api.ticketconnect.example/v1/staff \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "first_name": "Dana", "last_name": "Reeves", "email": "dana@example.com", "phone": "+1-555-0142", "permissions": { "can_redeem_perks": true } }' ``` ### Example response `201 Created` ```json { "id": "staff_1720900000000_a1b2c3d4", "first_name": "Dana", "last_name": "Reeves", "email": "dana@example.com", "phone": "+1-555-0142", "status": "active", "permissions": { "can_scan_tickets": true, "can_sell_onsite": true, "can_redeem_perks": true, "can_manage_staff": false, "can_view_stats": true }, "assigned_event_ids": [], "scanned_tickets": 0, "created_at": "2026-07-10T18:30:00.000Z" } ``` --- ## GET /v1/staff List your door-staff roster, newest first. **Scope:** `staff:manage` ### Query params | 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 ```bash curl https://api.ticketconnect.example/v1/staff \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "object": "list", "has_more": false, "data": [ { "id": "staff_1720900000000_a1b2c3d4", "first_name": "Dana", "last_name": "Reeves", "email": "dana@example.com", "status": "active", "permissions": { "can_scan_tickets": true, "can_sell_onsite": true, "can_redeem_perks": true, "can_manage_staff": false, "can_view_stats": true }, "assigned_event_ids": ["evt_1a2b3c"], "scanned_tickets": 128, "created_at": "2026-07-10T18:30:00.000Z" } ] } ``` --- ## GET /v1/staff/{id} Retrieve one staff member. **Scope:** `staff:manage` ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Staff id. Unknown id returns `404 resource_missing`. | ### Example request ```bash 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 ```bash 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 ```bash curl https://api.ticketconnect.example/v1/staff/staff_1720900000000_a1b2c3d4/assignments \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "event_id": "evt_1a2b3c" }' ``` The full staff object is returned with `evt_1a2b3c` now in `assigned_event_ids`. > **Note:** The event must belong to your account. An unknown staff id **or** an > unknown / unowned `event_id` returns `404 resource_missing` — you can only > assign staff to events you own. --- ## DELETE /v1/staff/{id}/assignments/{eventId} Unassign a staff member from an event. **Scope:** `staff:manage` Idempotent — unassigning an event the staff member isn't on is a no-op and still returns the current staff object. ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Staff id. | | `eventId` | string | Yes | The event to remove from the assignment list. | ### Example request ```bash 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 ```bash curl https://api.ticketconnect.example/v1/staff/staff_1720900000000_a1b2c3d4/stats \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "staff_id": "staff_1720900000000_a1b2c3d4", "scanned_tickets": 128, "events": [ { "event_id": "evt_1a2b3c", "scanned": 96 }, { "event_id": "evt_4d5e6f", "scanned": 32 } ] } ``` --- ## DELETE /v1/staff/{id} **Soft-deactivate** a staff member: their `status` is set to `inactive` and the record is retained (so their historical scan stats stay intact). Idempotent. **Scope:** `staff:manage` ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Staff id. Unknown id returns `404 resource_missing`. | ### Example request ```bash 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](#api-segments) to a discount code, then lets you read exactly **who qualifies for that code right now**. Use it to run targeted offers: "20% off for repeat buyers", "a comeback code for no-shows". All trigger endpoints require the `events:write` scope. > **This is a read model, not an automation.** A trigger never emails anyone and > never applies the code on its own. Unlike the first-party organizer panel — > which can auto-send a code to a matching audience on a schedule — the API > hands you the current match list and **you** deliver the code through your own > channels (email, SMS, push, in-app). You stay in control of the message and > the timing. Because matches are evaluated **fresh on every call** against your live ticket history, the list you read is always current — add a trigger once, then poll `GET .../matches` whenever you're ready to send. A serialized trigger object: ```json { "id": "664f0a1b2c3d4e5f60718293", "discount_id": "664f0a1b2c3d4e5f60700001", "segment_id": "seg_1f2e3d4c5b6a", "active": true, "created_at": "2026-07-11T10:00:00.000Z" } ``` | 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 ```bash curl https://api.ticketconnect.example/v1/discounts/664f0a1b2c3d4e5f60700001/triggers \ -H "Authorization: Bearer sk_test_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "segment_id": "seg_1f2e3d4c5b6a" }' ``` ### Example response `201 Created` ```json { "id": "664f0a1b2c3d4e5f60718293", "discount_id": "664f0a1b2c3d4e5f60700001", "segment_id": "seg_1f2e3d4c5b6a", "active": true, "created_at": "2026-07-11T10:00:00.000Z" } ``` --- ## GET /v1/discounts/{id}/triggers List the trigger rules attached to a discount code, newest first. **Scope:** `events:write` ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Discount id. Unknown id returns `404 resource_missing`. | ### Example request ```bash curl https://api.ticketconnect.example/v1/discounts/664f0a1b2c3d4e5f60700001/triggers \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "object": "list", "has_more": false, "data": [ { "id": "664f0a1b2c3d4e5f60718293", "discount_id": "664f0a1b2c3d4e5f60700001", "segment_id": "seg_1f2e3d4c5b6a", "active": true, "created_at": "2026-07-11T10:00:00.000Z" } ] } ``` --- ## DELETE /v1/discounts/{id}/triggers/{ruleId} Remove a trigger from a discount code. The discount code itself is untouched. **Scope:** `events:write` ### Path params | Param | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | Yes | Discount id. | | `ruleId` | string | Yes | Trigger rule id. Unknown id returns `404 resource_missing`. | ### Example request ```bash curl https://api.ticketconnect.example/v1/discounts/664f0a1b2c3d4e5f60700001/triggers/664f0a1b2c3d4e5f60718293 \ -H "Authorization: Bearer sk_test_your_key_here" \ -X DELETE ``` ### Example response ```json { "id": "664f0a1b2c3d4e5f60718293", "deleted": true } ``` --- ## GET /v1/discounts/{id}/triggers/{ruleId}/matches Return the customers who qualify for this code **right now**. The trigger's linked segment is re-evaluated against your live ticket history on every call — so this list is exactly who you'd send the code to today. Deliver the code to them through your own channels. **Scope:** `events:write` Each row is a segment member: `customer_id`, `email`, the one kind-relevant count (`purchase_count` | `attended_event_count` | `no_show_count`), and `last_activity_at`. Results are identical to what [`GET /v1/segments/{id}/members`](#api-segments) 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 ```bash curl "https://api.ticketconnect.example/v1/discounts/664f0a1b2c3d4e5f60700001/triggers/664f0a1b2c3d4e5f60718293/matches?limit=2" \ -H "Authorization: Bearer sk_test_your_key_here" ``` ### Example response ```json { "object": "list", "has_more": false, "data": [ { "customer_id": "cus_ext_42", "email": "dana@example.com", "purchase_count": 5, "last_activity_at": "2026-07-02T21:14:00.000Z" } ] } ``` > **Note:** If the linked segment has since been deleted, `matches` returns an > empty list rather than an error — the trigger simply has no one to match. --- # For organizers TicketConnect is a full ticketing stack for event organizers: you create your events in the organizer panel, sell tamper-proof tickets on your storefront, run the door from the TicketConnect app, and get paid through escrow-backed payouts. This page is your map of what the platform does for you and where each guide lives. > **Two doc tracks, one platform.** You're reading the **organizer** track — > for teams running events on the TicketConnect platform with the organizer > panel and app. If you're building your own ticketing product on top of our > API instead, head to the [White-Label API introduction](#introduction). ## What you get **Self-serve from day one.** Sign up and create your organization directly in the organizer panel — no invite, no sales call. From there you manage events, staff, discounts, guest lists, reports, and billing in one place. **Tamper-proof tickets.** Every event you create gets its own cryptographic signing key, and every ticket carries a signed QR code. Scanners at your venue verify tickets against that key — even with no internet connection — so a screenshot, forgery, or reused ticket never gets through. **Transparent pricing for your fans.** Ticket tiers have a name, a price, and a supply. You can publish a price schedule up front and release tiers on a timer or when an earlier tier sells out — prices can only follow the schedule you published, so buyers always know what's coming. **Built-in resale with royalties.** Fans who can't attend resell through the official marketplace under rules you control — price floors and ceilings, resale windows, anti-scalping limits. Your organization earns a royalty on every resale. **Door tooling in the TicketConnect app.** Your staff scan tickets fully offline, redeem perks, and sell walk-up tickets at the gate with card terminals — all from the mobile app, with per-event permissions you assign. **Escrow-backed payouts.** Primary-sale proceeds are held in escrow and released after your event ends, protecting buyers if an event is cancelled (they're refunded automatically) and building your trust tier so a growing share of each sale reaches you immediately. ## Where to go next Start with your first event, then dip into the guides as you need them: | Guide | What it covers | | --- | --- | | [Create your first event](#organizers-first-event) | Sign up, create an event, add tiers, publish your storefront. | | [Selling tickets](#organizers-selling) | Payment methods, waiting rooms, purchase caps, and fees. | | [Discounts, comps & audience](#organizers-discounts-comps) | Discount codes, guest-list comps, and audience segments. | | [Resale & royalties](#organizers-resale-royalties) | Marketplace rules and the royalties you earn on resales. | | [At the door](#organizers-door-operations) | Staff, offline scanning, gate sales, and wristbands. | | [Getting paid](#organizers-getting-paid) | Escrow, trust tiers, claiming proceeds, refunds, and reports. | | [Plans & billing](#organizers-plans-billing) | Free, Pro, and Enterprise plans and how to subscribe. | --- # Create your first event This guide takes you from zero to an event that fans can buy tickets for: sign up, create your organization, set up the event and its ticket tiers, and see what buyers get on the storefront. Nothing here requires an invite or a conversation with us — the whole flow is self-serve in the organizer panel. ## Sign up and create your organization Log in to the organizer panel and create your organization. That's it — you can create organizations yourself, no invite needed. The person who creates the organization becomes its **owner**; you can add more members later, and only the owner manages billing. Once your organization exists, the panel gives you everything in one place: Dashboard, Events, Staff, Discounts, Perks, Guest List, Audience, Reports, Wallet, Audit Log, and Billing. ## Create the event From **Events**, create your event with its name, venue, and dates. > **Tip:** Fill in the **Tags** field (e.g. rock, outdoor, festival) and a real > description. They directly power event discovery — fans see your event in > "similar events" and personalized recommendations based on them. Well-tagged > events get surfaced; untagged ones rely on search alone. When the event is created, TicketConnect generates a unique signing key for it. Every ticket sold for this event carries a QR code signed with that key, and door scanners verify tickets against it — even offline. You don't manage the key yourself; it just means tickets for your event can't be forged or tampered with. > **One event, one key.** Each event's tickets are verifiable independently. > A ticket for one event can never scan into another. ## Set up gate payments & wristbands On the event page, the **Set up event** button walks you through two one-time choices: how you take card payments at the door (today: **Tap to Pay** on a staff phone — no terminal hardware) and whether the event hands out claim-QR **wristbands** to walk-up buyers. The button disappears once both are set. The details live in [At the door](#organizers-door-operations). ## Add ticket tiers A **ticket tier** is what a buyer actually purchases: General Admission, VIP, Early Bird. Each tier has three things: | Field | What it means | | --- | --- | | Name | What buyers see at checkout and on their ticket. | | Price | The tier's face value. | | Supply | How many tickets the tier can ever sell. | You don't pre-generate tickets. Tickets are created on demand, one by one, at the moment of purchase — so a tier with 10,000 supply costs you nothing until tickets actually sell, and supply is claimed atomically so you can never oversell. ## Publish a price schedule (optional) If your pricing changes over time — early bird, regular, last minute — you can publish **price steps** up front and schedule tiers to release at a set time or automatically when an earlier tier sells out. The schedule is public and binding: prices can only follow the schedule you published, and past steps stay visible to buyers. Fans see exactly when the price went up and when the next change is coming — no surge opacity, no surprise pricing. > **Price transparency is a feature.** Buyers trust an on-sale where the > pricing rules were published before it started. If you don't need steps, > skip this — a single fixed price per tier is the default. ## Design a seat map (optional) For seated venues, the panel includes a seat map designer. Lay out your sections and seats, and buyers pick their exact seat at checkout instead of buying general admission. ## What buyers see Your event gets a page on the **storefront** — the buyer-facing site. Fans see the event details, the available tiers with their prices, the published price schedule if you set one, and the seat map if the event is seated. They buy there, and their tickets land in their TicketConnect wallet, ready to be scanned at the door. For payment methods, waiting rooms for high-demand on-sales, and purchase limits, see [Selling tickets](#organizers-selling). ## If you need to cancel Cancelling an event automatically refunds every buyer their ticket's face value — you don't process refunds by hand, and you can preview the effect of a cancellation before confirming it. How refunds, escrow, and your proceeds interact is covered in [Getting paid](#organizers-getting-paid). --- # Selling tickets Once your event is live on the storefront, TicketConnect handles the sale end to end: payment, fair-queue protection for big on-sales, purchase limits, and the paper trail. This page explains how buyers pay, how you keep a hot on-sale fair, and what the platform takes per sale. ## How buyers pay Buyers choose between fiat and on-chain payment at checkout: | Method | How it works | | --- | --- | | Card | Paid via Stripe. | | BLIK | Poland's mobile payment standard, also via Stripe. | | USDC on Base | An on-chain stablecoin payment, settled directly on Base. | Whichever method a fan picks, the result is the same ticket in their TicketConnect wallet — you don't run separate inventories or flows per payment method. ## Waiting room for high-demand on-sales For an on-sale you expect to be slammed, enable the **waiting room** on the event. Instead of everyone hammering checkout at once: * Fans who arrive during the on-sale join a queue. * The queue admits buyers in batches, in order. * Each admitted buyer gets a time-boxed access window to complete their purchase; when it expires, their spot goes to the next fan in line. By default the queue admits 25 buyers per batch with an 8-minute purchase window, and you can tune both. The waiting room is a per-event toggle — events without one sell normally, and gate sales at the venue are never queued. > **Turn it on before the on-sale, not during.** The waiting room protects the > first minutes of a hot on-sale — the moment demand outstrips supply. Enable > it when you publish the event so the queue is already standing when fans > arrive. ## Purchase caps Every event enforces a per-buyer ticket limit: by default, one buyer can hold at most **10 tickets** for an event. You can set the cap anywhere from 1 to 50 per event. Caps count per identity across the buyer's purchases, so splitting an order into several checkouts doesn't get around them. Together with the waiting room, this is your main defense against bulk buying during an on-sale. ## What a sale costs you TicketConnect charges a **3% platform fee** on each primary sale, with a **minimum of 2.50 PLN per paid ticket**. The minimum is a floor rather than an addition — you pay whichever is higher, so above roughly 83 PLN the fee is just the 3%. Free tickets cost you nothing. The fee comes out of the ticket price — buyers pay the face value you set, and your proceeds are the remainder. Resales on the marketplace and walk-up sales at the gate are priced differently; see [Resale & royalties](#organizers-resale-royalties) and [At the door](#organizers-door-operations) for those. > **If you cancel, buyers are made exactly whole.** On cancellation the buyer > gets the full face value back — including the platform's fee portion, which > TicketConnect refunds from its own side. ## Where your sales show up Every sale appears in the organizer panel as it happens: * **Dashboard** — live per-event stats: tickets sold, revenue, door activity. * **Reports** — exportable CSV and PDF reports covering ticket sales, financial transactions, revenue statements, and payout breakdowns net of platform fees. When and how the money itself reaches you — escrow timing, trust tiers, and claiming proceeds — is covered in [Getting paid](#organizers-getting-paid). ## Selling below face value Want to run a promotion instead of lowering the tier price? Discount codes, automatic triggers, and zero-price comp tickets are covered in [Discounts, comps & audience](#organizers-discounts-comps). --- # Discounts, comps & audience Not every ticket sells at face value to a stranger. This page covers the tools in the organizer panel for rewarding the people who matter: discount codes and the email blasts that deliver them, audience segments that describe *who* those people are, guest list comps for free tickets, and perks redeemed at the venue. ## Discount codes Create a code under **Discounts** in the organizer panel. A code takes a percentage off the ticket price at checkout on your storefront. | Setting | What it does | | --- | --- | | Percentage | A whole number from 1 to 100 — the discount applied at checkout. | | Event scope | Optional. Limit the code to one event, or leave it valid across all of yours. | | Expiry | Optional. The code stops working after this date. | | Total-use cap | Optional. The maximum number of times the code can be redeemed overall. | | Per-buyer limit | How many times a single buyer can use the code. Defaults to **1**. | > Codes are stored uppercase and can be up to 32 characters, so `earlybird` and > `EARLYBIRD` are the same code. Pick something short enough to type at the door. ## Sending a code by email blast A code nobody knows about sells nothing. From the code's page you can send an email blast to any combination of: * **Explicit emails** — paste a list of addresses. * **A segment** — everyone currently in one of your audience segments (below). * **Past attendees of an event** — everyone who attended an event you pick. The recipient lists are merged, so a person matched by more than one source still receives a single email. ## Automatic triggers Instead of blasting manually, you can attach **trigger rules** to a code so it is granted the moment a buyer earns it. Two conditions are available: | Condition | Fires when | | --- | --- | | Attendance threshold | A buyer's number of attended events reaches the count you set. | | Segment membership | A buyer joins the segment you pick. | > Each buyer receives **at most one automatic email**, no matter how many > times they re-qualify — your loyal customers won't be spammed. ## Segments Segments live under **Audience** and describe a slice of your buyers. They feed both email blasts and trigger rules. Three types exist: | Type | Who's in it | | --- | --- | | Repeat buyers | Buyers with at least N attended events (minimum 2, default 2). | | Big spenders | Buyers who have spent at least the USD amount you set. | | No-shows | Buyers with at least N tickets bought but never scanned (minimum 1). | Optionally add a **time window** in days, so "repeat buyers" means *recent* repeat buyers rather than all-time ones. Before you use a segment, preview it: the panel shows the **total membership count** and how many members are **reachable by email**. Only reachable members can receive a blast. > Segment names are unique within your organization and a segment's type can't > be changed after creation — create a new segment instead. A segment that is > referenced by a trigger rule can't be deleted until the rule is removed. ## Guest list comps Comps are free tickets you hand out against one of your ticket tiers — for artists, press, sponsors, or friends. Issue them under **Guest List**: * Up to **50 recipients per batch**, each receiving **1 to 10 tickets**. * Comps draw from the tier's supply atomically — if the batch doesn't fit in the remaining supply, nothing is issued and you're told how many seats are left. You can never oversell a tier by comping it. * A comp scans at the door exactly like a paid ticket. Door staff see no difference — see [At the door](#organizers-door-operations). * Every comp is **revocable**. Revoke it from the guest list and the ticket stops scanning. The guest list also shows each comp's check-in and revocation state, so you know who actually showed up. ## Perks Perks are extras a ticket holder redeems at the venue — a drink, a merch item, a backstage visit. Define perks under **Perks**, then grant them either **manually** to specific people or automatically through **loyalty** distribution. At the event, your staff scan perk redemptions with the TicketConnect app, the same way they scan tickets. Each perk page shows **redemption analytics** — how many grants were redeemed over a window of up to **90 days** (30 by default). ## See also * [Selling tickets](#organizers-selling) — tiers, price steps, and your storefront. * [At the door](#organizers-door-operations) — scanning tickets and perks. --- # Resale & royalties When a fan can't make it, their ticket doesn't have to die in a drawer — and it doesn't have to end up on a scalper site either. Resale is built into your storefront: ticket holders list directly there, buyers pay there, and you earn a royalty on every resale. This page covers the per-event resale settings, how royalties and fees work, and what happens when a sold-out event gets returns. ## Resale settings, per event Each event has its own resale configuration in the organizer panel: | Setting | What it does | | --- | --- | | Resale enabled | Master switch. When off, tickets for this event can't be listed at all. | | Anti-scalping bounds | Minimum and maximum resale price, as a percentage of face value. | | Resale opens | When listing becomes possible: immediately, or a number of hours or days before the event. | These rules are enforced when a holder tries to list, not merely suggested: a listing outside the price bounds, before the opening window, or on an event with resale disabled is rejected. > **The anti-scalping ceiling defaults to 150% of face value.** If you turn > anti-scalping on without setting your own bounds, no ticket can be resold for > more than 1.5× what you charged for it. Use the opening window to keep early resale pressure off your primary sales — for example, allow listings only from 7 days before the event, once most fans who want a ticket have bought one from you. ## Your royalty on every resale Every time one of your tickets changes hands on the storefront, you earn a **royalty** — a percentage of the resale price, credited to you automatically. * The royalty is set **per organization** by TicketConnect and applies to your events' resales. The default is **5%**. * You can't change it yourself in the panel — **contact us** if you'd like it adjusted for your organization. A new rate applies to listings created from that point on. Resales also carry a flat **8% marketplace fee**, paid to the platform. The seller receives the resale price minus your royalty and the marketplace fee. ## Markup escrow: resale buyers are protected When a ticket resells above face value, the **markup** — the part of the price above what the ticket originally cost — is not paid out immediately. It's held in escrow until the event completes. > If you cancel the event, every buyer is made exactly whole: a resale buyer > gets back the face value **and** the escrowed markup — precisely what they > paid. You never have to chase down a seller to fix a cancelled event. The seller receives the face-value portion of the sale on the normal schedule and the markup once your event has taken place. For how and when your own proceeds are released, see [Getting paid](#organizers-getting-paid). ## Waitlist returns When an event sells out, fans can join a **waitlist** on your storefront. If a ticket holder later returns their ticket, it isn't relisted publicly — it's offered privately to the waitlist, first come, first served: * The first fan in line gets a **time-boxed offer** to buy the ticket at **face value** — 24 hours by default. If they don't buy in time, the offer moves to the next fan. * Waitlist purchases are **card or BLIK only**. * The returning holder is **refunded automatically** once the ticket is re-sold — there's nothing for you to process. > **No platform fee and no royalty apply to waitlist returns.** They're a > face-value swap between two fans, not a resale — the new buyer pays what the > original one did. Waitlist returns cover tickets originally sold through your primary sale. ## Revealed tickets lock — by design A fan's entry code stays hidden until they **reveal** it in the app (usually at the venue, right before the gate). From the moment a ticket is revealed it is **locked for good**: it can no longer be listed for resale, transferred, or returned — only scanned at the door. > **Why:** once a code has been displayed on a screen it can be screenshotted. > Locking the ticket at reveal guarantees a resale buyer can never receive a > ticket whose code someone else has already seen — every ticket bought on > your marketplace comes with a code no previous holder ever saw. This is automatic and not configurable; it protects your resale market's integrity. If a fan reveals early and then asks to return the ticket, the lock is the reason support says no. ## What this means for your fans * Genuine fans who missed the on-sale get a fair, face-value path back in through the waitlist. * Buyers on the resale market never pay more than your anti-scalping ceiling allows, and they're fully refunded — markup included — if you cancel. * A resale ticket always arrives with a **fresh, unseen entry code** — the reveal lock above means screenshots can't outlive an owner change. * You earn on the secondary market instead of watching scalpers do it. ## See also * [Selling tickets](#organizers-selling) — tiers, price steps, and primary sales. * [Getting paid](#organizers-getting-paid) — escrow, trust tiers, and claiming proceeds. --- # At the door Show night is where everything comes together: your staff scan tickets at the gates, redeem perks at the bar, and sell walk-up tickets paid by contactless card — all from the TicketConnect app, even when the venue Wi-Fi gives up. This page covers setting up your door team and running entry, perks, and gate sales. ## Staff accounts Your door team works from **staff accounts**, which you create and manage in the organizer panel under **Staff**. Each staff member gets their own login for the TicketConnect app and their own set of permissions: | Permission | What it allows | | --- | --- | | **Scan** | Check tickets in at the gate. | | **Stats** | See live check-in numbers for their assigned events. | | **Sell** | Sell walk-up tickets at the gate (see Gate sales below). | | **Manage** | Adjust door settings during the event. | New staff start with scan, stats, and sell; you can change permissions at any time. To put someone on the door, **assign them to the event** — staff only see and work the events they're assigned to. If someone leaves your team or a device goes missing, suspend the account to cut access immediately, or remove it entirely. ## Scanning tickets — fully offline Staff scan tickets with the TicketConnect app. Scanning **does not depend on an internet connection**: every ticket QR is verified cryptographically on the device itself, against ticket data the app downloads before doors open. If the venue's connectivity drops mid-show, your gates keep moving at full speed, and each scanner uploads its scan history automatically as soon as it's back online. Running multiple gates? The scanner phones form a **mesh that needs no venue Wi-Fi and no internet**: each phone talks directly to the ones near it, and a ticket scanned at gate A is rejected at gate B within seconds. If the venue does have Wi-Fi, scanners on it talk over that too — the two paths back each other up. In the rare case a duplicate still slips through (a phone completely out of reach of every other scanner), it isn't lost: the attempt is recorded, flagged for review, and surfaced to you in the panel. > **A ticket admits one person, once.** Duplicate scans are caught on the > device, across gates within seconds — over the direct phone-to-phone mesh or > the venue Wi-Fi — and on the server when scanners sync — whichever comes > first. ## Live codes — why a screenshot doesn't get anyone in A signature proves a ticket is real. It cannot, on its own, prove that the person holding up the phone is the one who bought it — a screenshot of a valid ticket is still a picture of a valid ticket. So on events with live codes enabled, the QR in the attendee's app **refreshes every 30 seconds**. Each refresh is signed on their device with a key that only their copy of that ticket has. Your scanners accept a code from roughly the last minute and reject anything older, so a screenshot, a forwarded photo or a printout stops working within about a minute of being taken — and it cannot be refreshed, because whoever took it doesn't have the key. This costs your gates nothing. The check runs entirely on the scanner, with no connection, at the same speed as everything else on this page. Two things worth knowing on the night: * **The attendee has to open the app.** A live ticket is not a picture, so it can't be added to Apple Wallet or Google Wallet, and a saved photo won't scan. If someone gets turned away for an expired code, have them open their ticket in TicketConnect and show it again. * **Arm each scanner before doors.** Scanners pick up what they need to check live codes when they connect to the event. A device that has never armed for this event will refuse live tickets rather than wave them through — if a scanner starts reporting that it needs re-arming, connect it once and it recovers. > **Your staff's devices cannot produce a valid ticket.** A scanner carries only > what it needs to *check* a code, never what it would take to *make* one. A > lost or stolen staff phone is an access problem to solve by suspending the > account — it is not a way to manufacture tickets for your event. ## Redeeming perks If your tiers or campaigns include perks — a free drink, merch, VIP lounge access — staff redeem them with the app's **perk scanner**. The attendee shows their ticket, staff scan it in perk mode, and the app shows which perks the attendee holds and marks the chosen one as redeemed. Perk redemptions work at the bar or merch stand the same way ticket scans work at the gate, and each redemption is recorded so a perk can't be claimed twice. See [Discounts, comps & audience](#organizers-discounts-comps) for granting perks. ## Gate sales Staff with the **sell** permission can sell tickets to walk-up buyers straight from the app. How those card payments are taken is something you decide **once per event**, in the **Set up event** step on the event page — the same one-time step where you choose wristbands. The button disappears after you've made both choices. Today gate payments run as **Tap to Pay**: the staff member's own phone takes the contactless card — no terminal hardware at all. The phone needs NFC and, for now, **Android** (Tap to Pay on iPhone is pending Apple approval). The platform also supports physical readers — your own Stripe Terminal, a rented terminal, or a SumUp Solo — contact us if your event needs one. Two things to know: * **Gate sales carry the standard platform fee** — the same as storefront sales, no extra gate commission. See [Getting paid](#organizers-getting-paid) for the full fee picture. * **Picking "no gate sales"** in the setup step turns the POS off for that event — staff can't sell, even with the sell permission. A single gate order can contain up to **50 tickets**. Because walk-up buyers usually don't have the app installed yet, gate sales pair with pre-printed wristbands: the buyer pays, gets a wristband, and claims their ticket from it later. ## Wristbands with claim QRs You order **pre-printed wristbands** in the panel, per ticket tier, ahead of the event; they're produced in bulk and shipped to you. Each wristband carries a unique claim QR. When a walk-up buyer completes a gate purchase, staff hand them a wristband; the buyer scans its QR with the TicketConnect app — whenever they get around to installing it — and the ticket lands in their app wallet. Claiming is safe to retry: scanning the same wristband again simply confirms the ticket is already theirs. > **Entry is always validated by the ticket, never the wristband.** The > wristband is only a hand-off mechanism for claiming; at the gate, staff scan > the ticket QR in the attendee's app. A lost wristband that was already > claimed admits nobody. ## Door dashboard While doors are open, the event's **door dashboard** in the panel shows live check-in stats: how many attendees are in, scan activity per staff member, and anything flagged for review. Staff with the **stats** permission see the same live numbers in the app, so your gate lead doesn't need a laptop. ## See also * [Selling tickets](#organizers-selling) — tiers, pricing, and the storefront. * [Discounts, comps & audience](#organizers-discounts-comps) — guest list comps scan at the gate exactly like paid tickets. * [Getting paid](#organizers-getting-paid) — how gate sales settle, fees, and payouts. --- # Getting paid This page is the one place that explains the money: what the platform charges, where your ticket revenue sits while the event is coming up, when you can take it out, what happens if you cancel, and how to get the numbers into your accounting. ## Your event's currency You pick it when you create the event — **PLN, EUR, USD, GBP or CZK** — and it's the currency of everything that follows: the prices you type, what card buyers are charged, your reports and your payouts. You can change it while the event is still a draft; once tickets are selling, leave it alone. Buyers paying in **USDC** pay the equivalent at the live rate, converted at the moment they check out. They see the price in your currency and never deal with the conversion. > **Pricing in USD unlocks on-chain escrow.** The contract can only hold a price > that doesn't move, so USD events settle crypto payments inside it — the > strongest protection for buyers, and refunds come out of it automatically. In > any other currency the equivalent shifts daily, so crypto payments settle with > TicketConnect instead. Either way you're paid the same; see > [Where the money actually lands](#organizers-getting-paid) below. ## Platform fees Fees are the same on every plan — plans gate capabilities, not fees (see [Plans & billing](#organizers-plans-billing)). | Sale type | Fee | | --- | --- | | Primary sale (storefront) | **3%** of the ticket price, minimum **2.50 PLN** per paid ticket | | Gate sale (at the door) | **3%** — same as the storefront, same minimum | | Resale on the marketplace | **8%** of the resale price, paid by the seller | **About the minimum.** It is a floor, not an extra: you pay 3% *or* 2.50 PLN per paid ticket, whichever is higher — never both. On a ticket above about 83 PLN the 3% is already the larger number, so the minimum never applies and 3% is the whole fee. Below that it covers the card-processing cost, which is a fixed amount per payment no matter how cheap the ticket. **Free tickets are always free** — the minimum never applies to them, and neither does anything else. **Card and BLIK processing is on us.** Those payments run through TicketConnect's own account, and the processing cost comes out of our margin — never yours. The rates above are the only deduction from your revenue, on every rail, including card and BLIK. Nothing is added to the buyer's price either: they pay the ticket price, full stop. > **Rented-terminal package.** Events on the "zero upfront" rented-terminal > package (a free terminal in exchange for commission, arranged with us) carry > an additional **1.5%** on that event's primary sales — storefront and gate > alike. Standard gate setups (Tap to Pay, your own reader) pay no extra fee. On top of the resale fee, you earn a royalty on every resale of your tickets — **5% by default**. Royalties are covered in [Resale & royalties](#organizers-resale-royalties). ## Proceeds and escrow Every primary sale — storefront or gate — accrues to a per-event proceeds balance, which you can watch live on the event page in the panel. Those proceeds are held in escrow and released at **event end + 7 days**. For a multi-day event, the clock starts after the final day. This window is what lets TicketConnect guarantee full refunds to buyers if an event is cancelled, which in turn is why buyers trust the storefront enough to buy early. > **Proceeds release at event end + 7 days.** Until then they sit in escrow, > visible on your event page but not yet claimable — except for the immediate > share your trust tier unlocks. ## Trust tiers As you run events successfully, your organization earns a **trust tier**, and each tier unlocks a share of every sale that is paid out immediately instead of waiting for the escrow window: | Trust tier | Immediate payout | How you get there | | --- | --- | --- | | New | 0% | Every organization starts here. | | Verified | 50% | Complete identity verification (KYC) and run 3 events. | | Trusted | 80% | Run 10 events. | | Platinum | 95% | Granted to established, high-volume organizers. | Trust tiers are assigned by TicketConnect — if you believe your track record qualifies you for a higher tier, contact us. A new tier applies to sales made after the upgrade. > **The immediate share applies to on-chain (USDC) sales.** It is baked into > each ticket tier when the event is created, so it also only affects tiers > created after an upgrade. Card and BLIK sales follow the payout option you > chose above: settled into your own Stripe straight away (Option A), or held > until the escrow window closes (Option B). ## Where the money actually lands There are two ways to get paid, and you choose which by whether you connect your own Stripe account. **Billing → Payouts** in the panel shows which one you're on. ### Option A — connect your own Stripe Link a Stripe account from **Billing → Payouts** and card payments for your events settle straight into it. From that point the money is yours in Stripe and you pay out to your bank on Stripe's schedule, exactly as you would with any other Stripe business. TicketConnect never holds it. Pick this if you already run on Stripe, want money in your bank quickly, or would rather not deal with a crypto wallet at all. ### Option B — no Stripe account (the default) If you don't connect Stripe, card payments settle on TicketConnect's account and we hold your share until the escrow window closes. You then send it to your organization's **Safe wallet** — a multi-owner treasury on Base that your organization controls, not TicketConnect — from **Billing → Payouts**, where each event shows its pending amount and whether it has been released yet. The transfer goes out as USDC. You manage the Safe from the panel's **Wallet** page: view its balances, propose transfers, and approve transfers proposed by other owners. Because the Safe can require multiple owners to approve a transfer before it executes, no single compromised account can drain your revenue. > **USDC sales never touch Stripe**, whichever option you pick above — Stripe > only handles card and BLIK. A USDC sale settles one of two ways, and the platform picks per order: * **In the contract** — the normal case. The buyer's money goes straight into the event's on-chain escrow, and you claim it from the **event page** after the release date. This is the strongest version for the buyer: if you cancel, the refund comes out of the contract automatically. * **Held by TicketConnect** — when the order can't settle on-chain. The contract charges a price fixed when the tier was created, so it can't apply a discount code or follow a price step you scheduled later. Those orders settle with us instead and appear in the same pending balance under **Billing → Payouts**. You don't configure this and buyers never see it — but it explains why one event can show proceeds in both places. Card money and USDC money are tracked separately — you'll see a line per currency — because they came in as different things and are paid out at the rate on the day, not converted twice. ### What refunds do to your balance Refunding a buyer removes your share of that ticket from the pending amount — whether it's one buyer asking for their money back or a full event cancellation. The buyer is repaid from the same place your proceeds are held, so the two always move together and a refunded ticket never gets paid out to you later. ## Refunds and cancellation If you have to cancel an event, TicketConnect handles refunds automatically — you don't chase individual orders: * Every buyer gets back **exactly what they paid**: the face value of the ticket plus the platform fee, refunded in full. * Buyers who bought on the resale marketplace also recover their escrowed markup — they're made whole too, at the price they actually paid. Before you commit, the panel shows you a **dry-run preview** of the cancellation: how many tickets will be refunded and the total amount, so there are no surprises. Outside of cancellation, each event has a **refund policy toggle** — you decide per event whether individual refunds are offered. > **Cancelling refunds buyers in full, automatically.** Face value comes from > escrow, the platform refunds its own fee, and resale buyers recover their > markup. This is funded by the escrow window above. ## Reports Everything above is exportable from the panel's **Reports** page as CSV or PDF: | Report | What it contains | | --- | --- | | Ticket sales | Every ticket sold, per event. | | Financials | Financial transactions across your organization. | | Attendee check-in list | Who attended, with check-in status per attendee. | | Revenue statement | Periodic revenue, broken out as net / VAT / gross. | | Payout breakdown | Gross, platform fee, already paid out, and still owed — one line per currency. | | Royalty distribution | Royalties earned from resales of your tickets. | ## See also * [Resale & royalties](#organizers-resale-royalties) — resale rules and your royalty on every resale. * [At the door](#organizers-door-operations) — gate sales, Tap to Pay, and wristbands. * [Plans & billing](#organizers-plans-billing) — what plans cost and what they include. --- # Plans & billing TicketConnect has three plans. Plans gate **capabilities** — branding, analytics, white-label — not fees: the platform fees on your ticket sales are identical on every plan. This page covers what each plan includes, how to pay, and what happens if a payment fails. ## The plans | Plan | Price | What it adds | | --- | --- | --- | | **Free** | $0 | The full core product: events, tiers, storefront sales, discounts, comps, resale, door operations, payouts, reports. | | **Pro** | $99/month | Custom branding (your logo and colors on the storefront) and advanced analytics. | | **Enterprise** | $499/month + $2,500 one-time setup | Full white-label: your own domain, plus the Developers portal. | Both paid plans are also available with **annual billing**. > **Fees don't change with your plan.** Primary sales carry a 3% platform fee > (minimum 2.50 PLN per paid ticket, which only applies below about 83 PLN) and > resales an 8% marketplace fee on Free, Pro, and Enterprise alike — see > [Getting paid](#organizers-getting-paid) and > [Resale & royalties](#organizers-resale-royalties). ## What Pro adds * **Custom branding** — put your own logo and colors on your storefront, so buyers see your brand, not ours. * **Advanced analytics** — deeper reporting on sales and audience behavior than the standard dashboard. ## What Enterprise adds Enterprise turns TicketConnect into infrastructure that runs under your name: * **Your own domain** — the storefront lives on a domain you own. * **The Developers portal** — a section of the panel where you manage API keys, webhooks, and API usage for programmatic access to the platform. When your Enterprise subscription activates, your white-label workspace is provisioned automatically and the Developers section appears in your panel. The API itself is documented in the licensee docs — start with the [introduction](#introduction). ## How to pay You choose between two payment methods: * **Card via Stripe** — subscribe through Stripe Checkout, monthly or annual. Afterwards you manage everything — card on file, invoices, plan changes, cancellation — through the self-serve Stripe billing portal, opened straight from the panel's **Billing** page. * **Prepay in USDC** — pay upfront from a crypto wallet, for a minimum of one month at a time. Your plan stays active for the period you've prepaid. Your current plan, its entitlements, and your invoice history are always visible on the Billing page. > **Only the organization owner can change billing.** Other members can see > the current plan, but subscribing, upgrading, cancelling, and payment > changes are reserved for the owner. ## If a renewal payment fails If a renewal charge doesn't go through, your plan doesn't drop immediately: you get a **3-day grace period** to fix the payment method (via the Stripe billing portal). If the payment still hasn't succeeded when the grace period ends, your organization falls back to the Free plan — your events, tickets, and payouts keep working, but paid-plan capabilities (branding, advanced analytics, white-label) switch off until you resubscribe. ## See also * [Getting paid](#organizers-getting-paid) — platform fees, escrow, and payouts, which are the same on every plan. * [Resale & royalties](#organizers-resale-royalties) — the resale fee and your royalty. * [For organizers](#organizers-introduction) — overview of the whole organizer product. --- # Marketing links & sales sources If you promote your event in more than one place — an Instagram ad, a newsletter, a partner's story — you want to know **which of them actually sells tickets**. Tag your links, and the platform records the source on every ticket sold, then shows you the totals in the **Sales sources** report. ## Tag your links Add UTM parameters to any link that points at your event page: ``` https://ticketconnect.xyz/events/abc123?utm_source=instagram&utm_medium=paid&utm_campaign=spring-tour ``` | Parameter | What to put there | Example | | --- | --- | --- | | `utm_source` | Where the link lives | `instagram`, `newsletter`, `partner-cafe` | | `utm_medium` | The kind of placement | `paid`, `email`, `story`, `qr` | | `utm_campaign` | Which push this is | `spring-tour`, `early-bird` | Meta Ads and Google Ads can fill these in automatically (look for “URL parameters” in the campaign settings); for posters and print, put the tagged link behind a QR code. > **Be consistent.** `Instagram`, `instagram` and `ig` show up as three > separate rows. Pick one spelling per source and stick to it — the report > groups by the exact text. ## Read the report Panel → **Sales sources**. Pick a date range (and optionally a single event) and you get, per source → medium → campaign: **tickets sold** and **revenue**, plus summary cards with the share of sales that came from campaigns versus **(direct)** — buyers who arrived without a tagged link (typed the address, found you on the platform, clicked an untagged share). A few things to know when reading the numbers: * Attribution is **last-touch within 30 days** — if someone clicks your newsletter link on Monday and your Instagram ad on Friday, the Friday purchase counts for Instagram. * Revenue is shown **per currency** — amounts in different currencies are never added together. * Guest-list (comp) tickets don't count; only real sales do. * The report is first-party: it works for **every** buyer, including those who decline cookies, so it's your most complete picture — ad-platform dashboards will always show less than this. ## Where this data comes from When a buyer lands on your event page through a tagged link, the tag rides along until checkout and is saved with the ticket. There's nothing to enable and nothing to install — every organizer has this from the first tagged link they share.