Litestore · Developer guide

Webhooks

Learn how outbound deliveries are signed and retried, when an endpoint is auto-disabled, and how the inbound Stripe and Resend routes differ.

Webhooks run in both directions.

Outbound deliveries carry your store's events to an endpoint you register. Inbound routes receive Stripe and Resend callbacks.

They share almost nothing beyond the name. Outbound is a delivery engine this codebase owns — signing, retries, auto-disable. Inbound is signature verification on someone else's retry schedule.

Outbound

An endpoint is registered with a URL and a list of event names.

The URL must be a public HTTP(S) endpoint. The event names must come from the 56 DOMAIN_EVENT_TYPES, at least one and at most 50, and the schema deduplicates and sorts them before they are stored.

Each delivery is signed with HMAC-SHA256 under a v1 scheme, using a secret shown exactly once at creation or rotation.

Signing

The secret is 32 random bytes, hex-encoded. createWebhook returns it to the caller and the activity trail never records its value.

Every delivery carries both a bare and a timestamped signature:

  • X-Webhook-Signature — HMAC of the payload alone
  • X-Webhook-Signature-256v1= followed by the HMAC of <timestamp>.<payload>

Alongside them the request carries X-Webhook-Event, X-Webhook-Delivery, X-Webhook-Timestamp, and on a retry, X-Webhook-Retry.

The timestamped form is the one to verify against. Signing the timestamp with the body is what stops a captured payload from being replayed later under a still-valid signature.

The delivery itself

Each attempt has a 30-second timeout, and redirects are not followed — maxRedirects: 0 means a 3xx is a failed delivery.

The URL is re-validated at send time, not only at registration. A host that was safe when the endpoint was created can later DNS-rebind or redirect into a private network, so isSafeFetchUrl runs again on every attempt.

Every attempt writes a WebhookDeliveryLog row: the event type, the full payload, the status, the HTTP status code, the error, the attempt count, and the response body truncated to 1,000 characters.

Retries

A failed delivery is retried up to 5 times, with exponential backoff from 1 second, plus up to a second of jitter, capped at 5 minutes.

Nothing retries itself. functions/cron.webhook-retries.ts is the driver, and it runs on:

*/10 * * * *

Each run takes at most 100 deliveries whose nextRetry is due and processes them 8 at a time.

The cron interval is the effective retry latency floor. Backoff caps at 5 minutes, so 10 minutes keeps retries reasonably prompt while halving the always-on invocation baseline.

A retry claims its row before it fetches. It pushes nextRetry forward first, so a concurrent run will not select the same delivery and send it twice.

Auto-disable

An endpoint is disabled after 15 consecutive failed completed deliveries. One success anywhere in that window resets it.

"Completed" is load-bearing. Only success and failed rows count:

pending    → not decided yet, ignored
retrying   → not decided yet, ignored
success    → keeps the endpoint alive
failed     → counts toward the threshold

If in-flight rows counted, a busy dead endpoint would keep fresh retrying rows at the top of the recency order and the check would never fire.

The decision is compute-on-read from the delivery log. There is no stored failure counter to keep in sync — isActive on the Webhook row is the only state.

Disabling writes a webhook_disabled activity entry, so an endpoint that goes quiet has a recorded reason rather than silently stopping.

An operator re-enables it from Settings → Developer. Until they do, retrying an individual delivery is refused rather than queued.

Event delivery is best-effort

Domain events are dispatched as a post-response side effect. A crash between the response and the send drops the event. At-least-once delivery would require a transactional outbox, which is not implemented. Treat a webhook as a notification, not as a ledger.

Managing endpoints over the API

Endpoints are managed through the oRPC surface at /api/rpc, which is the reason that surface exists.

Two scopes gate it:

  • read lists and reads
  • write:webhooks creates, updates, deletes and rotates

See oRPC.

The same lifecycle functions back the REST routes under app/api/webhooks, so both surfaces manage one entity rather than two.

GET /api/webhooks/events is the exception: it lists the subscribable event types grouped by category and needs no authentication at all.

A test delivery goes out as webhook.test through the same pipeline as a live event — same signing, same delivery log, same retry behaviour.

Deleting an endpoint is a hard delete. Webhook has no deletedAt, so the delivery history cascades with it.

Inbound

app/api/webhooks/stripe and app/api/webhooks/resend verify the provider's signature before doing anything else.

These are not the same mechanism as outbound delivery and do not share its retry machinery. The provider owns retries.

Stripe's route is where payment settlement lands. The @@unique([provider, providerId, type]) constraint on the ledger makes a replayed event a no-op.

Resend's route applies suppression: a complaint or a permanent bounce unsubscribes the address, while a transient bounce does not.

Which direction you are looking at

If you are asking why your server did not receive something, that is outbound. Check the delivery log for the endpoint first: a failed row tells you the attempt happened and what the response was, and no row at all tells you the event never reached dispatch.

If the endpoint is inactive, check whether it was auto-disabled. Fifteen consecutive completed failures is a deliberate stop, and the activity feed records it.

If you are asking why an order did not settle or an address did not unsubscribe, that is inbound, and the first question is whether the signature verified at all.

The one thing to hold onto across both directions:

Outbound webhooks are a notification that something happened, not the record that it happened. The record is in your database.

On this page