Skip to content

feat(api): add HMAC-signed webhook delivery system with retries#90

Merged
Miracle656 merged 3 commits into
Miracle656:mainfrom
Ipramking:feat/webhooks
May 30, 2026
Merged

feat(api): add HMAC-signed webhook delivery system with retries#90
Miracle656 merged 3 commits into
Miracle656:mainfrom
Ipramking:feat/webhooks

Conversation

@Ipramking
Copy link
Copy Markdown
Contributor

Summary

  • New WebhookSubscription and WebhookDelivery Prisma models — subscription stores url, secret, JSON filter, active flag; delivery log tracks every attempt with status, lastStatusCode, lastError, nextRetryAt, deliveredAt
  • src/workers/webhooks.ts — on every ingested transfer, evaluates active subscription filters and enqueues WebhookDelivery rows; POSTs JSON payload with X-Wraith-Signature: sha256=<hmac> header; retries up to 5× with exponential backoff (immediate → 30 s → 5 min → 30 min → 2 h); background retry loop polls DB every 15 s
  • Filter expression supports: contract, from, to, min_amount — null filter receives every transfer
  • src/api/webhooks.ts router at /webhooks:
    • POST /webhooks — create subscription (url, secret, filter?)
    • GET /webhooks — list all (secret field always redacted)
    • DELETE /webhooks/:id — delete + cascade delivery history
    • GET /webhooks/:id/deliveries — paginated delivery log, filterable by status
  • Worker started in index.ts alongside the indexer; listens to the existing transferEmitter
  • Tests: HMAC signing correctness, all filter field combinations, all four REST endpoints with mocked DB

Test plan

  • npm test — all webhook tests pass
  • POST /webhooks creates a subscription; secret never appears in GET response
  • New transfer triggers a POST to subscriber URL with correct X-Wraith-Signature
  • Receiver can verify signature: sha256=HMAC-SHA256(secret, body)
  • Failed delivery is retried with backoff; GET /webhooks/:id/deliveries shows attempt history
  • After 5 failures, status becomes failed and retries stop
  • DELETE /webhooks/:id removes subscription and all delivery rows
  • contract / from / to / min_amount filters work independently and combined

Closes #60

@drips-wave
Copy link
Copy Markdown

drips-wave Bot commented May 30, 2026

@Ipramking Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Copy link
Copy Markdown
Owner

@Miracle656 Miracle656 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid webhook delivery system. The implementation choices are right across the board:

  • HMAC: sha256=<hex> prefix in X-Wraith-Signature matches the convention used by GitHub, Stripe, etc. Receiver-side verification: HMAC-SHA256 of the raw request body (not a re-stringified parse) using the shared secret — please document that in the response of POST /webhooks so subscribers don't run into JSON-canonicalization bugs.
  • Idempotency hint: X-Wraith-Delivery: <id> header lets receivers safely dedupe retries on their side. Nice touch.
  • Backoff schedule: [0, 30s, 5min, 30min, 2h] indexed by attempt is exactly what the PR description says. The ?? 7200 fallback is a belt-and-suspenders for an out-of-range index that the canRetry guard already prevents — harmless.
  • AbortSignal.timeout(10_000) per attempt prevents a slow receiver from blocking the worker.
  • Filter eval uses BigInt(amount) < BigInt(filter.min_amount) — proper big-integer comparison, no precision loss.
  • Secret is never returned by GET endpoints (select lists every column except secret), and DELETE cascades the delivery log.
  • POST validation: required string fields, URL scheme guard, unknown-filter-key rejection.

One concern — not blocking, but worth documenting

There's no URL allowlist or SSRF guard. A user who can call POST /webhooks can register http://localhost:6379 or an internal-network address as a target, and the worker will dutifully POST to it. For Wraith as a single-tenant operator deployment this is fine; if you ever expose POST /webhooks publicly, you'll want either an allowlist or to block private IP ranges (127/8, 169.254/16, 10/8, 172.16/12, 192.168/16, IPv6 link-local, etc.) before fetch.

Merge note

Merge will conflict with prisma/schema.prisma because #89 just landed and added AccountSummary adjacent to where your new WebhookSubscription/WebhookDelivery blocks want to live. Same quick rebase as #88:

git fetch origin
git rebase origin/main
# Conflict in prisma/schema.prisma — keep both blocks (AccountSummary from main + your two webhook models)
git add prisma/schema.prisma
git rebase --continue
git push --force-with-lease

Will merge as soon as it's pushed — the approval stays. Closes #60.

Ipramking added 2 commits May 30, 2026 10:10
- Add WebhookSubscription and WebhookDelivery Prisma models: subscription
  stores url, secret, JSON filter (contract/from/to/min_amount), active flag;
  delivery log tracks every attempt with status, statusCode, error, nextRetryAt
- Add src/workers/webhooks.ts: on every new transfer, evaluates active
  subscription filters and enqueues WebhookDelivery rows; POSTs payload with
  X-Wraith-Signature: sha256=<hmac> header; retries up to 5x with exponential
  backoff (30s, 5m, 30m, 2h); retry loop polls DB every 15s for due deliveries
- Add src/api/webhooks.ts router at /webhooks:
    POST   /webhooks                   create subscription
    GET    /webhooks                   list subscriptions (secret redacted)
    DELETE /webhooks/:id               delete subscription + cascade deliveries
    GET    /webhooks/:id/deliveries    paginated delivery log with status filter
- Mount webhooks router in api.ts
- Start webhook worker in index.ts alongside the indexer
- Add comprehensive tests: HMAC signing, filter evaluation (all fields),
  all four REST endpoints with mock DB, mock receiver

Closes Miracle656#60
TS2552: 'attempts' was the Prisma field name used as a shorthand but
the local variable is 'attempt'. Explicitly assign attempts: attempt
in both the success and failure update paths.
Rebase left <<<<<<< HEAD / ======= / >>>>>>> markers in src/api.ts
around the router mounts. Resolved by keeping both:
- app.use('/accounts', createAccountsRouter())
- app.use('/webhooks', createWebhooksRouter())
@Miracle656 Miracle656 merged commit 7c79a08 into Miracle656:main May 30, 2026
2 checks passed
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.

Add webhook delivery system for new transfers

2 participants