Build an AI reply agent
Build an AI reply agent that answers WhatsApp, Messenger and Instagram messages in Flownally with a language model (LLM).
Receive → Verify → Queue → Load history → Reply or hand off
The examples are in Node.js 18 or later, Python and PHP 8, and work with any model provider. To build a WhatsApp bot without writing code, use the no-code WhatsApp chatbot builder instead.
What you need
- A service-account API key. See Authentication. The agent calls endpoints that require
conversation:read,message:createandconversation:assign. Webhook setup requireswebhook:readandwebhook:update. - A public webhook endpoint. See Webhooks for setup, event types and retries.
- A durable queue or background worker. The webhook handler can then respond before the model finishes.
- Access to a language model. Use your provider's SDK or HTTP API.
- The user ID of the teammate who takes over. List people with
GET /users, or a team's members withGET /teams/{id}/agents.
Subscribe to the message.created webhook
Add message.created to the webhook's eventTypes with PATCH /webhooks. GET /webhooks returns the event types you already receive.
curl -X PATCH "https://api.flownally.com/v1/webhooks" \
-H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"enabled": true,
"url": "https://example.com/flownally/webhooks",
"eventTypes": ["message.created"]
}'Store the signing key, then confirm the endpoint with POST /webhooks:verify.
Flownally delivers events only while the webhook is enabled and verified. Set up the endpoint walks through all three requests.
Receive message.created and acknowledge with 2xx
Each delivery is a JSON envelope. Trimmed example:
{
"id": "whevt_…",
"type": "message.created",
"timestamp": "2026-04-30T10:35:00Z",
"data": {
"conversationId": "cnv_d7pigoip4tfcdebp5mlg",
"inboxId": "ibx_d7pi2efp0k7nba4e1mt0",
"message": {
"id": "msg_d7pj3rljs4umccbo6tdg",
"conversationId": "cnv_d7pigoip4tfcdebp5mlg",
"kind": "message",
"senderType": "customer",
"content": {
"type": "text",
"text": { "body": "Hi, is my order ready?" }
},
"createdAt": "2026-04-30T10:35:00.412305Z"
}
}
}idis the event ID. It equals thewebhook-idheader and stays the same on every retry.data.messageis the message, withid,senderType,kindandcontent.data.conversationIdis the conversation to reply in.data.sessionis the session that handled the message, ornull.
Each delivery attempt times out after 15 seconds, and a model call can take longer.
Verify the signature, enqueue the event, and return 204 right away. Do the rest in a worker.
import express from "express";
import { Webhook } from "standardwebhooks";
const app = express();
const webhook = new Webhook(process.env.FLOWNALLY_WEBHOOK_SIGNING_KEY);
app.post("/flownally/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = webhook.verify(req.body, req.headers);
} catch {
return res.sendStatus(401);
}
if (event.type === "message.created") {
// Your durable queue: skip event ids it has seen, run one job per conversation at a time.
await queue.add(event, { id: event.id, group: event.data.conversationId });
}
res.sendStatus(204);
});
app.listen(3000);- Deduplicate on the event
id. Delivery is at least once, so the same event can arrive twice. - Run one job per conversation at a time. Two quick customer messages then don't produce two overlapping replies.
Ignore messages your agent sent
Without a filter, the agent answers itself. message.created is sent for messages that are sent as well as received, including the replies your agent sends.
data.message.senderType says who caused the entry: customer, user (a teammate), bot, journey, campaign, service_account, inbox or system. Reply only when it's customer.
Also skip entries whose kind isn't message. observation, note and system entries don't travel over a channel.
function isCustomerMessage(message) {
return message.senderType === "customer" && (message.kind ?? "message") === "message";
}Load recent conversation history
GET /conversations/{id}/messages returns messages newest first. pageSize defaults to 50 and is capped at 100.
The response also includes conversation, with the activeOwner, messagingWindow and actionContext the agent needs.
Don't pass sessionId here
With sessionId, the response omits messagingWindow and actionContext.
const API = "https://api.flownally.com/v1";
async function flownally(path, { method = "GET", body, headers } = {}) {
const response = await fetch(`${API}${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}`,
"Content-Type": "application/json",
...headers,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const { code } = await response.json().catch(() => ({}));
throw Object.assign(new Error(`Flownally returned ${response.status}`), { status: response.status, code });
}
return response.status === 204 ? null : response.json();
}
async function loadConversation(conversationId, incomingMessageId) {
const page = await flownally(`/conversations/${conversationId}/messages?pageSize=20`);
const history = page.messages
.filter((m) => m.id !== incomingMessageId)
.filter((m) => (m.kind ?? "message") === "message" && m.content?.type === "text")
.reverse()
.map((m) => ({
role: m.senderType === "customer" ? "customer" : "business",
text: m.content.text.body,
}));
return { conversation: page.conversation, history };
}The filter keeps text messages only and drops internal notes, so they don't reach the model. The incoming message is passed to the model separately.
Generate a reply with your language model
Keep the model behind one function that you implement with your provider. It returns either a reply or a handoff.
// Returns { type: "reply", text } or { type: "handoff" }.
async function generateReply({ history, customerMessage }) {
// Build a prompt from your instructions, the history and the customer message,
// call your model, and parse its answer into one of the two shapes.
throw new Error("Implement generateReply with your model provider");
}Give the model instructions such as:
- Answer only from information you provide, such as your help content or order data.
- Return a handoff when you're unsure, when the customer asks for a person, or when the topic needs the team, such as refunds or complaints.
- Keep replies short.
Treat the customer's text as data, not as instructions to the model.
For tone and wording, see how to automate customer replies without sounding like a robot.
Reply with an Idempotency-Key derived from the message ID
The same event can arrive twice, and your worker can retry. Without a stable key, each run can send the customer another reply.
Send the reply with POST /conversations/{id}/messages. Set Idempotency-Key to a value derived from the incoming message ID, such as ai-reply-msg_d7pj3rljs4umccbo6tdg. A retry with the same key and the same body returns the original message instead of sending another one.
The conversation can also change while the model is working: a new message arrives, a new session starts, or the owner changes. Pass the actionContext from the history response as expectedContext, so Flownally rejects a stale reply.
In that case, Flownally answers 409 with code messaging_inbox_context_changed, and nothing is sent. Drop the reply. A newer customer message has its own message.created event, and a new owner means someone else is handling the conversation.
const SKIP_CODES = ["messaging_inbox_context_changed", "messaging_messaging_idempotency_conflict"];
async function sendReply(conversation, incomingMessageId, text) {
try {
await flownally(`/conversations/${conversation.id}/messages`, {
method: "POST",
headers: { "Idempotency-Key": `ai-reply-${incomingMessageId}` },
body: {
expectedContext: conversation.actionContext,
content: { type: "text", text: { body: text } },
},
});
} catch (error) {
if (error.status === 409 && SKIP_CODES.includes(error.code)) return;
throw error;
}
}A model can produce different text on a second run. Reusing the key with a different body within 24 hours returns 409 with code messaging_messaging_idempotency_conflict instead of sending.
Store the reply text and actionContext with the job before you send, so a retried job sends an identical request.
Let other errors, such as 429 and 5xx, fail the job so your queue retries it with the same key.
Hand off the conversation to a person
Reassign the conversation with POST /conversations/{id}:reassign. The body takes the userId of the teammate. A successful call returns 204.
async function handOff(conversationId) {
await flownally(`/conversations/${conversationId}:reassign`, {
method: "POST",
body: { userId: process.env.FLOWNALLY_HANDOFF_USER_ID },
});
}Hand off when:
- the model returns a handoff,
- the customer sends content the model can't read, such as media or a location, or
- the reply window has closed.
To tell the customer first, send a short text with its own key, such as ai-handoff-msg_d7pj3rljs4umccbo6tdg, before you reassign.
Before every reply, check conversation.activeOwner. If its type is user, a person owns the conversation, and the agent stays silent.
For when a team should answer instead of a bot, see shared inbox vs chatbot.
Respect the WhatsApp reply window
A customer message opens the WhatsApp reply window, so it's usually open when the agent answers. A backlog or a retried job can still run after it closes.
Check conversation.messagingWindow.state from the history response. If it's CLOSED, don't call the model. Hand off so a person can follow up with an approved template.
A reply can also fail after Flownally accepts it, with deliveryStatus failed and failureCode messaging_window_closed. See Handle the WhatsApp 24-hour reply window for templates and safe retries.
Verify webhook signatures
The agent sends messages to customers, so it must act only on requests that Flownally signed. The receiver above verifies webhook-signature with the Standard Webhooks library before it enqueues anything.
For by-hand verification in Node.js, Python and PHP, raw-body handling per framework, and key rotation, see Verify webhook signatures.
Put the agent together
The worker runs this function for each queued message.created event:
Filter → Load history → Check owner and window → Generate → Reply or hand off
export async function handleMessageCreated(event) {
const { message, conversationId } = event.data;
if (!isCustomerMessage(message)) return;
const { conversation, history } = await loadConversation(conversationId, message.id);
if (conversation.activeOwner?.type === "user") return;
if (conversation.messagingWindow?.state === "CLOSED") return handOff(conversationId);
if (message.content?.type !== "text") return handOff(conversationId);
const result = await generateReply({ history, customerMessage: message.content.text.body });
if (result.type === "handoff") return handOff(conversationId);
await sendReply(conversation, message.id, result.text);
}FAQ
Why does the agent reply to its own messages?
Flownally sends message.created for outgoing messages too, the agent's own replies included. Reply only when data.message.senderType is customer and kind is message.
Why did a reply fail with messaging_inbox_context_changed?
messaging_inbox_context_changed?A new message arrived, a new session started or the owner changed while the model was working, so Flownally sent nothing. Drop the reply. A newer customer message has its own message.created event.
Can a retried job send the same reply twice?
Not if it sends the same Idempotency-Key and the same body. Store the reply text and actionContext with the job before you send, because a different body with the same key within 24 hours returns 409 with code messaging_messaging_idempotency_conflict.
What should the agent do when the reply window has closed?
Skip the model and hand the conversation to a person, who can send an approved template. Handle the WhatsApp 24-hour reply window shows the template send.
Why is messagingWindow missing from the history response?
messagingWindow missing from the history response?You passed sessionId. With it, the response omits messagingWindow and actionContext, so call GET /conversations/{id}/messages without it.