Skip to content

Webhooks

Try the interactive example ↓

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 forbidden on webhooks they do not own.
  • Events come from a fixed catalog - entity.status_changed, entity.liveness_anomaly, rate_limit.exceeded, and test.webhook (list them with snug webhooks events). Subscribing to any other name fails with 400 BAD_REQUEST.
  • Every delivery is signed with the webhook's whsec_-prefixed secret (HMAC-SHA256 in the Snug-Signature header). The secret is returned on create and included in info/list responses - 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). enable clears 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

Interactive example

Send signed events to another system and track each delivery attempt. Failed deliveries can be retried or inspected when they need attention.

An illustrative example that runs in your browser. No account or API calls required.