Skip to content

Delivery and retries

Build an idempotent webhook receiver for asynchronous, scoped, bounded-attempt delivery.

Updated View as Markdown

Webhook delivery is asynchronous. A hold or booking does not wait for your receiver, and delivery order across independent occurrences is not guaranteed. Treat each verified message as a fact that may arrive late or more than once.

Delivery contract

  • Each HTTP attempt has a five-second timeout.
  • Any 2xx acknowledges the delivery.
  • Non-2xx responses and transport failures are retryable.
  • The queue allows the first attempt plus up to three retries.
  • Exhausted messages leave the automatic path; attempts remain inspectable.
  • One failing subscription can cause the shared occurrence to retry, so an already successful endpoint must still deduplicate.

Scoped delivery

An occurrence reaches a subscription only when the subscribed event name and both routing axes match.

Subscription mode Environment Receives
null null Every matching event
live null Live events in any environment
live prod Live events created in prod
test staging Test events created in staging

A null subscription field is a wildcard. Event environment is stamped from the secret key used to create that event. Payload livemode remains available as a second handler guard.

Idempotent processing

Use occurrenceId as the primary deduplication key. Keep deliveryId for transport debugging.

server/idempotent-receiver.tsts
await database.transaction(async (tx) => {
  const inserted = await tx.claimWebhook(message.occurrenceId);
  if (!inserted) return;

  await applyBusinessEvent(tx, message);
});

return new Response(null, { status: 204 });

Useful secondary constraints include:

  • (eventId, bookingRef) for a completed order;
  • (eventId, holdId, event) for hold lifecycle;
  • your own immutable order or tenant reference.

Do not mark an occurrence processed before the business transaction succeeds. Do not return 2xx for a failure you expect SeatLayer to retry.

Delivery ledger

The API stores the newest attempts for each subscription, including:

  • HTTP status (0 when no response arrived);
  • attempt and maximum attempts;
  • willRetry;
  • occurrence id and transport id;
  • the sent payload;
  • a truncated non-2xx response body; or
  • a transport error message.

The list defaults to 20, caps at 50 per page, and supports ok/failed filtering. SeatLayer retains a bounded per-subscription history and applies a 30-day retention cleanup; export operational evidence you must retain longer.

Manual redelivery

Retrying from the dashboard or API sends the stored payload only to the selected subscription. It preserves occurrenceId, creates a new attempt/signature, and does not re-arm automatic retries.

Your receiver should return success for an occurrence that was already applied:

if (await wasProcessed(message.occurrenceId)) {
  return new Response(null, { status: 204 });
}

Failure-response hygiene

Non-2xx response text can appear in the delivery ledger for debugging. Return a short safe error code. Never include secrets, stack traces, customer records, request headers, or raw internal exception messages.

Production checklist

  • Verify the raw body before JSON parsing.
  • Compare signatures in constant time.
  • Store and atomically claim occurrenceId.
  • Return quickly; queue slow downstream work in your own system.
  • Support events arriving out of order.
  • Route by trusted workspace mapping and validate livemode.
  • Monitor failed deliveries and seven-day uptime.
  • Keep receiver errors short and non-sensitive.
  • Test duplicate, delayed, malformed, expired-secret, and retry cases.

See manage subscriptions, webhook events, and signature verification.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close