Skip to content

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.