# Build an AI reply agent

:::summary Quick answer
An AI reply agent receives the `message.created` webhook, loads recent history with `GET /conversations/{id}/messages`, asks your model for a reply, and sends it with `POST /conversations/{id}/messages`. When the model is unsure or the customer asks for a person, it hands the conversation to a teammate with `POST /conversations/{id}:reassign`.

**Reply only to customer messages, with a key derived from the incoming message ID.**
:::

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](https://www.flownally.com/uses/whatsapp-chatbot-builder) instead.

## What you need

- **A service-account API key.** See [Authentication](/api/authentication). The agent calls endpoints that require `conversation:read`, `message:create` and `conversation:assign`. Webhook setup requires `webhook:read` and `webhook:update`.
- **A public webhook endpoint.** See [Webhooks](/api/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`](/api/reference/users#get-users), or a team's members with [`GET /teams/{id}/agents`](/api/reference/teams#list-team-agents).

## Subscribe to the message.created webhook

Add `message.created` to the webhook's `eventTypes` with [`PATCH /webhooks`](/api/reference/webhooks#update-webhook-settings). `GET /webhooks` returns the event types you already receive.

:::code Subscribe to message.created
```bash
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"]
  }'
```

```js
const response = await fetch("https://api.flownally.com/v1/webhooks", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    enabled: true,
    url: "https://example.com/flownally/webhooks",
    eventTypes: ["message.created"],
  }),
});
if (!response.ok) throw new Error(`Flownally API returned ${response.status}`);
```

```python
import os

import requests

response = requests.patch(
    "https://api.flownally.com/v1/webhooks",
    headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
    json={
        "enabled": True,
        "url": "https://example.com/flownally/webhooks",
        "eventTypes": ["message.created"],
    },
)
response.raise_for_status()
```

```php
$client = new GuzzleHttp\Client();

$client->patch('https://api.flownally.com/v1/webhooks', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
    'json' => [
        'enabled' => true,
        'url' => 'https://example.com/flownally/webhooks',
        'eventTypes' => ['message.created'],
    ],
]);
```
:::

Store the signing key, then confirm the endpoint with [`POST /webhooks:verify`](/api/reference/webhooks#verify-webhook).

Flownally delivers events only while the webhook is enabled and verified. [Set up the endpoint](/api/webhooks#set-up-the-endpoint) walks through all three requests.

## Receive message.created and acknowledge with 2xx

Each delivery is a JSON envelope. Trimmed example:

```json
{
  "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"
    }
  }
}
```

- `id` is the event ID. It equals the `webhook-id` header and stays the same on every retry.
- `data.message` is the message, with `id`, `senderType`, `kind` and `content`.
- `data.conversationId` is the conversation to reply in.
- `data.session` is the session that handled the message, or `null`.

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.

:::code Receive message.created
```js
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);
```

```python
import os

from flask import Flask, request
from standardwebhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
webhook = Webhook(os.environ["FLOWNALLY_WEBHOOK_SIGNING_KEY"])


@app.post("/flownally/webhooks")
def flownally_webhooks():
    try:
        event = webhook.verify(request.get_data(), dict(request.headers))
    except WebhookVerificationError:
        return "", 401

    if event["type"] == "message.created":
        # Your durable queue: skip event ids it has seen, run one job per conversation at a time.
        queue.add(event, id=event["id"], group=event["data"]["conversationId"])
    return "", 204
```

```php
<?php

use StandardWebhooks\Exception\WebhookVerificationException;
use StandardWebhooks\Webhook;

$webhook = new Webhook(getenv('FLOWNALLY_WEBHOOK_SIGNING_KEY'));

try {
    $event = $webhook->verify(file_get_contents('php://input'), array_change_key_case(getallheaders()));
} catch (WebhookVerificationException) {
    http_response_code(401);
    exit;
}

if ($event['type'] === 'message.created') {
    // Your durable queue: skip event ids it has seen, run one job per conversation at a time.
    $queue->add($event, ['id' => $event['id'], 'group' => $event['data']['conversationId']]);
}
http_response_code(204);
```
:::

- **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.

:::code Filter customer messages
```js
function isCustomerMessage(message) {
  return message.senderType === "customer" && (message.kind ?? "message") === "message";
}
```

```python
def is_customer_message(message):
    return message["senderType"] == "customer" and message.get("kind", "message") == "message"
```

```php
function isCustomerMessage(array $message): bool
{
    return $message['senderType'] === 'customer' && ($message['kind'] ?? 'message') === 'message';
}
```
:::

## Load recent conversation history

[`GET /conversations/{id}/messages`](/api/reference/messaging#list-conversation-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.

:::callout Don't pass sessionId here
With `sessionId`, the response omits `messagingWindow` and `actionContext`.
:::

:::code Load history
```js
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 };
}
```

```python
import os

import requests

API = "https://api.flownally.com/v1"


def flownally(path, method="GET", body=None, headers=None):
    response = requests.request(
        method,
        f"{API}{path}",
        headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}", **(headers or {})},
        json=body,
    )
    response.raise_for_status()
    return response.json() if response.content else None


def load_conversation(conversation_id, incoming_message_id):
    page = flownally(f"/conversations/{conversation_id}/messages?pageSize=20")
    history = [
        {
            "role": "customer" if m["senderType"] == "customer" else "business",
            "text": m["content"]["text"]["body"],
        }
        for m in reversed(page["messages"])
        if m["id"] != incoming_message_id
        and m.get("kind", "message") == "message"
        and m.get("content", {}).get("type") == "text"
    ]
    return page["conversation"], history
