Chat Trigger
A Chat Trigger is an ingress node that exposes a visitor-facing chat UI wired into a pipeline. Visitors type, the message flows through your canvas (usually into an 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.
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 dispatches the payload to the first connected node.
Streaming response
The response is streamed back as Server-Sent Events. Event types:
event: session data: { "sessionId": "sess_abc123" }
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.
Session lifecycle
Each conversation is keyed by a sessionId. The widget generates one per visitor and persists it in localStorage so returning visitors continue the same conversation. The server uses it 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 |
| 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 embed the chat. Empty = any site, though the request still has to carry an Origin (so a bare curl is refused). Matched exactly — scheme, host and port, no paths and no wildcards. Locking the list also blocks the shareable /chat/… page unless you include this dashboard's own origin. |
| 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 |
| capAlertsEnabled | Advanced › Usage caps | boolean | false | Emails you when any of the four caps above crosses 75%, 90% and 100%. One email per threshold per period, so a busy chat does not flood the inbox — and the 100% one also fires when a turn is rejected for not fitting under the cap, which is the case where the counter can sit still forever. |
| 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. |
| 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://app.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 and signed triggers. For signed triggers, our server auto-signs on behalf of the page using the trigger's own secret — so signed mode doesn't break the share link.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://app.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://app.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
POST /api/chat-triggers/in/:chatId/upload-url # attachments: 3 calls
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 lists X-Session-Id in Access-Control-Allow-Headers, and an empty allowedOrigins accepts any origin. If the trigger is signed, you compute the HMAC yourself — see Signed mode. Full request and response shapes in the API Reference.
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. Rate-limited per IP (20 messages/min) and plan-capped monthly to prevent abuse.
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://app.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. Zero setup. The secret stays server-side and the page is rate-limited to 20 auth calls/min/IP so the webhook can't be abused as an oracle.
/hosted-auth — they need their own signing webhook (see below).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 | Works as-is — we auto-sign via /hosted-auth |
| 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 |
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 trusts 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.
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 every message comes back 400 sessionId required while the history still loads, which reads like a broken pipeline. 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-Session-Id from the request headers. 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 already allows it: the ingress lists X-Session-Id in Access-Control-Allow-Headers.
query
The server reads ?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.
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).
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, 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 }, 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.
Plan Limits
Chat Triggers have two kinds of limits: per-request (rate limiting, always enforced) and monthly (plan-capped).
| Limit | Value |
|---|---|
| Messages per IP | 20 per minute |
| 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 — soft 429 when hit, resets on plan anniversary |
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
curl -X POST https://api.hostwebhook.com/api/chat-triggers/in/abc123/hosted-auth \
-H "Content-Type: application/json" \
-d '{ "sessionId": "sess_abc" }'
# => { "sig": "…", "ts": 1712345678 }Only returns a signature for the trigger's own hosted page. Not for use from external embeds — your backend signs those, never ours.
Trigger CRUD (authenticated)
curl -X POST https://api.hostwebhook.com/api/chat-triggers \
-H "Authorization: Bearer hwk_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Product support",
"title": "How can we help?",
"subtitle": "Usually replies in minutes",
"placeholder": "Type your question…",
"authMode": "signed",
"attachmentsEnabled": true,
"theme": { "primaryColor": "#0f3beb", "mode": "dark" }
}'curl -X POST https://api.hostwebhook.com/api/chat-triggers/:id/reveal-secret \
-H "Authorization: Bearer hwk_..."curl -X POST https://api.hostwebhook.com/api/chat-triggers/:id/regenerate-secret \
-H "Authorization: Bearer hwk_..."