Skip to content

Webhooks

Craft Easy provides a full incoming webhook receiver that takes care of the tricky parts: signature verification, idempotency, persistence, failure tracking, and a dead-letter queue with retry. You register a provider once (its secret, its verifier, and a handler coroutine) and the framework exposes a public POST /webhooks/{provider} endpoint that is ready to receive traffic.

Webhooks are disabled by default. Enable them in settings.py:

WEBHOOKS_ENABLED=true
WEBHOOK_EVENT_RETENTION_DAYS=90

Registering a provider

A webhook provider is a combination of:

  • A secret (shared with the sender)
  • A verifier (how to validate the signature)
  • A handler coroutine (what to do with the parsed payload)
  • An event_id_path to extract a stable ID for deduplication
from craft_easy.core.webhooks import webhook_registry
from craft_easy.core.webhooks.verifier import HmacSha256Verifier

async def handle_stripe_event(payload: dict) -> None:
    event_type = payload.get("type")
    if event_type == "payment_intent.succeeded":
        ...
    elif event_type == "charge.refunded":
        ...

webhook_registry.register(
    provider="stripe",
    handler=handle_stripe_event,
    secret=settings.STRIPE_WEBHOOK_SECRET,
    verifier=HmacSha256Verifier(prefix="t=,v1=", encoding="hex"),
    event_id_path="id",                    # extract from payload["id"]
    event_type_path="type",                # extract from payload["type"]
    signature_header="Stripe-Signature",
    delivery_id_header="Stripe-Request-Id",
    max_retries=5,
)

That is the whole wiring. Once registered, Stripe can POST to /webhooks/stripe and the framework will verify the signature, deduplicate by event_id, persist the event, and invoke your handler.

Nested event IDs

Some providers nest the identifier. Use dot notation:

webhook_registry.register(
    provider="swish",
    handler=handle_swish,
    secret=...,
    event_id_path="payment.id",            # payload["payment"]["id"]
    event_type_path="payment.status",
)

Built-in verifiers

from craft_easy.core.webhooks.verifier import (
    HmacSha256Verifier,
    HmacSha512Verifier,
)

HmacSha256Verifier(prefix="sha256=", encoding="hex")    # GitHub-style
HmacSha256Verifier(prefix="", encoding="base64")        # Stripe-style (partial)
HmacSha512Verifier(prefix="", encoding="hex")           # BankID-style

prefix is stripped before comparison — GitHub sends sha256=abc..., so the verifier removes sha256= before checking. encoding is "hex" or "base64" depending on how the sender encodes the digest.

Custom verifiers

Subclass WebhookVerifier to handle non-HMAC schemes (JWT signatures, OAuth-signed payloads, etc.):

from craft_easy.core.webhooks.verifier import WebhookVerifier

class MyVerifier(WebhookVerifier):
    async def verify(
        self,
        payload: bytes,
        signature: str,
        secret: str,
    ) -> bool:
        # Return True if the signature is valid
        ...

Request flow

The receiver (POST /webhooks/{provider}) executes the following steps:

  1. Look up the provider in the registry → 404 if unknown.
  2. Verify the signature using the configured verifier → 401 if invalid.
  3. Extract event_id via event_id_path. This is the idempotency key.
  4. Extract event_type via event_type_path (optional).
  5. Extract delivery_id from the configured header (optional — used for distinguishing retries from duplicates).
  6. Check for existing event by (provider, event_id):
    • If already processed: return {"status": "already_processed"} (200). The provider knows this is a duplicate retry and stops re-sending.
    • If still processing: return {"status": "processing"}. Avoids race conditions when the same event arrives twice in quick succession.
  7. Create a WebhookEvent with status="processing".
  8. Invoke the handler (awaited).
  9. Update status to processed or failed, plus the error if any.
  10. Respond with {"status": "ok", "event_id": ...} or {"status": "failed"}.

The handler runs synchronously with the request. If you need to do heavy lifting, write the job to your domain models or the event bus and let a background worker pick it up — don't block the webhook endpoint.

The WebhookEvent model

Every incoming webhook is persisted so you have a complete audit trail and can replay failures:

class WebhookEvent(BaseDocument):
    provider: str                   # e.g. "stripe"
    event_id: str                   # provider's ID, used for idempotency
    event_type: str | None          # e.g. "payment_intent.succeeded"
    delivery_id: str | None         # distinguishes retries from the original
    payload: dict                   # raw body
    headers: dict                   # signature, content-type, etc.
    status: str                     # received | processing | processed | failed
    processed_at: datetime | None
    error: str | None
    retry_count: int                # how many times the provider retried
    is_duplicate: bool              # set on replays

Indexes on (provider, event_id), (provider, status), status, and created_at. Webhooks are system-scoped (tenant_scoped = False) — a single event collection covers all tenants.

Admin and dead-letter endpoints

In addition to the public receiver, the framework exposes admin endpoints for operators:

Method Path Purpose
GET /webhooks/events List events, filter by provider and status, paginated
GET /webhooks/dead-letter List only events with status="failed"
POST /webhooks/dead-letter/{event_id}/retry Re-invoke the handler for a failed event
DELETE /webhooks/events/purge?retention_days=N Permanently delete events older than N days (only status="processed")

Retrying a dead-lettered event re-runs the handler with the original payload. If the handler succeeds, the event moves back to processed; if it fails again, it stays in the dead-letter queue with an updated error.

# List failed webhooks
curl http://localhost:5001/webhooks/dead-letter

# Retry a specific failed event
curl -X POST http://localhost:5001/webhooks/dead-letter/664abc.../retry

Retention and cleanup

Webhook events grow quickly — a busy Stripe integration produces thousands of events per day. Schedule periodic purging:

# Manual purge
curl -X DELETE "http://localhost:5001/webhooks/events/purge?retention_days=90"

Or schedule the purge as a job (see Jobs — Scheduling). WEBHOOK_EVENT_RETENTION_DAYS (default 90) is the recommended default for the purge job — keep failed events indefinitely so operators can investigate and only purge successfully processed ones.