Document / EN
Server-to-server checkout (reserve → quote → pay → confirm)
Copy-paste flow for booking objects after the buyer selects seats in the iframe. Same story as a typical “book objects” guide — SeatsChart path names stay /api/seats/* (not Seats.io aliases).
Rules
- Partner secret: header
x-api-key: sk_live_xxx(placeholder only — never commit a real key). - Every mutation needs
Idempotency-Key(UUID per attempt;@seatschart/sdkgenerates one if you omit it). - Every new confirmation needs a quote created before you charge the buyer. The event must have a currency.
- Never call
POST /api/quotesorPOST /api/seats/confirmwith the iframe buyer JWT. They are partner-only.
Prefer @seatschart/sdk on Node/Bun. Curl works the same.
1. Install
npm install @seatschart/sdk
# or: bun add @seatschart/sdkimport { SeatsChart } from "@seatschart/sdk";
const sc = new SeatsChart({
apiBase: "https://api.seatschart.com",
apiKey: process.env.SEATSCHART_API_KEY!, // sk_live_xxx — server env only
});Viewer token for the iframe
The buyer's viewer needs a short-lived token. Mint it with the same API key — no JWT secret is shared with you:
const { token } = await sc.viewerTokens.create({ ttlSeconds: 900 });curl -sS -X POST "https://api.seatschart.com/api/viewer-tokens" \
-H "Content-Type: application/json" \
-H "x-api-key: sk_live_xxx" \
-d '{ "ttlSeconds": 900 }'
# → { "token": "eyJ…", "tokenType": "Bearer", "expiresInSeconds": 900, "expiresAt": "…" }2. Reserve (temporary hold)
After the iframe postMessage hold (or from your backend):
await sc.seats.reserve({
eventId: "00000000-0000-4000-8000-000000000000",
seatIds: ["11111111-1111-4111-8111-111111111111"],
userId: "buyer-order-42",
holdToken: "22222222-2222-4222-8222-222222222222", // optional session
channel: "web",
});curl
curl -sS -X POST "https://api.seatschart.com/api/seats/reserve" \
-H "Content-Type: application/json" \
-H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: 33333333-3333-4333-8333-333333333333" \
-d '{
"eventId": "00000000-0000-4000-8000-000000000000",
"seatIds": ["11111111-1111-4111-8111-111111111111"],
"userId": "buyer-order-42",
"holdToken": "22222222-2222-4222-8222-222222222222",
"channel": "web"
}'Seats stay available in Postgres until confirm; Redis holds them with a TTL (SSE locked). Holds from POST /api/seats/best-available (with hold: true) work the same way in the next steps.
3. Quote (freeze the price)
Before charging, ask SeatsChart for the price of the whole hold. Amounts come from the catalog; the caller cannot send prices, fees or discounts.
const quote = (await sc.quotes.create({
eventId: "00000000-0000-4000-8000-000000000000",
userId: "buyer-order-42",
holdToken: "22222222-2222-4222-8222-222222222222",
// seatTicketTypes: { "<seatId>": "<ticketTypeId>" }, // when categories have ticket types
})) as { id: string; totalCents: number; currency: string; expiresAt: string };curl
curl -sS -X POST "https://api.seatschart.com/api/quotes" \
-H "Content-Type: application/json" \
-H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: 44444444-4444-4444-8444-444444444444" \
-d '{
"eventId": "00000000-0000-4000-8000-000000000000",
"userId": "buyer-order-42",
"holdToken": "22222222-2222-4222-8222-222222222222"
}'
# → { "id": "66666666-…", "currency": "BRL", "totalCents": 12000, "expiresAt": "…", "items": [ … ], "state": "open" }The quote is valid for 10 minutes. It does not extend the hold.
4. Charge on your PSP
SeatsChart does not run the buyer’s payment stack. Use Stripe, Adyen, etc. on your server and charge exactly quote.totalCents in quote.currency. Keep eventId, seatIds, userId, holdToken, quote.id and the PSP payment id for the next step.
5. Confirm (commit sold)
Only after the PSP reports success:
await sc.seats.confirm({
eventId: "00000000-0000-4000-8000-000000000000",
seatIds: ["11111111-1111-4111-8111-111111111111"],
userId: "buyer-order-42",
holdToken: "22222222-2222-4222-8222-222222222222",
quoteId: quote.id,
externalPaymentId: "psp:ch_123",
paidAmountCents: quote.totalCents,
paidCurrency: quote.currency,
channel: "web",
});curl
curl -sS -X POST "https://api.seatschart.com/api/seats/confirm" \
-H "Content-Type: application/json" \
-H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: 55555555-5555-4555-8555-555555555555" \
-d '{
"eventId": "00000000-0000-4000-8000-000000000000",
"seatIds": ["11111111-1111-4111-8111-111111111111"],
"userId": "buyer-order-42",
"holdToken": "22222222-2222-4222-8222-222222222222",
"quoteId": "66666666-6666-4666-8666-666666666666",
"externalPaymentId": "psp:ch_123",
"paidAmountCents": 12000,
"paidCurrency": "BRL",
"channel": "web"
}'202 means the order is durably accepted: the API claims the Redis holds, commits the order in Postgres, then marks seats sold and emits SSE / webhooks. Retries with the same order identity return the same order. Follow GET /api/orders/:orderId before issuing tickets.
Errors
| HTTP | error | Meaning / fix |
|---|---|---|
| 409 | quote_required | No quoteId. Create a quote for the hold (step 3) before charging. |
| 409 | quote_mismatch | The quote belongs to another event, user, hold or API key, the seats / ticket types differ from the quote, or externalPaymentId / paidAmountCents / paidCurrency do not match it. |
| 409 | idempotency_conflict | Same identity with a different payload, quote expired or already used, or hold lost. Reconcile the payment; never charge a new price silently. |
| 403 | forbidden | Called with a buyer JWT. Use x-api-key. |
6. Abandon / payment failed
await sc.seats.release({
eventId: "00000000-0000-4000-8000-000000000000",
seatIds: ["11111111-1111-4111-8111-111111111111"],
userId: "buyer-order-42",
holdToken: "22222222-2222-4222-8222-222222222222",
});curl -sS -X POST "https://api.seatschart.com/api/seats/release" \
-H "Content-Type: application/json" \
-H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: 77777777-7777-4777-8777-777777777777" \
-d '{
"eventId": "00000000-0000-4000-8000-000000000000",
"seatIds": ["11111111-1111-4111-8111-111111111111"],
"userId": "buyer-order-42",
"holdToken": "22222222-2222-4222-8222-222222222222"
}'GA areas follow the same steps: POST /api/ga/reserve → POST /api/quotes (with ticketTypeId) → PSP → POST /api/ga/confirm with quoteId, externalPaymentId, paidAmountCents, paidCurrency (other conflicts return conflict).
7. Errors
import { SeatsChartConflictError, SeatsChartHttpError, SeatsChartRateLimitError, SeatsChartValidationError } from "@seatschart/sdk";
try {
await client.seats.confirm(body, { idempotencyKey });
} catch (error) {
if (error instanceof SeatsChartConflictError && error.isBookingLimitExceeded) { /* error.body.limit */ }
else if (error instanceof SeatsChartConflictError && error.isQuoteError) { /* new quote */ }
else if (error instanceof SeatsChartRateLimitError) { /* wait error.retryAfterSeconds */ }
else if (error instanceof SeatsChartValidationError) { /* fix the request */ }
else if (error instanceof SeatsChartHttpError) { console.error(error.code, error.requestId); }
throw error;
}The TS SDK throws one subclass of SeatsChartHttpError per category — SeatsChartValidationError (400/413/422), SeatsChartAuthError (401/403, reason, requiredScope), SeatsChartNotFoundError (404), SeatsChartConflictError (409/410/412, isBookingLimitExceeded, isQuoteError), SeatsChartRateLimitError (429, retryAfterMs / retryAfterSeconds) and SeatsChartServerError (5xx); 402 and others stay SeatsChartHttpError. instanceof SeatsChartHttpError keeps working, and every error has status, body, the stable code (deprecated aliases such as validation normalized to validation_error; rawCode keeps the original), detailCode and requestId (x-request-id, quote it to support). The full catalog: Error codes.
Each attempt times out after timeoutMs (30 s by default; 0 turns it off; every call also takes its own timeoutMs and an AbortSignal as signal). A timeout throws SeatsChartTimeoutError (code timeout, attempts), which is not an HTTP error: the outcome on the server is unknown, so retry the operation with the same Idempotency-Key. Calls that are safe to repeat (GET, PUT, DELETE, and every call with an Idempotency-Key, such as reserve and confirm) are already retried after a timeout, as after a 429 or 503.
Related
- Embed iframe +
postMessage: Integration guide - Generate PHP / Java / .NET / Go clients: Server SDKs
- OpenAPI:
openapi.json
