Webhooks
Get an event posted to your URL the moment it happens, instead of polling a trigger.
A webhook is a subscription to one event. When the event happens, ContactZapp sends it to your URL. It carries the same item the trigger for that event lists, so a workflow can use either.
Subscribe
Subscribes a URL to one event. Zapier, Make and n8n call this when an automation is switched on. The URL must use HTTPS and reach a public address. The answer is 201 with the subscription and its signing secret, which is shown only here. Needs the webhooks:manage and contacts:read scopes. A user can have up to 25 subscriptions.
The event is one of these:
| Event | It happens when |
|---|---|
contact.created | A contact was added. |
contact.updated | A contact was changed. |
contacts.merged | Contacts were merged into one. |
match.needs_review | A contact is flagged as a possible duplicate and waits in Review duplicates. |
account.needs_attention | A linked account needs the user to look at it. |
curl -s -X POST https://app.contactz.app/api/v1/webhooks \
-H "Authorization: Bearer $CZ_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/contactzapp", "event": "contact.created"}'{
"id": "…",
"webhook": { "id": "…", "url": "https://example.com/hooks/contactzapp", "event": "contact.created", "state": "active" },
"secret": "cz_whsec_…"
}What a delivery looks like
Each event is sent to the URL as a POST with a JSON body. The body is one item, exactly as the polling trigger for the same event lists it. Every delivery has these headers:
| Header | Value |
|---|---|
ContactZapp-Signature | t=<unix seconds>,v1=<signature>, where the signature is the hex HMAC-SHA256 of the timestamp, a full stop and the raw body, keyed with the secret. Check it, and refuse a timestamp more than five minutes from your clock. |
ContactZapp-Delivery | The delivery's id. A retry sends the same id. |
ContactZapp-Event | The event, for example contact.created. |
Check the signature
Anyone can post to your URL, so check each delivery before you act on it. Compute the HMAC-SHA256 of the timestamp, a full stop and the raw body, keyed with the secret, and compare it with v1. Use the body exactly as it arrived: parse it only after the check. Refuse a timestamp more than 5 minutes from your clock.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
// header: the ContactZapp-Signature value. rawBody: the body as a string, before JSON.parse.
export function isFromContactZapp(header, rawBody, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(t + "." + rawBody).digest("hex");
const given = Buffer.from(parts.v1, "hex");
const wanted = Buffer.from(expected, "hex");
return given.length === wanted.length && timingSafeEqual(given, wanted);
}Answer, retries and duplicates
Answer with any 2xx status. An answer of 410 removes the subscription. Any other answer, or none within 10 seconds, is retried with backoff for about a day; after the last attempt the subscription is turned off. Each event is sent at least once: deduplicate by the item's id.
- Answer fast. Reply with a 2xx first, and do the work after. A slow answer counts as a failure.
- Deduplicate by
id. A retry sends the sameContactZapp-Delivery, and the item’sidis the same in every delivery of that event. Keep the ids you have handled. - A turned-off subscription shows as
disabledinGET /api/v1/webhooks, and gets no more deliveries.
Unsubscribe
- Send
DELETE /api/v1/webhooks/{id}with theidfrom the subscribe answer. The key needs thewebhooks:managescope. The answer is 204. GET /api/v1/webhookslists your subscriptions with their state.
Tools that switch on and off, such as n8n and Make, subscribe when the workflow is switched on and unsubscribe when it is switched off. See the n8n and Make recipes.