Backend
Webhooks
pleate_backend.webhooks lets your users subscribe their own servers to events of your app: signed deliveries, retries with backoff, a delivery log with redelivery, and a guard against requests to private networks. It also has the helpers to receive webhooks from others (Stripe, GitHub, another pleate app) exactly once.
Sending events
from pleate_backend.webhooks import dispatch, register_event
register_event("order.shipped", "An order left the warehouse.") # e.g. in AppConfig.ready()
dispatch("order.shipped", {"id": str(order.pk), "tracking": order.tracking}, owner=order.user)
dispatch("catalog.updated", {"count": 12}) # no owner: endpoints of staff usersBuilt in events are ping, user.created and user.deleted (the last two go to staff endpoints). Extra names can also be declared in PLEATE["WEBHOOKS"]["EVENTS"].
What a receiver gets
POST /hooks/plate HTTP/1.1
Content-Type: application/json
User-Agent: pleate-webhooks/1
Pleate-Event: order.shipped
Pleate-Delivery: 6720e1a2b3c4d5e6f7a8b9c0
Pleate-Signature: t=1792065600,v1=5f8c0e7a1b2c…
{"id": "evt_3k9…", "type": "order.shipped", "created_at": "2026-10-26T11:00:00Z", "data": {"id": "…", "tracking": "…"}}- Any 2xx is a success. Redirects are not followed.
- Failures are retried after 60 s, 5 min, 30 min, 2 h, 5 h, 10 h, then daily, up to
MAX_ATTEMPTS. A redelivery keeps the same eventid: deduplicate on it. - After 20 consecutive failures the endpoint is disabled and its owner is notified.
- Delivery logs, with a response excerpt, are kept 30 days.
Verifying the signature
v1 is the hex HMAC SHA-256 of <t>.<raw body> keyed with the endpoint secret. Check it against the raw bytes, before parsing JSON, in constant time, and reject old timestamps to stop replays.
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(request: Request) {
const raw = await request.text();
if (!verify(process.env.PLATE_WEBHOOK_SECRET!, raw, request.headers.get("pleate-signature") ?? "")) {
return new Response("bad signature", { status: 400 });
}
const event = JSON.parse(raw);
// ...handle event.type, deduplicate on event.id
return new Response(null, { status: 204 });
}
function verify(secret: string, raw: string, header: string, tolerance = 300) {
const parts = Object.groupBy(header.split(","), (p) => p.split("=")[0]);
const t = Number(parts.t?.[0]?.slice(2));
if (!t || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${raw}`).digest("hex"));
return (parts.v1 ?? []).some((p) => {
const candidate = Buffer.from(p.slice(3));
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
});
}import hashlib, hmac, time
def verify(secret: str, raw: bytes, header: str, tolerance: int = 300) -> bool:
parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
timestamps = [v for k, v in parts if k == "t"]
signatures = [v for k, v in parts if k == "v1"]
if not timestamps or not signatures or abs(time.time() - int(timestamps[0])) > tolerance:
return False
message = timestamps[0].encode() + b"." + raw
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)In a Django project with pleate-backend installed, use the helper instead: verify_signature(secret, request.body, header, tolerance=300).
Receiving webhooks
Senders retry, so the same event can arrive twice, sometimes at the same moment. claim_inbound stores the event id under a unique index and answers True only the first time:
from pleate_backend.webhooks.inbound import claim_inbound, mark_processed, release_inbound, verify_signature
@csrf_exempt
def plate_hook(request):
if not verify_signature(SECRET, request.body, request.headers.get("Pleate-Signature", "")):
return HttpResponse(status=400)
event = json.loads(request.body)
if not claim_inbound("plate", event["id"], event):
return HttpResponse(status=200) # already handled
try:
handle(event)
except Exception:
release_inbound("plate", event["id"]) # let the sender's retry be handled
raise
mark_processed("plate", event["id"])
return HttpResponse(status=200)The billing app receives Stripe events the same way.
Endpoints
Mounted at /api/webhooks, scoped to the signed in user, so customers can manage their endpoints from your settings page. Listing, creating, changing, deleting endpoints and rotating secrets need a signed in session: an API key gets 403 api_key_not_allowed. The other routes also accept API keys.
| Method | Path | Purpose |
|---|---|---|
| GET | /webhooks/endpoints | My endpoints. Page list, ordering created_at. |
| POST | /webhooks/endpoints | {url, description?, events?: ["*"] | [names], active?} → 201 with the full secret, shown only this once. |
| GET | /webhooks/endpoints/<id> | Endpoint with the secret masked. |
| PATCH | /webhooks/endpoints/<id> | url, description, events, active. Turning it back on resets the failure counter. |
| DELETE | /webhooks/endpoints/<id> | 204. |
| POST | /webhooks/endpoints/<id>/rotate-secret | → {secret} |
| POST | /webhooks/endpoints/<id>/test | Sends a ping now (5 second timeout) → {delivery, status, status_code, duration_ms, error}. |
| GET | /webhooks/endpoints/<id>/deliveries | Page list. Filters status, event. |
| GET | /webhooks/deliveries/<id> | Delivery with payload and response_excerpt. |
| POST | /webhooks/deliveries/<id>/redeliver | 202 with the new delivery (same event id and payload). |
| GET | /webhooks/events | [{name, description}], for a checkbox list. |
Delivery status is pending, success or failed; a failed delivery with next_attempt_at will be retried.
SSRF guard
Endpoint URLs must use https and resolve only to public addresses: no loopback, private, link local, reserved or multicast ranges. The check runs on create, on update and again before every attempt. The attempt then connects to the exact address that was checked (the Host header and the TLS certificate still use the hostname), so a DNS change between the check and the connection cannot sneak a delivery into your network. Redirects are not followed and proxy environment variables are ignored. ALLOW_PRIVATE_URLS lifts the guard for local development.
Settings
PLEATE["WEBHOOKS"]:
| Key | Default | Meaning |
|---|---|---|
| MAX_ATTEMPTS | 8 | Attempts per delivery. |
| TIMEOUT_SECONDS | 10 | Per attempt. |
| ALLOW_PRIVATE_URLS | False | Allow http and private addresses (development only). |
| EVENTS | [] | Extra event names, or {name, description} dicts. |
| RETRY_SCHEDULE | built in | Optional list of delays in seconds. |
| DISABLE_AFTER_FAILURES | 20 | Consecutive failures before an endpoint is disabled. |
Error codes
| Code | Status | When |
|---|---|---|
| url_not_allowed | 400 | Not https, or resolves to a private address. |
| unknown_event | 400 | An event name that is not registered. |
| events_required | 400 | The events list is empty. |
| endpoint_limit | 400 | 20 endpoints per user. |
| endpoint_inactive | 409 | Redelivering to a disabled endpoint. |