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/sdk generates 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/quotes or POST /api/seats/confirm with the iframe buyer JWT. They are partner-only.

Prefer @seatschart/sdk on Node/Bun. Curl works the same.


1. Install

bash
npm install @seatschart/sdk
# or: bun add @seatschart/sdk
ts
import { 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:

ts
const { token } = await sc.viewerTokens.create({ ttlSeconds: 900 });
bash
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):

ts
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

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

ts
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

bash
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:

ts
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

bash
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

HTTPerrorMeaning / fix
409quote_requiredNo quoteId. Create a quote for the hold (step 3) before charging.
409quote_mismatchThe 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.
409idempotency_conflictSame identity with a different payload, quote expired or already used, or hold lost. Reconcile the payment; never charge a new price silently.
403forbiddenCalled with a buyer JWT. Use x-api-key.

6. Abandon / payment failed

ts
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",
});
bash
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

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