260 lines
13 KiB
Markdown
260 lines
13 KiB
Markdown
# 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=<unix seconds>,v1=<hex>` |
|
|
|
|
`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: `"<t>." + 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 `<!channel>` 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 /<id>/` | one webhook (`secret_hint` only) |
|
|
| `PATCH /<id>/` | change `url`, `description`, `events`, `format`, `is_active` |
|
|
| `DELETE /<id>/` | delete it and its delivery log |
|
|
| `POST /<id>/test/` | queue a `ping` |
|
|
| `POST /<id>/rotate-secret/` | new secret; the response carries it, once |
|
|
| `GET /<id>/deliveries/` | delivery log, `limit`/`offset` paginated |
|
|
| `POST /deliveries/<delivery id>/redeliver/` | send a settled delivery again |
|
|
|
|
Another org's id answers `404`.
|