BETA
Skip to content

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:

HeaderValue
content-typeapplication/json
x-krafter-eventevent name (e.g. email.delivered, submission.created, flag.toggled, notification.sent)
x-krafter-webhook-idUUID v4, regenerated on every attempt so retries are individually identifiable
x-krafter-signaturesha256=<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)
end

Retry policy

Failed deliveries — any non-2xx HTTP response, network error, or timeout — are re-enqueued by Oban.

WorkerServicemax_attemptsBackoff
WebhookDeliveryMail5Oban default exponential ((2 ^ attempt) + 15 seconds + jitter)
FormWebhookDeliveryForms5Linear attempt * 15 seconds → 15s, 30s, 45s, 60s, 75s
FlagWebhookWorkerFlags5Oban default exponential
PushWebhookWorkerPush5Linear attempt * 15 seconds
SignalWebhookDeliverySignal5Oban default exponential
SurveyWebhookDeliverySurveys5Linear 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:

ServiceField set to false
Mail, Flags, Push, Signalenabled
Forms, Surveysactive

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:

  1. Fix the receiving endpoint.
  2. Re-enable / reactivate the webhook in the dashboard (this also resets the failure counter).
  3. 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.

Built by Krafter Studio