Idempotent webhook to BullMQ worker pipeline. HMAC-SHA256, fixed-schedule retry, dead-letter replay.
Anvil is the piece between a provider's webhook and your business logic. It verifies the signature, drops duplicates, puts one job on a queue, and returns 202 fast. A worker processes the job in the background with a fixed retry schedule and a dead-letter queue for jobs that never succeed.
A webhook arrives over HTTP. Anvil:
- verifies the HMAC-SHA256 signature over the raw body, in constant time;
- computes an idempotency key from the signature and the payload bytes;
- enqueues exactly one BullMQ job per key, even under re-delivery;
- returns 202 with the job id;
- runs your handler in a worker, retrying on backoff and dead-lettering after the schedule is spent.
Replay of dead jobs is a separate, manual step so a broken handler cannot loop.
Install the SDK in your app with npm install @ykstormsorg/anvil — published
with SLSA build provenance, which npm verifies on install.
To run this repo (worker + server + examples) from source you need Node 20+ and
a Redis instance. Local Redis in one line:
docker run -p 6379:6379 redis:7Then:
pnpm install
pnpm -r build
# terminal 1: the worker
REDIS_URL=redis://localhost:6379 pnpm --filter @anvil/worker start
# terminal 2: the server
WEBHOOK_SECRET=whsec_dev REDIS_URL=redis://localhost:6379 \
pnpm --filter @anvil/server startSend a signed request:
BODY='{"id":"evt_1","type":"charge.succeeded"}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac whsec_dev | awk '{print $2}')"
curl -i -X POST http://localhost:3000/webhooks \
-H "x-signature: $SIG" \
-H "content-type: application/json" \
--data "$BODY"You get back 202 { "jobId": "...", "replayed": false }. Send the same request
again and replayed is true with the same jobId; the worker still runs the
job once.
Local dev — Redis, server, and worker in one command:
docker compose up --buildThe server listens on :3000. Both app images are multi-stage
node:20-alpine builds that run as a non-root user; see
apps/server/Dockerfile and
apps/worker/Dockerfile.
These five behaviours have tests. Each is the reason a line of code exists.
- One job per delivery. The idempotency key is
sha256(signature + raw payload). Re-delivering a webhook N times enqueues one job; the server returns the original job id withreplayed: true. A different body under the same signature is a different key, so it gets its own job. - Constant-time signature check.
verify(body, sigHeader, secret)accepts a validsha256=<hex>signature and rejects a tampered body or a flipped signature byte. It compares withcrypto.timingSafeEqualand guards the length check first so the compare never throws. - Fixed retry backoff. Failed jobs retry on
[1000, 5000, 30000, 300000]ms. After the fourth failure the job moves towebhooks.deadcarryingfailureContext: { attempts, lastError }. - Replay is a separate consumer.
replayDeadLetter(jobId)moves a dead job back to the main queue and returns{ replayed: true }. The replay module starts no worker on the main queue, so importing it cannot kick off a retry loop. - Small SDK surface.
@ykstormsorg/anvilexports exactlycreateServer,createWorker, andreplayDeadLetter.createServer({ secret })returns an Express app;createWorker(handler, opts)returns{ start, close };replayDeadLetteris async.
See docs/ARCHITECTURE.md for the request flow and the other docs for the reasoning behind each contract.
Signature verification runs on every inbound webhook, and "constant-time"
should mean what it says. Measured in CI (GitHub Actions ubuntu-latest,
Node 20) — the numbers below are produced by
.github/workflows/benchmark.yml on every
push, over 500k verifications of a ~340-byte payload:
| Metric | Result |
|---|---|
| Per-verify cost | ~3.3 µs (~305k verifies/sec) |
| Valid vs same-length wrong signature | 0.9% timing delta |
| Ingress throughput | ~10.5k req/s (verify → dedupe → enqueue, in-memory queue) |
| Ingress latency p50 / p99 | 4 ms / 8 ms |
A sub-1% delta between a valid signature and a same-length forgery is the
evidence behind the constant-time claim — timingSafeEqual plus the
length-guard means there is no timing or length oracle for an attacker to grind
against. Reproduce with node bench/verify.mjs (Node 24, pure CPU, no Redis).
Ingress throughput is measured over the real verify → idempotency →
dedupe-enqueue path with an in-memory queue mock (no Redis, no worker), so it
isolates Anvil's own cost rather than Redis'. Full percentiles and the
dedupe/reject breakdown are in bench/report-latest.md;
methodology in bench/README.md. Reproduce with
node bench/throughput.mjs.
Two ways to stand up the pipeline (server + worker + Redis). Both are 0.x scaffolds and provision only what Anvil uses; there is no database.
- Hetzner Cloud (Terraform): infra/terraform/ brings
up a Redis VM, the webhook server, and a worker pool whose size is set by
worker_count. Hetzner has no managed Redis, so the module runs Redis on a VM via cloud-init; the README there explains the trade. - Kubernetes (Helm): charts/anvil/ deploys the server
(Deployment + Service + Ingress on
/webhooks), the worker (replicas =worker.replicas), and an in-cluster Redis.REDIS_URLand theWEBHOOK_SECRETare wired in for you.
# Terraform
terraform -chdir=infra/terraform init && terraform -chdir=infra/terraform apply
# Helm
helm install anvil ./charts/anvil --set secret.webhookSecret=whsec_realThis is 0.1. It is honest about what it is not yet.
- The replay path is single-job and manual. There is no batch-replay tool and no UI.
- Assumes one Redis and one region. Multi-region delivery and cross-region dedupe are out of scope.
- Replay-attack timestamp checking is documented but left to the handler; the server does not enforce a timestamp window for you.
- A
replayDeadLetterbatch mode and a small CLI. - An optional timestamp-tolerance check in the server middleware.
MIT. See LICENSE.