Webhook

Outbound webhook delivery with cryptographic signing in a single Webhook-Signature header, retry + dead-letter via b.retry, and idempotency keys baked into the signed string so a captured signature cannot be replayed with a fresh id. Inbound verification is the symmetric primitive: verifier() returns a middleware that parses the header, enforces the timestamp window, finds a matching kid, runs constant-time signature compare, and (when configured) consults a nonce store for replay defense.

Algorithms: hmac-sha3-512 (symmetric, kid → Buffer/string secret) or pqc-pem (asymmetric — SLH-DSA-SHAKE-256f / ML-DSA-87 / ML-DSA-65, auto-detected by Node from the PEM). No classical (Ed25519 / RSA / ECDSA) signature scheme is exposed.

Signed string is prefix-bound to defend against algorithm- and key-substitution attacks: ..... Header is the Stripe-shape t=,id=,=; t and id are reserved segment names, every other pair is a kid → signature mapping. The signer emits exactly one kid; the verifier accepts any number so operators rotating keys point the verifier at both old + new keys and migrate signers progressively.

PQC signatures are emitted as base64url (~40 KB for SLH-DSA-SHAKE- 256f, vs ~59 KB hex) to fit common front-end header caps; the verifier accepts EITHER encoding for transition windows.

Replay defense: passing a nonceStore (any object exposing checkAndInsert(nonce, expireAt) → bool/Promise) records seen ids; a second delivery with the same id rejects with REPLAY. b.nonceStore is the reference implementation; operators plug in Redis / SQL by satisfying the same shape.

Audit defaults are ON for both success and failure on both sides — the inbound verify IS the auditable boundary event, not a precursor to one. Operators with extreme volume opt out via auditSuccess: false; failures remain on regardless.

b.webhook.dispatcher(opts) #

stable0.15.13
{
  externalDb:      b.externalDb,        // required — storage backend
  endpointsTable:  string,              // default frameworkSchema.tableName("webhook_endpoints")
  deliveriesTable: string,              // default frameworkSchema.tableName("webhook_deliveries")
  maxAttempts:     number,              // default 8 → then dead-letter
  retryBackoff:    { initialMs, maxMs, factor },   // default 5s / 60min / 2x
  claimReclaimMs:  number,              // default 5 min stale-in-flight lease
  batchSize:       number,              // default 100 deliveries per processRetries
  signatureHeader: string,              // forwarded to b.webhook.signer
  allowedProtocols: object,             // b.safeUrl protocol set (default ALLOW_HTTP_TLS)
  allowInternalDestinations: boolean,   // default false — refuse SSRF (private/loopback/metadata)
  httpRequest:     function,            // (url, body, headers) → { status } — inject for tests
  now:             function,            // clock injection → ms epoch
  dnsLookup:       function,            // (host) → [{ address, family }] — override the SSRF destination resolver
}

Build a durable signed-webhook delivery store backed by the operator's b.externalDb. The returned object exposes:

- declareSchema(xdb?) — idempotent CREATE TABLE for the endpoints + deliveries tables (run once at boot, like b.outbox.declareSchema). - registerEndpoint({ endpointId, url, eventTypes, secret }) — persist a subscriber. The URL is validated through b.safeUrl (SSRF destinations refused); the secret is sealed at rest with b.vault.seal. eventTypes is an array of event names, or ["*"] to receive every event. - removeEndpoint(endpointId) / listEndpoints(). - dispatch(eventType, payload) — fan the event out to every subscribed endpoint as its own durable delivery row, sign each via b.webhook.signer, and attempt delivery once inline. Returns { delivered, failed, deliveries: [...] }. - processRetries() — poll/alarm entry point: claim every delivery whose next_attempt_at is due, re-attempt, back off on the b.outbox curve, and dead-letter after maxAttempts. Reaps deliveries stranded in-flight by a crashed worker. Returns { attempted, delivered, dead }. - deliveries.list({ endpointId?, status?, limit? }) / deliveries.get(id) / deliveries.retry(id) — operator-console surface. - dlq.list({ limit? }) / dlq.replay(id) — dead-letter inspect + replay.

Each delivery carries a stable X-Webhook-Delivery-Id (so a re-delivery is deduped by the receiver, not rejected as a replay) plus the signer's fresh per-attempt nonce in the signature (replay defense at the signature layer).

var wd = b.webhook.dispatcher({ externalDb: b.externalDb });
await wd.declareSchema();
await wd.registerEndpoint({
  endpointId: "acct_42",
  url:        "https://partner.example/hooks",
  eventTypes: ["invoice.paid", "invoice.refunded"],
  secret:     "whsec_partner_secret",
});
await wd.dispatch("invoice.paid", { id: "inv_1", amount: 4200 });
// later, from a cron / alarm:
await wd.processRetries();

b.webhook.signer(opts) #

