bottleneckcrm/docs/integrations/webhooks.md

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.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). 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:

{
  "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 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.

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 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.