Lead webhooks
Get a signed HTTP POST the moment a conversation produces a contact. Works with Zapier, Make, n8n or your own endpoint.
One URL, not a per-provider integration. Zapier, Make, n8n and a hand-written endpoint all consume a webhook, so building an adapter for one of them would be building what they all already speak.
When it fires
Once per conversation, at the moment a contact becomes reachable.
It fires when
- A visitor gives an email address or a phone number, in ordinary conversation. There is no form.
- A signed-in visitor sends their first message, if you use verified identity.
It does not fire
- For a name on its own. A name is not something you can follow up with, so the later message that supplies an address is what triggers the send.
- A second time for the same conversation, however many details arrive afterwards.
What arrives
A JSON POST with three headers you care about.
- X-Whiz-Signature
- HMAC-SHA256, hex encoded. Absent if you have not generated a signing secret.
- X-Whiz-Timestamp
- Unix seconds. Part of the signed string, so a captured delivery cannot be replayed forever.
- X-Whiz-Event
- Currently always
lead.captured. Switch on it rather than assuming.
{
"event": "lead.captured",
"businessId": "8f54b286-ffe8-4a84-94d4-dba6e16f9ade",
"businessName": "Riverside Dental",
"conversationId": "6433b3d8-4b6e-43bb-ba28-33d2f7f3b33b",
"lead": {
"name": "Jo Kelly",
"email": "jo@example.com",
"phone": null
},
"occurredAt": "2026-09-25T04:41:03.068Z"
}Every field in lead can be null except that at least one of email and phone is always set, since that is what “reachable” means. conversationId can be null for a contact created outside a conversation.
Verifying the signature
Sign timestamp + "." + rawBody with your secret and compare. Two details matter more than the rest.
- Use the raw request body. Parse it to JSON afterwards if you like, but re-serialising an object and signing that will not match. Key order and whitespace are part of the bytes.
- Compare in constant time. Every language below has a function for it. An
==comparison leaks the correct signature a byte at a time through response timing, which is the whole reason to bother having one.
import { createHmac, timingSafeEqual } from "node:crypto";
// The RAW body, not a re-serialised object. JSON.stringify(req.body)
// reorders nothing today and may tomorrow; either way the bytes differ.
export function verify(rawBody, headers, secret) {
const timestamp = headers["x-whiz-timestamp"];
const signature = headers["x-whiz-signature"];
if (!timestamp || !signature) return false;
// Reject anything older than five minutes.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signature, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}import hashlib, hmac, time
def verify(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers.get("X-Whiz-Timestamp")
signature = headers.get("X-Whiz-Signature")
if not timestamp or not signature:
return False
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
# compare_digest, never ==: a plain comparison leaks the answer by timing.
return hmac.compare_digest(expected, signature)require "openssl"
def verify(raw_body, headers, secret)
timestamp = headers["X-Whiz-Timestamp"]
signature = headers["X-Whiz-Signature"]
return false unless timestamp && signature
return false if (Time.now.to_i - timestamp.to_i).abs > 300
# Note the argument order: key first, then data.
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
OpenSSL.secure_compare(expected, signature)
end<?php
function verify(string $rawBody, array $headers, string $secret): bool {
$timestamp = $headers['X-Whiz-Timestamp'] ?? null;
$signature = $headers['X-Whiz-Signature'] ?? null;
if (!$timestamp || !$signature) return false;
if (abs(time() - (int) $timestamp) > 300) return false;
// Note the argument order: data first, then the key.
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}Delivery, retries and secrets
What to expect from us, and what we expect from your endpoint.
- Two attempts, not five. A 4xx is treated as a permanent refusal and is not retried, because a 400 means the next identical request will also be a 400.
- Answer quickly, then do the work. Return a 2xx as soon as you have the payload and process it after. We are calling this from inside a visitor's message, so a slow endpoint is latency for your own customer.
- Your secret is minted once and never rotated on save. Changing the URL keeps the same secret, deliberately: rotating it would silently break a receiver that verifies signatures, with no error visible on our side.
- Public HTTPS endpoints only. We resolve the hostname and refuse private address space, at save time and again at delivery, so a URL that is repointed later is still caught. Redirects are not followed.
Set the URL and generate a secret in the dashboard under Settings → Notifications.
Free to try
Send your leads wherever you already work
Paste a URL, generate a secret, and every contact your chat agent captures arrives in your CRM the moment it happens. No card needed to start.
No credit card, no plugins, no developer.
Already set up? Log in
