Webhook icon
Queries icon
Script icon
Process icon
If icon
Fail icon
OutputValues icon
Sequential icon
Set icon
event_type }} icon
Loop icon
Switch icon
Query icon
Log icon
SlackIncomingWebhook icon
Delete icon

Idempotent Webhook Event Intake with Schema Validation and a Replayable DLQ

Signed webhook intake in Kestra with HMAC checks, JSON Schema validation, KV idempotency claims, a SQLite event store and a replayable dead-letter queue.

Categories
CoreData

Payment, CRM and IoT providers deliver webhooks at least once: the same event comes again after a timeout, sometimes in parallel, sometimes before the event it depends on. This flow is a reusable intake that applies each event exactly once. It answers the sender synchronously, rejects forged or replayed requests, and keeps every event it could not apply in a replayable dead-letter queue. The default JSON Schema describes sample order events (order.created, order.paid, order.cancelled); the orders table is the real action and stands in for your own handler.

How it works

  1. order_events (Webhook) uses fetchType: STORE, so the body is kept byte for byte (the default fetch re-serialises JSON, 1.50 becomes 1.5, and the HMAC breaks). wait and returnOutputs return the flow output result to the sender.
  2. init_store creates the event store, orders, dead_letter and a trigger refusing a payment that differs from the order total.
  3. screen (stdlib Python) checks the size, the Standard Webhooks webhook-id, webhook-timestamp and webhook-signature headers, the timestamp tolerance and the HMAC-SHA256 in constant time (several v1, signatures allowed for key rotation), then validates the payload against event_schema (subset on that input). reject_unverified fails the run before any state change.
  4. dedupe applies three idempotency layers: a live KV key answers retries without touching the database; take_claim writes the key with overwrite: false and ttl: dedupe_window; record_event inserts into the primary-keyed event store, the atomic arbiter. A reused id with another body is refused (id_conflict).
  5. process (Loop) handles one event per iteration. route (Switch) sends invalid payloads to mark_schema_invalid, the three known types to their SQL handlers and anything else to mark_unknown_type (the Switch defaults). Business misses (paid before created) are written as data; exceptions (the payment trigger) are caught by mark_exception. settle is the only writer of the DLQ: attempts + 1, parked at max_attempts, resolved when a replay succeeds.
  6. report reads the committed state back; respond answers processed, duplicate, dead_lettered or parked with HTTP 200, rejected or id_conflict with HTTP 500.
  7. mode: replay claims the oldest retryable dead letters in one transaction, re-validates them and runs them through the same Loop. dry_run (the default) only lists them; include_parked retries parked events once you fixed the cause.

What this blueprint teaches

  • KV overwrite: false as a claim and KV ttl as the dedupe window (take_claim). Tested on 2.0.5: of three rounds of twelve parallel claims on one key, one round had two winners, because the store checks, then writes. That is why record_event (INSERT OR IGNORE on the primary key) stays as the final guard.
  • Webhook fetchType: STORE with wait and returnOutputs (order_events): exact bytes for signatures and a synchronous answer; outputs are returned even when the run fails.
  • Flow-level checks: a manual mode=intake run, or a schema that is not an object, is refused with HTTP 422 before an execution exists.
  • sla and afterExecution (sender_timeout_budget, release_stranded_claim): when the SLA stops a run, finally does not run but afterExecution does. It deletes this run's processing claim and uncommitted row, so the sender's retry is processed, not answered as a duplicate. delete_claim comes last because the If condition is evaluated again after each child.
  • finally (audit_and_gauge, publish_dlq_gauge): an audit row and a dlq_summary KV gauge, also after rejections.
  • Per-item isolation (process, apply, settle): Loop transmitFailed: false, a Sequential with allowFailure and local errors, and a settle step that always runs (errorLogs() is empty inside Loop iterations on 2.0.5, so each failure is recorded by the task that saw it).
  • concurrency (limit 5, QUEUE) bounds parallel SQLite writers.

What you get

  • One row per event id, and an orders table changed once per event.
  • A dead_letter table with reason class (schema_invalid, unknown_type, processing_failed), attempts and status, plus a dlq_summary key in KV.
  • Clear rejections: bad_signature, stale_timestamp, missing_headers, body_too_large, not_utf8_json, id_conflict.
  • Alerts with the failed task id and execution link; a note when an event is parked or the SLA breached.

Who it's for

  • Integration and platform engineers receiving signed provider webhooks.
  • Data engineers who need an auditable event store.

Why orchestrate this with Kestra

Every delivery is an inspectable execution with declared retries, timeouts and masked secrets, and the DLQ is a mode of the same flow. SLA, checks and afterExecution give guarantees a bare HTTP handler must build by hand.

Prerequisites

  • python3 on the worker for the Process runner (included in the official Kestra image), or a Docker task runner with python:3.13-slim.
  • A writable path for the SQLite file in variables.event_store_url (/app/storage is the Docker Compose data volume).

Secrets

  • ORDER_WEBHOOK_KEY: the key in the webhook URL.
  • ORDER_WEBHOOK_SIGNING_SECRET: the shared HMAC secret, whsec_ + base64 as in the Standard Webhooks spec, or any raw string.
  • SLACK_WEBHOOK_URL: optional alerts; the tasks that use it have allowFailure.

Quick start

  1. Add the secrets and save the flow. The URL is /api/v1/main/executions/webhook/company.team/<flow id>/<key>.
  2. Send a signed sample event (SECRET and URL set in your shell):
    ID=evt_demo_1; TS=$(date +%s)
    BODY='{"type":"order.created","timestamp":"2026-10-20T12:00:00Z","data":{"order_id":"ord_demo1","customer_email":"a@example.com","amount_cents":1200,"currency":"EUR"}}'
    SIG=$(printf '%s.%s.%s' "$ID" "$TS" "$BODY" | openssl dgst -sha256 -mac HMAC -macopt hexkey:$(printf '%s' "${SECRET#whsec_}" | base64 -d | xxd -p -c 256) -binary | base64)
    curl -X POST "$URL" -H "webhook-id: $ID" -H "webhook-timestamp: $TS" -H "webhook-signature: v1,$SIG" -H 'Content-Type: application/json' -d "$BODY"
    
  3. Send it again (duplicate), then an order.paid for an unknown order (dead_lettered). Run mode=replay to list the queue, and with dry_run=false once the missing order arrived.

How to extend

  • Replace the SQL handlers with your own (a Subflow per type, an HTTP call) and add cases to route.
  • Schedule mode=replay every 15 minutes and alert on dlq_summary.parked.
  • Move the store to PostgreSQL when several workers share the intake.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.