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:
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:
- Look up the provider in the registry → 404 if unknown.
- Verify the signature using the configured verifier → 401 if invalid.
- Extract event_id via
event_id_path. This is the idempotency key. - Extract event_type via
event_type_path(optional). - Extract delivery_id from the configured header (optional — used for distinguishing retries from duplicates).
- 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.
- If already
- Create a
WebhookEventwithstatus="processing". - Invoke the handler (awaited).
- Update status to
processedorfailed, plus the error if any. - 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:
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.