A webhook delivers a record-change notification from campai to your server. Verify its signature, durably accept the notification, and process it separately. The response deadline is not a suitable place to synchronize an entire account or call a slow downstream service.
This guide covers the current CRM webhooks under More → Developers → Webhooks. Legacy webhook types have a separate implementation. Do not mix their payload contracts.
What you’ll have at the end
A running local receiver that verifies signatures and durably accepts a synthetic event before acknowledging it.
- Raw request bytes
- HMAC verification
- Durable inbox
- 204 response
Before you start
- Node.js 22+ or PHP 8.2+
- A private writable inbox directory
- The same test secret in sender and receiver
On this page
The reviewed implementation supports created, updated, and deleted events for each of four collections: contacts, debtors, creditors, and postings. For example, a contact update is contacts.updated. Contact options also depend on the organization's CRM mode.
There are no booking, event, or receipt webhook types in this set. Use the corresponding read APIs and polling for those workflows. A webhook subscription is also separate from a developer app's Record changes group.
In the Webhooks tab, create a webhook with the required event types and your HTTPS receiver URL. Open its edit dialog to obtain the Signature key and store it in your receiver's secret configuration. The signature key is not an API key. Use a separate receiver configuration for each subscription so a request header cannot choose an arbitrary organization or secret.
The JSON body contains event, data, and operationTime. data is the record supplied by the change processor. Do not assume every event is a patch or that a deleted record can still be retrieved.
campai computes HMAC-SHA512, using the webhook's signature key, over the JSON bytes it sends. It encodes the digest as Base64 in X-Signature. Verify the raw body before parsing or reserializing JSON. Changing whitespace or property order changes the signature.
Choose Node.js 22 or later, or PHP 8.2 or later. Each receiver is one complete file and uses only built-in functions. cURL sends HTTP requests; it cannot host a receiver. Use the three-language delivery check below to test either receiver.
Save the Node.js tab as webhook.mjs and run node webhook.mjs. For PHP, save the PHP tab as webhook.php and run php -d display_errors=0 -S 127.0.0.1:3000 webhook.php. Run only one receiver at a time. Both use the local-only key local-test-signature-key for the synthetic check below.
Example
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
import { mkdir, open, rename, rm } from 'node:fs/promises';
import { join } from 'node:path';
const events = new Set(['contacts', 'debtors', 'creditors', 'postings']
.flatMap(name => ['created', 'updated', 'deleted'].map(action => `${name}.${action}`)));
const failure = (status, message) => Object.assign(new Error(message), { status });
export function verifyWebhook(raw, signature, secret) {
if (typeof signature !== 'string' || !/^[A-Za-z0-9+/]{86}==$/.test(signature)) {
throw failure(401, 'Invalid signature.');
}
const expected = createHmac('sha512', secret).update(raw).digest();
const actual = Buffer.from(signature, 'base64');
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
throw failure(401, 'Invalid signature.');
}
let payload;
try { payload = JSON.parse(raw.toString('utf8')); } catch {
throw failure(400, 'Invalid JSON.');
}
if (!payload || !events.has(payload.event) || !Object.hasOwn(payload, 'data') ||
!Object.hasOwn(payload, 'operationTime')) {
throw failure(400, 'Unsupported payload.');
}
return payload;
}
export function createReceiver({ secret, inbox }) {
if (!secret || !inbox) throw new Error('Configure a signature secret and inbox directory.');
return createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/campai') {
res.writeHead(404).end();
return;
}
let temporary;
try {
const chunks = [];
let length = 0;
for await (const chunk of req) {
length += chunk.length;
if (length > 1024 * 1024) throw failure(413, 'Body too large.');
chunks.push(chunk);
}
const raw = Buffer.concat(chunks);
verifyWebhook(raw, req.headers['x-signature'], secret);
const digest = createHash('sha256').update(raw).digest('hex');
await mkdir(inbox, { recursive: true, mode: 0o700 });
temporary = join(inbox, `.${randomUUID()}.tmp`);
const file = await open(temporary, 'wx', 0o600);
try { await file.writeFile(raw); await file.sync(); } finally { await file.close(); }
await rename(temporary, join(inbox, `${digest}.json`));
temporary = undefined;
const directory = await open(inbox, 'r');
try { await directory.sync(); } finally { await directory.close(); }
res.writeHead(204).end();
} catch (error) {
if (temporary) await rm(temporary, { force: true }).catch(() => {});
res.writeHead(error.status ?? 503).end();
}
});
}
// Use this key only for the local synthetic delivery below.
export const server = createReceiver({
secret: 'local-test-signature-key',
inbox: './webhook-inbox', // Keep outside your public web root.
});
server.requestTimeout = 8_000;
server.listen(3000, '127.0.0.1', () => console.log('Receiver listening on port 3000.'));Use a persistent local filesystem on Linux. The receivers acknowledge only after writing, syncing, and atomically renaming the inbox file, then syncing its directory. Separate instance disks or ephemeral serverless storage do not provide the same inbox; use a durable queue or database there.
The one-megabyte limit belongs to this example. Match your proxy limit to your expected record sizes. Restrict and expire the inbox because verified payloads may contain personal data.
With the local receiver running, send the request below. All tabs use the same bytes and local test key. Expect HTTP 204 and one file in webhook-inbox. Sending it again leaves one item. Change a body byte without changing the signature to verify rejection with HTTP 401.
Example
curl --write-out '\nHTTP %{http_code}\n' --fail-with-body --silent --show-error --max-time 30 --globoff \
--request POST 'http://127.0.0.1:3000/campai' \
--header 'X-Signature: m7eWmUcKvj4riXLv+eWKXeEACsji5De0z+SJsG2bDbAhgpv6XZU8LlAuoCl8t88Hxmf9zpw0teGrMtKGz6VPbg==' \
--header 'Content-Type: application/json' \
--data-raw '{"event":"contacts.updated","data":{"_id":"synthetic"},"operationTime":"2026-11-04T10:00:00Z"}'Before accepting real deliveries, replace the local test key with your subscription’s Signature key from server secret configuration. Set an absolute private inbox path outside the public web root. Do not commit the real key. Terminate HTTPS at your reverse proxy and forward only /campai to the receiver. PHP’s built-in server is for this local check; use your normal PHP server for deployment with error display disabled.
The receivers return 204 after storing a valid body, 401 for an invalid signature, 400 for an unsupported signed payload, and 503 for storage failure. Preserve the raw request bytes and X-Signature at your proxy.
The SHA-256 filename collapses byte-identical notifications into the same stored item. It is not a universal event ID or an exactly-once guarantee. A delivery arriving after a worker completed can recreate the inbox file. Your worker therefore needs a durable processed-item ledger and idempotent business operations. See Handle missed or repeated deliveries.
For a contact update, an authorized worker can use Get contact to refresh the current record. Bind organization and mandate to trusted receiver configuration. Do not let unsigned headers or a payload field expand the key's intended scope.
Test a correctly signed synthetic body, a changed byte, a missing signature, signed invalid JSON, an unsupported event, repeated delivery, and an unwritable inbox. Confirm a storage failure produces a retryable response and no success acknowledgment.
The example's signature and local HTTP behavior are tested against synthetic fixtures using the same algorithm as the reviewed worker. That does not verify your public DNS, TLS, proxy, or live delivery. After deployment, use a designated synthetic record in your test organization and confirm the delivery in campai's webhook logs and your inbox before enabling business actions.
Check your result
- Valid signature
- One durable inbox item and a success response.
- Invalid signature
- Rejected before processing.
- Storage failure
- A non-success response, so acceptance is not falsely acknowledged.