Chat Trigger
A Chat Trigger is an ingress node that exposes a visitor-facing chat UI wired into a pipeline. Visitors type, the message goes to the trigger's AI Node, and the assistant response streams back token-by-token over Server-Sent Events. Five ways to ship it: a hosted URL you share as a link, a one-line <script> embed for any site, an npm package for framework apps, the API on its own if you would rather build the interface, and an API key to create triggers from your own backend.
Overview
A Chat Trigger sits at the start of a pipeline, just like a Webhook or Scheduled Workflow. The difference is the front-end: instead of a third-party system POSTing JSON to a URL, a human types into a chat widget and the widget streams the response back.
{{payload.message}}, what the visitor typed, and its Memory is on, so the chat remembers what was said earlier in the same conversation (see Memory). Pick the AI Node's provider and credential, then activate both.A Chat Trigger answers through one AI Node — the one connected to it. Only that node receives what the visitor typed, and its answer is what streams back. With one connected, the canvas will not add a second: the + on the trigger and a dragged link both refuse and say why, and the API refuses to save a second connection with a 400. To do more with each message, connect it after the AI Node — what hangs off the AI Node runs with its output, as in any flow.
The same trigger can be surfaced in several places at once — a shared URL for quick testing, a <script> embed on a marketing site, a React component or Web Component inside a product app, or your own interface talking straight to the ingress — all against the same pipeline and the same configuration. The first three run the same packaged widget, so what the widget cannot do, none of them can; the fourth is the one where you control the request.
How it works
Ingress webhook
Every Chat Trigger gets a unique short chatId and two public webhooks:
GET /api/chat-triggers/public/:chatId— returns visible config (title, theme, initial messages) for the widget to renderPOST /api/chat-triggers/in/:chatId/message— the SSE ingress for one conversation turn
When a visitor sends a message, the widget POSTs to the message webhook with { message, sessionId, fileRefs? }. HostWebhook validates CORS + auth, resolves file attachments, and hands the payload to the trigger's AI Node.
Streaming response
The response is streamed back as Server-Sent Events. Event types:
event: session data: { "sessionId": "hws_kQ8…", "hwsSessionId": "hws_kQ8…" }
event: tool_start data: { "name": "search_db", "input": { ... } }
event: tool_end data: { "name": "search_db", "output": "...", "success": true }
event: token data: { "text": "Hello" }
event: token data: { "text": ", how" }
event: token data: { "text": " can I help?" }
event: done data: { "response": "Hello, how can I help?", "conversationId": "..." }
event: error data: { "message": "..." }Token streaming is true streaming when the AI node uses a streaming provider — Anthropic and OpenAI both fire tokens the moment the LLM emits them, not at the end of the run. Tool events fire in real time so the widget can show a “running” indicator while the LLM reasons through a tool call.
The session event carries the same id under two keys — sessionId and hwsSessionId. They are always equal. hwsSessionId is the current name; sessionId is kept so widget bundles already pinned in customer package.json files keep finding the field they read. The same pair holds on the request side: the message body accepts hwsSessionId or sessionId, with the new name winning when both arrive.
Session lifecycle
Each conversation is keyed by a sessionId. Where that id comes from depends on who is signing, and the two cases are not symmetric:
- Public triggers, and signed triggers your own backend signs — the client picks the id. The widget generates one per visitor and persists it in
localStorageso returning visitors continue the same conversation, and your signing webhook can hand back a different one to tie the chat to a logged-in user. - The hosted page (
/chat/:chatId) — the server picks the id, not the browser./hosted-authis an anonymous endpoint, so any id a caller could name would be an id it could name for someone else. See Hosted URL auto-signs.
Either way, the server uses the resolved id to group messages into a single conversation record and to scope HMAC signatures (see Authentication).
Configuration
Everything here is set from the trigger's page in the dashboard — the Where column says which tab and section, so you are never hunting for a field you read about.
| Field | Where | Type | Default | Description |
|---|---|---|---|---|
| name | Header | string | — | Internal label shown on the canvas + in the dashboard list |
| title | Config | string | Chat with us | Header text the visitor sees at the top of the widget |
| subtitle | Config | string | — | Smaller line under the title (often a tagline) |
| placeholder | Config | string | Type your message… | Placeholder shown in the textarea before the visitor types |
| showPoweredBy | Config › Appearance | boolean | true | The small “Powered by HostWebhook” link at the bottom of the chat. Paid plans (Starter and up) can turn it off; on Free it always shows and turning it off is refused with a 403. |
| initialMessages | Config › Initial messages | assistant[] | [] | Greeting messages rendered before the visitor types anything. Pure UI — they are NOT sent to the AI node. |
| theme.primaryColor | Config › Theme | hex | #7c3aed | Accent color — FAB background, user bubble, focus ring, cursor |
| theme.mode | Config › Theme | enum | dark | dark, light, or auto (follows system) |
| theme.avatarUrl | Config › Theme | url | — | Avatar image in the widget header + OpenGraph preview |
| systemPromptOverride | Config › System prompt override | string | — | Optional persona override. Lets one AI node power many triggers with different personalities. |
| authMode | Advanced › Authentication | enum | public | public (anyone can chat) or signed (HMAC-signed requests only) — see Authentication |
| sessionIdStrategy | Advanced › Session ID strategy | enum | auto | auto, header, or query — see Session Strategies |
| allowedOrigins | Advanced › Allowed origins | string[] | [] | Which sites may use the chat — a restriction only in signed mode. There the list is mandatory (saving it empty is a 400, and /hosted-auth refuses to mint) and it decides which pages the hosted auto-sign hands a signature to; that includes the shareable /chat/… page, which only works if this dashboard's own origin is on it. On a public trigger it has no effect: messages and attachments are accepted whatever origin sends them — see Public mode. Matched exactly — scheme, host and port, no paths and no wildcards. |
| rateLimitMessagesPerMinute | Advanced › Rate limit | int | 60 | Sliding window enforced in Redis, 1–600. Every message is an LLM call, so this is the first line against burst cost. |
| rateLimitScope | Advanced › Rate limit | enum | session | session or ip — what counts as one visitor for the throttle above |
| dailyMessageCapPerSession | Advanced › Usage caps | int | null | null | Hard stop per visitor per day. Empty means no cap. Unlike the rate limit, this one persists — it is counted in chattriggercounters, not in a rolling window. |
| monthlyMessageCapPerTrigger | Advanced › Usage caps | int | null | null | Hard stop for the whole trigger per month — the ceiling on what this chat can cost you |
| dailyTokenCapPerSession | Advanced › Usage caps | int | null | null | Same idea counted in tokens, which tracks cost better than message count when answers are long |
| monthlyTokenCapPerTrigger | Advanced › Usage caps | int | null | null | Token ceiling for the whole trigger per month |
| dailyCostMicroUsdCapPerSession | Advanced › Usage caps | int | null | null | LLM spend ceiling per visitor per day, in whole micro-dollars (1e-6 USD). The dashboard converts from dollars for you. See Spend caps for what they do not cover. |
| monthlyCostMicroUsdCapPerTrigger | Advanced › Usage caps | int | null | null | Same unit, for the whole trigger per month — the one that maps to the invoice |
| capAlertsEnabled | Advanced › Usage caps | boolean | false | Emails you when one of the three monthly caps crosses 75%, 90% and 100%. The three per-visitor daily caps are enforced just the same but never email — see Cap alerts. |
| capAlertEmail | Advanced › Usage caps | string | — | Where those emails go. Empty with the switch on sends nothing — it is a half-filled form, not an error. Each email carries what is left, when the counter resets, the current burn rate and today's usage across every visitor. |
| capMessageEnabled | Advanced › Usage caps | boolean | false | Show your own wording to a visitor who hits a cap, instead of the built-in one. Off means the generic text — see What the visitor reads. |
| capMessage | Advanced › Usage caps | string | — | That wording. Plain text, max 500 characters, sanitised on save. Empty with the switch on falls back to the generic — it is a half-filled form, not an error. |
| voiceProvider | Advanced › Voice mode | string | — | Set it and the widget grows a mic button. The audio loop runs visitor↔provider; HostWebhook is not in the middle of it. |
| voiceAgentId | Advanced › Voice mode | string | — | Which agent on that provider answers the call |
| attachmentsEnabled | Advanced › Attachments | boolean | false | When true, the widget shows a paperclip and accepts file uploads from visitors |
| isActive | Header › Pause | boolean | true | Disables public ingress without deleting the trigger |
Integration Methods
Pick the method that fits where you're shipping. Every method talks to the same backend and honors the same trigger config — you can use several at once for the same chatId.
1. Hosted URL (shared link)
Every trigger has a ready-to-use hosted page at https://www.hostwebhook.com/chat/:chatId. Copy the link from the Share tab and paste it anywhere — Slack, email, a QR code, a Notion doc. Zero code, zero embed, zero backend work.
public trigger works here out of the box. A signed one carries no secret in the page either — our server auto-signs on its behalf with the trigger's own secret — but that path is not configuration-free. The page is served from https://www.hostwebhook.com and calls the API cross-origin, so the auto-sign route sees that origin and judges it against allowedOrigins like any other. Two things follow. In signed mode the list is mandatory, so an empty one is a 403 before anything else; and a list naming only your own site is a 403 too, because the shared link is not calling from your site. Put https://www.hostwebhook.com on the list alongside it. The preview inside the dashboard panel is the same page in an iframe calling from that same origin, but it is not the same case: its X-Preview-Token exempts it from the origin test, so a preview that works proves nothing about the shared link. What the token does not exempt is the mandatory list — with the list empty the preview is refused as well, on purpose. See Hosted URL auto-signs.Use it for: lightweight deployment, quick demos, internal tools, embedding inside an iframe where full control over the host page isn't worth the integration effort.
2. Script Embed (widget.js)
Drop a single <script> tag into any HTML page. The loader creates a floating chat button (FAB) in the corner and a panel that opens on click. The UI lives in a Shadow DOM — host CSS can't leak in and the widget's styles don't leak out.
<script src="https://www.hostwebhook.com/widget.js"
data-chat-id="abc123"
data-position="right"
data-primary="#7c3aed"
data-label="Chat with us"
defer></script>For signed triggers, add data-auth-endpoint:
<script src="https://www.hostwebhook.com/widget.js"
data-chat-id="abc123"
data-auth-endpoint="/api/chat-auth"
data-primary="#7c3aed"
defer></script>| Attribute | Required | Description |
|---|---|---|
| data-chat-id | yes | The short chat ID from the dashboard |
| data-auth-endpoint | signed only | URL on your site the widget POSTs to for a signature. See Backend signing webhook. |
| data-position | no | right (default) or left — where the FAB docks |
| data-primary | no | Hex accent color. Overrides the trigger's theme. |
| data-label | no | FAB tooltip text |
| data-api-base | no | Override the API host (self-hosted / staging). Default https://api.hostwebhook.com. |
Once the loader runs, window.HwChat gives you programmatic control:
window.HwChat.open(); // expand the panel
window.HwChat.close(); // collapse it
window.HwChat.toggle(); // flip state
// Swap in a custom auth provider at runtime (alt. to data-auth-endpoint):
window.HwChat.setAuthProvider(async ({ sessionId }) => {
const r = await fetch('/my/sig', { method: 'POST', body: JSON.stringify({ sessionId }) });
return r.json(); // { sig, ts }
});3. npm package (React or Web Component)
For framework apps (React, Next.js, Vue, Svelte, Astro) that already have a build step. Install the package from npm — every widget update propagates on your next npm install, no dashboard redeploy needed.
npm install @hostwebhook/chat-widgetReact / Next.js — use the typed component:
import { HwChat } from '@hostwebhook/chat-widget/react';
<HwChat chatId="abc123" />import { HwChat } from '@hostwebhook/chat-widget/react';
// authEndpoint is the one to reach for: the widget calls it with your
// cookies and adopts a sessionId you return. See the props table.
<HwChat chatId="abc123" authEndpoint="/api/chat-auth" />Use authProvider instead only when fetching the signature needs logic a URL can't express — an in-memory token, a different transport. It cannot reassign the session:
<HwChat
chatId="abc123"
authProvider={async ({ sessionId }) => {
const r = await fetch('/api/chat-auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId }),
});
if (!r.ok) throw new Error(`Auth webhook ${r.status}`);
return r.json(); // { sig, ts } — a sessionId here is ignored
}}
/>Vue / Svelte / Angular / plain bundled HTML — import once as a side effect and use the Web Component tag:
<script type="module">
import '@hostwebhook/chat-widget';
</script>
<hw-chat chat-id="abc123"></hw-chat>Component props:
| Prop | Type | Description |
|---|---|---|
| chatId | string | Required. The short chat ID from the dashboard. |
| authProvider | function | For signed triggers. ({ sessionId }) => Promise<{ sig, ts }> — fetches a signature from your backend. Cannot reassign the session: a sessionId in what it returns is ignored. |
| authEndpoint | string | For signed triggers. A URL the widget POSTs to itself, with cookies. Unlike authProvider, it does adopt a sessionId returned in the response — use this one when the chat has to belong to a logged-in user. |
| theme | enum | dark, light, or auto. Overrides the trigger's theme. |
| primaryColor | string | Hex color override for the accent. |
| apiBase | string | Override the API host. Default https://api.hostwebhook.com. |
| style, className | CSS | Forwarded to the outer element. The widget fills its container — give it a sized parent (e.g. height: 540px). |
4. Direct API (your own interface)
No widget at all: you build the request and render the conversation yourself. This is the method for a custom UI, a mobile app, or a backend-to-backend integration — and the only one where sessionIdStrategy can be header or query, because you are the one constructing the call.
GET /api/chat-triggers/public/:chatId # config the widget renders from
POST /api/chat-triggers/in/:chatId/message # one turn, replies as SSE
GET /api/chat-triggers/in/:chatId/messages?session=… # earlier conversation
# attachments — on a signed trigger browser-only, see the callout below
POST /api/chat-triggers/in/:chatId/upload-url # signed: listed Origin + HMAC
POST /api/chat-triggers/in/:chatId/confirm-upload
GET /api/chat-triggers/in/:chatId/file-status/:fileId?sessionId=…CORS is already set up for it. The ingress answers with five allowed request headers — Content-Type, Authorization, X-Session-Id, X-Hws-Session-Id and X-Preview-Token — and an empty allowedOrigins advertises any origin back. Note that the OPTIONS preflight is answered by the API's global CORS layer, not by the chat route: the route's header list only rides on the real response. If the trigger is signed, you compute the HMAC yourself — see Signed mode. Full request and response shapes in the API Reference.
signed trigger two of these routes check the Origin header rather than merely advertising it, and upload-url is one of them. Emitting CORS headers never rejects anybody — a non-browser client just ignores them. So the two routes that mint a credential, hosted-auth and upload-url, test the origin against allowedOrigins for real and answer 403; a server-side call sends no origin and is refused, so on a signed trigger the attachment flow is a browser surface, not a backend-to-backend one. There upload-url additionally demands the HMAC, taking the session from body.sessionId alone. The one exemption is the owner's dashboard preview, whose X-Preview-Token skips the origin test on both of them — but only the origin test, not the mandatory list. On a public trigger upload-url tests neither origin nor signature: what limits it is the rate limit, the file type and the size. The other two attachment calls, confirm-upload and file-status, check neither origin nor signature — but they are useless without a presigned URL to confirm. message, messages and the config route do not test the origin at all, which is what makes those genuinely callable from your own backend.5. Management API (create triggers from code)
Not a way to chat — a way to provision. With an API key (hwk_…) you can create and configure chat triggers without opening the dashboard. The case this exists for is multi-tenant: one chatbot per customer of yours, created by your backend, which then drops the returned chatId into whichever of the four methods above that customer gets.
POST /api/chat-triggers # create; returns the chatId
GET /api/chat-triggers/:id/auth-secret # the signing secret
POST /api/chat-triggers/:id/regenerate-secret # rotate it
GET /api/chat-triggers/:id/conversations # read what people asked
POST /api/chat-triggers/:id/test # one real turn through the agentPlus the usual list, read, update and delete on /api/chat-triggers. Examples in the API Reference.
Authentication
Every Chat Trigger has an authMode. Which mode you pick determines whether anonymous visitors on the public internet can use the chat, and how the three embed surfaces differ.
Public mode
Anyone with the chat URL, embed script, or component can send messages. The ingress accepts requests without a signature. Good for marketing widgets, public demos, open support chats.
allowedOrigins does not fence a public chat. The message, history and config routes never test the origin, so any site — or any script, with no browser at all — can chat with a public trigger whatever the list says. The list is a restriction in signed mode, where it decides who gets a signature. Attachment uploads on a public trigger ignore it too. To keep a chat to your own visitors, make it signed.The brake that matters here is yours, not ours: rateLimitMessagesPerMinute (default 60, range 1–600) counted in a Redis sliding window, keyed by whatever rateLimitScope says — session (the default) or ip. Over it the ingress answers 429 Too many messages with a Retry-After header. On top of that sits a fixed 20 requests/min/IP throttle on the message route, which is a platform backstop against bots and not the number you tune. Set the per-trigger limit low on a public chat: every message is an LLM call.
/hosted-auth minted, the limit is keyed by IP even if you configured session. A session anyone can mint for free is not an identity — you would rotate sessions and walk straight past a per-session limit. The 429 body reports the scope that was applied, not the one configured, so you are not sent looking in the wrong place.Signed mode
Every ingress call must include a short-lived HMAC signature bound to the caller's sessionId. Random internet traffic without a signature gets a 401 Invalid or missing signature. Use signed mode for authenticated embeds (logged-in users, paid tools, anything where you want to gate access at the edge).
The signature is sent as an Authorization header:
Authorization: Bearer <sig>:<ts>
where sig = HMAC-SHA256(secret, `${sessionId}:${ts}`) in hex
ts = Unix timestamp in seconds
TTL = 5 minutes (older timestamps rejected as replay){ sig, ts } from a signing webhook you host — only the pre-computed signature rides on the wire.Hosted URL auto-signs
When you open https://www.hostwebhook.com/chat/:chatId for a signed trigger, the hosted page calls a special webhook on our backend — POST /api/chat-triggers/in/:chatId/hosted-auth — which signs on behalf of the page using the secret already stored in the trigger. No secret ships to the browser and none is yours to handle — but there is one thing to configure, and the page will not come up without it: allowedOrigins has to be non-empty and has to name https://www.hostwebhook.com, the origin this page calls from. The endpoint is throttled to 20 calls/min/IP.
The way it stays stateless and still survives a token refresh is that the id carries its own proof. It has the shape hws_<random>.<mac>, where the trailing MAC is derived from the trigger's own secret. Present an id back and the server re-signs it only if it recognises that MAC — in other words, only if it issued the id itself. Anything else, including someone else's session, is dropped and a fresh id is minted. You get no error saying which happened, on purpose: an error there would be the oracle all over again.
Two more things the endpoint enforces, both easy to trip over:
- It checks the
Originheader and rejects with403. The origin must be onallowedOrigins. (On apublictrigger an empty list means any origin is accepted, but anOriginmust at least be present; on asignedone an empty list never gets this far — see the callout below.) A barecurlsends noOriginand is refused. Be clear about what that buys: outside a browser anOriginis one command-line argument away, so this is friction against lazy abuse, not a boundary. The cost boundary is the per-IP limit. The owner testing from the dashboard panel is the one exemption: a validX-Preview-Tokenis checked against this trigger and stands in for the origin test, exactly as it stands in for the HMAC onmessageandupload-url. The shared/chat/…link carries no such token, so for the link the hosted origin has to be on the list for real. - The widget has to be new enough to adopt what it is given. Bundles older than
0.8.0ignore the session id in the response and carry on using their own, which no longer validates.
hosted-auth never asks whether the request came from the hosted /chat/… URL, or from any particular host. It reads the Origin header and compares it to allowedOrigins, and that is the only host check standing between an anonymous caller and a valid signature. Which is why, in signed mode, that list is now mandatory: a trigger with authMode: "signed" and an empty allowedOrigins is refused with a 403 carrying code: "allowed_origins_required", before the origin is read at all — an empty list there is read as not configured, never as any origin will do. The same refusal guards the other door: saving that combination from the panel or the API is a 400 with the identical body, so you cannot get into the state in the first place. (In public mode nothing changes — no credential is being minted, and an empty list still means any origin that sends one.)/chat/… link, one of those origins is ours — https://www.hostwebhook.com, which is where that page is served from and therefore what the API sees it call from. Leave it out and the list is accepted, the mandatory-list error goes away, and the shared link starts failing the plain origin test instead. The refusal names that origin for you, so you are not left to work it out. Only the panel's own preview escapes this second step, on its preview token — so do not read a working preview as a working link.None of this touches the other path, the signing webhook on your own backend (below), which is still what an embed on your own site is meant to use: there the secret is yours and you decide who is who, which is what signed means.
Backend signing webhook
For script embeds and the React component on your own site, you must host a tiny webhook that signs requests with the shared secret. The widget POSTs { sessionId } and expects { sig, ts } in response.
Two things about that call are what make it useful for gating access, and neither is obvious:
- It goes out with
credentials: "include", so your own cookies reach your own route. That is where you decide whether this visitor is allowed to chat at all — no session, no signature, no chat. - You may return a
sessionIdalongside{ sig, ts }. The widget adopts it, persists it tolocalStorageand uses it from then on. Returninguser_42is how you tie the conversation to a person instead of a browser — without touching the session strategy.
401 Invalid or missing signature.import { NextResponse } from "next/server";
import crypto from "crypto";
export const runtime = "nodejs";
export async function POST(req: Request) {
const secret = process.env.HW_CHAT_SECRET;
if (!secret) return NextResponse.json({ error: "secret not configured" }, { status: 500 });
const { sessionId } = await req.json();
if (typeof sessionId !== "string" || !sessionId) {
return NextResponse.json({ error: "sessionId required" }, { status: 400 });
}
const ts = Math.floor(Date.now() / 1000);
const sig = crypto.createHmac("sha256", secret).update(`${sessionId}:${ts}`).digest("hex");
return NextResponse.json({ sig, ts });
}import express from "express";
import crypto from "crypto";
const app = express();
app.use(express.json());
app.post("/api/chat-auth", (req, res) => {
const secret = process.env.HW_CHAT_SECRET;
if (!secret) return res.status(500).json({ error: "secret not configured" });
const sessionId = String(req.body?.sessionId ?? "").trim();
if (!sessionId) return res.status(400).json({ error: "sessionId required" });
const ts = Math.floor(Date.now() / 1000);
const sig = crypto.createHmac("sha256", secret).update(`${sessionId}:${ts}`).digest("hex");
res.json({ sig, ts });
});import os, hmac, hashlib, time
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.post("/api/chat-auth")
def chat_auth():
secret = os.environ.get("HW_CHAT_SECRET")
if not secret:
return jsonify({"error": "secret not configured"}), 500
session_id = (request.get_json() or {}).get("sessionId", "").strip()
if not session_id:
return jsonify({"error": "sessionId required"}), 400
ts = int(time.time())
mac = hmac.new(secret.encode(), f"{session_id}:{ts}".encode(), hashlib.sha256)
return jsonify({"sig": mac.hexdigest(), "ts": ts})<?php
header('Content-Type: application/json');
$secret = getenv('HW_CHAT_SECRET');
if (!$secret) { http_response_code(500); exit(json_encode(['error' => 'secret not configured'])); }
$body = json_decode(file_get_contents('php://input'), true);
$sessionId = trim($body['sessionId'] ?? '');
if (!$sessionId) { http_response_code(400); exit(json_encode(['error' => 'sessionId required'])); }
$ts = time();
$sig = hash_hmac('sha256', "$sessionId:$ts", $secret);
echo json_encode(['sig' => $sig, 'ts' => $ts]);• Railway / Vercel / Render → env var in the dashboard
• AWS → Secrets Manager or SSM Parameter Store
• Docker → pass through
--env or a secret mountIf it ever leaks, regenerate from the dashboard (Advanced → Regenerate) and update every place the old value is stored.
Which surface needs what
| Surface | Public trigger | Signed trigger |
|---|---|---|
| Hosted URL | Works as-is | We auto-sign via /hosted-auth, so there is no signing webhook to host — but allowedOrigins is required and must include https://www.hostwebhook.com, the origin the hosted page calls from. Empty list or your site alone, and the link is a 403 |
| Script embed | Works with just data-chat-id | Requires data-auth-endpoint pointing at your signing webhook |
| React component | Works with just chatId | Requires authEndpoint (or authProvider, which cannot reassign the session) pointing at your signing webhook |
| Direct API | Works with just the chatId in the path | You compute the signature yourself — no widget in the way |
Why a 401 happened
A rejected signature answers 401 with three fields. error is always the same string — Invalid or missing signature — because clients read it verbatim. message is prose for a human squinting at the response. The one to branch on is code:
| code | What it means |
|---|---|
| signature_missing | No Authorization header arrived at all. A proxy stripping it, or a CORS setup that does not allow it, is the usual cause. |
| signature_malformed | The header is not Bearer <hex-signature>:<unix-seconds>. |
| signature_expired | The signed timestamp is outside the ±300s window. The message names the drift in seconds and which way it runs, so a wrong clock on the signing machine diagnoses itself. |
| session_unresolved | No session id was found where this trigger looks for it. Check sessionIdStrategy against where your client actually puts it. Most routes catch this earlier and answer 400 sessionId required with this same code. |
| signature_mismatch | The HMAC did not match. Deliberately says nothing further — see below. |
signature_mismatch is coarse on purpose, and it will stay that way. The four codes above it all describe something the caller controls and wrote themselves, so naming them gives nothing away. A mismatch is different: splitting it into finer reasons would turn the endpoint into an oracle — anyone holding one valid signature of their own could probe and be told which sessions exist. That is the exact hole the server-minted hosted session closes, so this endpoint will not reopen it from the other side. Treat signature_mismatch as one condition: recheck the secret in your signer and recheck that you signed the id you are sending. The server log, which only you can read, carries the extra detail.Session Strategies
The sessionIdStrategy field controls where the server reads the session ID from. HMAC signatures bind to this ID — pick the strategy that matches where your session state lives.
auto (default)
The widget generates a random session ID on first load and persists it in the visitor's localStorage. The server reads whatever comes in the request body. Zero setup — fine for anonymous chat, public widgets, and most signed use cases where you don't have a stronger identity anyway. On the hosted page the id the widget keeps is the one the server minted, not one it invented.
One asymmetry worth knowing: a GET cannot carry a body, so on the history endpoint auto falls back to reading ?hwsSession= or ?session= from the query string. That fallback is for auto and for bodiless requests only — a POST still resolves from the body and nothing else, and an explicit header or query choice always wins.
header and query only work when you build the request yourself. The hosted URL, the script embed and the React component all run the same widget, and it sends the session id in the request body and nowhere else — no X-Session-Id header, and ?session= only on the history call. Point either strategy at a trigger used through those surfaces and messages come back 400 sessionId required. Under query the history call still loads, which reads like a broken pipeline rather than a misconfiguration; under header history fails too, since that strategy reads headers on every route and takes no fallback. Pick them for a direct API integration, or put a proxy of your own in front. To scope a chat to a logged-in user through the widget, keep auto and reassign the session from your signing webhook (below).header
The server reads X-Hws-Session-Id, falling back to X-Session-Id, from the request headers — and nowhere else. Use this when your own client already has an authenticated session (cookie, JWT) and you want the conversation scoped to that identity. Your backend sets the header when calling the ingress, or you wire it through a proxy. CORS allows both names: they are in the global preflight list alongside Content-Type, Authorization and X-Preview-Token.
POST /message, POST /voice-turn and GET /messages. On those three, header means the two header names and nothing else: no body, no query, and none of the bodiless-GET fallback, which belongs to auto alone. The three attachment routes never look at sessionIdStrategy at all. They read the session from a fixed place whatever you set it to, so a header will never satisfy them — which means flipping this setting can leave messages working and break uploads, not only the other way round.| Route | Where it reads the session | If it finds none |
|---|---|---|
| POST /message POST /voice-turn GET /messages | Follows the strategy. Under header: X-Hws-Session-Id, else X-Session-Id. | 400 sessionId required, code: session_unresolved. On a signed trigger this still comes back as a 400: the session is resolved before the signature is looked at, so you never reach the 401. |
| POST /upload-url | Always sessionId in the JSON body — that key only, not hwsSessionId. | Public trigger: 400 fileName, mimeType, sessionId required, no code. Signed trigger: 401 Invalid or missing signature with code: session_unresolved — the signature gate runs first here, and this is the one route under this heading where a missing session surfaces as a 401. |
| POST /confirm-upload | Always sessionId in the JSON body. | 400 sessionId required, with no code field. |
| GET /file-status/:fileId | Always ?sessionId= in the query — a third spelling again, unrelated to ?hwsSession= and ?session=. | 400 sessionId required, with no code field. |
Nothing in any of those responses names the strategy you just changed — the code field is the whole clue, and half the routes do not even carry one. Before you flip this setting, confirm on the wire that your client sends one of the two headers on the message routes and keeps putting sessionId in the body of the attachment ones.
query
The server reads ?hwsSession=..., falling back to ?session=..., from the query string of the API request — not from the page's own URL. So it is the client calling POST /api/chat-triggers/in/:chatId/message?session=… that decides the id. Useful when you render from something that can't set headers.
Memory and Conversations
What a visitor says is kept in two places that do different jobs: the AI Node's memory, which is what lets the chat remember earlier messages, and the conversation, the transcript you read in the dashboard.
Memory — what makes the chat remember
Each turn, the AI Node gets one message: {{payload.message}}, what the visitor just typed. Earlier messages reach the model only through the node's Memory. With it on, the node recalls the previous exchanges of the same session — keyed by the visitor's session id, so every visitor has a memory of their own with nothing to configure. With it off, every message arrives on its own and the chat does not remember earlier messages: tell it your name, ask for it on the next message, and it will not know.
That is why the AI Node that comes with a Chat Trigger is born with Memory on, using the built-in backend that HostWebhook manages. If you turn it off, or connect an AI Node that has it off, the Chat Trigger's panel warns you that the chat won't remember earlier messages. Backends and their settings are in AI Node › Memory.
Reading and deleting conversations
Every session's transcript is kept as a conversation, one per chat and session id, whether or not the AI Node has Memory on. How long depends on your plan — a number of days after its last message, or until you delete it; the page tells you which. The Chat Trigger's Share & Activity tab shows the recent ones under Visitor sessions; Manage sessions → there takes you to the same chat in Settings → Chat Sessions, which is where you read and delete them.
- The list — every Chat Trigger you can see, with its sessions, messages and last activity. Search by chat name or ID; Manage → opens one chat.
- One chat — its sessions, newest first, 25 to a page: the first thing the visitor wrote, the session id, how many messages, when it started and when the last message came. View opens the whole transcript.
- Deleting — one session (from its row, or from the open transcript), every session idle for more than 7, 30 or 90 days, or all of them. It asks first, and it can't be undone.
What it does not delete: the run history of each turn and the AI Node's traces. Those are technical records with their own retention.
The same from code, with an API key — canvas:read to read, canvas:write to delete. See Chat sessions in the API Reference.
Attachments
When attachmentsEnabled is on, the widget shows a paperclip button. Visitors drag or pick files; each file uploads directly to our R2 bucket via a presigned URL (no bytes through the browser's local memory twice).
Attachments work in signed mode too. The widget sends the same Authorization: Bearer <sig>:<ts> on the attachment calls that it sends on /message, so the signature upload-url demands on a signed trigger is already there — on the hosted page, the script embed and the npm package alike. Widget bundles up to 0.10.0 did not send it, and on a signed trigger their uploads fail with 401 signature_missing.
Allowed file types
The whitelist is deliberately narrow — only formats the AI + File Transform nodes can actually consume:
- Images:
image/jpeg,image/png,image/webp,image/gif - Documents:
application/pdf,text/csv,text/plain
Max 5 attachments per message. Per-file size cap depends on your plan (Free 10 MB, Starter 25 MB, Pro 50 MB, Enterprise 500 MB). Rejected uploads fail visibly in the widget so visitors know why.
Upload flow
The widget does three round-trips per file:
POST /api/chat-triggers/in/:chatId/upload-url— widget sends{ fileName, mimeType, sessionId, size }(plus the signature, on a signed trigger), we return a short-lived R2 presigned PUT URL + a file ID.PUT <presigned URL>— browser uploads bytes directly to R2. We never see the bytes.POST /api/chat-triggers/in/:chatId/confirm-upload— widget sends{ fileId, sessionId }. Flips the file status toscanningand enqueues the scan worker. The widget then polls/file-status/:fileId?sessionId=…every few hundred ms until status isclean— only then is the Send button enabled.
Every attachment call carries the sessionId that uploaded the file, and the API refuses any that names a different one. A chat ID is shared by every visitor of the widget, so on its own it says which chat a file belongs to — not whose it is. Binding to the session is what keeps one visitor from reading, or deleting, another visitor's upload. Widget 0.7.0 and up sends it; older builds can no longer confirm attachments.
In the downstream pipeline, each attachment arrives as a _file reference on the payload — the AI node can read it via vision, or the File Transform node can extract text/CSV. After the conversation turn finishes, files are auto-deleted from R2; a 24h GC cron catches anything that slips through.
Usage Caps
Separate from the per-minute rate limit, a trigger can carry six persistent caps: three metrics — messages, LLM tokens, LLM spend — each in a per-visitor daily version and a whole-trigger monthly one. They live in counter documents rather than a rolling window, so they survive restarts and reset on a UTC boundary. Leave one null and it does not apply.
| Field | Scope | Resets | Emails you | 429 reason |
|---|---|---|---|---|
| dailyMessageCapPerSession | One visitor | Daily, 00:00 UTC | No | daily_messages |
| dailyTokenCapPerSession | One visitor | Daily, 00:00 UTC | No | daily_tokens |
| dailyCostMicroUsdCapPerSession | One visitor | Daily, 00:00 UTC | No | daily_cost |
| monthlyMessageCapPerTrigger | Whole trigger | Monthly, 1st 00:00 UTC | Yes | monthly_messages |
| monthlyTokenCapPerTrigger | Whole trigger | Monthly, 1st 00:00 UTC | Yes | monthly_tokens |
| monthlyCostMicroUsdCapPerTrigger | Whole trigger | Monthly, 1st 00:00 UTC | Yes | monthly_cost |
A cap is checked twice per turn. First against what has already been spent, before anything downstream runs. Then again once the AI node is known, with an estimate of this turn reserved up front: its input, and the node's maximum output. The estimate is deliberately pessimistic, and input and output are priced separately because their rates typically run about 1:5.
A turn that does not fit is shortened, not refused. When what is left of a token or spend cap does not cover that maximum output, the turn still runs, with the AI Node's output limited to what is left — for that turn only; the node's own setting does not change. For a spend cap, what is left is turned into tokens at the model's price. Only when what is left cannot pay for even a small minimum of output is the turn refused. So a visitor sitting at 999/1000 still cannot fire off one 50,000-token turn, and a small per-visitor cap does not refuse the very first message of a session just because the node's maximum output — 8,192 tokens when its Max Tokens field is left empty — is bigger than the cap. A shortened answer streams and ends with done like any other; it may just stop where the budget ran out.
Spend caps, and where they stop working
The two spend caps are counted in whole micro-dollars (1e-6 USD), and the unit is in the field name because a bare 50000 in a document does not say whether it is five cents or fifty thousand dollars. Integers rather than floating-point dollars, for three reasons that all bite in practice: the counter is incremented with Mongo $inc, and a double accumulates rounding error across thousands of turns while an integer does not; cents are too coarse, since a cheap model's turn costs about $0.0002, which in cents is 0 — a cap against a counter that always adds zero never fires; and the pricing helper already rounds to six decimals, so micro-dollars is the unit it was working in anyway. The dashboard converts from dollars for you, once.
• Voice spend is not counted. When
voiceProvider is set, that LLM runs on the provider's side and its cost is tracked elsewhere entirely. A spend cap on this trigger will not restrain it.• Long prompts are under-estimated. The pricing table does not model the long-context rate tier, so the reservation for a big prompt comes out lower than the real charge. Prompt caching is not modelled either — the API does not report cache tokens.
• An unpriced model reserves zero. If the pricing table does not know the model configured on the AI node, this turn's reservation is 0 — and that zero means “unknown”, not “free”. The cap still applies, but only against what has already been recorded, so the forward-looking half of the protection is gone.
A related wrinkle even when everything is priced: the reservation uses the model stored on the node, while the charge recorded afterwards uses the model actually called. Both are the credential's model — the node always takes it from its credential — so they match; they are still answers to two different questions, “what could this cost” and “what did it cost”. And a node with tools makes several LLM calls in one turn while the reservation covers one, so the exposure is bounded to a turn's extra iterations rather than to an arbitrarily large turn.
Cap alerts
With capAlertsEnabled on and capAlertEmail filled in, crossing 75%, 90% or 100% of a cap emails you once per threshold per period. The 75 and 90 are amber warnings with what is left and when it runs out at today's rate; the 100 is the red one, which no longer warns of anything but reports a cut-off. It also fires when a turn is rejected for not fitting under the cap — the case where the counter would otherwise sit still forever and never cross anything.
What the visitor reads
Both gates show the visitor the same sentence, but they deliver it very differently, because by the time the second one fires the response has already started.
- The first gate answers
429. Nothing has been sent yet, so it is a plain JSON body:reason,limitandcurrentfor machines, and the human-readable text in botherrorandmessage— the same string in both, so there is no better version hiding in the field you did not check. - The reservation gate, when it does refuse, cannot answer anything. It runs inside the turn, after the ingress has already flushed
200 text/event-streamand emitted thesessionframe. The status line is spent. So the rejection arrives as an SSEerrorframe —{ "message": "…" }, carrying that same sentence — and the stream then ends with nodoneframe. There is no429to check and noreason,limitorcurrentanywhere in it; those numbers go to the server log only.
error frame, or a stream that ends without done, as a failed turn.That text never includes current or limit. A stranger has no use for “900 of 1000”, and with spend caps it would be telling them what the owner spends on their chat. By default they get a generic line that names when access returns — the absolute UTC time and the relative “in 6 hours” together, since each covers the other's gap — or, when the reset time is not known, a plain try-again-later that does not promise a tomorrow nobody can vouch for.
Set capMessageEnabled and write your own in capMessage to replace it. Note what the field is and is not:
- Plain text, one sentence. Max 500 characters. Runs of whitespace, newlines included, collapse to a single space — the widget shows this in an error box where line breaks do not render anyway.
- Links are defused on save.
[label](https://…)keeps the label and loses the target, and a bare URL loses its scheme so it reads fine but stops being clickable. The widget renders this string through its markdown path, and a chat owner should not be able to put a clickable link to somebody else's domain on a stranger's screen. - Empty with the switch on is not an error. The settings page saves as you type, so the field passes through
""every time you clear it. At runtime that reads as “not configured” and the generic text comes back.
Plan Limits
There's no limit on how many Chat Triggers a plan can have. Each one has two kinds of limits: per-request (rate limiting, always enforced) and monthly (plan-capped). The per-IP throttles below are platform backstops that apply to every trigger and are not configurable — they sit underneath the per-trigger rateLimitMessagesPerMinute, which is the one you set, and underneath the usage caps, which are the ones that bound cost.
| Limit | Value |
|---|---|
| Messages per IP (platform backstop) | 20 per minute — not the per-trigger limit, see Public mode |
| Hosted-auth calls per IP | 20 per minute |
| Upload-url calls per IP | 30 per minute |
| File status polls per IP | 120 per minute |
| Attachments per message | 5 |
| Max file size | Plan-dependent (10 MB / 50 MB / 500 MB) |
| Conversations per month | Plan-dependent, counted per Chat Trigger. It counts new conversations — a session's first message — not messages: the rest of that conversation adds nothing. A 429 when hit; resets at 00:00 UTC on the 1st of each month |
API Reference
Get public config
curl https://api.hostwebhook.com/api/chat-triggers/public/abc123Returns only the fields visible to the widget (no secrets, no connected nodes). The response shape:
{
"chatId": "abc123",
"title": "Chat with us",
"subtitle": "We reply in seconds",
"placeholder": "Type your message…",
"initialMessages": [{ "role": "assistant", "content": "Hi!" }],
"theme": { "primaryColor": "#7c3aed", "mode": "dark" },
"authMode": "public",
"sessionIdStrategy": "auto",
"attachments": { "enabled": false, "maxFileSize": 10000000, "maxPerMessage": 5, "allowedMimeTypes": [...] }
}Send a message (SSE)
curl -N -X POST https://api.hostwebhook.com/api/chat-triggers/in/abc123/message \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <sig>:<ts>" \
-d '{ "message": "Hello", "sessionId": "sess_abc" }'Response is text/event-stream. See Streaming response for the event types.
Hosted auto-sign
# First call of a session: send no id and let the server mint one.
curl -X POST https://api.hostwebhook.com/api/chat-triggers/in/abc123/hosted-auth \
-H "Content-Type: application/json" \
-H "Origin: https://www.hostwebhook.com" \
-d '{}'
# => {
# "sig": "…",
# "ts": 1712345678,
# "hwsSessionId": "hws_….…", # the minted id — copy it verbatim
# "sessionId": "hws_….…" # same value, older key
# }
# Refreshing the token five minutes later: hand back the id you were given,
# or the conversation splits in two.
curl -X POST https://api.hostwebhook.com/api/chat-triggers/in/abc123/hosted-auth \
-H "Content-Type: application/json" \
-H "Origin: https://www.hostwebhook.com" \
-d "{\"hwsSessionId\": \"$HWS_SESSION_ID\"}"Two things the shape above is telling you. First, the session comes back under both hwsSessionId and sessionId — the same value twice, the older key kept for widget bundles already in the wild. Second, the id you sent is a request, not an instruction: it is kept only if the server minted it, and otherwise a fresh one is returned with no explanation of which happened. Always use the id from the response.
hws_<random>.<mac> is derived from your trigger's secret, so an id typed out in a document — or one whose halves are simply the wrong length; the MAC is exactly 22 characters — cannot match on any chat. It is not an error: the id is discarded in silence and a different one is minted, so the response you get back will not be the one you sent. Start from {}, then echo back what the server hands you.The Origin header above is not decoration. Without one the endpoint answers 403, and it must be on allowedOrigins — which on a signed trigger is never empty, because an empty one is refused first. Beyond that: 400 if the trigger is public and needs no signature, and 404 for an unknown or paused chat. What the route does not check is whether you are the hosted page: there is no test for the /chat/… URL, only for the origin you claim. The origins on the list are exactly who can mint a signature — with one exemption, a valid X-Preview-Token, which stands in for the origin test here just as it does on upload-url. It does not stand in for the list being configured. See Hosted URL auto-signs.
403s here, and they ask for different things. A signed trigger with an empty allowedOrigins is refused before the origin is even read, with code: "allowed_origins_required" and a body that names both the field to fill and the hosted origin to put in it. Fill the list with your own site alone and the next call fails the origin test instead, with code: "origin_not_allowed" — because the hosted page calls from https://www.hostwebhook.com, not from your site. That second body names the origin it rejected and tells you to add it exactly as written, with no trailing slash; it never lists the origins the chat already allows. Branch on code, not on the message: the first means “fill the list”, the second means “add this origin to it”. (The dashboard preview does not hit the second one: its X-Preview-Token takes the place of the origin test. It does hit the first — a preview will not paper over a chat that has no origins configured.) List the hosted origin next to your own.Trigger CRUD (authenticated)
curl -X POST https://api.hostwebhook.com/api/chat-triggers \
-H "Authorization: Bearer hwk_..." \
-H "Content-Type: application/json" \
-d '{
"workspaceId": "<your-workspace-id>",
"name": "Product support",
"title": "How can we help?",
"subtitle": "Usually replies in minutes",
"placeholder": "Type your question…",
"authMode": "signed",
"allowedOrigins": ["https://your-site.com", "https://www.hostwebhook.com"],
"attachmentsEnabled": true,
"theme": { "primaryColor": "#0f3beb", "mode": "dark" }
}'allowedOrigins is not optional in that body: authMode: "signed" with an empty or absent list is a 400, carrying the same { error, code, message } shape and the same allowed_origins_required code as the 403 from hosted-auth, so a client can branch on it identically at either door. The same check runs on PATCH, against the state the patch would produce — the dashboard saves one field at a time, so “switch to signed” and “clear the origins” arrive as separate requests and each looks innocent alone. It runs only when the body touches authMode or allowedOrigins: patching the title of a trigger already in that state is not blocked, because blocking it would take away the place you fix it from.
curl https://api.hostwebhook.com/api/chat-triggers/:id/auth-secret \
-H "Authorization: Bearer hwk_..."A GET, and the reveal is audited. Note that :id here is the trigger's internal id, not the public chatId from the widget — everything under /api/chat-triggers/:id is keyed that way, while the public ingress under /in/:chatId is keyed by the short public id. Every trigger document this API serialises has the secret replaced with ***masked***, so it never rides along on a list, a read or an update. Two routes return the raw value: this one and POST :id/regenerate-secret just below, which answers with the freshly generated secret. Both build that response by hand instead of from the document, which is why the mask does not apply. Both require the canvas:write scope, both are audited, and both refuse with 403 on a trigger that is not in signed mode.
curl -X POST https://api.hostwebhook.com/api/chat-triggers/:id/regenerate-secret \
-H "Authorization: Bearer hwk_..."Chat sessions (authenticated)
What Settings → Chat Sessions does, as routes. Reading needs canvas:read; deleting needs canvas:write and is audited.
GET /api/chat-sessions # every chat you can see, with counts
GET /api/chat-sessions/:id?limit=25&offset=0 # one chat's sessions, newest first (limit up to 100)
GET /api/chat-sessions/:id/:conversationId # one session's full transcript
DELETE /api/chat-sessions/:id/:conversationId # delete one session
DELETE /api/chat-sessions/:id?olderThanDays=30 # delete sessions idle for more than 30 days
DELETE /api/chat-sessions/:id # delete every session of the chat:id is the trigger's internal id, as everywhere under /api/chat-triggers/:id. :conversationId is the conversation's own id from the session list — not the visitor's sessionId, which comes alongside it. olderThanDays must be a whole number, 1 or more, or the call is a 400. An id that is not one of your organization's chats is a 404, so the answer does not confirm that it exists; a chat of yours in a workspace you have not been given access to is a 403.
curl -X DELETE https://api.hostwebhook.com/api/chat-sessions/:id/:conversationId \
-H "Authorization: Bearer hwk_..."
# => {
# "id": "…", "name": "Product support",
# "deletedConversations": 1,
# "deletedMemoryRecords": 6,
# "memoryNotCleared": []
# }Both deletes answer with the same shape: how many conversations and memory records went, and in memoryNotCleared each AI Node whose memory could not be cleared, as { aiNodeId, reason }. An empty array means the memory went too. Run history and AI Node traces are not touched — see what deleting removes.