Signed, at-least-once webhook delivery from a durable outbox. The schemas are versioned artifacts: current schemaVersion is 1.1.0, with 1.0.0 still accepted; additive changes bump the minor version with a CHANGELOG entry, and breaking changes require a written deprecation note.
Every event carries a stable event id (x-dynamo-event-id — your idempotency key), a schema version, a stable externalRef for correlation, and — where money is involved — the settled amount asset-denominated plus an asset label (R7). Delivery is AT-LEAST-ONCE: you WILL see duplicates and you MAY see reordering; dedupe on the event id and treat settlement amounts as cumulative monotone values (R1/R3). Credit on settlement.final only (R2) — settlement.checkpointed is a visible pending state, never a credit signal. Verify x-dynamo-signature before touching state; reject on mismatch.
| Version | Events |
|---|---|
| 1.0.0 | settlement.checkpointed · settlement.final · capture.created (card) · refund.final · stream.revoked · stream.exhausted · budget.released · budget.halted |
| 1.1.0 additive | subscription.period_opened · subscription.renewal_declined · subscription.closed |
budget.halted carries an objective reason code only (revoked, anomaly, sla, budget_closed, exposure, rate, payer_concurrency, cap, ceiling_dead) — never a subjective judgment.
The signature is sha256=<hex HMAC-SHA256> over the exact request body with your subscription secret. This sample is the complete check:
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyDelivery(secret: string, rawBody: string, signatureHeader: string): boolean {
const expected = `sha256=${createHmac("sha256", secret).update(rawBody, "utf8").digest("hex")}`;
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
return a.length === b.length && timingSafeEqual(a, b);
}
const secret = "whsec_example_docs_secret";
const body = JSON.stringify({
id: "evt_0123456789abcdef01234567",
type: "settlement.final",
schemaVersion: "1.1.0",
occurredAt: "1754000000",
externalRef: "order-42",
data: {},
});
const signature = `sha256=${createHmac("sha256", secret).update(body, "utf8").digest("hex")}`;
if (!verifyDelivery(secret, body, signature)) throw new Error("valid signature must verify");
if (verifyDelivery(secret, body.replace("final", "FINAL"), signature)) {
throw new Error("a tampered body must be rejected");
}
Reject on mismatch, before touching any state — fail closed.
@dynamo/events ships ReferenceConsumer, the executable form of the rules above: HMAC check, event-id dedupe, monotone cumulative settlements, credit on final only. make demo-webhook-dupes delivers every event twice and once out of order to two live HTTP consumers and proves their state byte-identical to a single ordered delivery. Copy the consumer, or copy its rules.
Webhook subscriptions are managed through the merchant API: POST /v1/webhooks, GET /v1/webhooks, DELETE /v1/webhooks/{id} — bearer-authenticated and ownership-scoped.