🪝 Webhooks
Webhooks send a request to a URL after editors change content in the admin.
Use them to rebuild a static site, clear a cache or notify another service,
for example with the build hook of your hosting provider. In Python, the
content_changed signal gives you the same changes
without a webhook.
Set up a webhook
-
Add the webhooks app to your project:
# mysite/settings.pyINSTALLED_APPS = [# ..."content_studio","content_studio.webhooks",] -
Run
manage.py migrateto create its tables. -
Schedule the delivery command, which retries failed requests and removes old ones. For example, every minute from cron:
* * * * * /srv/mysite/venv/bin/python /srv/mysite/manage.py content_studio_deliver_webhooks -
In the admin, open Webhooks in the main menu and create a webhook with a name and the URL to call, such as your hosting provider's build hook.
-
Copy the secret the admin shows after you create the webhook. It is shown only this once; you need it to verify the signature.
From now on, every change an editor makes in the admin sends a POST
request to the URL.
What triggers a webhook
These changes in the admin trigger webhooks:
- creating, editing and deleting objects, including bulk deletes;
- moving objects to the trash and restoring them;
- reordering objects;
- uploading, editing, cropping, moving and deleting media items, and creating, renaming, moving and deleting media folders. Deleting a folder also counts for the subfolders and media items deleted with it.
Changes are sent after they are saved; a change that fails or is rolled back sends nothing. Everything one action changes goes into one request per webhook, so a bulk delete of fifty articles starts one build, not fifty.
Changes made outside the admin, such as in manage.py shell, a data import
or your own views, do not trigger webhooks. Your own
bulk actions only do when
they record their changes with record_change. Changes to the webhooks
themselves never trigger a webhook.
Choose what triggers it
By default a webhook fires on every change. Three fields on the webhook narrow it down; an empty field matches everything:
- Events: created, updated, deleted, restored and reordered.
- Models: the models whose changes count. You choose from the models registered in the admin.
- Tenant: with multi-tenancy, the primary key of the tenant whose changes count. Changes to models without a tenant then do not count.
Turn off Active to pause a webhook without deleting it.
Work with webhooks in the admin
A webhook's edit page has two buttons in its header:
- Trigger now sends a request without changes right away, with the
event
webhook.triggered. Use it to start a build by hand, or to check that the URL works: the admin tells you how the webhook responded. - Regenerate secret replaces the secret and shows the new one once. Requests are signed with the new secret from then on, so update every receiver that checks the signature.
Under the fields, the delivery log lists the webhook's requests, newest first, with their status, attempts, response and error. Open a delivery to see what it sent and received. A failed delivery has a Retry button that sends it once more, right away.
The webhook models follow Django's permissions, like any registered model.
Viewing a webhook shows its delivery log; the buttons need change
permission. Choosing models also needs view permission on content types
(contenttypes.view_contenttype), which superusers have.
To create webhooks in code instead, such as in a data migration, use the models directly:
from django.contrib.contenttypes.models import ContentType
from blog.models import Article, Category
from content_studio.webhooks.models import Webhook
webhook = Webhook.objects.create(
name="Rebuild the blog",
url="https://api.netlify.com/build_hooks/5f3a9c0e2b1d4e0012345678",
events=["created", "updated", "deleted", "restored"],
)
webhook.content_types.set(
ContentType.objects.get_for_models(Article, Category).values()
)
A deploy button and a dashboard widget
Two optional additions put webhooks within reach of every editor. Neither appears until you add it to your site.
DeployButton is a button in the main menu that triggers the active
webhooks, after confirming, with the same request as Trigger now. Pass
webhooks to trigger only the webhooks with those names; with
multi-tenancy, it triggers the current tenant's
webhooks and those without a tenant. It is shown to editors who may change
webhooks, and takes the options of a
MainMenuButton, such as its label.
WebhookDeliveriesWidget shows every webhook on the dashboard with its
latest delivery: when it was made, whether it was delivered, the error of a
failed one and when a retry is due. Editors who may not view webhooks see an
empty list.
# mysite/studio.py
from content_studio.admin import ContentStudioSite
from content_studio.dashboard import Dashboard
from content_studio.webhooks.dashboard import WebhookDeliveriesWidget
from content_studio.webhooks.extensions import DeployButton
class BlogStudioSite(ContentStudioSite):
extensions = [DeployButton(webhooks=["Rebuild the blog"])]
dashboard = Dashboard(widgets=[WebhookDeliveriesWidget()])
admin_site = BlogStudioSite()
The request
Each request is a POST with a JSON body:
{
"version": 1,
"event": "content.changed",
"delivery": "0b8e2c55-6f4e-4f6a-9d1c-3c2f5a7e9b10",
"webhook": "Rebuild the blog",
"timestamp": "2026-10-27T09:30:12.481930+00:00",
"changes": [
{
"action": "updated",
"model": "blog.article",
"id": "42",
"label": "Ten tips for better headlines",
"tenant": null,
"timestamp": "2026-10-27T09:30:12.402117+00:00"
}
]
}
version: the format of the body; it only changes in a major release.event:content.changedfor changes in the admin,webhook.triggeredfor a request sent with Trigger now or the deploy button, which has no changes.delivery: the request's ID. A retry sends the same ID, so the receiver can skip a request it has already handled.changes: the changes the webhook matches, each with its action, the model asapp_label.model_name, the object's primary key as a string, its name, its tenant and when it was made.
The body does not contain the content itself. To fetch the changed objects, the receiver reads them from your own API, for example one built with django-headless.
These headers come with it:
| Header | Value |
|---|---|
X-Content-Studio-Event | The event, as in the body. |
X-Content-Studio-Delivery | The request's ID, as in the body. |
X-Content-Studio-Signature | t=<timestamp>,v1=<signature>: see below. |
Verify the signature
Every webhook has a secret, generated when you create it and shown once then, and again when you regenerate it. The signature is the HMAC-SHA256 of the timestamp, a period and the raw body, with the secret as the key. Check it before you trust a request, and refuse old timestamps so a recorded request cannot be replayed:
# receiver.py
import hashlib
import hmac
import time
def is_valid(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
fields = dict(part.split("=", 1) for part in header.split(","))
timestamp = int(fields["t"])
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
).hexdigest()
return abs(time.time() - timestamp) <= tolerance and hmac.compare_digest(
expected, fields["v1"]
)
A build hook URL that contains its own token, like the one above, does not need to check the signature.
Delivery and retries
Each request is stored as a WebhookDelivery, with the response status,
the start of the response body and any error; the webhook's delivery log
shows them.
Content Studio sends deliveries as Django tasks,
so the project's TASKS setting decides when they run. Without one,
Django sends them straight away, at the end of the editor's request. With a
task worker, the request is sent from the worker instead. On Django 5.2,
deliveries are sent straight away, unless the django-tasks package is
installed.
A response with a 2xx status code counts as delivered. Anything else, such as
a timeout or a 503, is retried by content_studio_deliver_webhooks, after 1,
4, 16 and 64 minutes. After WEBHOOK_MAX_ATTEMPTS
attempts, the delivery is marked as failed and a warning is logged to the
content_studio.webhooks logger. Retry in the delivery log makes one
more attempt. The same command removes finished
deliveries after WEBHOOK_DELIVERY_RETENTION
days.
Without the scheduled command, a delivery that fails once is never retried, and with a task backend that only stores tasks, nothing is sent at all. Schedule the command in every deployment that uses webhooks.
Security
Webhooks only connect to public IP addresses, over HTTPS, and do not follow
redirects. A URL that resolves to a private, loopback or link-local address
fails, so a webhook cannot reach services on your own network. To call such
a host on purpose, such as a build server on your network, add it to
WEBHOOK_ALLOWED_HOSTS. Plain HTTP
works for those hosts, and for every host while DEBUG is on.
Anyone who can change a webhook can send your content changes to any URL. Give the permissions on the webhook models only to people you trust with that, like the permissions on users. See Access control. With multi-tenancy, webhooks are shared by every tenant: an editor with these permissions manages the webhooks of all tenants.
React in Python
To act on changes in your own code, connect a receiver to the
content_changed signal. It is sent after the changes of a request in the
admin are saved, with all of them in changes:
# blog/signals.py
from django.dispatch import receiver
from content_studio.signals import content_changed
@receiver(content_changed)
def log_changes(sender, changes, user, **kwargs):
for change in changes:
print(change.action, change.model.__name__, change.object_id)
Import the module in your app's ready() method so the receiver is
connected. Each change is a Change with action, model, object_id,
object_repr, tenant_id, user and timestamp. The signal does not need
the webhooks app; an error in a receiver is logged and does not affect the
editor's request.
Related pages: Settings reference, System checks.