Backend

Webhooks

Signed outgoing webhooks with retries, delivery logs and an SSRF guard, plus helpers to receive webhooks exactly once.

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

python
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 users

Built 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

http
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 event id: 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.

Node.js (Next.js route handler)
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);
  });
}
Python (any framework)
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:

python
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.

MethodPathPurpose
GET/webhooks/endpointsMy 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>/testSends a ping now (5 second timeout) → {delivery, status, status_code, duration_ms, error}.
GET/webhooks/endpoints/<id>/deliveriesPage list. Filters status, event.
GET/webhooks/deliveries/<id>Delivery with payload and response_excerpt.
POST/webhooks/deliveries/<id>/redeliver202 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"]:

KeyDefaultMeaning
MAX_ATTEMPTS8Attempts per delivery.
TIMEOUT_SECONDS10Per attempt.
ALLOW_PRIVATE_URLSFalseAllow http and private addresses (development only).
EVENTS[]Extra event names, or {name, description} dicts.
RETRY_SCHEDULEbuilt inOptional list of delays in seconds.
DISABLE_AFTER_FAILURES20Consecutive failures before an endpoint is disabled.

Error codes

CodeStatusWhen
url_not_allowed400Not https, or resolves to a private address.
unknown_event400An event name that is not registered.
events_required400The events list is empty.
endpoint_limit40020 endpoints per user.
endpoint_inactive409Redelivering to a disabled endpoint.