---
title: "Holders and customers"
description: "Read and update the customer behind a season holder reference, list their seasons and tickets, give seats back, renew them, and find customers by email."
---

A **holder reference** is your own name for a season ticket holder: the
`holderRef` your imports and bookings already carry. A season bought through
SeatLayer checkout uses its order id as the reference.

A **customer** is the record SeatLayer keeps for a holder, one per email and
mode (`cus_…`). These routes connect the two, with your secret key. The key's
mode decides which customers you see: a test key never reaches a live customer.

## How a holder gets a customer

`PATCH /v1/holders/:holderRef` links a holder to a customer at once, and makes
the customer if it does not exist. Until then the holder is `pending`, and the
reads on this page still answer for them.

An import row with `holderEmail`, and "Bring holders from a previous season",
keep the email on the holder, so `PATCH` needs no `email` for them. Only rows
the import committed keep one, and an import never changes a holder that already
has a link: to change a holder's email, use `PATCH /v1/holders/:holderRef`.

## Retrieve a holder

```http
GET /v1/holders/legacy-cara
Authorization: Bearer sk_live_…
```

```json title="200"
{
  "holder": {
    "holderRef": "legacy-cara",
    "customerId": "cus_5f0c2e9a7b1d4c3e8f6a2b10",
    "email": "cara@example.org",
    "name": "Cara Holder",
    "phone": null,
    "status": "active"
  }
}
```

`status` is `pending` while the holder has no customer; `customerId` is then
`null`. `email` and `name` are the customer's own, else the ones you set, else
the ones a season import gave the holder. A reference SeatLayer has never seen
answers `404 holder_not_found`.

## Update a holder

```http
PATCH /v1/holders/legacy-cara
Authorization: Bearer sk_live_…
Idempotency-Key: crm-update-8812

{ "email": "cara.new@example.org", "phone": "+44 7700 900123" }
```

