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