# 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. Everything lives in `backend/webhooks/`. ## Setting one up `/settings/webhooks` in the web app, **Settings -> Webhooks** in the phone app, or the API at `/api/webhooks/`. Only an org admin can see or change webhooks, and that includes reading the list: 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. A member gets `403` on every route. Webhooks are managed from a signed-in session only. `/api/webhooks/` is on the credential deny-list in `backend/common/scopes.py`, so a personal access token or the org API key is refused there whatever scopes it carries. A webhook keeps sending after the token that created it is revoked, which makes creating one the same kind of act as minting a token. Each webhook has: - **URL**: `https://` on port 443, reachable from the public internet. See [URL rules](#url-rules) for what is refused. - **Events**: one or more from the [catalogue](#events). - **Format**: *Signed JSON* (the default) or *Slack message*. - **Signing secret**: generated by the server (`whsec_...`). It is shown in full exactly twice in its life: in the response that creates the webhook and in the response to **Rotate secret**. Everywhere else you see only the last four characters. You cannot choose a secret; a request that names one is refused. An org can have up to 10 webhooks. ## 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.won` / `deal.lost` fire when a deal moves into a stage whose kind is won or lost, whatever the stage is called in your pipeline. Saving a deal that is already won does not fire again. - `invoice.paid` fires when an invoice's status becomes Paid. - `ticket.comment_added` fires for **public** replies only. Internal notes are never sent anywhere. - An org can switch the Leads, Pipeline, Tickets, Invoices and Tasks modules off (see [Modules](../api/modules.md#records-that-mention-an-off-module)). Nothing is sent for an off module, the catalogue leaves its events out, and an endpoint keeps the events it already subscribed to; subscribing to a new event of an off module is a `400` under `events`. Deliveries already queued are still sent. A task event still carries the bare ids of the task's deal, ticket or lead. - `ping` is sent only by **Send test** and cannot be subscribed to. Events are collected per request and sent once the request has finished, so creating a record and assigning it in one save arrives as a single `*.created` whose `assigned_to` is already filled in, not a create followed by an update. A change that was rolled back sends nothing. Changes made by bulk database operations that skip Django's save signals (queryset `update()` and `bulk_create()`) do not produce events. ## Payload Every JSON delivery has the same envelope: ```json { "id": "4b0f7e0c-8f0e-4b8e-9d0f-2f4d6c1a9e11", "event": "deal.won", "created_at": "2026-09-26T10:15:02.114Z", "org_id": "a3f1c2d4-0000-4000-8000-000000000001", "data": { "id": "0d6f...", "name": "Acme renewal", "pipeline_id": "91be...", "stage": "CLOSED_WON", "amount": "12000.00", "currency": "USD", "probability": 100, "closed_on": "2026-09-26", "account_id": "5c1a...", "assigned_to": ["e2d9..."], "created_at": "2026-09-01T08:00:00Z", "updated_at": "2026-09-26T10:15:02Z" } } ``` `id` is the event's id. It is the same for every endpoint the event went to and for every redelivery of it, so use it to de-duplicate. Amounts are strings, to keep their decimals exact. `data` is a fixed, explicit set of fields per module, listed in `backend/webhooks/events.py`: ids, name or title, status or stage, amounts with currency, key dates, and the ids of the assigned profiles. Nothing else is sent. In particular an invoice's public link token, any secret and any password field never are, and an id from another org never appears. A `*.deleted` event carries the record as it was just before it was deleted. For a lead, contact or account merged away into another, that is from before the merge started: from django-crm 1.13.0 its `assigned_to` lists the profiles it was assigned to, even when the merge moved them to the record it kept. ## Headers and signature | Header | Value | | ----------------------- | --------------------------------------------------- | | `Content-Type` | `application/json` | | `User-Agent` | `BottleCRM-Webhooks` | | `X-BottleCRM-Event` | the event name, for example `lead.created` | | `X-BottleCRM-Delivery` | the delivery id (new on every redelivery) | | `X-BottleCRM-Signature` | `t=,v1=` | `v1` is the hex HMAC-SHA256, keyed with the webhook's secret, of the timestamp, a full stop, and the **raw request body** exactly as received: `"." + body`. Verify it before trusting a delivery, compare in constant time, and reject a timestamp more than five minutes old so a captured request cannot be replayed later. Python: ```python 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"]) # Django: verify(SECRET, request.headers["X-BottleCRM-Signature"], request.body) ``` Node: ```js import crypto from 'node:crypto'; export function verify(secret, header, rawBody, tolerance = 300) { const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2))); const timestamp = Number(parts.t); if (Math.abs(Date.now() / 1000 - timestamp) > tolerance) return false; const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.`) .update(rawBody) .digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(parts.v1 ?? ''); return a.length === b.length && crypto.timingSafeEqual(a, b); } // Express: app.post('/hook', express.raw({ type: 'application/json' }), (req, res) => { // if (!verify(SECRET, req.get('X-BottleCRM-Signature'), req.body)) return res.sendStatus(401); // const event = JSON.parse(req.body); // res.sendStatus(204); // }); ``` Verify against the raw bytes. Re-serialising parsed JSON changes spacing and key order, and the signature will not match. ## Delivery, retries and the log - A delivery is attempted as soon as the change is committed, with a 10 second timeout. - Any `2xx` is success. Redirects are **not** followed and count as a failure. - Anything else (a non-2xx status, a timeout, a refused connection) is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours: six attempts over about eight and a half hours, then the delivery is marked Failed. - `410 Gone` means "stop": the delivery fails at once and the webhook is turned off, with the reason shown beside it. Turning it back on clears the reason. - Delivery is at least once. A receiver that is slow to answer can see the same event twice, so de-duplicate on the envelope `id`. Order across events is not guaranteed. Each webhook's page shows its delivery log: event, status, attempts, the HTTP status the endpoint answered, and a short error (never the endpoint's own response body). **Redeliver** sends a settled delivery again as a new row carrying the same event id. **Send test** queues a `ping`. The log keeps 30 days; older rows are pruned nightly. Retries are driven by the Celery beat task `webhooks.tasks.dispatch_due_deliveries`, which runs every minute, so Celery beat must be running for retries (and for anything queued while the worker was down) to go out. See [Email and Celery](../self-hosting/email-and-celery.md). ## URL rules The server makes requests to a URL an admin typed, so the URL is checked when it is saved and again before every attempt (`backend/webhooks/ssrf.py`): - `https` only, on port 443. With `DEBUG=True`, `http` and port 80 are also accepted. - No username or password in the URL. - The host is resolved and **every** address it resolves to must be public. Loopback, private (10/8, 172.16/12, 192.168/16), link-local (including the cloud metadata address 169.254.169.254), carrier-grade NAT (100.64/10), multicast, reserved and unspecified addresses are refused, as are unique-local and site-local IPv6 and IPv6 forms that wrap one of those IPv4 addresses (IPv4-mapped, 6to4, NAT64). - The connection is made to the exact address that passed the check, with the original host name used for the `Host` header, TLS SNI and certificate verification. A DNS answer that changes between the check and the connection cannot redirect the request. A self-hosted receiver on your own private network therefore cannot be a webhook target. Expose it through a public HTTPS endpoint instead. ## Slack Create an [incoming webhook](https://api.slack.com/messaging/webhooks) in Slack, paste its `https://hooks.slack.com/services/...` URL, and choose **Slack message** as the format. Each event is posted as one line of text, for example `BottleCRM: Deal won: Acme renewal`. Names are escaped, so a lead called `` cannot ping your channel. Slack messages are not signed in a way Slack checks; the webhook URL is the secret, so treat it like a password and rotate it in Slack if it leaks. ## Zapier recipe 1. In Zapier, create a Zap whose trigger is **Webhooks by Zapier -> Catch Hook**. Copy the custom webhook URL it gives you. 2. In BottleCRM, add a webhook with that URL, format **Signed JSON**, and the events you want, for example `lead.created`. 3. Create a lead in BottleCRM (a `ping` from **Send test** has a different shape, so a real event gives Zapier a better sample). In Zapier, **Test trigger** and pick the request. 4. Map fields from `data` (`data__first_name`, `data__email`, and so on) into your action steps. Filter on `event` if one hook listens for several. Catch Hook cannot check the HMAC signature, so the unguessable hook URL is what protects it. If it leaks, replace the hook in Zapier and update the webhook in BottleCRM. ## n8n recipe 1. Add a **Webhook** node. HTTP method `POST`, a path of your choice, **Respond**: *Immediately*. Use the node's **Production URL** (the test URL only listens while the editor is open), and make sure your n8n is reachable over public HTTPS. 2. In BottleCRM, add a webhook with that URL, format **Signed JSON**, and your events. Activate the workflow, then use **Send test** or change a record. 3. To verify the signature, turn on the Webhook node's **Raw Body** option and add a **Code** node after it that runs the Node `verify` function above on the raw body and the `x-bottlecrm-signature` header, stopping the workflow when it returns false. On self-hosted n8n the Code node needs `NODE_FUNCTION_ALLOW_BUILTIN=crypto` to load `crypto`. 4. Route on `{{$json.body.event}}` with a **Switch** node when one webhook listens for several events. ## API All under `/api/webhooks/`, admin-only, JWT session only: | Method and path | Does | | ----------------------------------------- | ------------------------------------------------------- | | `GET /` | list, plus `event_catalogue` and `limit` | | `POST /` | create; the response carries `secret`, once | | `GET //` | one webhook (`secret_hint` only) | | `PATCH //` | change `url`, `description`, `events`, `format`, `is_active` | | `DELETE //` | delete it and its delivery log | | `POST //test/` | queue a `ping` | | `POST //rotate-secret/` | new secret; the response carries it, once | | `GET //deliveries/` | delivery log, `limit`/`offset` paginated | | `POST /deliveries//redeliver/` | send a settled delivery again | Another org's id answers `404`.