# 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 &amp; upgrade](#transfer-refund-upgrade) — lifecycle operations.
* [API reference: issue a ticket](#api-tickets)
