Webhooks
Outbound HTTP event notifications: register an endpoint URL, subscribe it to events from a fixed catalog, and Snug POSTs a signed JSON payload whenever a matching event fires. Delivery is asynchronous through stream workers with retries, a dead-letter queue for exhausted events, and automatic disabling of endpoints that keep failing.
When to reach for it: pushing Snug events to external systems - alerting when a liveness entity dies, feeding rate-limit breaches to an ops channel, verifying an integration endpoint with test deliveries.
When not to: webhooks are outbound only - Snug calling you, never you posting events in. Fan-out to connected clients belongs to PubSub; pull-based consumption with ack semantics belongs to Message Queue; running work later on a schedule belongs to the Job Queue.
Concepts
- A webhook is a URL plus a set of subscribed events, owned by its
creator. Platform admins can list and manage every user's webhooks;
everyone else gets
403 forbiddenon webhooks they do not own. - Events come from a fixed catalog -
entity.status_changed,entity.liveness_anomaly,rate_limit.exceeded, andtest.webhook(list them withsnug webhooks events). Subscribing to any other name fails with400 BAD_REQUEST. - Every delivery is signed with the webhook's
whsec_-prefixed secret (HMAC-SHA256 in theSnug-Signatureheader). The secret is returned on create and included ininfo/listresponses - treat that output as sensitive. - Delivery is asynchronous - emitting an event enqueues it; workers deliver, retry with exponential backoff, and dead-letter events that exhaust their attempt budget (5 by default).
- Failing endpoints are auto-disabled after consecutive exhausted events
(10 by default).
enableclears the disabled state and counters.
Manage webhooks
snug webhooks create -u https://example.com/hook \
-e entity.status_changed,rate_limit.exceeded -H '{"X-Custom":"value"}'
snug webhooks list
snug webhooks info <webhook_id>
snug webhooks update <webhook_id> --active false
snug webhooks delete <webhook_id>The webhook ID is positional on every subcommand. -H custom headers (sent
with each delivery) must be a JSON object string. Over HTTP, creation returns
201 with the resource - including the signing secret - in data:
curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hook", "events": ["test.webhook"]}' \
http://localhost:4000/api/v1/webhooks{
"status": 201,
"msg": "Created",
"data": {
"id": "CTYMFbVtmfnQsUJmeUZh",
"url": "https://example.com/hook",
"events": ["test.webhook"],
"secret": "whsec_UZve9TZRBJPuEuOBkJLbk8tENt746-...",
"active": true,
"headers": null,
"created_at": 1787893342
}
}Timestamps are Unix epoch seconds throughout. list (CLI and
GET /api/v1/webhooks) takes the
shared list parameters: ?q=hook2,
?filter[active][eq]=false (also events, created_by, created_at,
updated_at), and ?sort=-created_at (the default) or updated_at.
Outbound URL guard: create rejects anything but an http/https URL
without embedded credentials, and in production builds also any IP-literal
host that is loopback, private, link-local, or cloud metadata
(400 invalid_configuration, "webhook url is not an allowed target");
the delivery client refuses those same targets at send time too (including
via DNS rebinding or redirects). Point webhooks at endpoints that are publicly reachable from the
API server. (Debug builds relax this, which is how the local captures on
these pages were made.)
Test deliveries
test emits a synthetic test.webhook event. It is asynchronous: the
response says the event was enqueued, not that it landed - check deliveries
or your receiver for the outcome:
snug --output json webhooks test <webhook_id> --message 'ping'{ "webhook_id": "vPjqTYTwBqWYMvRWUxYq", "enqueued": true }Two verified gotchas: the target webhook must be active, otherwise
400 invalid_configuration; and the emitted event fans out to every
active webhook subscribed to test.webhook, not just the one you named -
with two subscribed webhooks registered, one test call produced a signed
delivery to both.
What your endpoint receives - headers, payload, signature verification, and the response contract - has its own page: Receiving deliveries.
Delivery history, retries, and the DLQ
snug webhooks deliveries <webhook_id> --limit 20 --total
snug webhooks dlq <webhook_id> --all
snug webhooks redrive <webhook_id> <event_id>
snug webhooks purge-dlq <webhook_id>Both lists come newest first and page by cursor. Deliveries count with
--total; the dead-letter list scans a stream shared by every webhook, so it
cannot count and a page can come back short with has_more still true.
A 2xx from your endpoint counts as delivered. A 4xx (except 429) is
treated as permanent and dead-letters immediately; anything else - 5xx,
429, timeouts, connection failures - is retried with exponential backoff
until the attempt budget is exhausted, then dead-lettered. An exhausted connection failure looks like:
{
"message_id": "1787893416051-0",
"webhook_id": "SewcMSNafxyzEPMFAUqF",
"event_id": "evt_HuyXHLtHfdMb",
"event_type": "test.webhook",
"error": "error sending request for url (http://127.0.0.1:4981/docs4-dead)",
"attempt_count": 5,
"enqueued_at": 1787893416,
"dlq_at": 1787893416
}redrive re-enqueues one dead-lettered event (the replayed delivery carries
a fresh redrive_-prefixed ID and the original payload) and removes it from
the DLQ; an unknown event ID is 404 dlq_entry_not_found. purge-dlq
discards everything and reports {"purged": n}.
Auto-disable and recovery
Each event that exhausts its attempts bumps the webhook's
consecutive_failures; a successful delivery resets it. At the limit
(10 by default) the webhook is switched off - drive ten failed events through one webhook and info shows active: false
with disabled_at and disabled_reason: "HTTP 404 Not Found". Deliveries
stop until you fix the receiver and run:
snug webhooks enable <webhook_id> # active again, counters and disabled state cleared
snug webhooks rotate-secret <webhook_id>rotate-secret returns the new secret and signs all subsequent deliveries
with it immediately - update the receiver's verification key first, or
deliveries in the gap will fail verification.
Limits and configuration
Request timeout (30 s), worker counts, retry budget and backoff curve,
auto-disable threshold, and stream/dedupe tuning are all set via the
WEBHOOK_* variables.
Reference
- Webhooks API - every endpoint, callable
- Receiving deliveries - the receiver-side contract
- Related: PubSub for client fan-out, Message Queue for pull-based consumption, Job Queue for scheduled work
