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 | sha256=<hex> HMAC-SHA256 of the raw request body keyed by your webhook secret. 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
Compare your computed HMAC to the value in x-krafter-signature using a constant-time check. Always use the raw request body bytes — re- serialising parsed JSON will not match.
Node.js
javascript
const crypto = require('crypto');
function verify(rawBody, signatureHeader, secret) {
const expected = 'sha256=' +
crypto.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected)
);
}Elixir
elixir
def verify(raw_body, signature_header, secret) do
expected =
"sha256=" <>
(:crypto.mac(:hmac, :sha256, secret, raw_body)
|> Base.encode16(case: :lower))
Plug.Crypto.secure_compare(signature_header, expected)
endRetry 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, sends unsigned payloads, has no x-krafter-signature header, 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.
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.