Your server knows its holder, so a new email applies at once and sends
[`customer.email_changed`](/webhooks/events/#customeremail_changed). If another
customer of yours already has the email, the answer is `409 email_in_use`.
Nothing bought with the new address joins the customer, so a mistyped address
shows a stranger nothing. The box office's walk-up placeholder is not an email
and answers `422`.

A holder with no customer gets one for the email you send, or for the email an
import named. With neither, the answer is `422 email_required`. Send `null` to
clear `name` or `phone`; a name an import gave then shows again. The answer is
the holder as `GET` reads it. Retry with the same `Idempotency-Key` to get the
original answer back.

## Seasons and tickets

`GET /v1/holders/:holderRef/seasons` lists every season the holder's customer
holds seats in:

```json title="200"
{
  "seasons": [{
    "seasonKey": "sea_a245…",
    "name": "2027 Season",
    "package": { "planActivationId": "spa_0b23…", "name": "Full season" },
    "seats": ["A-6", "A-7"],
    "status": "reserved",
    "dates": [],
    "renewalOffer": null
  }]
}
```

| `status` | Meaning |
|---|---|
| `active` | Booked on at least one date you have not cancelled. `dates` lists each one. |
| `reserved` | Imported: the seats are held for them, not yet offered or booked. |
| `offered` | A renewal offer is open; `renewalOffer` has its deadline. |
| `renewed` | Renewed. |
| `released` | Their seats were let go. |
| `cancelled` | Every date was cancelled. |

A date given back reads `cancelled` once every seat of it is back, whoever
gave it back (the holder, or you with
`POST /v1/seasons/:key/occurrences/:eventKey/returns`). Its seats leave the
season's `seats` while other dates stay `booked`.

A date you [cancelled](/server-api/seasons/#postpone-move-or-cancel-a-date)
reads `date_cancelled`. It stops counting towards `active`, and once every date
is cancelled the season reads `cancelled`. A date you postponed reads
`postponed` and still counts, with `newStartsAt` once it moves (`null` until
then).

`GET /v1/holders/:holderRef/tickets` lists tickets SeatLayer issued for the
holder's orders, earliest date first, 50 a page (`limit` up to 100). Filter with
`seasonKey`, and `from` / `to` in epoch milliseconds on the date's start. Pass
`nextCursor` back as `cursor` for the next page; it is `null` on the last.

`GET /v1/holders/:holderRef/tickets/:ticketId` adds `qrUrl` and `scanUrl`.

<Aside type="caution" title="A ticket link is a ticket">
  The token inside `qrUrl` and `scanUrl` lets whoever has it be scanned in.
  Show it only to the holder, and never log it.
</Aside>

A season sold through your own checkout has no SeatLayer tickets, so its dates
show under `seasons` and the ticket list is empty.

## Send tickets to someone else

Customer accounts are off, so a recipient has no page to accept tickets on.
`POST /v1/holders/:holderRef/transfers` answers `409 customer_accounts_off`, and
nothing is saved or sent. `GET /v1/holders/:holderRef/transfers` lists nothing,
and the accept and cancel routes answer `404 transfer_not_found`.

## Give a seat back on one date

A holder who cannot come to one date can give that seat back through your
server, so your club can sell it again. Each ticket is one seat on one date; the
holder keeps every other seat and date, and the right to renew.

```http
POST /v1/holders/legacy-cara/returns
Authorization: Bearer sk_live_…
Idempotency-Key: crm-return-0412

{ "ticketIds": ["tkt_3f1c…"] }
```

```json title="201"
{
  "return": {
    "returnId": "ret_8d02…",
    "state": "returned",
    "seasonKey": "sea_a245…",
    "tickets": [{ "ticketId": "tkt_3f1c…", "eventKey": "ev_41b2…", "label": "B-14", "state": "returned", "error": null }],
    "createdAt": 1793664000000
  }
}
```

The ticket is voided first, so its QR stops working online and the door list's
version changes, which makes a door phone mark it void. Then the seat goes back
on sale for that date, like any free seat. No money moves: a refund for the
date is a separate partial refund. You get
[`season.ticket.voided`](/webhooks/events/#season-tickets) with reason
`returned`, and [`season.occurrence.returned`](/webhooks/events/#season-dates-and-amendments) with origin
`holder` once the seat is back on sale. In the season's Orders tab and
`orders.csv`, a date whose seats were all given back shows as cancelled.

Each ticket, and the return as a whole, has a `state`:

| `state` | Meaning |
|---|---|
| `returned` | The ticket is void and the seat is back on sale. |
| `pending` | The ticket is void and the seat is still being released. It finishes on its own within the hour, or at once when you send the same tickets again. |
| `failed` | The ticket is void, but the seat could not be given back. `error` says why: `source_not_active` means the booking ended meanwhile (cancelled or refunded), so its seats went back with it. |

Sending the same tickets again answers the same return as it stands now
(`200`). If a return was cut off part way, sending them again finishes the
tickets it had not reached (`201`).

| Refusal | When |
|---|---|
| `404 ticket_not_found` | A ticket is not this holder's. |
| `409 ticket_checked_in` | A ticket was used at the door. |
| `409 ticket_not_returnable` | A ticket is void or not held now, or they span seasons. |
| `409 own_checkout` | The season sells through your own checkout; SeatLayer holds no seat to give back. |
| `409 return_cutoff_passed` | A date starts within the season's return cut-off (`returnCutoffHours`, set with `PATCH /v1/seasons/:key`, 0–720; `null` means until the date starts). |
| `409 performance_cancelled` | The date was cancelled: its money goes back through the date's refund job. |

## Renew a holder's seats

When SeatLayer takes the payments for a season, your server can renew a
holder with no SeatLayer page in between. Take the `offerId` from the holder's
season (`renewalOffer` in [Seasons and tickets](#seasons-and-tickets)).

```http
POST /v1/holders/legacy-cara/renewal-offers/sro_51e0…/checkout
Authorization: Bearer sk_live_…
Idempotency-Key: crm-renew-cara-2027

{ "returnUrl": "https://tickets.yourclub.example/renewed" }
```

```json title="201"
{
  "orderId": "ord_ea11…",
  "totalMinor": 18000,
  "currency": "GBP",
  "expiresAt": 1793664900000,
  "payment": { "url": "https://checkout.provider.example/…" }
}
```

Send the holder to `payment.url`. Paying renews the offer on every date, and
you get `season.order.paid` and `season.renewal.renewed`. The request, the
answer and the rules are those of the
[season's renewal checkout](/server-api/seasons/#renew-with-seatlayer-checkout),
where the quote is too.

```http
POST /v1/holders/legacy-cara/renewal-offers/sro_51e0…/decline
Authorization: Bearer sk_live_…
```

Declines for the holder and releases their seats at once; you get
`season.renewal.declined`. An optional `{ "reason": "Moving away" }` (1–200
characters) is kept on the offer as `declineReason`. Declining again answers
the same offer, with the first reason.

Both answer `404 offer_not_found` for an offer that is not this holder's. The
offers of every reference of the holder's customer count, as in
`GET /v1/holders/:holderRef/seasons`.

## Find customers

`GET /v1/customers` lists your customers in the key's mode, in customer id
order (ids are random, so this is a stable order for paging, not by age), each
with its `holderRefs`. Filter with `email` or `holderRef`; page with `cursor`
and `limit` as above.

```json title="200"
{
  "customers": [{
    "id": "cus_5f0c2e9a7b1d4c3e8f6a2b10",
    "email": "cara@example.org",
    "name": "Cara Holder",
    "phone": null,
    "status": "active",
    "mode": "live",
    "createdAt": 1793664000000,
    "holderRefs": ["legacy-cara"]
  }],
  "nextCursor": null
}
```

These routes share the season API's budget of 600 calls a minute per key. No
server SDK wraps them; call the REST routes from any language.