Skip to content

django-outbound-webhooks

Outbound webhooks for Django: a customer-facing endpoint registry, Standard Webhooks signing, and per-endpoint delivery built as a durable receiver on django-domain-events.

Your customers subscribe to domain events, not to your post_save signals.

Body formats

An endpoint is pinned to one published format version at registration, and a published version is frozen: changing what it renders would change what an already-integrated consumer receives, under a signature that still verifies. A change is a new version, and golden fixtures generated by the package's own renderer are what make that a gate rather than a note.

Family Content type Shape
envelope application/json id, type, timestamp, and the payload under data
cloudevents application/cloudevents+json; charset=UTF-8 CloudEvents 1.0, structured mode

cloudevents is published only when the deployment names the system its events come from, because the specification requires a non-empty source and an invented one would be signed into every body:

DJANGO_OUTBOUND_WEBHOOKS = {"CLOUDEVENTS_SOURCE": "https://shop.example/events"}

Until it is set, cloudevents is not a family an endpoint can pin, and a system check says so when DEFAULT_FORMAT names it.

An operator's own format is any object with a name, a version and a render(), published from AppConfig.ready() with formats.register(MyFormat()). It inherits from nothing -- BodyFormat is a Protocol -- and the registry verifies at registration that it can actually render a delivery, because a format is published once at startup and called hours later in another process.

Operations

replay_delivery(message_id=...) fires a logged delivery again. It is a new delivery rather than a re-run: a new webhook-id, because a receiver deduplicates on that one; the endpoint's format and secrets as they stand now, because those are its integration contract today; and its own delivery row, attempt budget, backoff and log rows, because it goes through the substrate like everything else. It refuses rather than firing something that cannot arrive - an unlogged message id, a deleted endpoint, an inactive one, or an event retention has already pruned.

rotate_secret(endpoint, new_secret=...) changes a secret without a coordinated cutover. Both secrets sign for SECRET_ROTATION_OVERLAP_SECONDS, and a receiver accepts the delivery if either signature verifies, so the customer deploys the new secret on their own schedule. overlap_seconds=0 cuts over immediately, which is right for a leaked secret and wrong for everything else.

Auto-disable switches an endpoint off after AUTO_DISABLE_AFTER_DEAD_DELIVERIES consecutive dead deliveries - the outer tier, each already having spent its whole attempt budget - and fires EndpointDisabled so something in your project can tell the customer. A delivery that lands resets the count, which is what makes the threshold mean sustained. reactivate_endpoint(endpoint) puts it back into service and clears the count, which has to happen together: an endpoint re-enabled with its count still at the threshold is switched off again by its very next dead delivery.

Admin

Install django.contrib.admin and both models appear. The registry page shows per-endpoint health in the unit that means something - "3 dead deliveries in a row" rather than a bare number - and links each endpoint to its own attempts instead of counting them, because the next question is what the last one said. The log page is read-only and renders both attempt numbers as 3.2, so which tier each counts stays legible.

Two actions: reactivate on the registry, which clears the dead-delivery count along with the flag, and replay on the log, which sends once per delivery rather than once per row - a failed delivery is several rows, and replaying per row would send the customer one webhook per attempt the original made.

Three refusals are deliberate. No signing secret is ever rendered, on any page. Endpoints cannot be created through a form, because register_endpoint is where the cross-field rules and the format pinning live. And tenant, format_name and format_version are read-only: the first is the isolation boundary and the other two are the shape the customer integrated against.

Both actions declare permissions=, because Django offers an action without one to anyone who can reach the changelist - view-only staff included - and has_change_permission gates the form rather than the action. Replay is gated on the endpoint's change permission rather than the log's, since a replay mutates nothing in the log and what it does is send a customer a webhook.