ServicesWebhooks

Receiving Deliveries

What arrives at your endpoint, how to verify it, and what your response means.

The request

Each delivery is a POST with a JSON body and these headers (captured verbatim, signature trimmed):

Content-Type: application/json
User-Agent: snug-api-webhook/1.0
X-Webhook-Id: vPjqTYTwBqWYMvRWUxYq
X-Webhook-Event: test.webhook
Snug-Signature: t=1787893347,v1=906b244f44ea02bc996c1003c9ef9de99...
X-Webhook-Timestamp: 1787893347
X-Request-Id: evt_eFrjPvMZJZfZ
X-Delivery-Attempt: 1
X-Docs4: demo

X-Request-Id is the event ID - the same event redelivered on retry keeps its ID, so use it for idempotency. X-Delivery-Attempt counts retries of that event. Custom headers configured on the webhook (X-Docs4 above) are appended to every delivery.

The payload

{
  "id": "evt_eFrjPvMZJZfZ",
  "type": "test.webhook",
  "message": "docs4 ping",
  "timestamp": 1787893347,
  "created": 1787893347
}

id, type, and created (enqueue time, epoch seconds) are always present; the remaining fields are the event's own, flattened into the same object. Per event type:

  • entity.status_changed - entity_id, previous_status, current_status (alive/dead), last_liveness, metadata
  • entity.liveness_anomaly - entity_id, anomaly_type (high_network_delay/clock_drift/rapid_status_change), network_delay_ms, threshold_ms, metadata
  • rate_limit.exceeded - token_id, endpoint, limit, window_seconds, attempts
  • test.webhook - message, timestamp

Verifying the signature

Snug-Signature is t=<epoch seconds>,v1=<hex HMAC-SHA256>. The signed message is the timestamp, a dot, and the raw request body - verify against the exact bytes received, before any JSON parsing or re-encoding. This example reproduces the captured signature above from the webhook's secret:

import hmac, hashlib

def verify(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool:
    t_part, v1_part = header.split(",")
    timestamp, signature = t_part[2:], v1_part[3:]
    # reject timestamps outside the tolerance window to prevent replay
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

verify("whsec_V8QpK6nDjdTZr9rTRCqz2_Y9lnQtIG0SOalFld6YsHc",
       b'{"id":"evt_eFrjPvMZJZfZ","type":"test.webhook","message":"docs4 ping",'
       b'"timestamp":1787893347,"created":1787893347}',
       "t=1787893347,v1=906b244f44ea02bc996c1003c9ef9de9928ec8cc1f9c4799cc10231c0f0a22c2")
# True

The server signs with a fresh timestamp on every attempt and tolerates 5 minutes of clock skew when verifying its own scheme - enforce the same window and use a constant-time comparison, as above. After rotate-secret, deliveries are signed with the new secret immediately.

What your response means

  • 2xx - delivered. The webhook's consecutive-failure counter resets.
  • 4xx except 429 - permanent failure. No retry: the event dead-letters after that single attempt (a 404 receiver produces one delivery attempt and a DLQ entry with attempt_count: 1).
  • Anything else - 5xx, 429, timeouts, connection failures - is retried with exponential backoff until the attempt budget (default 5) is exhausted, then dead-lettered.

Respond quickly - the delivery request times out after 30 seconds by default (WEBHOOK_REQUEST_TIMEOUT_SECONDS). Accept the delivery first and process it afterwards; a slow handler that times out counts as a failed attempt and, repeated, will auto-disable the webhook.

Failed attempts are visible from the sending side in snug webhooks deliveries (status code or error text per event) and the DLQ commands on the main page.

On this page