Litestore · Developer guide
Platform services

Webhooks

This page shows how Litestore sends your store's events to other systems and how it receives callbacks from Stripe and Resend.

Webhooks let your store talk to other systems in both directions. Read this page when you connect an outside service to your store's events, or when you debug a callback from Stripe or Resend.

Outbound webhooks send your 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 decides when to retry.

Outbound

You register an endpoint with a URL and a list of event names.

The URL must be a public HTTP(S) address. Each event name must be one of the 56 DOMAIN_EVENT_TYPES. You must pick at least one event and at most 50, and the schema removes duplicates and sorts the list before saving it.

Litestore signs each delivery with HMAC-SHA256 using a v1 scheme. The signing secret is shown 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 two signatures, one plain and one with a timestamp:

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

The request also includes X-Webhook-Event, X-Webhook-Delivery and X-Webhook-Timestamp. On a retry it adds X-Webhook-Retry.

Verify against the timestamped signature. Because the timestamp is signed together with the body, nobody can capture a payload and replay it later with a signature that still checks out.

The delivery itself

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. functions/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    → not decided yet, ignored
retrying   → not decided yet, ignored
success    → keeps the endpoint alive
failed     → counts toward the threshold

If 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 tells you why.

An operator turns the endpoint back on in Settings → Developer. Until then, Litestore refuses to 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.

Managing endpoints over the API

You manage endpoints through the oRPC API at /api/rpc. Managing webhooks is the reason that API exists.

Two scopes control access:

  • read lets a key list and read endpoints.
  • write:webhooks lets a key create, update, delete and rotate them.

See oRPC.

The REST routes under app/api/webhooks call the same functions. Both APIs therefore manage the same endpoints rather than two separate sets.

GET /api/webhooks/events is the exception. It lists the event types you can subscribe to, 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. It is signed, logged and retried in the same way.

Deleting an endpoint removes it for good. Webhook has no deletedAt column, so its delivery history is deleted along with it.

Inbound

app/api/webhooks/stripe 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 decides 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.

Resend's route handles unsubscribes. A complaint or a permanent bounce unsubscribes the address, but a temporary bounce does not.

Which direction to look at

If your server did not receive something, the problem is outbound. Check the endpoint's delivery log first. A failed row means Litestore made the attempt, and it shows you the response. No row at all means the event never reached the sending step.

If the endpoint is inactive, check whether Litestore disabled it automatically. It stops on purpose after 15 completed failures in a row, and the activity feed records when that happened.

If an order did not settle or an address did not unsubscribe, the problem is inbound. First check whether the signature passed at all.

Keep one rule in mind for both directions:

An outbound webhook tells you that something happened. It is not the record that it happened. That record is in your database.

On this page