Skip to content

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:

bash
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 ​

EventWhendata
conversation.startedA visitor opened a chatconversationId, 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.receivedA 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.sentThe 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.capturedContact details arrived: the form, typed in the chat, or found by the assistantconversationId, source (form, chat, ai), name, email, phone, fields (custom form fields), message
callback.requestedThe 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 CallbackscallbackId, conversationId, name, email, phone, message, requestedAt
callback.updatedA 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.updatedA lead's status, details, notes or attributes changedleadId, conversationId, changed, lead
feedback.receivedA visitor rated a replyconversationId, messageId, rating (up, down, cleared)
conversation.completedA 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.requestedA visitor asked for a person (Live chat): the team was notifiedconversationId, reason, page
handover.missedNobody took a live chat within live.waitSeconds: the visitor was offered the callback formconversationId
handover.endedA person handed the chat back to the assistantconversationId, by, outcome
conversation.assignedA conversation was taken, given to someone, or unassignedconversationId, assignedTo (an email, telegram:<id>, or null), name, by
conversation.closedA conversation was closed by the team, or a live chat went quiet for live.closeAfterMinutesconversationId, by, reason (team or idle), wasLive
conversation.summarizedSomeone pressed Summarise in the dashboardconversationId, summary, intent, sentiment, leadQuality, outcome, topics, unanswered, followUp
conversation.endedThe visitor started a new chat, or the page called HelpPuff.reset(). Most visitors just leave, so prefer conversation.completedconversationId
job.createdA job was created: by the assistant, the quote questions, the API or the dashboardjob (id, number, title, stage, status, fields, contact, conversationId, source, value, currency, dueAt, assignedTo…)
job.updatedA 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_changedA job moved to another stagejob, from, to (stage names)
job.wonA job moved to a won stage. Sent with job.stage_changedjob
job.lostA job moved to a lost stage. Sent with job.stage_changedjob, reason
appointment.createdAn appointment was booked: in the chat, the dashboard, the API, or found in the synced calendar (Bookings)appointment
appointment.updatedAn appointment moved, or its status, title, place or notes changedappointment, previous (when it moved)
appointment.cancelledAn appointment was cancelled anywhere, the synced calendar includedappointment, reason
appointment.reminderAn appointment starts within a day: send your own reminder. Once per appointment, not for one booked in the last hourappointment
budget.warningToday's AI budget is 80% used; answers are kept shorter. Once a dayday, neuronsUsed, dailyBudget, resetsAt
budget.exhaustedToday's AI budget is used up; visitors get your contact details and a callback form until resetsAt. Once a dayday, neuronsUsed, dailyBudget, resetsAt
knowledge.crawl.finishedLearning the website finishedrunId, status, trigger, pages {learned,failed}, passages
knowledge.file.processedAn uploaded file was learned, or failedfileId, name, status (indexed or error), passages, truncated, error
knowledge.gapA 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 growsid, question, count, firstAskedAt, lastAskedAt, conversationId, status
test.pingSend test in the dashboard, or helppuff webhooks testmessage

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.

json
{
  "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"
  }
}
json
{
  "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.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 ​

http
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:

bash
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:

ValueWhat 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 &lt;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:

text
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 secretwhsec_3f8a1c9e7b2d4f6a8c0e2b4d6f8a1c3e5b7d9f0a2c4e6b8d
X-HelpPuff-Timestamp1791234567
Body{"id":"evt_4f0c2a","type":"lead.captured","createdAt":"2026-10-05T01:02:03.000Z","site":"acme","data":{"name":"Ada","email":"ada@example.com"}}
  1. The text to sign is the timestamp, a dot, then the body:

    text
    1791234567.{"id":"evt_4f0c2a","type":"lead.captured","createdAt":"2026-10-05T01:02:03.000Z","site":"acme","data":{"name":"Ada","email":"ada@example.com"&#125;&#125;
  2. Its HMAC-SHA256 with the secret, in hex, with sha256= in front:

    text
    sha256=bc0ab9609836f16c5d455173aaef1cf366ba702c3452f460388e19f94d214232

You can check this in a terminal:

bash
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)= 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.

js
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);
python
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
<?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.

MIT licensed. Runs in your own Cloudflare account.