Skip to content

Custom Webhook — send your events anywhere

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.

  1. Go to Business setup. In the Integrations card, click the Integrations button.

  2. Find the Custom Webhook tile and click Set up webhook.

  3. Paste your endpoint URL. It must be a public HTTPS URL that answers with a 2xx status.

  4. Pick the events you want to receive. If you select none, the webhook is saved but sends nothing.

  5. Generate the signing secret (optional but recommended) with Generate secret. Copy it right there: for security it is never shown again once you save.

  6. Leave the Send events toggle on. It’s on by default; turn it off whenever you want to pause deliveries without losing the setup.

  7. Save.

  8. Reopen the dialog and click Send test event. ZeroChats sends a sample ping shaped exactly like a real event and shows you the HTTP status and your endpoint’s response time straight away.

EventWhen it firesWhat it adds to the payload
lead.createdA lead contacts the business for the first time.
lead.taggedOne or more tags are assigned to a lead.addedTags
lead.state_changedA lead moves to a new stage in the funnel.state, stateTag
lead.media_repliedA 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.

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",
"email": "[email protected]",
"phone": null,
"state": "IN_PROGRESS",
"tags": ["interested"],
"createdAt": "2026-08-11T09:12:30.000Z"
}
}
}
FieldWhat it is
idUnique identifier for this delivery. Same value as X-ZeroChats-Delivery.
eventEvent name: lead.created, lead.tagged, lead.state_changed or lead.media_replied.
createdAtWhen the event was generated, in ISO 8601 format (UTC).
businessIdYour business identifier in ZeroChats.
dataThe event payload. Always includes lead.
FieldWhat it is
idThe lead’s identifier in ZeroChats. Stable — use it as your key so you don’t duplicate.
nameThe lead’s name.
usernameTheir username on the source platform.
platformSource channel: INSTAGRAM, WHATSAPP, WASENDERAPI or MESSENGER.
externalIdThe lead’s identifier on their platform: the Instagram-scoped id, or the phone digits on the WhatsApp channels. null when unknown.
emailThe lead’s email, or null if they haven’t given it yet.
phoneThe lead’s phone number, or null if they haven’t given it yet.
stateStage in the lead funnel, by name: IN_PROGRESS, QUALIFYING, LEAD, BOOKED, CLIENT
tagsThe lead’s full tag list at that moment.
createdAtWhen the lead was created, in ISO 8601 format (UTC).

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",
"email": "[email protected]",
"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.

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",
"email": "[email protected]",
"phone": null,
"state": "BOOKED",
"tags": ["interested", "pricing"],
"createdAt": "2026-08-11T09:12:30.000Z"
},
"state": "Booked",
"stateTag": "Status: Booked"
}
FieldWhat it is
stateThe name of the stage just reached: Discovering, Qualifying, Proposal, Booked, Paid
stateTagThat 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 appstatestateTag
DiscoveringDiscoveringStatus: Discovering
QualifyingQualifyingStatus: Qualifying
ProposalProposalStatus: Proposal
BookedBookedStatus: Booked
PaidPaidStatus: Paid
UnqualifiedUnqualifiedStatus: Unqualified
LostLostStatus: Lost
OpenOpenStatus: Open
PostponedPostponedStatus: 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.

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",
"email": "[email protected]",
"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"
}
FieldWhat it is
media.idThe publication’s identifier on Instagram. Always sent, always as text.
media.typePublication type: STORY, REELS, FEED or AD.
media.permalinkLink to the publication, or null when Instagram no longer exposes it.
media.previewUrlPreview image, or null when we couldn’t fetch it.
sourceHow 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.

HeaderValue
Content-Typeapplication/json
X-ZeroChats-EventEvent name, e.g. lead.created.
X-ZeroChats-DeliveryUnique identifier for this delivery.
X-ZeroChats-TimestampWhen the request was signed, in Unix seconds.
X-ZeroChats-Signaturesha256=<hex>. Only present if you set a signing secret.

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:

  1. Reject stale requests, outside a sensible tolerance (5 minutes works well).
  2. Compute the HMAC over the raw body, before any JSON.parse.
  3. 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);
}

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
});
  • 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, a 408, a 429, or there’s a network error. The wait between attempts is 1 s and then 5 s; if you answer a 429, 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-After header. If you answer a 429 (or a 503) 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 4xx is 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-Delivery value 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.

  • 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-Delivery if your flow does something you don’t want repeated (charging, sending an email, creating a row).
  • Use lead.id as 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.

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 localhost address, 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.created only fires for new leads; existing ones won’t fire it again.

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.

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.