Custom Webhook — send your events anywhere
What it is
Section titled “What it is”The Custom Webhook sends your lead events to any HTTPS URL you control. Every time something relevant happens — a new lead arrives, a tag is assigned, a lead moves stage, a lead replies to one of your publications — ZeroChats makes a POST with a JSON payload to your endpoint.
This is what lets you plug ZeroChats into whatever you already use without waiting for a dedicated integration: n8n, Make, Zapier, an internal Slack channel or your own backend. If the tool can receive a webhook, it’s already integrated.
If what you want is to push your leads into a CRM, check Integrations first: GoHighLevel, HubSpot and Airtable are already solved and need no code. The webhook is for everything else.
How to set it up
Section titled “How to set it up”-
Go to Business setup. In the Integrations card, click the Integrations button.
-
Find the Custom Webhook tile and click Set up webhook.
-
Paste your endpoint URL. It must be a public HTTPS URL that answers with a 2xx status.
-
Pick the events you want to receive. If you select none, the webhook is saved but sends nothing.
-
Generate the signing secret (optional but recommended) with Generate secret. Copy it right there: for security it is never shown again once you save.
-
Leave the Send events toggle on. It’s on by default; turn it off whenever you want to pause deliveries without losing the setup.
-
Save.
-
Reopen the dialog and click Send test event. ZeroChats sends a sample
pingshaped exactly like a real event and shows you the HTTP status and your endpoint’s response time straight away.
Available events
Section titled “Available events”| Event | When it fires | What it adds to the payload |
|---|---|---|
lead.created | A lead contacts the business for the first time. | — |
lead.tagged | One or more tags are assigned to a lead. | addedTags |
lead.state_changed | A lead moves to a new stage in the funnel. | state, stateTag |
lead.media_replied | A lead replies to a business publication or comments on it. | media, source |
In lead.tagged, the addedTags field holds only the tags that this event added, not every tag the lead has. The full list is always in tags.
What you receive
Section titled “What you receive”Every delivery is a POST with the same envelope; only data changes between events.
{ "id": "9f1c8a4e-2b77-4d0a-9d1c-3f2b6a5c7e10", "event": "lead.created", "createdAt": "2026-08-11T09:12:33.114Z", "businessId": "b7a2f1d9-5c34-4a61-8e2f-0d9c1b4a7e33", "data": { "lead": { "id": "3d51f0c2-9a18-4b7e-8c56-1e2d3f4a5b6c", "name": "Ada Lovelace", "username": "ada", "platform": "INSTAGRAM", "externalId": "17841400000000000", "phone": null, "state": "IN_PROGRESS", "tags": ["interested"], "createdAt": "2026-08-11T09:12:30.000Z" } }}Envelope fields
Section titled “Envelope fields”| Field | What it is |
|---|---|
id | Unique identifier for this delivery. Same value as X-ZeroChats-Delivery. |
event | Event name: lead.created, lead.tagged, lead.state_changed or lead.media_replied. |
createdAt | When the event was generated, in ISO 8601 format (UTC). |
businessId | Your business identifier in ZeroChats. |
data | The event payload. Always includes lead. |
Lead fields
Section titled “Lead fields”| Field | What it is |
|---|---|
id | The lead’s identifier in ZeroChats. Stable — use it as your key so you don’t duplicate. |
name | The lead’s name. |
username | Their username on the source platform. |
platform | Source channel: INSTAGRAM, WHATSAPP, WASENDERAPI or MESSENGER. |
externalId | The lead’s identifier on their platform: the Instagram-scoped id, or the phone digits on the WhatsApp channels. null when unknown. |
email | The lead’s email, or null if they haven’t given it yet. |
phone | The lead’s phone number, or null if they haven’t given it yet. |
state | Stage in the lead funnel, by name: IN_PROGRESS, QUALIFYING, LEAD, BOOKED, CLIENT… |
tags | The lead’s full tag list at that moment. |
createdAt | When the lead was created, in ISO 8601 format (UTC). |
The lead.tagged event
Section titled “The lead.tagged event”Same envelope, with event set to lead.tagged and one extra field inside data:
{ "lead": { "id": "3d51f0c2-9a18-4b7e-8c56-1e2d3f4a5b6c", "name": "Ada Lovelace", "username": "ada", "platform": "INSTAGRAM", "externalId": "17841400000000000", "phone": null, "state": "LEAD", "tags": ["interested", "pricing"], "createdAt": "2026-08-11T09:12:30.000Z" }, "addedTags": ["pricing"]}The lead already arrives with the new tags merged into tags; addedTags tells you which ones just came in.
The lead.state_changed event
Section titled “The lead.state_changed event”It fires every time a lead moves to a new stage of the lead funnel. Same envelope, with event set to lead.state_changed and two extra fields inside data:
{ "lead": { "id": "3d51f0c2-9a18-4b7e-8c56-1e2d3f4a5b6c", "name": "Ada Lovelace", "username": "ada", "platform": "INSTAGRAM", "externalId": "17841400000000000", "phone": null, "state": "BOOKED", "tags": ["interested", "pricing"], "createdAt": "2026-08-11T09:12:30.000Z" }, "state": "Booked", "stateTag": "Status: Booked"}| Field | What it is |
|---|---|
state | The name of the stage just reached: Discovering, Qualifying, Proposal, Booked, Paid… |
stateTag | That same name with a Status: prefix, for destinations that can only store tags and have no stages of their own. |
stateTag exists for tools with no native notion of a lead stage — GoHighLevel, HubSpot or Airtable, for example — which receive the change flattened into that tag. Whoever opens the contact in the CRM reads Status: Booked, not an internal identifier. If your destination works the same way, apply it as-is and you’ll stay aligned with the other integrations.
This is the full correspondence, stage by stage:
| Stage in the app | state | stateTag |
|---|---|---|
| Discovering | Discovering | Status: Discovering |
| Qualifying | Qualifying | Status: Qualifying |
| Proposal | Proposal | Status: Proposal |
| Booked | Booked | Status: Booked |
| Paid | Paid | Status: Paid |
| Unqualified | Unqualified | Status: Unqualified |
| Lost | Lost | Status: Lost |
| Open | Open | Status: Open |
| Postponed | Postponed | Status: Postponed |
Both fields are always in English, whatever language you work in inside ZeroChats: a business working in Spanish, whose funnel is shown translated, still receives state as Paid and stateTag as Status: Paid. That’s deliberate. The tag ends up in a shared CRM where filters and automations are built on its text, so one stage has to produce exactly the same value across every account. If it followed the language, the same stage would split into three different strings and those filters would stop matching.
One detail you’ll notice in the JSON: the top-level state is the readable name (Booked), but the state inside lead still carries an internal identifier (BOOKED), because that object is the same one lead.created and lead.tagged send. We’ll align them later; until then, read the top-level one.
The lead.media_replied event
Section titled “The lead.media_replied event”It fires when a lead replies to one of the business’s own publications — a story, a reel, a feed post or an ad — either by answering it in a direct message or by commenting on it. Same envelope, with event set to lead.media_replied and two extra fields inside data:
{ "lead": { "id": "3d51f0c2-9a18-4b7e-8c56-1e2d3f4a5b6c", "name": "Ada Lovelace", "username": "ada", "platform": "INSTAGRAM", "externalId": "17841400000000000", "phone": null, "state": "IN_PROGRESS", "tags": ["interested"], "createdAt": "2026-08-11T09:12:30.000Z" }, "media": { "id": "17900000000000123", "type": "STORY", "permalink": "https://www.instagram.com/p/abc/", "previewUrl": "https://.../preview.jpg" }, "source": "direct_message"}| Field | What it is |
|---|---|
media.id | The publication’s identifier on Instagram. Always sent, always as text. |
media.type | Publication type: STORY, REELS, FEED or AD. |
media.permalink | Link to the publication, or null when Instagram no longer exposes it. |
media.previewUrl | Preview image, or null when we couldn’t fetch it. |
source | How the reply came in: direct_message or comment. |
The direction is always the same: the lead reacting to one of your publications, never the other way round. If it’s the business replying to a lead’s story, no event is generated. And it’s emitted once per interaction: if the same lead replies to three different publications, you get three events.
By direct message you get every reply to a publication. By comment you only get the comments the business processes: the ones matching a keyword or, if you have Reply to the comment and send the content privately even if the keyword doesn’t match enabled, all of them. A comment the business chose to ignore generates no event — the same rule that decides whether the lead is created at all, explained in Reels and Posts.
Headers
Section titled “Headers”| Header | Value |
|---|---|
Content-Type | application/json |
X-ZeroChats-Event | Event name, e.g. lead.created. |
X-ZeroChats-Delivery | Unique identifier for this delivery. |
X-ZeroChats-Timestamp | When the request was signed, in Unix seconds. |
X-ZeroChats-Signature | sha256=<hex>. Only present if you set a signing secret. |
Verifying the signature
Section titled “Verifying the signature”Your endpoint is a public URL: anyone who guesses it can send you made-up JSON. The signing secret is what lets you tell a real delivery from a forged one.
We sign with HMAC-SHA256 over the string `${timestamp}.${body}` — the value of X-ZeroChats-Timestamp, a dot, and the request body exactly as it arrived. We do not sign the body alone: including the timestamp is what lets you reject an old request someone captured and is replaying.
Your receiver has to do three things:
- Reject stale requests, outside a sensible tolerance (5 minutes works well).
- Compute the HMAC over the raw body, before any
JSON.parse. - Compare in constant time, not with
===.
const crypto = require('crypto');
const TOLERANCE_SECONDS = 300; // 5 minutes
/** * Checks that a request really came from ZeroChats. * * @param {string} rawBody The request body AS RECEIVED, unparsed. * @param {object} headers The request headers, lowercased. * @param {string} secret The signing secret you generated in the dialog. * @returns {boolean} true if the signature is valid and the request is recent. */function verifyZeroChatsWebhook(rawBody, headers, secret) { const timestamp = Number(headers['x-zerochats-timestamp']); const signature = String(headers['x-zerochats-signature'] || '');
// 1. Outside the tolerance window: drop it. if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) { return false; }
// 2. HMAC over "timestamp.body", using the raw body. const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
// 3. Constant-time comparison. const received = Buffer.from(signature); const computed = Buffer.from(expected);
return received.length === computed.length && crypto.timingSafeEqual(received, computed);}How to get the raw body
Section titled “How to get the raw body”Express parses the JSON before it reaches your route, so you have to capture the buffer with the verify hook:
app.use( express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); }, }));
app.post('/webhooks/zerochats', (req, res) => { const valid = verifyZeroChatsWebhook( req.rawBody, req.headers, process.env.ZEROCHATS_WEBHOOK_SECRET );
if (!valid) return res.status(401).end();
res.status(200).end(); // answer now handleEvent(req.body); // work afterwards});In a route handler, read the body as text with await request.text() and parse it yourself afterwards:
export async function POST(request: Request) { const rawBody = await request.text(); const headers = Object.fromEntries(request.headers);
const valid = verifyZeroChatsWebhook( rawBody, headers, process.env.ZEROCHATS_WEBHOOK_SECRET! );
if (!valid) { return new Response('Invalid signature', { status: 401 }); }
const event = JSON.parse(rawBody); handleEvent(event); // no await: answer first
return new Response(null, { status: 200 });}Delivery, retries and failures
Section titled “Delivery, retries and failures”- Success is any 2xx. Anything else counts as a failure.
- Answer fast and work afterwards. You get 10 seconds per attempt. A slow 200 costs the same as an error once that time runs out: queue the work and reply.
- Up to 3 attempts when the response is a
5xx, a408, a429, or there’s a network error. The wait between attempts is 1 s and then 5 s; if you answer a429, it goes up to 5 s and then 15 s, because a per-minute quota doesn’t clear in one second. - We honour your
Retry-Afterheader. If you answer a429(or a503) including it, we wait exactly as long as you ask instead of using our own backoff, capped at 30 s. It’s the most reliable way to get a rate-limited delivery through rather than dropped. - A
4xxis not retried. We read it as your endpoint deliberately rejecting the delivery. - Delivery is at-least-once. If your server processes the event but takes too long to answer, you’ll see the retry. If duplicates are a problem for you, store the
X-ZeroChats-Deliveryvalue and drop repeats.
A failing webhook never affects the conversation: the lead is still created, tagged and moved along the funnel even if your endpoint is down.
Best practices
Section titled “Best practices”- Always set the signing secret. Without it, anyone who finds your URL can inject fake leads.
- Return 200 before processing. Push the event onto a queue and hand control back immediately.
- Deduplicate on
X-ZeroChats-Deliveryif your flow does something you don’t want repeated (charging, sending an email, creating a row). - Use
lead.idas your key, not the email or the username: the email may arrive later and the username can change. - Pause instead of deleting. If you need to stop deliveries during a deploy, turn Send events off and back on; that way you keep the URL, the events and the secret.
Troubleshooting
Section titled “Troubleshooting”Nothing is arriving
Section titled “Nothing is arriving”Check, in this order:
- The Send events toggle is on. If the tile says Paused, that’s your answer.
- At least one event is selected. A webhook with no events is saved, but silent.
- The URL is public and HTTPS. A
localhostaddress, a private IP or a self-signed certificate will not work. For local development, expose your server through a tunnel (ngrok or similar) and use the URL it gives you. - The event actually happened.
lead.createdonly fires for new leads; existing ones won’t fire it again.
The signature doesn’t match
Section titled “The signature doesn’t match”It’s almost always one of these two:
- You’re computing the HMAC over the re-serialized JSON instead of the raw body.
- You’re signing the body alone, without prefixing
`${timestamp}.`.
Also check that you’re using the full secret, prefix included, and that no line break slipped in when you copied it.
My endpoint answers, but it fails
Section titled “My endpoint answers, but it fails”The Custom Webhook tile shows the status of the last delivery, so an endpoint that has started failing is visible at a glance. Click Send test event to reproduce it right away without waiting for a real lead: you’ll see the HTTP code and the response time, or the exact error if we couldn’t connect.