Build a webhook
This guide walks through implementing a webhook endpoint that Moveo can call during a conversation. It covers the request format, signature verification, response contracts per webhook type, operational practices, and a few worked use cases. A complete TypeScript type reference is at the end.
The examples use Next.js with TypeScript, but the patterns translate to any HTTP framework.
For configuring a webhook in the Moveo UI (URL, verification token, custom headers, types) and for the payload differences across types, see Webhooks.
What you'll build
By the end of this guide you will have:
- An HTTPS endpoint that accepts
POSTrequests from Moveo. - HMAC SHA256 verification of the
X-Moveo-Signatureheader. - Body validation with Zod.
- A response shaped for the webhook type you configured (dialog, first message, pre message, or post message).
Prerequisites
- Node.js 18 or newer
- A TypeScript project (Next.js, Express, Fastify, or any HTTP framework)
- A public-facing HTTPS URL — non-HTTPS URLs are rejected by Moveo in production
- A verification token configured on the webhook in the Moveo UI
The request from Moveo
Every webhook call is a POST with a JSON body. The body shape depends on the webhook type; the headers are the same across types:
| Header | Description |
|---|---|
X-Moveo-Signature | HMAC SHA256 of the request body, hex-encoded |
X-Moveo-Request-Id | Unique identifier for this call — log it for correlation |
X-Moveo-Session-Id | The conversation session |
X-Moveo-Account-Id | The Moveo account |
X-Moveo-Account-Slug | The account slug |
Custom headers configured on the webhook are sent on every call. The full payload reference per webhook type is in the Webhooks page; ready-to-use type definitions for TypeScript, Python, and Go are in the Type reference at the end of this guide.
Verify the signature
The signature confirms the request came from Moveo and was not tampered with. Always compute it before doing anything else with the body — including parsing it as JSON, in case the body was modified in transit.
Use the raw request body (not a parsed JSON object) and the verification token configured on the webhook.
- Node.js
- Python
import crypto from 'crypto';
export const verifySignature = (rawBody: string, signature: string, token: string): boolean => {
const expected = crypto.createHmac('sha256', token).update(rawBody).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
};
import hmac
import hashlib
def verify_signature(raw_body: bytes, signature: str, token: str) -> bool:
expected = hmac.new(token.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Use a constant-time comparison (timingSafeEqual / compare_digest) — a regular string === is vulnerable to timing attacks.
A complete handler
The handler below runs four steps in order: verify the signature, validate the body, do the work, respond. The example is for a dialog webhook; the same skeleton applies to event webhooks — only the body schema and the response shape change.
import type { NextApiRequest, NextApiResponse } from 'next';
import { z } from 'zod';
import { verifySignature } from './verify-signature';
import { fetchOrder } from './orders-api';
const WEBHOOK_TOKEN = process.env.MOVEO_WEBHOOK_TOKEN!;
const bodySchema = z.object({
context: z.object({
customer_id: z.string(),
}),
});
export const config = {
api: { bodyParser: false },
};
const readRawBody = async (req: NextApiRequest): Promise<string> => {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks).toString('utf-8');
};
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
// 1. Verify the signature against the raw body
const rawBody = await readRawBody(req);
const signature = req.headers['x-moveo-signature'];
if (typeof signature !== 'string' || !verifySignature(rawBody, signature, WEBHOOK_TOKEN)) {
return res.status(401).json({ error: 'invalid signature' });
}
// 2. Parse and validate the body
const body = bodySchema.parse(JSON.parse(rawBody));
// 3. Do the work
const order = await fetchOrder(body.context.customer_id);
// 4. Respond — context updates feed back into the conversation
return res.json({
context: {
order_status: order.status,
order_total: order.total,
},
});
};
export default handler;
A few notes on the structure:
- Disable the body parser, or capture the raw body before it is parsed, so the signature is computed against the exact bytes Moveo signed.
- Validate at the boundary. Use Zod (or your validator of choice) once at the top of the handler. Downstream functions should receive already-typed inputs.
- Keep the handler thin. External API calls belong in their own module. Idempotency, retries, and caching are concerns of the underlying API client, not the webhook handler.
Respond by webhook type
Each webhook type has its own response contract. Returning fields the contract does not honor is silently ignored.
Dialog webhooks
A dialog webhook can return a context object, a responses array, or both. The responses array supports text, media, event, carousel, webview, and reset entries — see the response action types below.
{
"context": {
"order_status": "pending",
"order_total": 49.5
},
"responses": [
{
"type": "text",
"texts": ["Your order is on the way!"]
}
]
}
Responses are validated. Invalid responses are logged as warnings and skipped; a request that fails validation entirely returns an error to the agent.
First message and pre message webhooks
Return a context object and, optionally, a modified input. These webhooks fire before the agent processes the user's input and cannot reply on the agent's behalf.
The input object accepts two fields:
input.text— replaces the user message before the agent sees it. Useful for normalization or moderation. 1–4096 characters.input.trigger_node_id— UUID of an existing dialog node. Routes directly to that node and bypasses intent classification. The UUID must reference a node on the AI Agent.
{
"context": {
"live_instructions": "The user is George, a VIP customer.",
"user_crm_level": "platinum"
}
}
Rewrite the user input:
{
"input": {
"text": "I want to cancel my order"
}
}
Route to a specific dialog node:
{
"input": {
"trigger_node_id": "9c8e4f1a-2d6b-4e3a-9f1b-1c2d3e4f5a6b"
}
}
Post message webhook
Return a context object and, optionally, a responses array. If responses is present, it replaces the agent's planned reply for this turn. Items in this array are sent to the user as-is — no templating or context-variable substitution is applied.
{
"context": {
"last_intent": "order_inquiry"
},
"responses": [
{
"type": "text",
"texts": ["Override: please contact us at support@example.com."]
}
]
}
Context update rules
The same rules apply to every webhook type that returns a context:
- At most 100 variables, 32 KB total.
- Names: 1–128 characters, alphanumeric plus
_,-,.. - Reserved names that cannot be set or overwritten:
user,global,channels,campaign,tags, anything beginning withsys-orchannels.. - Individual
user.*fields can be updated using dotted-path notation (e.g."user.email": "alice@example.com"); theuserobject as a whole cannot be replaced.
Operating in production
A few constraints to design around:
- Timeouts — Moveo waits 1.5 seconds for the connection and 15 seconds for a response. Defer non-critical work (analytics, log shipping, cache warming) until after the response is sent.
- No automatic retries — a failed call surfaces as an error to the agent and is not retried. If your endpoint must be exactly-once, implement idempotency on your side using
X-Moveo-Request-Idas the dedupe key. - HTTPS only — non-HTTPS URLs and loopback or private addresses are rejected at configuration time in production.
- History is opt in — to receive
historyin the body, addX-Moveo-Include-History: trueas a custom header on the webhook configuration. Without it, history is omitted to keep payloads small. - Choose
fail_on_errordeliberately — leave it off for non-critical webhooks (analytics, enrichment) so the agent can keep talking when your endpoint hiccups. Turn it on for webhooks the conversation cannot proceed without (authentication, payment). - Don't log payload bodies as is — Moveo payloads typically contain user information. Log identifiers (
X-Moveo-Request-Id,session_id) instead.
Test locally
You can test your endpoint with curl once you compute a valid signature. The signature is HMAC SHA256 of the exact body bytes, keyed with the verification token from the Moveo UI:
BODY='{"channel":"web","session_id":"abc","brain_id":"xyz","lang":"en","context":{"customer_id":"123"}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$MOVEO_WEBHOOK_TOKEN" | awk '{print $2}')
curl -X POST https://your-app.example.com/api/orders \
-H "Content-Type: application/json" \
-H "X-Moveo-Signature: $SIG" \
-d "$BODY"
For an end-to-end test against Moveo itself, expose your local server with ngrok or Cloudflared, paste the public URL into the webhook configuration, and use the Send test request button in the UI tester.
Use cases
The patterns below are the worked counterparts of the brief use cases on the Webhooks page.
Use case: live instructions
Webhook type: first message or pre message.
Fetch user data from your system the moment a session starts and inject it into the conversation as live_instructions. The agent reads live_instructions from context on every turn and uses it to personalize replies. This is the cleanest way to keep the agent's prompt short while still acting on real-time customer data.
import type { NextApiRequest, NextApiResponse } from 'next';
import * as API from './api';
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const userId = req.body.context.user_id;
const userInfo = await API.getUserInfo(userId);
const { name, transactions, atm } = userInfo;
const latestTransaction = transactions[0];
const liveInstructions = [
`1. User name: ${name}`,
`2. Recent activity: ${name} made a ${latestTransaction.type} of ${latestTransaction.amount} ${latestTransaction.currency}, pending and expected to settle in 2–4 days.`,
`3. The nearest ATM is at ${atm.address}.`,
].join('\n');
return res.json({
context: {
live_instructions: liveInstructions,
},
});
};
export default handler;
Format live_instructions with newlines (\n) and consistent numbering. The agent reads it as plain text — well-structured input produces more reliable behavior.
Use case: validate input
Webhook type: dialog.
Drop a webhook action into a dialog node to check something the user said against your backend before the conversation continues. Update context with the result, and use a regular condition node downstream to branch on it.
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
// 1, 2 — verify signature + validate body (omitted for brevity)
const accountNumber = req.body.context.account_number;
const account = await API.lookupAccount(accountNumber);
if (!account) {
return res.json({
context: { account_valid: false },
});
}
return res.json({
context: {
account_valid: true,
account_balance: account.balance,
account_holder: account.holder,
},
});
};
Use case: post message audit
Webhook type: post message.
Inspect the agent's planned reply, log it, and optionally replace it. Useful for compliance review, trailing analytics, or A/B testing different response styles without changing the dialog.
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
// 1, 2 — verify signature + validate body (omitted for brevity)
const { session_id, output, intents } = req.body;
await API.logTurn({
sessionId: session_id,
intent: intents[0]?.intent,
responseTypes: output.map((response) => response.type),
});
// Pass through unchanged — return only context.
return res.json({
context: {
last_intent: intents[0]?.intent ?? null,
},
});
};
To override the reply instead of passing through, return a responses array — the rules are the same as for dialog webhooks.
Type reference
Drop-in type definitions are available in TypeScript, Python, and Go. Each file covers the request bodies for all four webhook types, the context object, the response shape per type, and the response action types you can return.
| Language | File |
|---|---|
| TypeScript | moveo-webhooks.ts |
| Python (Pydantic v2) | moveo_webhooks.py |
| Go | moveo_webhooks.go |
Using the types
- TypeScript
- Python
- Go
Import the request and response types you need, and parameterize them with an interface describing the custom variables your AI Agent collects:
import type {
DialogWebhookRequest,
DialogWebhookResponse,
} from './moveo-webhooks';
interface OrderVars {
customer_id: string;
order_status?: 'pending' | 'shipped' | 'delivered';
}
const handler = async (
req: DialogWebhookRequest<OrderVars>
): Promise<DialogWebhookResponse<OrderVars>> => {
// req.context.customer_id is typed as string
// req.context.user.display_name is available
return {
context: { order_status: 'shipped' },
};
};
Pydantic models validate the body at the boundary. Subclass MoveoContext to type your custom variables:
from moveo_webhooks import (
DialogWebhookRequest,
DialogWebhookResponse,
MoveoContext,
)
class OrderContext(MoveoContext):
customer_id: str
order_status: str | None = None
# Inside your handler:
body = DialogWebhookRequest.model_validate(payload)
order_context = OrderContext.model_validate(body.context.model_dump(by_alias=True))
return DialogWebhookResponse(
context={"order_status": "shipped"},
).model_dump(exclude_none=True)
Decode into the request struct, embed Context if you need typed custom variables, and encode a response struct:
import "encoding/json"
// Copy moveo_webhooks.go into your module under, for example,
// internal/moveowebhooks, and import it from there.
import mw "yourmodule/internal/moveowebhooks"
type OrderContext struct {
mw.Context
CustomerID string `json:"customer_id"`
OrderStatus string `json:"order_status,omitempty"`
}
type orderRequest struct {
mw.RequestBase
Input mw.MessageInput `json:"input"`
Intents []mw.Intent `json:"intents"`
Entities []mw.Entity `json:"entities"`
UserMessageCounter int `json:"user_message_counter"`
Debug mw.Debug `json:"debug"`
Context OrderContext `json:"context"`
}
var req orderRequest
if err := json.Unmarshal(rawBody, &req); err != nil {
// handle error
}
resp := mw.DialogWebhookResponse{
Context: map[string]any{"order_status": "shipped"},
}
return json.Marshal(resp)
Context
The session context, with the special user object and reserved system variables:
export interface MoveoContextUser {
readonly user_id: string;
readonly external_id?: string;
display_name?: string;
avatar?: string;
email?: string;
phone?: string;
address?: string;
language?: string;
timezone?: string;
location?: { city?: string; country?: string; region?: string; latitude?: number; longitude?: number };
browser?: string;
platform?: string;
device?: string;
ip?: string;
verified?: boolean;
verified_method?: 'jwt' | 'otp' | 'email' | 'qr';
verified_at?: string;
}
export type MoveoContext<TVars extends Record<string, unknown> = Record<string, unknown>> = TVars & {
user?: MoveoContextUser;
tags?: string[];
channels?: Record<string, Record<string, unknown>>;
global?: Record<string, unknown>;
campaign?: { campaign_id?: string; subscriber_id?: string; sender_id?: string };
live_instructions?: string | Record<string, unknown> | null;
// Read only — managed by Moveo and rejected when overwritten in a response.
'sys-channel'?: string;
'sys-session'?: string;
'sys-business'?: 'open' | 'closed';
'sys-user_message_counter'?: number;
'sys-unknown_counter'?: number;
};
Request bodies
All four request types share a common envelope and add type-specific fields. TVars is your interface describing the custom variables the AI Agent collects.
export interface MoveoWebhookRequestBase<TVars extends Record<string, unknown> = Record<string, unknown>> {
channel: string;
channel_type: string;
session_id: string;
desk_id: string;
integration_id: string;
brain_id: string;
lang: string;
context: MoveoContext<TVars>;
timestamp: number;
history?: MoveoHistoryMessage[]; // present only with X-Moveo-Include-History: true
}
export interface DialogWebhookRequest<TVars> extends MoveoWebhookRequestBase<TVars> {
input: MoveoMessageInput;
intents: MoveoIntent[];
entities: MoveoEntity[];
user_message_counter: number;
debug: { dialog_stack: { node_id: string; name: string }[] };
}
export interface FirstMessageWebhookRequest<TVars> extends MoveoWebhookRequestBase<TVars> {
input: MoveoMessageInput;
business_closed: boolean;
}
export interface PreMessageWebhookRequest<TVars> extends MoveoWebhookRequestBase<TVars> {
input: MoveoMessageInput;
business_closed: boolean;
}
export interface PostMessageWebhookRequest<TVars> extends MoveoWebhookRequestBase<TVars> {
input: MoveoMessageInput;
intents: MoveoIntent[];
entities: MoveoEntity[];
user_message_counter: number;
output: MoveoAgentAction[]; // broader than MoveoResponseAction — see below
debug: { dialog_stack: { node_id: string; name: string }[] };
}
Response actions
What your endpoint can put inside a responses array. Dialog and post message webhooks accept these; first message and pre message webhooks ignore them.
export type MoveoResponseAction =
| { type: 'text'; texts: string[]; options?: { label: string; text: string }[] }
| { type: 'image' | 'video' | 'audio' | 'file'; url: string; name?: string; size?: number }
| { type: 'event'; trigger_node_id: string }
| { type: 'webview'; name: string; label: string; url: string; height?: 'tall' | 'compact' | 'full'; trigger_node_id?: string }
| { type: 'carousel'; cards: MoveoCarouselCard[]; action_id: string }
| { type: 'reset' };
Agent actions
The post message webhook's output field is broader than MoveoResponseAction — the agent can plan additional action types (handover, url, tag, reminder, internal webhook calls) that you cannot return from your own endpoint:
export type MoveoAgentAction = (
| MoveoResponseAction
| { type: 'handover'; external: boolean }
| { type: 'url'; url: string }
| { type: 'tag'; tags: string[] }
| { type: 'reminder'; reminder_seconds: number; trigger_node_id: string }
| { type: 'webhook'; webhook_id: string; fallback: MoveoAgentAction[] }
) & {
action_id?: string;
metadata?: Record<string, string | number | boolean | string[]>;
};
Response bodies
The shape your endpoint returns, per webhook type:
export interface DialogWebhookResponse<TVars> {
context?: Partial<MoveoContext<TVars>>;
responses?: MoveoResponseAction[];
}
export interface PreMessageWebhookResponse<TVars> {
context?: Partial<MoveoContext<TVars>>;
input?: MoveoMessageInput; // rewrite the user input before the agent sees it
}
export type FirstMessageWebhookResponse<TVars> = PreMessageWebhookResponse<TVars>;
export interface PostMessageWebhookResponse<TVars> {
context?: Partial<MoveoContext<TVars>>;
responses?: MoveoResponseAction[]; // if present, replaces the agent's planned reply
}
Next steps
- Webhooks — concept overview, types, UI configuration
- Dialog webhooks — how to wire a dialog webhook into a workflow