---
title: "Errors, retries and idempotency"
description: "How the server SDKs classify failures, which operations retry, and how create keys differ from booking references."
---

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.

<Tabs>
<TabItem label="Node.js">

```ts
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;
}
```

</TabItem>
<TabItem label="Python">

```python
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
    raise
```

</TabItem>
<TabItem label="PHP">

```php
use 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;
}
```

</TabItem>
<TabItem label="Java">

```java
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;
}
```

</TabItem>
<TabItem label="Go">

```go
_, 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
}
```

</TabItem>
<TabItem label="Ruby">

```ruby
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
end
```

</TabItem>
<TabItem label=".NET">

```csharp
try
{
    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.");
}
```

</TabItem>
</Tabs>

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.

<Aside type="note" title="PHP names the slug errorCode">
  In the PHP SDK the machine-readable slug is `$e->errorCode`, not `$e->code`.
  PHP's base `Exception` already owns `$code` as an int and a property cannot be
  redeclared. Same value, forced rename.
</Aside>

<Aside type="note" title="404 is deliberate on another org's resource">
  Asking for a resource that belongs to someone else answers `404`, never `403`.
  A `403` would confirm the thing exists, which is not something one customer
  should be able to learn about another.
</Aside>

## 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`, and
  `createWorkspace`;
- Performance Groups: `createPerformanceGroup`; and
- Seasons: `createSeason`, `updateSeason`, `deleteSeason`, `createSeasonPlan`,
  `duplicateSeasonToLive`, `createSeasonHolderImport`,
  `createSeasonRenewalOffers`, and `createSeasonAmendment`.

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.

<Tabs>
<TabItem label="Node.js">

```ts
new SeatLayer({
  secretKey: process.env.SEATLAYER_SECRET_KEY!,
  maxRetries: 3,      // total attempts
  timeoutMs: 30_000,  // per attempt
});
```

</TabItem>
<TabItem label="Python">

```python
SeatLayer(
    os.environ["SEATLAYER_SECRET_KEY"],
    max_retries=3,   # total attempts
    timeout=30.0,    # seconds, per attempt
)
```

</TabItem>
<TabItem label="PHP">

```php
new SeatLayer(
    getenv('SEATLAYER_SECRET_KEY'),
    maxRetries: 3,   // total attempts
    timeout: 30.0,   // seconds, per attempt
);
```

</TabItem>
<TabItem label="Java">

```java
SeatLayer.builder()
    .secretKey(System.getenv("SEATLAYER_SECRET_KEY"))
    .maxRetries(3)                        // total attempts
    .timeout(Duration.ofSeconds(30))      // per attempt
    .build();
```

</TabItem>
<TabItem label="Go">

```go
client, err := seatlayer.New(
    os.Getenv("SEATLAYER_SECRET_KEY"),
    seatlayer.WithMaxRetries(3),                                  // total attempts
    seatlayer.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),  // per attempt
)
```

</TabItem>
<TabItem label="Ruby">

```ruby
SeatLayer::Client.new(
  ENV.fetch("SEATLAYER_SECRET_KEY"),
  max_retries: 3,   # total attempts
  timeout: 30.0     # seconds, per attempt
)
```

</TabItem>
<TabItem label=".NET">

```csharp
new SeatLayerClient(secretKey, new SeatLayerClientOptions
{
    MaxRetries = 3,                       // total attempts
    Timeout = TimeSpan.FromSeconds(30),   // per attempt
});
```

</TabItem>
</Tabs>

## 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](/server-api/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:

<Tabs>
<TabItem label="Node.js">

```ts
await seatlayer.inventory.book(
  eventKey,
  { holdId, bookingRef: `order-${orderId}` },
);
```

</TabItem>
<TabItem label="Python">

```python
seatlayer.inventory.book(
    event_key, hold_id=hold_id, booking_ref=f"order-{order_id}"
)
```

</TabItem>
<TabItem label="PHP">

```php
$seatlayer->inventory->book($eventKey, holdId: $holdId, bookingRef: "order-{$orderId}");
```

</TabItem>
<TabItem label="Java">

```java
seatlayer.inventory().book(eventKey, holdId, "order-" + orderId);
```

</TabItem>
<TabItem label="Go">

```go
client.Inventory.Book(ctx, eventKey, seatlayer.BookParams{
    HoldID: holdID, BookingRef: "order-" + orderID,
})
```

</TabItem>
<TabItem label="Ruby">

```ruby
client.inventory.book(event_key, hold_id: hold_id, booking_ref: "order-#{order_id}")
```

</TabItem>
<TabItem label=".NET">

```csharp
await client.Inventory.BookAsync(eventKey, holdId, bookingRef: $"order-{orderId}");
```

</TabItem>
</Tabs>

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.

<Aside type="warning" title="One-time credentials cannot be replayed">
  If a session, access-link, or [webhook secret](/server-sdk/webhooks/) response
  is lost, do not repeat
  the call blindly. Reconcile or revoke what was created, then mint/rotate once.
  The SDK intentionally makes only one attempt because SeatLayer does not store
  browser bearer tokens in a response-replay ledger.
</Aside>

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

<Tabs>
<TabItem label="Node.js">

```ts
await seatlayer.request('POST', '/v1/events/ev_1/some-new-route', { body: {} });
```

</TabItem>
<TabItem label="Python">

```python
seatlayer.request("POST", "/v1/events/ev_1/some-new-route", body={})
```

</TabItem>
<TabItem label="PHP">

```php
$seatlayer->request('POST', '/v1/events/ev_1/some-new-route', body: []);
```

</TabItem>
<TabItem label="Java">

```java
seatlayer.request("POST", "/v1/events/ev_1/some-new-route", null, Map.of("qty", 2));
```

</TabItem>
<TabItem label="Go">

```go
client.Do(ctx, http.MethodPost, "/v1/events/ev_1/some-new-route", nil,
    map[string]any{"qty": 2}, "")
```

</TabItem>
<TabItem label="Ruby">

```ruby
client.request("POST", "/v1/events/ev_1/some-new-route", body: { "qty" => 2 })
```

</TabItem>
<TabItem label=".NET">

```csharp
await client.SendAsync(HttpMethod.Post, "/v1/events/ev_1/some-new-route",
    body: new Dictionary<string, object?> { ["qty"] = 2 });
```

</TabItem>
</Tabs>