feat: signed, ordered, exactly-once webhook delivery - #1568
Open
Chucks1093 wants to merge 1 commit into
Open
Conversation
Replaces the ad-hoc dispatch path with a robust delivery pipeline that provides three formal guarantees to webhook consumers: **Exactly-once** — each Soroban event is stored with a content-addressed canonical_event_id (SHA-256 of ledger:txHash:eventIndex). Delivery rows are inserted with ON CONFLICT DO NOTHING on (subscription_id, canonical_event_id) so retries and concurrent workers never duplicate a delivery. **Ordered** — each delivery is assigned a gap-free monotonic subscription_sequence via a SELECT FOR UPDATE counter table. Consumers can enforce order locally and detect missing deliveries without server-side coordination. **Signed** — every outbound request carries six headers: X-Webhook-Id, X-Webhook-Subscription-Sequence, X-Webhook-Timestamp, X-Webhook-Nonce, X-Webhook-Key-Id, X-Webhook-Signature (v1=hmac-sha256). The signing message is `timestamp.nonce.rawBody`, making it resistant to body-swap and replay attacks within a 5-minute window. Includes: - Migration 1802: webhook_events, webhook_signing_keys, webhook_subscription_sequences, webhook_nonces tables; new columns on webhook_deliveries (canonical_event_id, subscription_sequence, status, key_id, nonce). - webhookSigner.ts: sign/verify helpers, key provisioning, rotation (old key enters 'retiring' for 24 h overlap, then revoked automatically). - webhookDispatcher.ts: enqueueEvent (idempotent ingest + fan-out to subscriptions), dispatchDelivery (lock row inflight, send, handle retries/dead-letter), fetchDueDeliveries. - eventIndexer.ts: calls enqueueEvent after every newly inserted event so the signed pipeline runs alongside the existing webhookService.dispatch. - New admin endpoints: GET /deliveries/ledger (cursor-paginated), GET /deliveries/stream (SSE real-time updates), POST /keys/rotate. - Frontend page: /admin/webhooks/[id]/deliveries — ordered ledger table with live SSE updates and one-click key rotation. - docs/webhook-consumer-recipe.md: end-to-end consumer guide covering signature verification, idempotent+ordered processing, nonce replay protection, key rotation, and delivery ledger API.
ogazboiz
requested changes
Aug 3, 2026
ogazboiz
left a comment
Contributor
There was a problem hiding this comment.
there's solid engineering in here (content-addressed event ids, ON CONFLICT idempotency, HMAC with key rotation), but it needs a round of changes before it can land:
- the new migration is numbered 1802000000000, which collides with two existing migrations at that number. renumber to 1803000000000 or higher.
- header naming: existing outgoing webhooks sign with x-remitlend-signature: sha256= (webhookService.ts:261). this PR introduces a parallel X-Webhook-Signature: v1= plus five more X-Webhook-* headers. align on x-remitlend-* naming, at minimum the signature header.
- the ordering guarantee is overstated: sequences are assigned at enqueue but dispatch has no per-subscription serialization, so a retried seq N is overtaken by N+1 while the consumer doc says to reject gaps (that would wedge consumers). also ON CONFLICT DO NOTHING after incrementing the counter burns sequence numbers, breaking the gap-free claim. either serialize per subscription or correct the documented contract.
- webhook_nonces is a new postgres idempotency table nothing ever reads or writes; repo convention is unique constraints plus redis. drop it.
- zero tests for ~600 lines of new backend service code. add unit tests for the signer (sign/verify/rotation) and dispatcher (idempotent enqueue, retry, dead-letter). remember the ESM pattern: jest.unstable_mockModule before the dynamic import, and mocks must export everything the consumer imports.
- deliveries locked in inflight are stuck forever if the worker dies (no reaper), and events currently flow through both the legacy webhookService.dispatch path and the new dispatcher, doubling deliveries. gate or migrate the legacy path.
- fix the lint errors in your own new code (prettier and prefer-const in the new controller/routes) and rebase on current main now that its CI is being fixed.
if you want to keep contributing, join us on Telegram: https://t.me/+DOylgFv1jyJlNzM0
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1383
Summary
canonical_event_id(SHA-256 ofledger:txHash:eventIndex) stored in a newwebhook_eventstable. Delivery rows useON CONFLICT DO NOTHINGon(subscription_id, canonical_event_id)so retries and concurrent workers never duplicate.SELECT FOR UPDATE) assigns gap-free monotonicsubscription_sequenceintegers. Consumers can enforce in-order processing and detect missed events without any server-side coordination.X-Webhook-Id,X-Webhook-Subscription-Sequence,X-Webhook-Timestamp,X-Webhook-Nonce,X-Webhook-Key-Id, andX-Webhook-Signature: v1=<hex>. The signing message istimestamp.nonce.rawBody, protecting against body-swap and replay attacks within a configurable 5-minute window.webhook_signing_keystable).POST /admin/webhooks/:id/keys/rotatetransitions the old key toretiring(valid for 24 h to drain in-flight retries) and activates a fresh key immediately.Files changed
backend/migrations/1802000000000_webhook-signed-ordered-delivery.jswebhook_events,webhook_signing_keys,webhook_subscription_sequences,webhook_nonces; new columns onwebhook_deliveriesbackend/src/services/webhookSigner.tsbackend/src/services/webhookDispatcher.tsenqueueEvent(idempotent fan-out),dispatchDelivery(in-flight lock, send, retry/dead-letter),fetchDueDeliveriesbackend/src/services/eventIndexer.tsenqueueEventafter each newly inserted eventbackend/src/controllers/indexerController.tsgetWebhookDeliveryLedger,rotateWebhookSigningKey,streamWebhookDeliveries(SSE)backend/src/routes/adminRoutes.ts/deliveries/ledger,/deliveries/stream,/keys/rotateendpointsfrontend/src/app/[locale]/admin/webhooks/[id]/deliveries/page.tsxdocs/webhook-consumer-recipe.mdAPI additions
Migration notes
The migration is additive: it extends
webhook_deliverieswithIF NOT EXISTScolumns and creates new tables. Existing rows will haveNULLfor the new columns and continue functioning through the legacywebhookService.dispatchpath. New events ingested after this migration will flow through both pipelines.