ServicesWebhooks

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. Every example on this page was executed against a live server.

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. The CLI list takes no filters, but the HTTP GET /api/v1/webhooks speaks the shared search grammar - verified live with ?q=hook2, ?filter=active:eq:false, and ?sort_by=created_at&sort_order=desc.

Outbound URL guard: the URL must be syntactically valid at create time, and in production builds the delivery client refuses loopback, private, link-local, and cloud-metadata targets (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
snug webhooks dlq <webhook_id>
snug webhooks redrive <webhook_id> <event_id>
snug webhooks purge-dlq <webhook_id>

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. Both paths were reproduced live; 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 - reproduced live by driving ten failed events through one webhook, after which info showed 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 in the Webhook CONFIG reference.

Reference

On this page