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