Webhooks - knowtific/helppuff GitHub Wiki
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 type
Each 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": "[email protected]",
"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": "[email protected]",
"reason": "A quote for two rooms",
"status": "done",
"note": "Booked a measure for Tuesday",
"requestedAt": 1791234567000,
"closedAt": 1791241767000,
"closedBy": "[email protected]",
"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": "[email protected]",
"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":"[email protected]"}} |
-
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":"[email protected]"}} -
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":"[email protected]"}}' \
| openssl dgst -sha256 -hmac 'whsec_3f8a1c9e7b2d4f6a8c0e2b4d6f8a1c3e5b7d9f0a2c4e6b8d'
# SHA2-256(stdin)= bc0ab9609836f16c5d455173aaef1cf366ba702c3452f460388e19f94d214232
If 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.