Webhooks
Litestore sends store events to other systems and receives callbacks from Stripe and Resend.
Webhooks connect the store to other systems in both directions. Read this page when you connect an outside service to the store's events, or when you debug a callback from Stripe or Resend.
Outbound webhooks send the store's events to an endpoint you register. Inbound routes receive callbacks from Stripe and Resend.
The two directions have little in common apart from the name. For outbound webhooks, Litestore runs the whole delivery itself. It signs each request, retries failures and disables endpoints that keep failing. For inbound webhooks, Litestore only checks the signature, and the provider determines when to retry.
Outbound
You register an endpoint with a URL and a list of event names.
| Field | Rule |
|---|---|
| URL | Must be a public HTTP(S) address. |
| Events | Each name must be one of the 53 DOMAIN_EVENT_TYPES. You must pick at least one event and at most 50. The schema removes duplicates and sorts the list before saving it. |
Litestore signs each delivery with HMAC-SHA256 using a v1 scheme. It shows the signing secret only once, when you create the endpoint or rotate its secret.
Signing
The secret is 32 random bytes, hex-encoded. createWebhook returns it to the caller, and the activity log never records its value. Every delivery includes these headers:
| Header | What it contains |
|---|---|
X-Webhook-Signature | The HMAC of the payload alone. |
X-Webhook-Signature-256 | v1= followed by the HMAC of <timestamp>.<payload>. |
X-Webhook-Event | The event name. |
X-Webhook-Delivery | The delivery id. |
X-Webhook-Timestamp | The signed timestamp. |
X-Webhook-Retry | Added only on a retry. |
Verify the timestamped signature
Check X-Webhook-Signature-256. Litestore signs the timestamp together with the body, so nobody can capture a payload and replay it later with a signature that still checks out.
Delivery
Each attempt times out after 30 seconds. Litestore does not follow redirects (maxRedirects: 0), so a 3xx response counts as a failed delivery.
Litestore checks the URL again every time it sends, not only when you register the endpoint. A host that was safe at registration could later use DNS rebinding or a redirect to reach a private network, so isSafeFetchUrl runs on every attempt.
Every attempt writes a WebhookDeliveryLog row. The row records the event type, the full payload, the status, the HTTP status code and any error. It also records the attempt count and the first 1,000 characters of the response body.
Retries
Litestore retries a failed delivery up to 5 times. The wait starts at 1 second and doubles each time, with up to one extra second of random jitter, and never exceeds 5 minutes.
A delivery does not retry on its own. jobs/cron.webhook-retries.ts runs the retries on this schedule:
*/10 * * * *Each run picks up at most 100 deliveries whose nextRetry time has passed and sends them 8 at a time.
The cron interval sets the shortest possible delay before a retry. Backoff never waits more than 5 minutes, so running every 10 minutes keeps retries reasonably quick while halving the number of runs that happen around the clock.
Before a retry sends anything, it claims its row by moving nextRetry forward. A second run happening at the same time will then skip that delivery instead of sending it twice.
Auto-disable
Litestore disables an endpoint after 15 completed deliveries in a row have failed. A single success within those 15 resets the count. Only completed deliveries count, meaning rows marked success or failed:
pending → no outcome yet, ignored
retrying → no outcome yet, ignored
success → keeps the endpoint alive
failed → counts toward the thresholdIf unfinished rows counted, a busy endpoint that was down would keep producing fresh retrying rows at the top of the list, and the check would never trigger.
Litestore works out this decision from the delivery log each time it checks. It does not keep a separate failure counter that could fall out of sync. The only stored state is isActive on the Webhook row.
When Litestore disables an endpoint, it writes a webhook_disabled activity entry. If an endpoint stops receiving events, the activity log records the reason.
An operator turns the endpoint back on in Settings → Developer. Until then, Litestore doesn't retry any single delivery for that endpoint instead of queuing it.
Event delivery is best-effort
Litestore sends domain events after it has already answered the request. If the server crashes between the response and the send, the event is lost. Guaranteeing at-least-once delivery would need a transactional outbox, which Litestore does not have. Treat a webhook as a notification, not as a ledger.
Endpoint API
You manage endpoints through the oRPC API at /api/rpc, and managing webhooks is the reason that API exists. A key with read can list and read endpoints. A key with write:webhooks can create, update, delete and rotate them. See oRPC.
The REST routes under app/api/webhooks call the same functions, so both APIs manage the same endpoints rather than two separate sets.
GET /api/webhooks/events is the exception. It lists the event types available for subscription, grouped by category, and it needs no authentication.
When you send a test delivery, Litestore sends a webhook.test event through the same steps as a real event. Litestore signs, logs and retries it in the same way.
Deleting an endpoint removes it for good. Webhook has no deletedAt column, so Litestore deletes its delivery history along with it.
Inbound
app/api/stripe/webhooks and app/api/webhooks/resend check the provider's signature before they do anything else. These routes work differently from outbound delivery and do not use its retry system. The provider determines when to retry.
Stripe's route is where Litestore records that a payment has settled. The ledger has a @@unique([provider, providerId, type]) constraint, so if Stripe sends the same event again, nothing changes.
Troubleshooting
Keep one rule in mind for both directions. An outbound webhook is a notification about an event. The record of the event is in the store's database.
Related
oRPC
The oRPC endpoint at /api/rpc manages webhooks for callers with a service key, and each procedure checks the key's scope.
Events
Litestore publishes its domain events through Inngest, handlers stay idempotent, and delivery is best-effort rather than guaranteed.
Payments
Stripe is the single payment provider, Postgres enforces the append-only transaction ledger, and replayed webhooks change nothing.