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