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.requested again with the same callbackId and the latest details: update the task, do not create a second one.
  • A new request after one was closed has a new callbackId.
  • status is open, done or dismissed; previousStatus says what it was (open → done is "called back"; done → open is reopened). A note change alone sends callback.updated with the same status.
  • message in callback.requested is the same text as reason in callback.updated: what the visitor wants.
  • closedBy is the dashboard user's email, or cli for helppuff 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-Delivery IDs 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]"}}
  1. 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]"}}
    
  2. 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's request.get_data(), PHP's php://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-Timestamp header'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.