Declarative webhooks
Webhooks are projections of Loomup's durable event journal, not callbacks bolted onto CRUD. Each endpoint owns an independent webhook:<name> cursor, expiring worker lease, retry history, and dead-letter state visible in Studio's Events panel and the existing consumer diagnostics API.
[webhooks]
enabled = true
poll_interval_ms = 1000
batch_size = 100
max_attempts = 10
[[webhooks.endpoints]]
name = "search_index"
url = "https://search.example.com/loomup/events"
secret_env = "LOOMUP_WEBHOOK_SEARCH_SECRET"
resources = ["products", "categories"]
operations = ["INSERT", "UPDATE", "DELETE"]
timeout_secs = 10
Set the named secret before loomup dev or loomup serve. Secrets shorter than 16 characters are rejected and are never written to the database, payload, or logs. Non-loopback endpoints must use HTTPS.
The endpoint is operational configuration; application intent attaches it to domain resources:
[resources.products]
access = "public"
webhooks = ["search_index"]
loomup plan/apply validates the endpoint name and generates its resource filter. A manifest cannot smuggle a destination URL or secret into application configuration.
Every POST contains a loomup.event.v1-compatible event payload:
{
"type": "resource.changed",
"event": {
"sequence": 42,
"event_id": "…",
"resource": "products",
"record_id": "prod_1",
"operation": "UPDATE",
"before": { "id": "prod_1", "name": "Old" },
"after": { "id": "prod_1", "name": "New" }
}
}
Headers include x-loomup-event-id, x-loomup-sequence, and x-loomup-signature. The signature is sha256=<hex HMAC-SHA256> over the exact request body.
Delivery is ordered and at-least-once per endpoint. A 2xx response advances that endpoint's cursor. Network errors and non-2xx responses retry; after max_attempts, the event is preserved as a dead letter and only that endpoint advances. Receivers should deduplicate by event_id.
Export the same open event stream without configuring a receiver:
loomup events export --output events.ndjson
loomup events export --after-sequence 1000 --limit 500
Each NDJSON line is a versioned { "format": "loomup.event.v1", "event": … } envelope.