stable0.1.0soc2pci-dss
{
  algo:            "hmac-sha3-512" | "pqc-pem",
  keys:            { [kid]: Buffer | string }       // hmac
                 | { [kid]: { privateKey, publicKey } }  // pqc-pem
  defaultKid:      string,                          // required when keys has >1 kid
  pqcAlgorithm:    "slh-dsa-shake-256f" | "ml-dsa-87" | "ml-dsa-65",
  signatureHeader: string,                          // default "Webhook-Signature"
  idGenerator:     function () => string,
  now:             function () => number,           // ms
  retry:           object,                          // b.retry.withRetry opts
  http:            object,                          // b.httpClient.request opts
  audit:           object,                          // b.audit handle
  auditFailures:   boolean,                         // default true
  auditSuccess:    boolean,                         // default true
}

Build an outbound signer. Returns { sign, headers, send }: sign computes the signature header pair for a body without doing I/O; headers returns just the headers map; send performs the POST via b.httpClient.request wrapped in b.retry.withRetry. Each call generates a fresh idempotency id (ULID-shaped via b.crypto. generateToken by default; operators override with idGenerator) that's bound into the signed string so captured signatures cannot replay with a different id.

var b = require("@blamejs/core");
var signer = b.webhook.signer({
  algo:       "hmac-sha3-512",
  keys:       { v1: Buffer.from("0123456789abcdef0123456789abcdef") },
  defaultKid: "v1",
});
var headers = signer.headers('{"event":"user.created"}');
// → { "Webhook-Signature": "t=1714500000,id=...,v1=" }

b.webhook.verifier(opts) #

stable0.1.0soc2pci-dss
{
  algo:            "hmac-sha3-512" | "pqc-pem",
  keys:            { [kid]: Buffer | string }       // hmac
                 | { [kid]: string | Buffer },      // pqc-pem (PEM public key)
  pqcAlgorithm:    "slh-dsa-shake-256f" | "ml-dsa-87" | "ml-dsa-65",
  toleranceMs:     number,                          // default 5 minutes
  clockSkewMs:     number,                          // default 1 minute
  signatureHeader: string,                          // default "Webhook-Signature"
  nonceStore:      { checkAndInsert(nonce, expireAt) },
  now:             function () => number,
  audit:           object,
  auditFailures:   boolean,                         // default true
  auditSuccess:    boolean,                         // default true
}

Build an inbound verifier. Returns { verify, middleware }: verify checks an explicit { body, headers } pair and resolves to { algo, kid, timestamp, id } on success; middleware is an Express-style middleware that pulls req.bodyRaw (requires b.middleware.bodyParser({ keepRawBody: true })), verifies, and stashes the result on req.webhook. Failures throw WebhookError with a stable code (MISSING_HEADER / BAD_HEADER_FORMAT / EXPIRED / FUTURE / UNKNOWN_KID / BAD_SIGNATURE / REPLAY / ...) and the middleware translates them to HTTP 401 / 500.

var b = require("@blamejs/core");
var verifier = b.webhook.verifier({
  algo:        "hmac-sha3-512",
  keys:        { v1: Buffer.from("0123456789abcdef0123456789abcdef") },
  toleranceMs: b.constants.TIME.minutes(5),
});
// wire into a router:
//   router.use(b.middleware.bodyParser({ keepRawBody: true }));
//   router.post("/inbound", verifier.middleware(), function (req, res) {
//     // req.webhook = { algo, kid, timestamp, id }
//   });
var mw = verifier.middleware();
// → function (req, res, next) { ... }

b.webhook.verify(input) #

stable0.11.25
{
  alg:        "hmac-sha256-stripe",
  secret:     string | Buffer,                      // whsec_... bytes verbatim
  header:     string,                               // Stripe-Signature value
  body:       string | Buffer,                      // raw request body
  toleranceMs: number,                              // default 5 min, min 30 s
  nonceStore: { checkAndInsert(nonce, expireAt) },  // optional atomic replay defense
}

Stripe-spec inbound webhook signature verifier. Validates the Stripe-Signature: t=,v1=[,v1=...] header against an HMAC-SHA-256 over the literal . string using the operator's whsec_... secret bytes verbatim (the prefix IS the key). Refuses signatures older than the tolerance window (default 5 min, minimum 30 s). When nonceStore is supplied the verifier atomically records the accepted v1 signature so a replay within the tolerance window is refused; the store speaks the same checkAndInsert(nonce, expireAt) → bool contract as b.webhook.verifier and b.nonceStore.create, so the framework's own replay store plugs in directly and concurrent redeliveries cannot race. Constant-time compare via b.crypto.timingSafeEqual.

await b.webhook.verify({
  alg:        "hmac-sha256-stripe",
  secret:     "whsec_abc...",
  header:     req.headers["stripe-signature"],
  body:       rawBodyBuffer,
  nonceStore: b.nonceStore.create({ backend: "memory" }),
});
// → { ok: true, timestamp: 1700000000, scheme: "v1" }

b.webhook.sign(input) #

stable0.11.25

Round-trip companion to b.webhook.verify for the hmac-sha256-stripe algorithm. Returns the Stripe-Signature header value t=,v1= for a given body + secret + (optional) timestamp. Operators emitting Stripe-shaped webhooks downstream — and the test surface — use this to produce the matching header.

var header = b.webhook.sign({
  alg:    "hmac-sha256-stripe",
  secret: "whsec_abc...",
  body:   '{"id":"evt_1"}',
});

Last updated 2026-08-08T16:39:49.652Z by seeder.