Skip to main content

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.

note

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:

  1. An HTTPS endpoint that accepts POST requests from Moveo.
  2. HMAC SHA256 verification of the X-Moveo-Signature header.
  3. Body validation with Zod.
  4. 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:

HeaderDescription
X-Moveo-SignatureHMAC SHA256 of the request body, hex-encoded
X-Moveo-Request-IdUnique identifier for this call — log it for correlation
X-Moveo-Session-IdThe conversation session
X-Moveo-Account-IdThe Moveo account
X-Moveo-Account-SlugThe 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.

verify-signature.ts
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);
};

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.

api/orders.ts
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 with sys- or channels..
  • Individual user.* fields can be updated using dotted-path notation (e.g. "user.email": "alice@example.com"); the user object 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-Id as 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 history in the body, add X-Moveo-Include-History: true as a custom header on the webhook configuration. Without it, history is omitted to keep payloads small.
  • Choose fail_on_error deliberately — 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.

api/live-instructions.ts
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;
tip

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.

api/check-account.ts
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.

api/post-message.ts
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.

LanguageFile
TypeScriptmoveo-webhooks.ts
Python (Pydantic v2)moveo_webhooks.py
Gomoveo_webhooks.go

Using the types

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' },
};
};

Context

The session context, with the special user object and reserved system variables:

moveo-webhooks.ts
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.

moveo-webhooks.ts
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.

moveo-webhooks.ts
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:

moveo-webhooks.ts
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:

moveo-webhooks.ts
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