```

```php
function flownally(string $path, string $method = 'GET', ?array $body = null, array $headers = []): ?array
{
    $client = new GuzzleHttp\Client();
    $response = $client->request($method, "https://api.flownally.com/v1{$path}", [
        'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')] + $headers,
        'json' => $body,
    ]);
    return json_decode((string) $response->getBody(), true);
}

function loadConversation(string $conversationId, string $incomingMessageId): array
{
    $page = flownally("/conversations/{$conversationId}/messages?pageSize=20");
    $history = [];
    foreach (array_reverse($page['messages']) as $m) {
        $isText = ($m['kind'] ?? 'message') === 'message' && ($m['content']['type'] ?? null) === 'text';
        if ($m['id'] !== $incomingMessageId && $isText) {
            $history[] = [
                'role' => $m['senderType'] === 'customer' ? 'customer' : 'business',
                'text' => $m['content']['text']['body'],
            ];
        }
    }
    return [$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.

:::code Generate a reply
```js
// 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");
}
```

```python
# Returns {"type": "reply", "text": ...} or {"type": "handoff"}.
def generate_reply(history, customer_message):
    # 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.
    raise NotImplementedError("Implement generate_reply with your model provider")
```

```php
// Returns ['type' => 'reply', 'text' => ...] or ['type' => 'handoff'].
function generateReply(array $history, string $customerMessage): array
{
    // 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 LogicException('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](https://www.flownally.com/blog/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`](/api/reference/messaging#send-conversation-message). 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.

:::code Send the reply
```js
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;
  }
}
```

```python
SKIP_CODES = {"messaging_inbox_context_changed", "messaging_messaging_idempotency_conflict"}


def send_reply(conversation, incoming_message_id, text):
    try:
        flownally(
            f"/conversations/{conversation['id']}/messages",
            method="POST",
            headers={"Idempotency-Key": f"ai-reply-{incoming_message_id}"},
            body={
                "expectedContext": conversation["actionContext"],
                "content": {"type": "text", "text": {"body": text}},
            },
        )
    except requests.HTTPError as error:
        response = error.response
        if not (response.status_code == 409 and response.json().get("code") in SKIP_CODES):
            raise
```

```php
const SKIP_CODES = ['messaging_inbox_context_changed', 'messaging_messaging_idempotency_conflict'];

function sendReply(array $conversation, string $incomingMessageId, string $text): void
{
    try {
        flownally("/conversations/{$conversation['id']}/messages", 'POST', [
            'expectedContext' => $conversation['actionContext'],
            'content' => ['type' => 'text', 'text' => ['body' => $text]],
        ], ['Idempotency-Key' => "ai-reply-{$incomingMessageId}"]);
    } catch (GuzzleHttp\Exception\ClientException $e) {
        $response = $e->getResponse();
        $code = json_decode((string) $response->getBody(), true)['code'] ?? null;
        if ($response->getStatusCode() !== 409 || !in_array($code, SKIP_CODES, true)) {
            throw $e;
        }
    }
}
```
:::

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`](/api/reference/messaging#reassign-conversation). The body takes the `userId` of the teammate. A successful call returns `204`.

:::code Hand off
```js
async function handOff(conversationId) {
  await flownally(`/conversations/${conversationId}:reassign`, {
    method: "POST",
    body: { userId: process.env.FLOWNALLY_HANDOFF_USER_ID },
  });
}
```

```python
def hand_off(conversation_id):
    flownally(
        f"/conversations/{conversation_id}:reassign",
        method="POST",
        body={"userId": os.environ["FLOWNALLY_HANDOFF_USER_ID"]},
    )
```

```php
function handOff(string $conversationId): void
{
    flownally("/conversations/{$conversationId}:reassign", 'POST', [
        'userId' => getenv('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](https://www.flownally.com/blog/shared-inbox-vs-chatbot-ecommerce).

## 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](/api/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](/api/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**

:::code Handle message.created
```js
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);
}
```

```python
def handle_message_created(event):
    message, conversation_id = event["data"]["message"], event["data"]["conversationId"]
    if not is_customer_message(message):
        return

    conversation, history = load_conversation(conversation_id, message["id"])
    if (conversation.get("activeOwner") or {}).get("type") == "user":
        return
    if (conversation.get("messagingWindow") or {}).get("state") == "CLOSED":
        return hand_off(conversation_id)
    if message["content"]["type"] != "text":
        return hand_off(conversation_id)

    result = generate_reply(history, message["content"]["text"]["body"])
    if result["type"] == "handoff":
        return hand_off(conversation_id)

    send_reply(conversation, message["id"], result["text"])
```

```php
function handleMessageCreated(array $event): void
{
    ['message' => $message, 'conversationId' => $conversationId] = $event['data'];
    if (!isCustomerMessage($message)) {
        return;
    }

    [$conversation, $history] = loadConversation($conversationId, $message['id']);
    if (($conversation['activeOwner']['type'] ?? null) === 'user') {
        return;
    }
    if (($conversation['messagingWindow']['state'] ?? null) === 'CLOSED' || $message['content']['type'] !== 'text') {
        handOff($conversationId);
        return;
    }

    $result = generateReply($history, $message['content']['text']['body']);
    if ($result['type'] === 'handoff') {
        handOff($conversationId);
        return;
    }

    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`?

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](/api/reply-window) shows the template send.

### Why is `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.

## Related articles

- [Webhooks](/api/webhooks)
- [Verify webhook signatures](/api/webhook-signatures)
- [Handle the WhatsApp 24-hour reply window](/api/reply-window)
- [Errors](/api/errors)
