Developers

Webhooks guide

XPI CRM sends a signed HTTPS POST to your address whenever records change, so your tools stay in sync without polling. Version 1.

Setting up a webhook

An admin adds webhooks in Setup → API & Webhooks: a name, an https:// address, the events (record created, updated, deleted) for all objects or chosen ones, and on/off. A signing secret is shown once when the webhook is created; Rotate secret makes a new one (the old one stops working straight away). Addresses that point at private, local or internal networks are refused, both when saving and again before every send. Redirects are not followed.

To try it, point a webhook at a test receiver such as a request-inspection site, then click Send test event; the result appears straight away under Deliveries.

Events

  • <object>.created, <object>.updated, <object>.deleted, e.g. clients.updated, billing.created.
  • Changes from every source are sent: the app, CSV import, the API, automations (e.g. playbooks started by a rule) and the health score. source says which: app, import, api, automation or health score.
  • Saves that only recalculate the health score number are not sent. A change to the Health field itself is.
  • Bulk events: when one action changes more than 20 records of one object — one import batch, one API request, or one step that changes many records — you get one <object>.<event>.bulk event with count and up to 500 ids. Fetch the records from the API if you need them.
  • webhook.test is sent only by the Send test event button.

Payload

data is the record in exactly the REST API format: field keys, links and people as { id, name }, calculated values included, plus createdAt and updatedAt. For deleted events it is the record as it was last saved.

POST https://hooks.example.com/xpi
Content-Type: application/json
X-XPI-Event: clients.updated
X-XPI-Delivery: 18342
X-XPI-Timestamp: 2026-10-08T07:12:03.418Z
X-XPI-Signature: sha256=5d1c…

{
  "id": "6f1c0b9e-3c2a-4d1e-9a51-0b2b1f7c9e10",
  "event": "clients.updated",
  "object": "clients",
  "source": "api",
  "occurredAt": "2026-10-08T07:12:01.002Z",
  "sentAt": "2026-10-08T07:12:03.418Z",
  "data": {
    "id": "0b7c…",
    "name": "Globex Inc.",
    "health": "At Risk",
    "owner": { "id": "5f1e…", "name": "Alex Morgan" },
    "daysToRenewal": 68,
    "createdAt": "2026-03-02T09:14:00Z",
    "updatedAt": "2026-10-08T07:12:01Z"
  }
}
{
  "id": "a41e…",
  "event": "contacts.created.bulk",
  "object": "contacts",
  "source": "import",
  "occurredAt": "2026-10-08T07:20:00.000Z",
  "sentAt": "2026-10-08T07:20:41.120Z",
  "data": { "count": 480, "ids": ["2c4e…", "9a1d…", "…"] }
}

Delivery, order and duplicates

  • Reply with any 2xx status within 10 seconds. Anything else, a redirect or no answer counts as a failure.
  • Order isn't guaranteed. Events are usually sent within a minute, but retries and parallel sends can arrive out of order. Use occurredAt (when the change happened) to put them in order, and ignore an older change that arrives after a newer one.
  • "updated" events carry the record as it is when the event is first sent, not a list of changed fields. If a record changes twice quickly, both events may show the latest values.
  • Duplicates can happen (for example if your server saved the event but the reply was lost). Use X-XPI-Delivery (one delivery to your webhook) or the event id (the same for every webhook receiving that change) to ignore repeats.

Retries and switching off

  • A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. After the 6th failed attempt it is marked Failed. Every retry has a new sentAt and signature; data stays the same.
  • A webhook is switched off automatically only after at least 20 failed attempts in a row and no successful delivery for 24 hours, so a short outage doesn't disable it. Setup shows why it was switched off.
  • When a webhook is switched off (by an admin or automatically), its waiting deliveries are marked Skipped and are not sent later. Changes made while it is off are not queued.
  • Setup shows the last 50 deliveries for each webhook: time, event, status, response code, attempts and error. Deliveries are kept for 30 days.

Verifying the signature

X-XPI-Signature is sha256= followed by the hex HMAC-SHA256 of the raw request body, using your signing secret. Check it against the exact bytes you received (before parsing JSON), compare in constant time, and reject events whose sentAt (equal to X-XPI-Timestamp) is more than 5 minutes old; because sentAt is inside the signed body, an old event can't be replayed with a new time. These examples are run against real signatures in our test suite.

JavaScript (Node.js)

const crypto = require("crypto");

// rawBody: the request body exactly as received (a string or Buffer), before JSON parsing.
function verifyXpiWebhook(rawBody, signatureHeader, secret, maxAgeSeconds = 300) {
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(String(signatureHeader || ""));
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
  const sentAt = Date.parse(JSON.parse(rawBody.toString()).sentAt);
  return Math.abs(Date.now() - sentAt) <= maxAgeSeconds * 1000;
}

// Express: app.post("/xpi", express.raw({ type: "application/json" }), (req, res) => {
//   if (!verifyXpiWebhook(req.body, req.get("X-XPI-Signature"), process.env.XPI_WEBHOOK_SECRET)) return res.sendStatus(401);
//   const event = JSON.parse(req.body); // ignore it if you've already handled event.id
//   res.sendStatus(200);
// });

Python

import hashlib, hmac, json
from datetime import datetime, timezone

# raw_body: the request body exactly as received (bytes), before JSON parsing.
def verify_xpi_webhook(raw_body: bytes, signature_header: str, secret: str, max_age_seconds: int = 300) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, signature_header or ""):
        return False
    sent_at = datetime.fromisoformat(json.loads(raw_body)["sentAt"].replace("Z", "+00:00"))
    return abs((datetime.now(timezone.utc) - sent_at).total_seconds()) <= max_age_seconds

# Flask: ok = verify_xpi_webhook(request.get_data(), request.headers.get("X-XPI-Signature"), os.environ["XPI_WEBHOOK_SECRET"])

PHP

<?php
// $rawBody: the request body exactly as received, e.g. file_get_contents('php://input').
function verify_xpi_webhook(string $rawBody, ?string $signatureHeader, string $secret, int $maxAgeSeconds = 300): bool {
    $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
    if (!hash_equals($expected, (string) $signatureHeader)) return false;
    $sentAt = strtotime(json_decode($rawBody, true)['sentAt']);
    return $sentAt !== false && abs(time() - $sentAt) <= $maxAgeSeconds;
}

// $ok = verify_xpi_webhook(file_get_contents('php://input'), $_SERVER['HTTP_X_XPI_SIGNATURE'] ?? null, getenv('XPI_WEBHOOK_SECRET'));

See also the API reference and the changelog.