Webhooks
A webhook sends a signed HTTPS POST to a URL you choose whenever something happens in your org: a
lead is created, a deal is won, a customer gets a public reply on a ticket. Point one at Zapier, n8n,
a Slack channel or your own code. The code lives in backend/webhooks/ and ships from django-crm
1.10.0.
This page is a summary. The full reference, including the payload fields per module and the URL rules, is Webhooks in the CRM docs.
Setting one up
Settings → Webhooks in the web app (/settings/webhooks), Settings → Webhooks in the phone
app, or the API at /api/webhooks/.
- Admin only, reading included. A hook URL is often a secret of its own (Zapier and Slack put
theirs in the path), and every endpoint receives copies of your records, so a member gets
403. - Signed-in session only. A personal access token or the org API key is refused on
/api/webhooks/whatever scopes it carries, because a webhook keeps sending after the token that created it is revoked. - Up to 10 webhooks per org.
Each webhook has a URL (https:// on port 443, publicly reachable), one or more events, a format
(Signed JSON, the default, or Slack message), and a signing secret the server generates. The
secret is shown in full only when the webhook is created and when you rotate it.
Who answers for a webhook
From django-crm 1.11.0 every webhook shows the admin who answers for it: whoever added it, or the
admin who last turned it back on or changed where it sends. The list and the detail page show that person on the web and the
phone, and the API returns them as created_by (id, name and email; null once their user has been
deleted). It is set by the server and cannot be sent in a request.
A webhook keeps copying your records to an address somebody chose, so it should not outlive that person's right to choose it. When its creator stops being an admin, is deactivated, leaves the organization, or has their user deleted, each webhook they created is paused: turned off, with the reason shown beside it, and a Webhook Paused entry written to the audit log. The same check runs again before every delivery, so a change made outside the app (a script, the Django admin) still stops the sending. The reason names what happened: the creator is no longer an admin, was deactivated, left the organization, is not a member of this organization, or the user who created it was deleted.
Taking responsibility for a webhook is what makes you its owner:
- Turning it back on. Any admin can re-enable a paused webhook, and becomes the admin who answers for it. The audit log records Webhook Re-enabled.
- Changing where or what it sends. Editing its URL, events or format, or rotating its secret, also makes the editor its owner, and the audit log records Webhook Destination Changed. Without this, an admin could re-point a colleague's webhook at their own server and keep receiving your records after losing admin, since only the webhooks a demoted admin owns are paused.
- Editing the description changes nothing about who owns it.
Events
| Module | Events |
|---|---|
| Leads | lead.created, lead.updated, lead.deleted |
| Contacts | contact.created, contact.updated, contact.deleted |
| Accounts | account.created, account.updated, account.deleted |
| Deals | deal.created, deal.updated, deal.deleted, deal.won, deal.lost |
| Tickets | ticket.created, ticket.updated, ticket.comment_added |
| Invoices | invoice.created, invoice.updated, invoice.paid |
| Tasks | task.created, task.updated, task.deleted |
deal.wonanddeal.lostfire when a deal enters a stage whose kind is won or lost, whatever that stage is called in your pipeline.ticket.comment_addedfires for public replies only. Internal notes are never sent anywhere.- Changes made through bulk database operations that skip Django's save signals do not produce events.
Every JSON delivery has the same envelope: id (the event id, stable across redeliveries, so
de-duplicate on it), event, created_at, org_id and data. data is a fixed, explicit set of
fields per module. Secrets, password fields and an invoice's public link token are never included.
Verifying the signature
Each request carries X-BottleCRM-Event, X-BottleCRM-Delivery and
X-BottleCRM-Signature: t=<unix seconds>,v1=<hex>. v1 is the hex HMAC-SHA256, keyed with the
webhook's secret, of "<t>." + raw body. Verify against the raw bytes, compare in constant time, and
reject a timestamp more than five minutes old.
import hashlib
import hmac
import time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(item.split("=", 1) for item in header.split(","))
timestamp = int(parts["t"])
if abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
The CRM docs carry the same function in Node.
Delivery and retries
- A delivery is attempted once the change is committed, with a 10 second timeout. Any
2xxis success; redirects are not followed. - A failure is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours: six attempts in all, then the delivery is marked Failed.
410 Gonestops at once and turns the webhook off, with the reason shown beside it. Any admin can turn it back on.- Delivery is at least once and order is not guaranteed.
- Each webhook has a delivery log kept for 30 days, a Redeliver action and a Send test that
queues a
ping.
Retries are driven by Celery beat, so beat must be running on a self-hosted install.
Slack
Create an incoming webhook in Slack, paste its https://hooks.slack.com/services/... URL, and
choose Slack message. Each event is posted as one line, such as BottleCRM: Deal won: Acme renewal, with names escaped so a record cannot ping your channel. Slack does not check a signature,
so the URL is the secret.
Zapier
- Create a Zap with the trigger Webhooks by Zapier → Catch Hook and copy its URL.
- Add a webhook in BottleCRM with that URL, format Signed JSON, and the events you want.
- Create a real record (a
pinghas a different shape), then Test trigger in Zapier. - Map fields from
datainto your action steps, and filter oneventif one hook listens for several.
Catch Hook cannot check the HMAC signature, so the unguessable hook URL is what protects it.
n8n
- Add a Webhook node: method
POST, Respond Immediately, and use its Production URL on a publicly reachable n8n. - Add a webhook in BottleCRM with that URL, format Signed JSON, and your events.
- To verify the signature, turn on the node's Raw Body option and add a Code node that runs
the Node
verifyfunction from the CRM docs. Self-hosted n8n needsNODE_FUNCTION_ALLOW_BUILTIN=cryptofor that. - Route on
{{$json.body.event}}with a Switch node.
What a webhook cannot reach
The URL is checked when it is saved and again before every attempt. Loopback, private, link-local (including the cloud metadata address), carrier-grade NAT and other non-public addresses are refused, and the connection is pinned to the address that passed the check. A receiver on your own private network has to be exposed through a public HTTPS endpoint.
See also
- CSV import and export: moving records in and out in bulk.
- API authentication: for pulling data rather than being sent it.