Skip to main content

Crate cratefield_module_webhooks

Crate cratefield_module_webhooks 

Source
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: publish returns outbox INSERT statements for the caller’s own batch_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... }
}
HeaderMeaning
Cratefield-Signaturet=<unix seconds>,v1=<hex>, HMAC-SHA256 over {t}.{body} with the endpoint’s secret
Cratefield-Event-IdThe envelope’s id; the dedupe key for at-least-once delivery
Cratefield-Event-TypeThe envelope’s type
The WEBHOOKS_SIGNATURE_ALIAS name, when setThe 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:

OutcomeWhat happens
2xxLogged 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 replyLogged; 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 parsesDead-lettered as malformed — a bug, not weather
Endpoint deleted since the enqueueThe 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_endpoint validates 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, so order.paid never matches order.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 publish and at filter registration alike, so a type that could never ride the Cratefield-Event-Type header 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.

KeyDefaultMeaning
WEBHOOKS_MAX_ATTEMPTS5Dead-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_ALIASunsetA 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:

TableHolds
webhooks_endpointsOne row per registered URL, with its signing secret
webhooks_outboxThe core outbox contract, one row per (event, endpoint)
webhooks_deliveriesOne row per attempt actually made
webhooks_dead_lettersThe 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§

CreatedEndpoint
What Webhooks::create_endpoint returns: the endpoint’s id, and the signing secret — which is returned once. Store it now; it is never listed again.
DeadLetter
One row of webhooks_dead_letters, as crate::Webhooks::dead_letters reads it back and crate::Webhooks::replay acts on it.
Delivery
One row of webhooks_deliveries.
DrainReport
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, by crate::Webhooks::create_endpoint.
Published
What Webhooks::publish produced. Append Published::into_statements to the caller’s own db.batch_atomic(..); nothing is written until that batch runs.
Webhooks
A module that delivers signed outbound webhooks.

Enums§

DeliveryError
Why a whole drain pass cannot start.
EndpointError
Why an endpoint was refused before the database was touched.
PublishError
Why Webhooks::publish refused 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 scheme cratefield-adapter-webhook-tracker signs 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_types filter matches an event.
normalize_event_types
Normalises the create_endpoint filter: trimmed, empty parts dropped, duplicates collapsed, and an empty result meaning “everything”.