Webhooks — Shared Inbox and Delivery Archive
Platform-agnostic inbound-webhook plumbing: a claim-and-outcome idempotency inbox any provider module can use, and a 90-day archive of every delivery to /webhooks/*.
Platform-agnostic plumbing every inbound-webhook module can share: an idempotency inbox that makes replays free, and an archive of every delivery to a /webhooks/* route — including the ones we reject.
Source:
api-modules/webhooks(registered viaWebhooksModule.forRoot()inapps/api/src/app.module.ts).Nothing here is payment-specific. Shipping and marketing webhooks use it on the same terms.
Idempotency inbox
Backed by the unique (provider, external_event_id) index on processed_webhook_event, which is the actual enforcement point — concurrent deliveries of one event collide on insert.
const claim = await inbox.claim({ provider, externalEventId, eventType });
if (!claim.isNew) return ack(claim.previousOutcome); // replay
try {
await inbox.markProcessed(claim.id, await dispatch());
} catch (err) {
await inbox.markFailed(claim.id, err);
throw err;
}claim() returns { isNew, id, previousOutcome }, so a replay can be acknowledged with whatever the first delivery actually reached.
Row lifecycle
status | Meaning |
|---|---|
claimed | Dispatch started and never reported back — a crash mid-processing. The provider's own retry re-enters it, and a stuck-row query surfaces it to ops |
processed | Handled, drove a state change |
skipped | Authentic and parseable, but deliberately not acted on (an event type we ignore) |
failed | Dispatch threw. error holds a bounded message |
Recording the outcome — rather than only the fact of receipt — is what makes a crashed delivery recoverable instead of silently skipped forever.
Providers without an event id
Some gateways ship no stable event id. Compose one from the entity plus its terminal state, so retries of a delivery collide while genuinely distinct events do not. PhonePe, for example, uses `${orderId}-${state}` for order events and `${refundId}-${state}` for refunds.
Delivery archive
A single path-scoped interceptor captures every request under /webhooks/, so new webhook endpoints are covered with no work and existing ones needed no changes.
| Field | Notes |
|---|---|
provider | Third path segment — /webhooks/shipping/clickpost/platform is clickpost, not platform |
path · route · method · status_code | Status carries the coarse outcome: 401 rejected, 200 accepted |
request_id | Correlation id shared with audit_events and the inbox row |
body | Raw body, redacted and capped at 8 KB (truncated and body_bytes record the rest) |
headers | Allowlist only |
signature_present | Boolean — never the header value |
received_at · duration_ms · ip |
Rejected deliveries are archived deliberately: a forged-signature attempt is precisely what an operator needs to see, and previously it left no trace at all.
What is never stored
Headers use an allowlist, not a denylist — a denylist silently leaks the first credential-bearing header a future provider invents. Authorization is excluded outright, because for some providers (PhonePe) that header is a reusable static credential rather than a per-request MAC. Only its presence is recorded.
Bodies pass through the same redactBounded() used by the audit store, so keys like client_secret, signature, card and vpa are replaced with [REDACTED].
Storage and retention
ClickHouse table webhook_deliveries — MergeTree, partitioned by month, TTL received_at + INTERVAL 90 DAY. Deliberately a shorter window than audit_events (24 months), because these rows carry raw provider payloads.
The write rides the existing audit outbox with eventType: "webhook.received"; the audit worker routes that event type to this table instead of audit_events. That reuses the durable outbox, batching and 2-second drain rather than duplicating them.
Best-effort by design
Archiving is fire-and-forget inside a try/catch, and a failure is logged rather than raised — a ClickHouse outage must never fail a webhook, and providers time out in seconds. If AuditModule is not mounted there is no sink, and the interceptor logs a warning once at startup saying archiving is disabled.
Adding a provider
- Mount the route under
/webhooks/…— archiving then needs no further work. - Verify the provider's signature over
req.rawBody(captured globally inmain.ts). - Claim through the inbox, dispatch, record the outcome.
- Ack 2xx once authenticated, even for events you ignore, or the provider will retry forever.
Related
- payment-phonepe.md and webhooks/payment-phonepe.md — the reference consumer.
marketing-klaviyoandshipping-clickpostkeep their own domain-specific idempotency for now, but are archived by the shared interceptor.
Vendor Module
HTTP surface for vendor onboarding (a user registers to become a vendor via the step-wise wizard; an admin reviews + approves/rejects) and vendor directory reads (admin lists all approved vendors with team…
Auth — Storefront Registration
Customer sign-up for the storefront: creates an account and returns an authenticated session in one step. Phone number (E.164) is required.