Webhooks
Send what happens on the assistant to other tools as it happens: Zapier, Make, n8n, a CRM, a Slack bot, your own server, as signed JSON or as your own request (for email, see Email notifications). Add endpoints in the dashboard (Settings → Webhooks) or from the terminal:
helppuff webhooks add https://hooks.zapier.com/hooks/catch/123/abc --events lead.captured,callback.requested
helppuff webhooks test wh_1a2b3c # sends a test.ping and prints what came back
helppuff webhooks list --json # includes each signing secret
helppuff webhooks events # every event typeEach endpoint gets every event, or only the ones you pick. Up to 10 per site, https:// only. They are stored on your Worker (D1 webhooks), not in helppuff.json. The older leads.webhook in helppuff.json still works, for leads only; see Leads.
Events
| Event | When | data |
|---|---|---|
conversation.started | A visitor opened a chat | conversationId, page {url,title,referrer,utm}, locale, country, form (pre-chat form fields or null), firstMessage, data (what tools returned before the chat, or null) |
message.received | A visitor sent a message (including the pre-chat form's message) | conversationId, kind (text or action), text, action {id,value} for a button or form |
message.sent | The assistant replied, or a person on the team did (live chat) | conversationId, text (the reply as plain text), messages (the rich messages as the widget showed them), author {kind: "human", id, name} for a person's reply |
lead.captured | Contact details arrived: the form, typed in the chat, or found by the assistant | conversationId, source (form, chat, ai), name, email, phone, fields (custom form fields), message |
callback.requested | The visitor asked to be called back: the assistant requested it with the details it had, or the visitor sent the callback form. Sent with lead.captured; see Callbacks | callbackId, conversationId, name, email, phone, message, requestedAt |
callback.updated | A callback request was marked done or dismissed, reopened, or its note changed (dashboard or helppuff callbacks) | callback (id, conversationId, status, note, name, phone, email, reason, closedBy…), previousStatus |
lead.updated | A lead's status, details, notes or attributes changed | leadId, conversationId, changed, lead |
feedback.received | A visitor rated a reply | conversationId, messageId, rating (up, down, cleared) |
conversation.completed | A conversation went quiet: 5 minutes after its last message (a live chat: when it closes). Sent once (again if the visitor comes back later) | conversationId, startedAt, lastMessageAt, messageCount, page, country, summary, labels {intent, sentiment, leadQuality, outcome, topics}, tags (the site's own labels on it), attributes, assignedTo, unanswered (questions it could not answer), followUp, lead, data (what the site's tools returned or saved, by tool name), user (the signed-in visitor, verified, or null), transcript [{role, text, at}] (role: visitor, assistant or team) |
handover.requested | A visitor asked for a person (Live chat): the team was notified | conversationId, reason, page |
handover.missed | Nobody took a live chat within live.waitSeconds: the visitor was offered the callback form | conversationId |
handover.ended | A person handed the chat back to the assistant | conversationId, by, outcome |
conversation.assigned | A conversation was taken, given to someone, or unassigned | conversationId, assignedTo (an email, telegram:<id>, or null), name, by |
conversation.closed | A conversation was closed by the team, or a live chat went quiet for live.closeAfterMinutes | conversationId, by, reason (team or idle), wasLive |
conversation.summarized | Someone pressed Summarise in the dashboard | conversationId, summary, intent, sentiment, leadQuality, outcome, topics, unanswered, followUp |
conversation.ended | The visitor started a new chat, or the page called HelpPuff.reset(). Most visitors just leave, so prefer conversation.completed | conversationId |
job.created | A job was created: by the assistant, the quote questions, the API or the dashboard | job (id, number, title, stage, status, fields, contact, conversationId, source, value, currency, dueAt, assignedTo…) |
job.updated | A job's title, details, fields, value, due date or assignee changed (also when a visitor fills in what the assistant asked for) | job |
job.stage_changed | A job moved to another stage | job, from, to (stage names) |
job.won | A job moved to a won stage. Sent with job.stage_changed | job |
job.lost | A job moved to a lost stage. Sent with job.stage_changed | job, reason |
appointment.created | An appointment was booked: in the chat, the dashboard, the API, or found in the synced calendar (Bookings) | appointment |
appointment.updated | An appointment moved, or its status, title, place or notes changed | appointment, previous (when it moved) |
appointment.cancelled | An appointment was cancelled anywhere, the synced calendar included | appointment, reason |
appointment.reminder | An appointment starts within a day: send your own reminder. Once per appointment, not for one booked in the last hour | appointment |
budget.warning | Today's AI budget is 80% used; answers are kept shorter. Once a day | day, neuronsUsed, dailyBudget, resetsAt |
budget.exhausted | Today's AI budget is used up; visitors get your contact details and a callback form until resetsAt. Once a day | day, neuronsUsed, dailyBudget, resetsAt |
knowledge.crawl.finished | Learning the website finished | runId, status, trigger, pages {learned,failed}, passages |
knowledge.file.processed | An uploaded file was learned, or failed | fileId, name, status (indexed or error), passages, truncated, error |
knowledge.gap | A visitor asked something about the business that the assistant could not answer and the knowledge base does not cover (Questions to answer). Once per question; asked again, its count grows | id, question, count, firstAskedAt, lastAskedAt, conversationId, status |
test.ping | Send test in the dashboard, or helppuff webhooks test | message |
Which to pick. For a CRM, a spreadsheet or a team chat, three cover most needs: conversation.started (who, from which page, the pre-chat form), callback.requested (act now) and conversation.completed (the summary, labels and transcript, once the chat is over). The per-message events are high-volume and mainly for mirroring chats live.
A lead is a person keyed by email: lead.captured can arrive more than once for the same person (the form, then a phone number typed later). Upsert on email in your CRM.
Callbacks
A callback request is a task with an id: callback.requested when it is made, callback.updated when the team works it (see Leads). Match the two on callbackId = callback.id.
{
"type": "callback.requested",
"data": {
"callbackId": "cb_mfx2k9a1b2c3",
"conversationId": "s_…",
"name": "Ada",
"email": "ada@example.com",
"phone": "0412 345 678",
"message": "A quote for two rooms",
"requestedAt": "2026-10-05T01:02:03.000Z"
}
}{
"type": "callback.updated",
"data": {
"previousStatus": "open",
"callback": {
"id": "cb_mfx2k9a1b2c3",
"conversationId": "s_…",
"leadId": "lead_s_…",
"name": "Ada",
"phone": "0412 345 678",
"email": "ada@example.com",
"reason": "A quote for two rooms",
"status": "done",
"note": "Booked a measure for Tuesday",
"requestedAt": 1791234567000,
"closedAt": 1791241767000,
"closedBy": "owner@acme.com",
"pageUrl": "https://acme.com/flooring"
}
}
}- Asking again in the same chat while the request is still waiting sends
callback.requestedagain with the samecallbackIdand the latest details: update the task, do not create a second one. - A new request after one was closed has a new
callbackId. statusisopen,doneordismissed;previousStatussays what it was (open→doneis "called back";done→openis reopened). A note change alone sendscallback.updatedwith the same status.messageincallback.requestedis the same text asreasonincallback.updated: what the visitor wants.closedByis the dashboard user's email, orcliforhelppuff callbacks.
In Zapier, Make or n8n: create a task (or a CRM activity) on callback.requested, keyed by callbackId, and complete it on callback.updated when callback.status is done.
The request
POST /your/endpoint
Content-Type: application/json
User-Agent: HelpPuff-Webhooks/1
X-HelpPuff-Event: lead.captured
X-HelpPuff-Delivery: evt_4f0c…
X-HelpPuff-Timestamp: 1791234567
X-HelpPuff-Signature: sha256=9a1b…
{
"id": "evt_4f0c…",
"type": "lead.captured",
"createdAt": "2026-10-05T01:02:03.000Z",
"site": "acme",
"data": {
"conversationId": "s_…",
"source": "form",
"name": "Ada",
"email": "ada@example.com",
"phone": "0412 345 678",
"fields": { "suburb": "Lilydale" },
"message": "Do you work weekends?"
}
}Answer with any 2xx within 8 seconds. A delivery that fails in a way a retry could fix (a timeout, a network error, 429 or 5xx) is tried again in the background, after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, so an endpoint that is down for a while still gets everything. A 4xx is taken as the endpoint saying no, and is not retried. The dashboard keeps the last 50 deliveries of each endpoint (Recent deliveries: every try, with its status and timing) and shows "trying again later" while a retry is pending. Nothing about a delivery ever reaches the visitor: events are sent after their reply.
Your own request
Some services want their own format rather than HelpPuff's: Slack's chat.postMessage, a CRM's "create a lead", an SMS API. A webhook can send your own request instead of the signed JSON, filled in for each event. No connector per service: you write the request, so a service changing its API never waits for a HelpPuff release.
In Settings → Webhooks → Add webhook → Your own request: the method, the address, headers (a credential header is stored encrypted, and never shown again) and the body. Or from the terminal:
helppuff secret set SLACK_BOT_TOKEN
helppuff webhooks add https://slack.com/api/chat.postMessage \
--events callback.requested \
--header 'Content-Type: application/json; charset=utf-8' \
--header 'Authorization: Bearer ${SLACK_BOT_TOKEN}' \
--body '{"channel": "#office", "text": "Call back {{data.name}} on {{data.phone}}: {{data.message}}"}'In the body, the headers and the address's path and query:
| Value | What it is |
|---|---|
{{event.type}}, {{event.id}}, {{event.createdAt}} | The event |
{{data.name}}, {{data.job.title}}… | Anything in the event's data (the table above) |
"{{data}}" (a whole JSON value) | The object itself |
{{site.id}} | The site |
Values are escaped for where they go (JSON in a JSON body, URL-encoded in the address), so nothing a visitor types can break out of its place. An empty body sends the signed JSON, so a custom webhook can also just add a header (an Authorization for your own API). The X-HelpPuff-* headers come with every custom request too, the signature over the body actually sent. Retries, the delivery log and Send test work the same. helppuff webhooks set <id> --format helppuff goes back to the signed JSON.
Checking a delivery came from us
Anyone who finds your endpoint's address could send it a fake event. Every delivery is signed, so your server can check it really came from HelpPuff before acting on it. Each webhook has its own signing secret: copy it from that webhook's card in Settings → Webhooks (or helppuff webhooks list --json).
The signature is:
X-HelpPuff-Signature: sha256=hex(HMAC-SHA256(secret, timestamp + "." + body))In words: join the X-HelpPuff-Timestamp header, a dot, and the request body exactly as it arrived. Compute an HMAC-SHA256 of that with the signing secret as the key, write it as lowercase hex, and put sha256= in front. If the result equals the X-HelpPuff-Signature header, the delivery is real.
Then two more checks:
- Reject old deliveries. If the timestamp is more than five minutes from your clock, refuse it, so a copied delivery cannot be replayed later. Each try, retries included, is signed with a fresh timestamp.
- Skip repeats. A retry after a timeout can bring an event you already handled. Keep the
X-HelpPuff-DeliveryIDs you have processed for a day and ignore one you have seen.
A worked example
Use these values to test your own code: it should print the same signature.
| Value | |
|---|---|
| Signing secret | whsec_3f8a1c9e7b2d4f6a8c0e2b4d6f8a1c3e5b7d9f0a2c4e6b8d |
X-HelpPuff-Timestamp | 1791234567 |
| Body | {"id":"evt_4f0c2a","type":"lead.captured","createdAt":"2026-10-05T01:02:03.000Z","site":"acme","data":{"name":"Ada","email":"ada@example.com"}} |
The text to sign is the timestamp, a dot, then the body:
text1791234567.{"id":"evt_4f0c2a","type":"lead.captured","createdAt":"2026-10-05T01:02:03.000Z","site":"acme","data":{"name":"Ada","email":"ada@example.com"}}Its HMAC-SHA256 with the secret, in hex, with
sha256=in front:textsha256=bc0ab9609836f16c5d455173aaef1cf366ba702c3452f460388e19f94d214232
You can check this in a terminal:
printf '%s' '1791234567.{"id":"evt_4f0c2a","type":"lead.captured","createdAt":"2026-10-05T01:02:03.000Z","site":"acme","data":{"name":"Ada","email":"ada@example.com"}}' \
| openssl dgst -sha256 -hmac 'whsec_3f8a1c9e7b2d4f6a8c0e2b4d6f8a1c3e5b7d9f0a2c4e6b8d'
# SHA2-256(stdin)= bc0ab9609836f16c5d455173aaef1cf366ba702c3452f460388e19f94d214232If you got sha256=ef16ceb1… instead, you signed the same JSON pretty-printed (re-indented by a framework). That is the most common mistake: see below.
In your server
Each example reads the raw body, checks the signature and the timestamp, and only then parses the JSON. Keep the secret in an environment variable, here HELPPUFF_WEBHOOK_SECRET.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const secret = process.env.HELPPUFF_WEBHOOK_SECRET;
const app = express();
function verify(headers, rawBody) {
const timestamp = headers['x-helppuff-timestamp'] ?? '';
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = 'sha256=' + createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex');
const given = headers['x-helppuff-signature'] ?? '';
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
// express.raw keeps the body as the bytes that arrived (not express.json).
app.post('/helppuff', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.headers, req.body)) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8'));
// …handle event.type and event.data, skipping an X-HelpPuff-Delivery you have seen.
res.sendStatus(200);
});
app.listen(3000);import hashlib, hmac, json, os, time
from flask import Flask, request, abort
SECRET = os.environ["HELPPUFF_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
def verify(headers, raw_body: bytes) -> bool:
timestamp = headers.get("X-HelpPuff-Timestamp", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
expected = "sha256=" + hmac.new(SECRET, f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, headers.get("X-HelpPuff-Signature", ""))
@app.post("/helppuff")
def helppuff():
raw = request.get_data() # the bytes that arrived, before any parsing
if not verify(request.headers, raw):
abort(401)
event = json.loads(raw)
# …handle event["type"] and event["data"]
return "", 200<?php
$secret = getenv('HELPPUFF_WEBHOOK_SECRET');
$body = file_get_contents('php://input'); // the bytes that arrived
$timestamp = $_SERVER['HTTP_X_HELPPUFF_TIMESTAMP'] ?? '';
$given = $_SERVER['HTTP_X_HELPPUFF_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300 || !hash_equals($expected, $given)) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
// …handle $event['type'] and $event['data']
http_response_code(200);When the signature does not match
- The body was changed before you signed it. Sign the raw bytes. A framework that parses JSON and you then turn back into text (
JSON.stringify(req.body),json.dumps(request.json)) can change spacing, key order or escaping, and the signature no longer matches. Use the raw body:express.raw, Flask'srequest.get_data(), PHP'sphp://input. - The wrong secret. Each webhook has its own. Use the whole secret,
whsec_included. After New secret, deliveries are signed with the new one at once. - The wrong timestamp. Sign the
X-HelpPuff-Timestampheader's value as it is (seconds, not milliseconds), with a dot after it. - Comparing the wrong form. The header is
sha256=followed by lowercase hex, not base64. - Your clock is off. If every delivery fails only the five-minute check, set your server's time automatically (NTP).
Send test on the webhook's card sends a signed test.ping event, so you can try your check without waiting for a real chat.
New secret in the dashboard (or PATCH /admin/api/webhooks/:id with {"rotateSecret": true}) replaces it at once.
Privacy
Deliveries carry what visitors typed and their contact details, to endpoints you chose. That is why only https:// is accepted. HelpPuff's own logs record only the event type and HTTP status of a failed delivery, never the payload.