Repository navigation
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.
| 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 |
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 |
handover.requested |
A visitor asked for a person ([[Live chat | Live-Chat]]): the team was notified |
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 | Jobs]] was created: by the assistant, the quote questions, the API or the dashboard |
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 | Knowledge-Base#5a-questions-to-answer]]). Once per question; asked again, its count grows |
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.
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.
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.
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.
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.
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:
1791234567.{"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:sha256=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.
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);-
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.
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.
Start
Tutorials
- All tutorials
- Order tracking
- Send chats to your CRM
- Nearest store
- Verified account changes
- Logged-in customer support
- Product and stock
- Hot leads to Slack
- Knowledge base or tool?
- Testing and monitoring
- Why the AI never books
Configure
- Configuration
- Configuration reference
- CLI reference
- Knowledge base
- Prompt and instructions
- Tools: your APIs in the chat
- Agent files: import and export
- Signed-in visitors
- The widget
- The playground
- Leads and callbacks
- Jobs
- Photos and files
- Bookings and the calendar
- Integrations
- Live chat
- Telegram
- Webhooks
- Email notifications
API
API reference
- Overview
- Account
- Chat
- Conversations
- Live chat
- Labels and notes
- Leads
- Callbacks
- Jobs
- Appointments
- Integrations
- Knowledge
- Prompt
- Tools
- Settings
- Webhooks
- Analytics
- Team
- API keys
- Audit
Providers
- Overview
- Models and providers
- Custom model
- Custom knowledge base
- Workers AI (default)
- AI models: speed, quality, cost
- Cloudflare AI Search
- OpenAI
- Gemini
- Anthropic Claude
- Retell
- Your own API
Operate
- Deployment
- Turn on Turnstile
- Upgrading
- Costs and limits
- Cloudflare Free plan limits
- Security
- Troubleshooting
Build