Appearance
Webhook delivery
Every Krafter webhook delivery — across Mail, Forms, Flags, Push, Signal, and Surveys — follows the same envelope, signing, retry, and auto-disable contract. Per-service pages link to this page rather than restating the contract in full.
Headers
Every delivery POST sets:
| Header | Value |
|---|---|
content-type | application/json |
x-krafter-event | event name (e.g. email.delivered, submission.created, flag.toggled, notification.sent) |
x-krafter-webhook-id | UUID v4, regenerated on every attempt so retries are individually identifiable |
x-krafter-signature | t=<unix>,v1=<hex> — HMAC-SHA256 of <t>.<body>; see Verifying signatures. Omitted only when the webhook has no secret (a nil or empty string) |
Headers are applied centrally by Krafter.Workers.WebhookDeliveryHelper.build_headers/3. The x-krafter-test header is added when a delivery is triggered from the dashboard's "Send test event" button.
Verifying signatures
Every signed delivery carries x-krafter-signature: t=<unix>,v1=<hex>, where the HMAC-SHA256 covers <t>.<raw body> — the timestamp is inside the signed payload, which is what lets a receiver reject a replayed request.
The verification recipe — splitting the header, checking that t is fresh, and the constant-time compare — is written once, with Node.js, Elixir and Python examples, on Verifying signatures. It is the same on every Krafter delivery, so there is nothing service-specific to add here.
Retry policy
Failed deliveries — any non-2xx HTTP response, network error, or timeout — are re-enqueued by Oban.
| Worker | Service | max_attempts | Backoff |
|---|---|---|---|
WebhookDelivery | 5 | Oban default exponential ((2 ^ attempt) + 15 seconds + jitter) | |
FormWebhookDelivery | Forms | 5 | Linear attempt * 15 seconds → 15s, 30s, 45s, 60s, 75s |
FlagWebhookWorker | Flags | 5 | Oban default exponential |
PushWebhookWorker | Push | 5 | Linear attempt * 15 seconds |
SignalWebhookDelivery | Signal | 5 | Oban default exponential |
SurveyWebhookDelivery | Surveys | 5 | Linear attempt * 15 seconds |
A delivery that exhausts max_attempts is logged and its failure counter is incremented (see auto-disable below). The Oban job is then discarded — it is not retried after Oban gives up.
Logs alert webhook is different
The Logs alerting webhook (Krafter.Workers.LogAlertWebhook) is not part of this contract. It uses max_attempts: 3 and does not participate in the auto-disable mechanism. Treat it as a fire-and-forget alert delivery, not a webhook in the platform sense.
It is signed, despite what this page said until now: LogAlertWebhook goes through the same build_headers/4, so an alert with a notify_webhook_secret carries both signature headers like any other delivery.
Auto-disable after consecutive failures
After @max_consecutive_failures = 10 consecutive failures, the shared helper flips the webhook's "is active" flag to false. The field name varies per service:
| Service | Field set to false |
|---|---|
| Mail, Flags, Push, Signal | enabled |
| Forms, Surveys | active |
A successful delivery (any 2xx response) resets failure_count to 0, so the counter only triggers auto-disable on a consecutive failure streak.
Owner notification
When auto-disable fires, services that opt in send an email to the team owner so the disable doesn't go unnoticed. Today this is wired for Forms and Push webhooks. Other services (Mail, Flags, Signal, Surveys, Webhooks Destinations, Forwarding) auto-disable silently — monitor failure_count via the dashboard or list endpoint until they opt in.
The notification email includes the webhook URL, the failure count, and a link to the relevant dashboard.
After auto-disable:
- Fix the receiving endpoint.
- Re-enable / reactivate the webhook in the dashboard (this also resets the failure counter).
- Use the per-service "test delivery" endpoint to confirm the endpoint is reachable before re-enabling, if your dashboard exposes one.
Idempotency
Receivers should treat x-krafter-webhook-id as the deduplication key for a single delivery attempt. The same logical event delivered after a retry will carry a new id — use the event payload's domain id (e.g. email_id, submission.id, notification_id) to deduplicate across retries of the same event.