Webhooks — event schema 1.1.0

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.

The contract in one paragraph

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.

Event set

VersionEvents
1.0.0settlement.checkpointed · settlement.final · capture.created (card) · refund.final · stream.revoked · stream.exhausted · budget.released · budget.halted
1.1.0 additivesubscription.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.

Verifying a delivery

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.

The reference consumer

@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.

Managing subscriptions

Webhook subscriptions are managed through the merchant API: POST /v1/webhooks, GET /v1/webhooks, DELETE /v1/webhooks/{id} — bearer-authenticated and ownership-scoped.