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