Adds a full server-push webhook system so integrators (mobile apps, dashboards) can receive real-time event notifications without polling.
| Model | Purpose |
|---|---|
WebhookSubscription |
Stores subscriber URL, event filters, HMAC secret, and active state per user |
WebhookDelivery |
Immutable delivery log — tracks attempts, HTTP status, and error per dispatch |
Migration: prisma/migrations/20260627000000_add_webhook_tables/
All routes require Authorization: Bearer <JWT>.
| Method | Path | Description |
|---|---|---|
POST |
/api/webhooks |
Create subscription — returns secret once |
GET |
/api/webhooks |
List subscriptions (no secrets) |
GET |
/api/webhooks/:id |
Get single subscription |
PATCH |
/api/webhooks/:id |
Update URL / events / active state |
DELETE |
/api/webhooks/:id |
Delete subscription + delivery history |
Every outbound webhook POST carries:
X-Neurowealth-Signature: sha256=<hmac-hex>
Computed as HMAC-SHA256(secret, raw_body). Recipients verify by recomputing with their stored secret.
| Event | Fired from |
|---|---|
transaction.confirmed |
transaction-controller.ts — on-chain tx confirmed |
deposit.received |
stellar/events.ts — deposit event processed |
withdraw.completed |
stellar/events.ts — withdraw event processed |
agent.rebalanced |
stellar/events.ts + agent/loop.ts — rebalance executed |
Failed deliveries are retried up to 3 times with exponential back-off:
attempt 1 → immediate
attempt 2 → wait 1 s
attempt 3 → wait 2 s
Each attempt is logged in WebhookDelivery. After all attempts fail the record is marked FAILED and logged as an error. Dispatch is always fire-and-forget — failures never block the request path.
prisma/schema.prisma ← new models + relation
prisma/migrations/20260627000000_add_webhook_tables/migration.sql
src/utils/webhookSignature.ts ← generateSecret + signPayload
src/services/webhookDispatcher.ts ← dispatch + retry + delivery log
src/routes/webhooks.ts ← CRUD router
src/validators/webhook-validators.ts ← Zod schemas
src/index.ts ← mount /api/webhooks
src/stellar/events.ts ← fire deposit/withdraw/rebalance events
src/agent/loop.ts ← fire agent.rebalanced on rebalance
src/controllers/transaction-controller.ts ← fire transaction.confirmed
tests/unit/utils/webhookSignature.test.ts ← 5 tests
tests/unit/services/webhookDispatcher.test.ts ← 8 tests
docs/openapi.yaml ← webhooks tag + schemas + paths
README.md ← API table updated
PASS tests/unit/utils/webhookSignature.test.ts (5 tests)
PASS tests/unit/services/webhookDispatcher.test.ts (8 tests)
Covers: secret uniqueness, HMAC correctness, success on first attempt, exhausted retry → FAILED, partial retry → SUCCESS, signature header format, subscription event filtering.
-
POST /api/webhookscreates subscription, returns signing secret once - Payload signed with HMAC-SHA256, verifiable by recipient via
X-Neurowealth-Signature - Failed deliveries retried (≤3) and logged in
WebhookDeliverytable - Unit tests for signature generation and retry logic
- OpenAPI spec updated
# 1. Apply migration
npx prisma migrate dev
# 2. Create a subscription
curl -X POST http://localhost:3000/api/webhooks \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"url":"https://webhook.site/your-id","events":["deposit.received","transaction.confirmed"]}'
# → response includes `secret` — save it
# 3. Verify a delivery signature on receipt
echo -n '<raw_body>' | openssl dgst -sha256 -hmac '<secret>'
# should match X-Neurowealth-Signature header (minus "sha256=" prefix)X-NW-Webhook-Timestamp: <unix>
X-NW-Webhook-Id: <deliveryId>
X-NW-Webhook-Signature: v2,<hex> v1,<hex>
v2 signs "<timestamp>.<deliveryId>.<body>". Consumers should reject if |now - timestamp| > 300s and dedupe on X-NW-Webhook-Id.
POST /api/webhooks/:id/rotate-secret— setssecretNext, dual-signs during overlapPOST /api/webhooks/:id/promote-secret— promotessecretNexttosecret
Exhausted deliveries (default 6 attempts, full-jitter backoff) move to WebhookDeadLetter as PENDING.
POST /api/webhooks/dead-letters/:id/replay— single replay withX-NW-Webhook-Replay: truePOST /api/webhooks/:id/replay?since=— bulk replay (max 50)
Per-subscription breaker: closed → open (after 5 failures) → half_open (probe). Open subscriptions skip delivery but capture payloads in DLQ. Prolonged open auto-disables the subscription.
GET /api/webhooks/:id/health — circuit state, DLQ depth, recent failure rate.
| Variable | Default | Description |
|---|---|---|
WEBHOOK_MAX_ATTEMPTS |
6 | Max delivery attempts |
WEBHOOK_CIRCUIT_BREAKER_THRESHOLD |
5 | Failures before open |
WEBHOOK_AUTO_DISABLE_HOURS |
24 | Auto-disable after open |
WEBHOOK_SEND_V1_SIGNATURE |
true | Include v1 during deprecation |