Skip to content

feat: signed, ordered, exactly-once webhook delivery - #1568

Open
Chucks1093 wants to merge 1 commit into
LabsCrypt:mainfrom
Chucks1093:feat/signed-ordered-webhook-delivery-1383
Open

feat: signed, ordered, exactly-once webhook delivery#1568
Chucks1093 wants to merge 1 commit into
LabsCrypt:mainfrom
Chucks1093:feat/signed-ordered-webhook-delivery-1383

Conversation

@Chucks1093

Copy link
Copy Markdown
Contributor

Closes #1383

Summary

  • Exactly-once delivery — Soroban events get a content-addressed canonical_event_id (SHA-256 of ledger:txHash:eventIndex) stored in a new webhook_events table. Delivery rows use ON CONFLICT DO NOTHING on (subscription_id, canonical_event_id) so retries and concurrent workers never duplicate.
  • Ordered delivery — A per-subscription sequence counter (incremented inside SELECT FOR UPDATE) assigns gap-free monotonic subscription_sequence integers. Consumers can enforce in-order processing and detect missed events without any server-side coordination.
  • HMAC-SHA256 signed delivery — Every outbound request carries X-Webhook-Id, X-Webhook-Subscription-Sequence, X-Webhook-Timestamp, X-Webhook-Nonce, X-Webhook-Key-Id, and X-Webhook-Signature: v1=<hex>. The signing message is timestamp.nonce.rawBody, protecting against body-swap and replay attacks within a configurable 5-minute window.
  • Key rotation — Subscriptions have dedicated signing keys (webhook_signing_keys table). POST /admin/webhooks/:id/keys/rotate transitions the old key to retiring (valid for 24 h to drain in-flight retries) and activates a fresh key immediately.

Files changed

File Change
backend/migrations/1802000000000_webhook-signed-ordered-delivery.js New tables: webhook_events, webhook_signing_keys, webhook_subscription_sequences, webhook_nonces; new columns on webhook_deliveries
backend/src/services/webhookSigner.ts HMAC sign/verify, canonical event ID derivation, key provisioning/rotation
backend/src/services/webhookDispatcher.ts enqueueEvent (idempotent fan-out), dispatchDelivery (in-flight lock, send, retry/dead-letter), fetchDueDeliveries
backend/src/services/eventIndexer.ts Calls enqueueEvent after each newly inserted event
backend/src/controllers/indexerController.ts New: getWebhookDeliveryLedger, rotateWebhookSigningKey, streamWebhookDeliveries (SSE)
backend/src/routes/adminRoutes.ts Registers /deliveries/ledger, /deliveries/stream, /keys/rotate endpoints
frontend/src/app/[locale]/admin/webhooks/[id]/deliveries/page.tsx Admin UI: ordered delivery ledger table with live SSE updates + key rotation
docs/webhook-consumer-recipe.md Consumer guide: signature verification, idempotent+ordered processing, replay guard, rotation, ledger API

API additions

GET  /api/admin/webhooks/:id/deliveries/ledger   # cursor-paginated ordered ledger
GET  /api/admin/webhooks/:id/deliveries/stream   # SSE real-time delivery status
POST /api/admin/webhooks/:id/keys/rotate         # rotate HMAC signing key

Migration notes

The migration is additive: it extends webhook_deliveries with IF NOT EXISTS columns and creates new tables. Existing rows will have NULL for the new columns and continue functioning through the legacy webhookService.dispatch path. New events ingested after this migration will flow through both pipelines.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Backend] Signed, Ordered, Exactly-Once Webhook Delivery for Remittance and Loan Lifecycle Events

1 participant