Selling seats is a competition. Two buyers want the same seat, networks drop mid-booking, and a retry that books twice is worse than one that fails. This page covers what the SDKs do about that so you do not have to.
Errors you branch on
A sold-out seat is a business outcome, not an exception to log and forget. The SDKs type failures by what an integration actually does about them.
import {
SeatLayerAuthError,
SeatLayerConflictError,
SeatLayerRateLimitError,
} from '@seatlayer/server';
try {
await seatlayer.inventory.holdBestAvailable(eventKey, { qty: 6 });
} catch (error) {
if (error instanceof SeatLayerConflictError && error.isSoldOut) {
return showAlternativeDates();
}
if (error instanceof SeatLayerRateLimitError) {
return retryAfter(error.retryAfterSeconds);
}
if (error instanceof SeatLayerAuthError && error.isModeMismatch) {
throw new Error('Test key pointed at a live event, or the reverse.');
}
throw error;
}from seatlayer import (
SeatLayerAuthError,
SeatLayerConflictError,
SeatLayerRateLimitError,
)
try:
seatlayer.inventory.hold_best_available(event_key, qty=6)
except SeatLayerConflictError as error:
if error.is_sold_out:
return show_alternative_dates()
raise
except SeatLayerRateLimitError as error:
return retry_after(error.retry_after_seconds)
except SeatLayerAuthError as error:
if error.is_mode_mismatch:
raise RuntimeError("Test key pointed at a live event, or the reverse.") from error
raiseuse SeatLayer\AuthException;
use SeatLayer\ConflictException;
use SeatLayer\RateLimitException;
try {
$seatlayer->inventory->holdBestAvailable($eventKey, qty: 6);
} catch (ConflictException $error) {
if ($error->isSoldOut()) {
return showAlternativeDates();
}
throw $error;
} catch (RateLimitException $error) {
return retryAfter($error->retryAfterSeconds);
} catch (AuthException $error) {
if ($error->isModeMismatch()) {
throw new RuntimeException('Test key pointed at a live event, or the reverse.');
}
throw $error;
}try {
seatlayer.inventory().holdBestAvailable(eventKey, 6);
} catch (SeatLayerConflictException e) {
if (e.isSoldOut()) {
return showAlternativeDates();
}
throw e;
} catch (SeatLayerRateLimitException e) {
return retryAfter(e.retryAfterSeconds());
} catch (SeatLayerAuthException e) {
if (e.isModeMismatch()) {
throw new IllegalStateException("Test key pointed at a live event, or the reverse.");
}
throw e;
}_, err := client.Inventory.HoldBestAvailable(ctx, eventKey,
seatlayer.BestAvailableParams{Qty: 6})
var conflict *seatlayer.ConflictError
var rateLimit *seatlayer.RateLimitError
var auth *seatlayer.AuthError
switch {
case errors.As(err, &conflict) && conflict.SoldOut():
return offerAlternativeDates() // a business outcome, not a bug
case errors.As(err, &rateLimit):
return retryAfter(rateLimit.RetryAfter)
case errors.As(err, &auth) && auth.ModeMismatch():
return errors.New("test key pointed at a live event, or the reverse")
case err != nil:
return err
}begin
client.inventory.hold_best_available(event_key, qty: 6)
rescue SeatLayer::ConflictError => e
return show_alternative_dates if e.sold_out? # a business outcome, not a bug
raise
rescue SeatLayer::RateLimitError => e
return retry_after(e.retry_after)
rescue SeatLayer::AuthError => e
raise "Test key pointed at a live event, or the reverse." if e.mode_mismatch?
raise
endtry
{
await client.Inventory.HoldBestAvailableAsync(eventKey, new BestAvailableRequest { Qty = 6 });
}
catch (SeatLayerConflictException e) when (e.IsSoldOut)
{
return OfferAlternativeDates(); // a business outcome, not a bug
}
catch (SeatLayerRateLimitException e)
{
return RetryAfter(e.RetryAfterSeconds);
}
catch (SeatLayerAuthException e) when (e.IsModeMismatch)
{
throw new InvalidOperationException("Test key pointed at a live event, or the reverse.");
}The names in this table are error families, not copyable class names. Use the tab for your language above or its language guide for the exact spelling; property casing also follows that language.
| Error family | Status | Means |
|---|---|---|
| Authentication / authorization | 401, 403 | Bad, revoked, forbidden, or wrong-mode key |
| Not found | 404 | No such resource for this organisation |
| Conflict | 409 | Inventory moved, or a guard rejected the change |
| Validation | 422 | Understood and rejected |
| Rate limit | 429 | Over budget; carries a retry delay when supplied |
| Connection | — | Never got an answer: DNS, TLS, socket, timeout |
API exceptions expose the HTTP status, machine error slug, parsed body, and
request ID using language-appropriate names. The request ID comes from
X-Request-ID; quote it in support requests. On the wire, error is the
required operation discriminator. Some legacy responses also include a code
alias; SDKs resolve the machine slug from body.code ?? body.error. Optional
message and detail fields remain operation-specific, and additive fields are
forward-compatible.
Retries
Retry policy is operation-specific. Reads retry connection failures, 408,
429, and 5xx with exponential backoff and full jitter. A mutation retries
automatically only when the API can replay its original HTTP status and body.
| Mutation policy | Operations | SDK behavior |
|---|---|---|
| Exact header replay | 14 operations listed below | Retries transient failures with one reused Idempotency-Key |
| Exact booking reference | Book objects, box-office book objects | One automatic attempt; you may reconcile and repeat the exact event + selection + bookingRef |
| One-time secret | Buyer/manage/designer sessions, access-link create/rotate, webhook create | One attempt; never automatically retried |
| Unsupported | Every other mutation | One attempt; reconcile current state before deciding what to do next |
The 14 exact-header-replay wire operations in every 0.8.0 SDK are:
- core:
createChart,copyChart,instantiateTemplate,createEvent, andcreateWorkspace; - Performance Groups:
createPerformanceGroup; and - Seasons:
createSeason,updateSeason,deleteSeason,createSeasonPlan,duplicateSeasonToLive,createSeasonHolderImport,createSeasonRenewalOffers, andcreateSeasonAmendment.
Those are wire operation IDs. Use the language page for the exact method
spelling. No other mutation inherits this policy merely because it accepts an
Idempotency-Key.
Best-available hold and booking are deliberately single-attempt. Retrying them
can select a different set of seats after the first response is lost. 4xx
responses are never retried.
maxRetries is the total-attempt ceiling for eligible reads and these 14 exact
header-replay mutations; it does not opt unsafe mutations into retries.
new SeatLayer({
secretKey: process.env.SEATLAYER_SECRET_KEY!,
maxRetries: 3, // total attempts
timeoutMs: 30_000, // per attempt
});SeatLayer(
os.environ["SEATLAYER_SECRET_KEY"],
max_retries=3, # total attempts
timeout=30.0, # seconds, per attempt
)new SeatLayer(
getenv('SEATLAYER_SECRET_KEY'),
maxRetries: 3, // total attempts
timeout: 30.0, // seconds, per attempt
);SeatLayer.builder()
.secretKey(System.getenv("SEATLAYER_SECRET_KEY"))
.maxRetries(3) // total attempts
.timeout(Duration.ofSeconds(30)) // per attempt
.build();client, err := seatlayer.New(
os.Getenv("SEATLAYER_SECRET_KEY"),
seatlayer.WithMaxRetries(3), // total attempts
seatlayer.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), // per attempt
)SeatLayer::Client.new(
ENV.fetch("SEATLAYER_SECRET_KEY"),
max_retries: 3, # total attempts
timeout: 30.0 # seconds, per attempt
)new SeatLayerClient(secretKey, new SeatLayerClientOptions
{
MaxRetries = 3, // total attempts
Timeout = TimeSpan.FromSeconds(30), // per attempt
});Idempotency
There are two different safety contracts. Do not treat them as interchangeable. The wire-level rules behind both are documented in idempotency and conflicts.
Exact HTTP replay for creates
The 14 operations above accept Idempotency-Key. The SDK generates one when
absent and reuses it across eligible retries. For 30 days, the same logical
request replays the original status and JSON body; a changed body, path, or
request scope returns 409 idempotency_conflict.
Keys match ^[A-Za-z0-9._:-]{1,128}$. Their namespace includes the account,
live/test mode, and routing environment, but not a particular key id, so key
rotation does not strand an in-flight retry.
Booking safety uses bookingRef
Booking does not use generic header replay. Put your immutable order id in the
required bookingRef body field:
await seatlayer.inventory.book(
eventKey,
{ holdId, bookingRef: `order-${orderId}` },
);seatlayer.inventory.book(
event_key, hold_id=hold_id, booking_ref=f"order-{order_id}"
)$seatlayer->inventory->book($eventKey, holdId: $holdId, bookingRef: "order-{$orderId}");seatlayer.inventory().book(eventKey, holdId, "order-" + orderId);client.Inventory.Book(ctx, eventKey, seatlayer.BookParams{
HoldID: holdID, BookingRef: "order-" + orderID,
})client.inventory.book(event_key, hold_id: hold_id, booking_ref: "order-#{order_id}")await client.Inventory.BookAsync(eventKey, holdId, bookingRef: $"order-{orderId}");Repeat only the exact same event and hold/label set with that reference. Seats
already booked under it are not sold again, but the replay response may contain
an empty booked array. Reusing one bookingRef with additional labels can add
those seats; one business order must therefore keep one immutable selection.
Rate limits
The server hold, extend, best-available hold, and best-available booking routes
share one durable 600 requests/minute budget per API-key record. Rotating that
key does not reset its budget; a different key has an independent bucket. A
429 carries Retry-After and RateLimit-* headers, and the SDK exposes the
delay as retryAfterSeconds.
These inventory mutations remain single-attempt because blindly repeating a hold or best-available selection can reserve different inventory. Your order workflow, not the HTTP transport, must decide whether it is safe to wait and submit a new logical request. Other secret-key resource CRUD does not claim a universal account budget.
The OpenAPI contract separately records a 30/minute per-IP, per-Worker-isolate
soft ceiling for readiness probes (no standard rate-limit headers), and a
durable 300/minute per-session ceiling for browser manage tokens
(Retry-After only). Those are not secret-key SDK budgets.
Escape hatch
For a surface the SDK does not wrap yet, the raw client keeps the same auth and error mapping. Raw reads retain transient retry behavior. Raw mutations are single-attempt and do not generate an idempotency key; consult the operation’s OpenAPI policy before building your own recovery flow:
await seatlayer.request('POST', '/v1/events/ev_1/some-new-route', { body: {} });seatlayer.request("POST", "/v1/events/ev_1/some-new-route", body={})$seatlayer->request('POST', '/v1/events/ev_1/some-new-route', body: []);seatlayer.request("POST", "/v1/events/ev_1/some-new-route", null, Map.of("qty", 2));client.Do(ctx, http.MethodPost, "/v1/events/ev_1/some-new-route", nil,
map[string]any{"qty": 2}, "")client.request("POST", "/v1/events/ev_1/some-new-route", body: { "qty" => 2 })await client.SendAsync(HttpMethod.Post, "/v1/events/ev_1/some-new-route",
body: new Dictionary<string, object?> { ["qty"] = 2 });