- Implement tests for closing tickets via menu and status picker, ensuring correct API interactions and error handling. - Create tests for calendar feed provider, verifying throttling behavior and success responses. - Add tests for deal board provider, covering reading and moving deals, including edge cases for card movement and filters. - Enhance macro tests to validate action parsing and application to tickets, including error handling. - Introduce session cache tests to ensure proper cleanup on sign-out and validate session-scoped providers. - Extend web form model tests to cover lead rotation functionality, ensuring correct serialization and defaults.
13 KiB
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 for what is refused. - Events: one or more from the catalogue.
- 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.lostfire 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.paidfires when an invoice's status becomes Paid.ticket.comment_addedfires for public replies only. Internal notes are never sent anywhere.pingis 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:
{
"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:
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:
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
2xxis 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 Gonemeans "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.
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):
httpsonly, on port 443. WithDEBUG=True,httpand 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
Hostheader, 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 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
- In Zapier, create a Zap whose trigger is Webhooks by Zapier -> Catch Hook. Copy the custom webhook URL it gives you.
- In BottleCRM, add a webhook with that URL, format Signed JSON, and the events you want, for
example
lead.created. - Create a lead in BottleCRM (a
pingfrom Send test has a different shape, so a real event gives Zapier a better sample). In Zapier, Test trigger and pick the request. - Map fields from
data(data__first_name,data__email, and so on) into your action steps. Filter oneventif 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
- 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. - 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.
- To verify the signature, turn on the Webhook node's Raw Body option and add a Code node
after it that runs the Node
verifyfunction above on the raw body and thex-bottlecrm-signatureheader, stopping the workflow when it returns false. On self-hosted n8n the Code node needsNODE_FUNCTION_ALLOW_BUILTIN=cryptoto loadcrypto. - 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.