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.");
}| Class | Status | Means |
|---|---|---|
SeatLayerAuthError |
401, 403 | Bad, revoked, or wrong-mode key |
SeatLayerNotFoundError |
404 | No such resource for this organisation |
SeatLayerConflictError |
409 | Inventory moved, or a guard rejected the change |
SeatLayerValidationError |
422 | Understood and rejected |
SeatLayerRateLimitError |
429 | Over budget; carries retryAfterSeconds |
SeatLayerConnectionError |
— | Never got an answer: DNS, TLS, socket, timeout |
Every API error carries status, code, body, and a requestId taken from
the X-Request-ID header. Quote that id in support requests.
Retries
The SDKs retry 429, 408, and 5xx with exponential backoff and full jitter,
and honour Retry-After when we send it. Jitter matters: a fleet of workers that
all get limited in the same second would otherwise retry in lockstep and
re-limit itself.
4xx responses are never retried. They will not start succeeding, and retrying
one only burns rate-limit budget and delays the error you need to see.
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
Every mutating request carries an Idempotency-Key, generated if you do not
supply one — and reused across retries, which is the entire point. A retried
booking collapses into the original instead of becoming a second sale.
Pass your own order id when you want deduplication end to end, across process restarts and job requeues:
await seatlayer.inventory.book(
eventKey,
{ holdId },
{ idempotencyKey: `order-${orderId}` },
);seatlayer.inventory.book(
event_key, hold_id=hold_id, idempotency_key=f"order-{order_id}"
)$seatlayer->inventory->book($eventKey, holdId: $holdId, idempotencyKey: "order-{$orderId}");seatlayer.inventory().book(eventKey, holdId, "order-" + orderId);client.Inventory.Book(ctx, eventKey, seatlayer.BookParams{
HoldID: holdID, IdempotencyKey: "order-" + orderID,
})client.inventory.book(event_key, hold_id: hold_id, idempotency_key: "order-#{order_id}")await client.Inventory.BookAsync(eventKey, holdId, idempotencyKey: $"order-{orderId}");Keys match ^[A-Za-z0-9._:-]{1,128}$ and are validated client-side, so an
invalid one fails immediately rather than after a round-trip. Reusing a key with
a different body answers 409 — that is the server refusing to let one key
stand for two different operations.
Rate limits
Server calls are budgeted per secret key rather than per IP, because one backend
legitimately speaks for every buyer on your platform. A 429 carries
Retry-After plus the RateLimit-Limit, RateLimit-Remaining and
RateLimit-Reset headers describing the active policy — the SDKs read these so
your backoff matches the real window instead of guessing.
Escape hatch
For surface the SDK does not wrap yet, with the same auth, retries, idempotency and error mapping:
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 });