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.
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.