Expand description
cratefield-module-webhooks: outbound webhook delivery.
use cratefield_module_webhooks::Webhooks;
let module = Webhooks::new().max_attempts(5);Two things, said once here and once in the README’s first section:
this module ships no HTTP routes of its own — the Rust API is the
surface — and Webhooks::create_endpoint hands back the endpoint’s
signing secret exactly once. The wire format, the delivery
policy, the configuration and the worked examples are the README
below.
§cratefield-module-webhooks
Outbound webhook delivery, as a module. A venture’s customer registers one
endpoint per subject — their account id, their tenant id — with an optional
event-type filter, and receives a signed POST for every published event the
filter matches. Delivery rides the core Outbox: durable exactly when the
event that caused it is, retried with exponential backoff, dead-lettered
after repeated failures, and replayable by hand.
Delivery is at-least-once — the outbox contract, not an SMTP promise. A receiver dedupes on the event id, which every delivery carries in its payload and its headers.
§The Rust API is the surface
This module ships no HTTP routes of its own. GET /v1/webhooks answers
404 by design. Two reasons, both of the same shape:
- Fan-out starts in the venture’s own code. What counts as an event is
the venture’s vocabulary, and enqueueing is a transaction join:
publishreturns outboxINSERTstatements for the caller’s ownbatch_atomic, so the event is durable exactly when the state change that caused it is. A JSON route could not offer that. - The signing secret is credential material. It is handed back exactly once, at registration, and a management UI that could show it again would need an auth decision this crate does not make.
use cratefield_module_webhooks::Webhooks;
// At boot, from the deployment's config: a bad WEBHOOKS_SIGNATURE_ALIAS is refused here.
let webhooks = Webhooks::from_config(&*config)?.max_attempts(5);
// Registration — the secret crosses this boundary once, and never again.
let created = webhooks
.create_endpoint(&db, &account_id, "https://example.com/hooks", &["order.paid"], &now)
.await?;
let secret = created.secret; // the customer stores it; this module cannot retrieve it
// Fan-out — inside the venture's own transaction.
let published = webhooks.publish(&db, &account_id, "order.paid", &payload, &now).await?;
let mut statements = the_venture_own_statements();
statements.extend(published.into_statements());
db.batch_atomic(&statements).await?;
// Drain — from the venture's scheduled entry point, with the runtime's context.
let report = webhooks.drain_with(&ctx).await?;An event no endpoint matches is published.endpoints == 0 and nothing
else: “nobody subscribed to that” is an ordinary answer, not an error.
§The wire
Each delivery is one POST with a JSON envelope:
{
"id": "01JARK2EXAMPLE9EXAMPLE",
"type": "order.paid",
"subject": "acct_01J...",
"created_at": "2026-09-27T12:00:00Z",
"data": { ...the published payload, exactly as published... }
}| Header | Meaning |
|---|---|
Cratefield-Signature | t=<unix seconds>,v1=<hex>, HMAC-SHA256 over {t}.{body} with the endpoint’s secret |
Cratefield-Event-Id | The envelope’s id; the dedupe key for at-least-once delivery |
Cratefield-Event-Type | The envelope’s type |
The WEBHOOKS_SIGNATURE_ALIAS name, when set | The same Cratefield-Signature value, under the configured name |
The timestamp is bound into the signature, so a captured delivery
cannot be replayed with a fresh timestamp: re-stamping invalidates the MAC.
A receiver verifies by recomputing it — the same scheme
cratefield-adapter-webhook-tracker signs with, so one verifier covers
both.
§Verifying deliveries
With WEBHOOKS_SIGNATURE_ALIAS set, a delivery carries both headers with
one identical value, so a receiver that expects its product’s own header
name verifies the same way. The alias must be an HTTP header token, and it
may not be Cratefield-Signature, Content-Type, a header listed above, or a
transport-managed header such as Host or Content-Length, compared
case-insensitively. An empty value counts as unset.
Webhooks::from_config refuses an invalid alias at startup. Webhooks::new()
does not read it, but validate_config, which the Workers runtime logs once
per isolate at cold start, still reports one. Deliveries read
WEBHOOKS_SIGNATURE_ALIAS from config on every pass. A value that is not
usable at that point fails the pass before any row is claimed, so nothing
is sent unsigned or under the wrong names. Webhooks::signature_headers()
lists the names from_config stored; the drain takes its names from the
config it is given.
§Delivery policy
One outbox row per (event, endpoint), so each endpoint retries
independently. The drain leases what is due (a five-minute lease, at most 16
rows a pass, and no more delivery attempts than the invocation’s
ctx.scheduled budget allows — one row processed is one subrequest (at
most one outbound POST), ADR
0023) and files each row:
| Outcome | What happens |
|---|---|
| 2xx | Logged with its status; the row completes |
410 Gone, or the HttpClient port refusing the destination (loopback, private, metadata — the SSRF contract) | Logged; dead-lettered immediately as rejected. Neither is weather; retrying burns attempts on a certainty |
| Any other status, transport failure, deadline, oversize reply | Logged; the row is retried with backoff — 30s, doubling, 60m ceiling |
Attempts exhausted (MAX_ATTEMPTS) | Logged; dead-lettered as attempts_exhausted |
| A payload that no longer parses | Dead-lettered as malformed — a bug, not weather |
| Endpoint deleted since the enqueue | The row is dropped; nobody is subscribed at the other end any more |
A per-row failure never aborts the pass. Every attempt lands in
webhooks_deliveries — the log a customer support conversation reads:
which endpoint, which event, which attempt, what status came back, and
when. When the budget runs out mid-pass, the rows the drain never reached
are released untouched — still queued, due immediately — and delivered on
the next tick; DrainReport::deferred counts them.
§Replay
A dead letter stays in webhooks_dead_letters until a human decides:
for letter in webhooks.dead_letters(&db, &account_id, 50).await? { /* show it */ }
webhooks.replay(&db, &account_id, &letter.id, &now).await?;Replay re-enqueues the event with attempts reset to zero and removes the dead letter, in one batch. The delivery log keeps the history. It refuses — the letter left exactly where it was — when the letter’s endpoint is gone: the drain would drop the re-enqueued row, and the event would be destroyed for good.
§Endpoints and secrets
create_endpointvalidates the URL (http/https, a non-empty host, no whitespace) and normalizes the filter; an empty filter stores*— everything. Everything else is an exact, comma-separated match, soorder.paidnever matchesorder.paid.v2.- An event type must be 1..=128 bytes of visible ASCII, no whitespace —
the characters an HTTP header value survives verbatim. It is validated
at
publishand at filter registration alike, so a type that could never ride theCratefield-Event-Typeheader is refused at the door instead of burning a delivery’s retry budget. - The secret is 32 OS-random bytes,
whsec_-prefixed hex, generated where it is stored. It is returned once; listing endpoints never includes it; it appears in no error and no log line; exports redact it. - Deleting an endpoint deletes its queued deliveries on the next drain — there is nobody left to deliver them to.
§Configuration
Keys are SCREAMING_SNAKE, prefixed with the module name. All optional.
| Key | Default | Meaning |
|---|---|---|
WEBHOOKS_MAX_ATTEMPTS | 5 | Dead-letter a delivery after this many failed attempts. Must be a whole number in 1..=1000 — the same range the builder’s max_attempts clamps to |
WEBHOOKS_SIGNATURE_ALIAS | unset | A second header name that carries the same Cratefield-Signature value. Read on every drain pass, and checked at boot by Webhooks::from_config; unset or empty sends the one header only. Must be an HTTP header token, not Cratefield-Signature, another header the module sends, or a transport-managed header such as Host |
§Data
Four tables, all keyed on the subject the event (or endpoint) belongs to, so account erasure reaches all four:
| Table | Holds |
|---|---|
webhooks_endpoints | One row per registered URL, with its signing secret |
webhooks_outbox | The core outbox contract, one row per (event, endpoint) |
webhooks_deliveries | One row per attempt actually made |
webhooks_dead_letters | The events that gave up, kept replayable |
§Ports
Requires Database, HttpClient and Clock. The drain refuses to start
when one of them is missing from the context — a wiring bug, named as
such, not papered over. The signature timestamp and every stored
timestamp come from the Clock port, so a test can advance time; all
HTTP goes through the HttpClient port, which is where the SSRF vetting
lives. Wasm-safe: no sockets, no threads, no filesystem.
Structs§
- Created
Endpoint - What
Webhooks::create_endpointreturns: the endpoint’s id, and the signing secret — which is returned once. Store it now; it is never listed again. - Dead
Letter - One row of
webhooks_dead_letters, ascrate::Webhooks::dead_lettersreads it back andcrate::Webhooks::replayacts on it. - Delivery
- One row of
webhooks_deliveries. - Drain
Report - What one drain pass did.
- Endpoint
- One row of
webhooks_endpoints, without the secret: a list never hands signing material back. The secret is returned exactly once, bycrate::Webhooks::create_endpoint. - Published
- What
Webhooks::publishproduced. AppendPublished::into_statementsto the caller’s owndb.batch_atomic(..); nothing is written until that batch runs. - Webhooks
- A module that delivers signed outbound webhooks.
Enums§
- Delivery
Error - Why a whole drain pass cannot start.
- Endpoint
Error - Why an endpoint was refused before the database was touched.
- Publish
Error - Why
Webhooks::publishrefused before writing anything.
Constants§
- EVENT_
ID_ HEADER - The event’s identity, so a receiver can dedupe the at-least-once redeliveries the outbox contract promises.
- EVENT_
TYPE_ HEADER - What happened, in the caller’s own vocabulary.
- MODULE_
NAME - The name the module mounts under:
/v1/webhooks. - SIGNATURE_
HEADER - The one header a delivery is verified by, Stripe-style:
t=<unix seconds>,v1=<lowercase hex>. The same schemecratefield-adapter-webhook-trackersigns with, so one receiver-side verifier covers both. - TOPIC_
DELIVER - The outbox topic a delivery row carries.
Functions§
- matches_
filter - Whether an endpoint’s stored
event_typesfilter matches an event. - normalize_
event_ types - Normalises the
create_endpointfilter: trimmed, empty parts dropped, duplicates collapsed, and an empty result meaning “everything”.