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
GET /v1/holders/legacy-cara
Authorization: Bearer sk_live_…{
"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
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. 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:
{
"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
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.
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.
POST /v1/holders/legacy-cara/returns
Authorization: Bearer sk_live_…
Idempotency-Key: crm-return-0412
{ "ticketIds": ["tkt_3f1c…"] }{
"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 with reason
returned, and season.occurrence.returned 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).
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" }{
"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,
where the quote is too.
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.
{
"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.