> ## Documentation Index
> Fetch the complete documentation index at: https://docs-agents.fpt.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# API integration

> For when you already have a chat interface with your customers: receive messages over a webhook, sign with HMAC, get answers back by callback, and reload conversation history

The API channel is for teams that already have a customer conversation channel: their own app, a self-run Zalo OA, a contact centre, a CRM. Your system forwards customer messages to the platform over a webhook; the agent's answers come back to your webhook as callbacks.

<Info icon="key">
  Both directions share one secret and one signing scheme, so you write the signature function once and reuse it for sending and receiving.
</Info>

## Choosing an integration channel

| Criterion | Live Chat | API channel |
| - | - | - |
| Chat interface | Supplied by FPT.AI | Built by you |
| Integration effort | Paste one script tag | Write a receiving webhook and a sending client |
| Answers | Streamed token by token in real time (SSE) | One callback per complete answer |
| Authentication | A `visitorToken` issued automatically per customer | HMAC-SHA256 signature on every packet |
| Customer identity | Generated by the platform (anonymous) | Supplied by you through `user.id` |
| Inbound attachments | Yes | Yes, if an administrator has enabled them |
| Buttons, carousels | Yes | Yes |

<Note>
  The two are not mutually exclusive. One agent can serve the website widget and your system over the API channel at the same time. Each is a separate connection with its own key.
</Note>

## Environments and domains

| Environment | API channel webhook | History API |
| - | - | - |
| Production | `https://console-agents.fpt.ai` | `https://console-agents.fpt.ai` |
| Staging | `https://console-agents-staging.fpt.ai` | `https://console-agents-staging.fpt.ai` |

<Note>
  Everything sits under one application domain and is told apart by path: the API channel’s inbound message webhook is `https://console-agents.fpt.ai/webhooks/api`, while the history API is `https://console-agents.fpt.ai/direct-bff/…`. Always copy the exact webhook address from Console rather than deriving it.
</Note>

## Connecting the API channel and getting your keys

<Steps>
  <Step title="Sign in to the FPT AI Agent Platform">
    Open Console and sign in with your account.
  </Step>

  <Step title="Pick the agent to configure">
    This agent will answer the messages your system forwards.
  </Step>

  <Step title="Open the Channels tab">
    **Channels** is on the agent's navigation bar.
  </Step>

  <Step title="Click the API tile">
    The API channel configuration panel opens.
  </Step>

  <Step title="Fill in the channel configuration">
    Two fields - see the table below.
  </Step>

  <Step title="Click Save configuration to create the channel">
    The API tile on the Channels tab switches to **Configured**.
  </Step>

  <Step title="Click Generate new key">
    Copy the **routing key** and the **signing key** before you close the dialog.
  </Step>
</Steps>

<img src="https://mintcdn.com/fpt-62e894b4/OEIa1zuMs6GjYr6X/images/en_api_config.jpg?fit=max&auto=format&n=OEIa1zuMs6GjYr6X&q=85&s=76dfea6991fa8ede2ebc2e27f1292616" alt="API channel configuration panel" width="1562" height="784" data-path="images/en_api_config.jpg" />

| Field | Description | Required |
| - | - | - |
| Callback URL | Where the platform sends the agent's answers back to you. Must be `https`, at most 2048 characters, and publicly reachable on the internet. You can leave it empty if your endpoint is not ready; the channel still receives messages, but no answers are sent back | Optional |
| Idle window (hours) | If a conversation goes quiet for longer than this, the customer's next message opens a new one. Between 1 and 720 hours, default 24 | Optional |

<Warning>
  The signing key is shown exactly once, at the moment it is created. The system only stores a hash, so there is no way to see it again. Copy it into your secret store before closing the dialog.
</Warning>

### What you get

| Value | Example | What it is |
| - | - | - |
| Routing key | `api_7Kd2xQ9mPz4vR8nLcJt3Aw` | Identifies your connection. Send it in the body's `integrationKey` field, not on the URL |
| Webhook URL | `https://console-agents.fpt.ai/webhooks/api` | Where your system sends customer messages. Shared by every organisation |
| Signing key | (shown once) | The secret used to sign and verify signatures in both directions |
| Callback URL | `https://your-domain.com/agent-replies` | Your webhook, entered in step 5 |

### Testing the callback URL

The **Test callback** button beside the input sends a signed request to your address and expects the right answer back. See Verifying your webhook endpoint below for how to implement it. The button is limited to 10 tests per minute per organisation.

### Rotating the secret

<Steps>
  <Step title="Click Generate new key">
    From this moment, both the old and the new key are accepted.
  </Step>

  <Step title="Roll the new key out to all your servers">
    Re-check both the sending and receiving paths.
  </Step>

  <Step title="Click Finish rotation">
    The old key is disabled. Only the new one works from then on.
  </Step>
</Steps>

<Warning>
  Do not skip the last step. The old key keeps working until you finish the rotation, so forgetting it means you have not actually rotated anything.
</Warning>

## Verifying event payloads

Every packet in both directions is signed with HMAC-SHA256 using the channel secret. The signature travels in the `X-Hub-Signature-256` header with a `sha256=` prefix, alongside an `X-Hub-Timestamp` header carrying the send time.

The signed string is the `timestamp`, a dot, and the raw body:

```text theme={null}
mac = HMAC-SHA256(secret, timestamp + "." + rawRequestBody)
```

* Sign the exact bytes you send. Parsing the JSON into an object and re-serialising it, even if only the key order changes, produces a different signature.
* The timestamp is inside the signature, so an attacker who captures a packet cannot alter it. Send the current Unix time in seconds.
* A timestamp more than 5 minutes off the server clock in either direction is rejected. Keep your servers NTP-synced.
* There are no exceptions. There is no signature-skipping mode, and no exemption for connections that have not generated a key.

### Signing in Go

```go theme={null}
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"strconv"
	"time"
)

func sign(secret string, body []byte, timestamp string) string {
	h := hmac.New(sha256.New, []byte(secret))
	h.Write([]byte(timestamp))
	h.Write([]byte("."))
	h.Write(body)
	return "sha256=" + hex.EncodeToString(h.Sum(nil))
}

func main() {
	// The raw body - exactly the bytes that will be sent.
	body := []byte(`{"integrationKey":"api_7Kd2xQ9mPz4vR8nLcJt3Aw",` +
		`"messageId":"msg-1001","user":{"id":"cust-42"},` +
		`"text":"Hello"}`)
	secretKey := "my_secret_key"
	timestamp := strconv.FormatInt(time.Now().Unix(), 10)
	fmt.Println("X-Hub-Timestamp:", timestamp)
	fmt.Println("X-Hub-Signature-256:", sign(secretKey, body, timestamp))
}
```

### Signing in Node.js

```js theme={null}
const crypto = require('crypto');

function sign(secret, body, timestamp) {
  return 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(timestamp + '.' + body)
    .digest('hex');
}

// The raw body - exactly the string that will be sent.
const body = JSON.stringify({
  integrationKey: 'api_7Kd2xQ9mPz4vR8nLcJt3Aw',
  messageId: 'msg-1001',
  user: { id: 'cust-42' },
  text: 'Hello'
});

const secretKey = 'my_secret_key';
const timestamp = Math.floor(Date.now() / 1000).toString();
console.log('X-Hub-Timestamp:', timestamp);
console.log('X-Hub-Signature-256:', sign(secretKey, body, timestamp));
```

### Verifying signatures the platform sends you

Reuse the same function, then compare with a constant-time comparison so nothing leaks through timing:

```js theme={null}
const express = require('express');
const crypto = require('crypto');
const app = express();

// Required: keep the RAW body so you can compute the signature.
app.use(express.raw({ type: 'application/json' }));

app.post('/agent-replies', (req, res) => {
  const ts  = req.get('X-Hub-Timestamp') || '';
  const got = req.get('X-Hub-Signature-256') || '';
  const raw = req.body;                       // Buffer, not JSON.parse-d

  // 1. Check the clock skew first (5 minutes max).
  const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(ts));
  if (!Number.isFinite(skew) || skew > 300) return res.sendStatus(401);

  // 2. Compare signatures.
  const want = sign(process.env.CHANNEL_SECRET, raw, ts);
  const a = Buffer.from(got), b = Buffer.from(want);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(raw.toString('utf8'));

  // 3. Return 200 IMMEDIATELY, then process asynchronously.
  res.sendStatus(200);
  handleAsync(event);
});
```

<Warning>
  Most signature failures come from a web framework parsing the body into an object before you can read it. Configure it to keep the raw bytes (`express.raw`, `bodyParser.raw`, or read the input stream directly) and parse the JSON only after verification.
</Warning>

## Receiving customer messages

Messages your customers send to your system are forwarded to the platform over the webhook. The event has two parts: the payload, and the signature that authenticates it.

```mermaid theme={null}
sequenceDiagram
  participant C as Your core app
  participant S as FPT AI Agents Webhook Server
  C->>S: POST /webhooks/api - user message event
  Note over C,S: Headers X-Hub-Timestamp and X-Hub-Signature-256
  S->>S: Verify HMAC-SHA256
  S->>S: Replay protection by messageId
  S->>S: Process the event, hand it to AI Agents
  S-->>C: 200 OK
```

| HTTP Method | URI |
| - | - |
| POST | `/webhooks/api` |

| HTTP header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `X-Hub-Timestamp` | Unix time in seconds |
| `X-Hub-Signature-256` | `sha256=…` |

### A text message

```json theme={null}
{
  "integrationKey": "api_7Kd2xQ9mPz4vR8nLcJt3Aw",
  "messageId": "msg-1001",
  "user": {
    "id": "cust-42",
    "name": "Nguyen Van A",
    "avatar": "https://example.com/avatar.png",
    "locale": "vi"
  },
  "text": "Where is my order?",
  "metadata": { "orderId": "SO-7781" }
}
```

### A message from a button or quick reply

```json theme={null}
{
  "integrationKey": "api_7Kd2xQ9mPz4vR8nLcJt3Aw",
  "messageId": "msg-1002",
  "user": { "id": "cust-42" },
  "text": "Track order",
  "postback": "track:SO-7781"
}
```

### A message with an attachment

```json theme={null}
{
  "integrationKey": "api_7Kd2xQ9mPz4vR8nLcJt3Aw",
  "messageId": "msg-1003",
  "user": { "id": "cust-42" },
  "text": "Here is a photo of the faulty product",
  "attachments": [
    {
      "url": "https://example.com/files/faulty-product.jpg",
      "name": "faulty-product.jpg",
      "mimeType": "image/jpeg"
    }
  ]
}
```

### Parameters

| Parameter | Example | Description | Required |
| - | - | - | - |
| `integrationKey` | `api_7Kd2xQ9…` | The channel routing key from Console. Up to 256 characters | Required |
| `messageId` | `msg-1001` | A message id you generate. It is the end-to-end deduplication key. Up to 256 characters | Required |
| `user` | | The sender object | Required |
| `user.id` | `cust-42` | The customer's id in your system. Up to 256 characters | Required |
| `user.name` | `Nguyen Van A` | Customer name. Up to 256 characters | Optional |
| `user.avatar` | `https://…/a.png` | Customer picture. Up to 2048 characters | Optional |
| `user.locale` | `vi` | The customer's language. Up to 256 characters | Optional |
| `text` | `Where is my order?` | The message content. Up to 16,384 characters. Always required, even when `postback` is present | Required |
| `postback` | `track:SO-7781` | The hidden payload of the button or quick reply they tapped. Up to 1024 characters | Optional |
| `metadata` | `{"orderId":"SO-7781"}` | Your own additional data. Up to 32 keys and 8192 bytes once JSON-encoded | Optional |
| `attachments` | | The attachment list. Up to 5 items | Optional |
| `attachments[].url` | `https://…/a.jpg` | The file URL; the platform downloads it. Up to 2048 characters | Required |
| `attachments[].name` | `photo.jpg` | File name. Up to 256 characters | Optional |
| `attachments[].mimeType` | `image/jpeg` | The file's content type | Optional |

<Warning>
  `text` is always required, even for a message that only carries a `postback`. The customer's turn is stored and shown to staff when they take over the conversation, so a wordless `postback` leaves an empty bubble in their inbox. Send the button label as `text`.
</Warning>

<Note>
  `postback` reaches the agent as separate context and is never treated as something the customer said. That is how the agent tells apart what a person typed from what your interface carried along. One field covers both buttons and quick replies.
</Note>

### A cURL example

```bash theme={null}
curl --location 'https://console-agents.fpt.ai/webhooks/api' \
  --header 'Content-Type: application/json' \
  --header 'X-Hub-Timestamp: 1755763200' \
  --header 'X-Hub-Signature-256: sha256=c22b7f7c1957fb301d6181c5fbf4dc841d735af48ddc14e9a4dade1658fdc3e5' \
  --data '{
    "integrationKey": "api_7Kd2xQ9mPz4vR8nLcJt3Aw",
    "messageId": "msg-1001",
    "user": { "id": "cust-42", "name": "Nguyen Duc Minh" },
    "metadata": { "customer_id": "2449956338403583" },
    "text": "hello"
  }'
```

### Response codes

| Status | Meaning | What to do |
| - | - | - |
| 200 | Accepted | Nothing. The agent answers asynchronously by callback |
| 400 | Invalid body: bad JSON, a missing required field, or a field over its limit | Fix the request. Retrying will not help |
| 401 | Invalid signature or timestamp | Check the secret and the server clock. The empty response body is deliberate |
| 405 | Wrong method | Only POST is accepted |
| 503 | The platform could not accept it | Retry with backoff. This is not a rejected message |

<Note>
  200 means **accepted**, not answered. The agent works asynchronously and the answer reaches your webhook afterwards.
</Note>

### Deduplication

`messageId` is the end-to-end deduplication key. A message with the same (`integrationKey`, `messageId`) pair resent within 5 minutes is recognised and skipped, so retrying after a timeout does not give your customer two answers.

<Warning>
  Use your own stable message id. Do not generate a new one per retry - that disables deduplication entirely.
</Warning>

### Inbound attachments

Each item in `attachments` needs a `url` the platform can fetch. The file contents are copied into your organisation's storage and handed to the agent, so the URL you supply does not need to live long afterwards.

<Note>
  Attachment upload is off until an administrator enables it. When it is off, and for any file that is over the limit or fails to download, the file is skipped but `text` still reaches the agent. A message with attachments is therefore never an error, and you cannot tell from the response code whether the file was taken.
</Note>

### Additional data

Your `metadata` object reaches the agent nested under `client_metadata`. It is never merged into the top level, because the keys there - the recipient identifiers above all - decide who the answer goes to.

Unknown fields at the top level of the body are ignored, so you can add your own without being rejected.

## Agent message callbacks

The agent's answers are sent to your webhook as a signed POST. Like the inbound direction, the event has two parts: the payload and its signature.

```mermaid theme={null}
sequenceDiagram
  participant M as Message Dispatching
  participant W as Your API webhook
  M->>W: POST callback_url - the agent's answer
  Note over M,W: Header X-Hub-Signature-256
  W->>W: Verify HMAC-SHA256
  W->>W: Deduplicate by eventId
  W->>W: Deliver the answer to the user
  W-->>M: 200 OK
  Note over M,W: Anything other than 2xx is retried up to 3 times (1s, 3s, 9s) then moved to the DLQ
```

### Verifying your webhook endpoint

Before the first real message, you can ask the platform to prove your endpoint is the one it thinks it is. The **Test callback** button on Console sends a signed request:

```mermaid theme={null}
sequenceDiagram
  participant P as FPT AI Agents Platform
  participant W as Your webhook server
  P->>W: POST callback_url - type verification, challenge 6326e43c...
  W->>W: Verify the X-Hub-Signature-256 signature
  W-->>P: 200 OK with challenge 6326e43c...
  P->>P: Compare the challenge
  Note over P,W: A match gives ok, a mismatch gives challenge_mismatch
```

```json theme={null}
{
  "type": "verification",
  "challenge": "6326e43c9f0b4a1d8e77c0b511a2d3f4",
  "occurredAt": "2026-09-07T11:08:40Z"
}
```

To pass, return a 2xx with a JSON body echoing the exact `challenge`:

```js theme={null}
app.post('/agent-replies', (req, res) => {
  // ... verify the signature as above ...
  const event = JSON.parse(raw.toString('utf8'));

  if (event.type === 'verification') {
    return res.json({ challenge: event.challenge });
  }

  res.sendStatus(200);
  handleAsync(event);
});
```

* The verification request is signed exactly like every other packet, so the function you already wrote works on it.
* `type` only appears on verification requests, never on a real message.
* Echoing `challenge` is mandatory. A bare 200 is not enough: a parked domain, a CDN error page and a load balancer all return 200.
* Each verification uses a different `challenge`.
* The platform does not follow redirects. A 301, 302 or 303 loses the request body, so it can never echo correctly.
* Nothing is stored. A pass means your endpoint answered correctly at that moment.

| Failure reason | What it means and what to do |
| - | - |
| `no_secret` | There is no secret yet, so the platform cannot sign. Generate a key first |
| `url_forbidden` | Not an absolute `https` URL, or it resolves to a non-public address |
| `unreachable` | Could not connect. Check the service is running and reachable from the internet |
| `http_status` | The endpoint returned a non-2xx code; the exact one is in the `httpStatus` field |
| `challenge_mismatch` | Returned 2xx but did not echo the `challenge` |

### The callback event structure

```http theme={null}
POST <callback_url>
Content-Type: application/json
User-Agent: fpt-agent-platform-channel-gateway/1
X-Hub-Timestamp: 1755763260
X-Hub-Signature-256: sha256=…
```

| Field | Type | Description |
| - | - | - |
| `eventId` | string | The event id, stable across retries. Use it as the deduplication key |
| `integrationKey` | string | The channel routing key |
| `conversationId` | string | The conversation id. This is the value you pass to the history API. Store it |
| `runId` | string | The id of one answer turn. A new value each turn |
| `from` | `bot` or `operator` | Whether this turn came from the agent or from a human who took over |
| `user.id` | string | The customer id, exactly as you sent it |
| `text` | string | The text content. Always present, even alongside buttons or carousels |
| `buttons` | Array\<Button> | Optional: a list of buttons |
| `quick_replies` | Array\<QuickReply> | Optional: a list of quick replies |
| `carousels` | Array\<Carousel> | Optional: a list of carousel cards |
| `references` | Array\<Reference> | Optional: the sources the answer cited |
| `attachments` | Array\<Attachment> | Optional. See the note at the end of this section |
| `occurredAt` | string | When it happened, RFC 3339 UTC |

<Note>
  **On naming**: the outer fields are lowerCamelCase (`eventId`, `integrationKey`, `conversationId`, `occurredAt`), while the content blocks and the fields inside them are snake\_case (`quick_replies`, `sub_title`, `image_url`, `file_name`). That is deliberate, not an oversight: the content blocks share a vocabulary with the chat widget and with conversation history.
</Note>

#### Button

| Field | Type | Description |
| - | - | - |
| `type` | `postback`, `url`, `phone_call`, `webview` | The button type |
| `title` | string | The button label |
| `data` | string | Optional: the hidden payload, when `type` is `postback`. Send this value back in `postback` when the customer taps it |
| `url` | string | Optional: the link, when `type` is `url` or `webview` |
| `phone` | string | Optional: the phone number, when `type` is `phone_call` |

#### QuickReply

| Field | Type | Description |
| - | - | - |
| `type` | `text` or `phone_call` | The quick reply type |
| `title` | string | The quick reply label |
| `data` | string | Optional: the hidden payload, when `type` is `text` |
| `phone` | string | Optional: the phone number, when `type` is `phone_call` |

#### Carousel

| Field | Type | Description |
| - | - | - |
| `title` | string | The carousel card title |
| `sub_title` | string | Optional: a subtitle |
| `image_url` | string | Optional: an image URL |
| `buttons` | Array\<Button> | Optional: the card's buttons |

<Note>
  An image with buttons is sent as a carousel with a single item.
</Note>

#### Reference

| Field | Type | Description |
| - | - | - |
| `id` | string | The source's identifier within this answer turn |
| `type` | `knowledge` or `web_search` | From the knowledge base or from a web search. An unknown value still renders as an ordinary card |
| `title` | string | Optional: the source title |
| `url` | string | Optional: the link, for web sources |
| `file_name` | string | Optional: the file name, for knowledge base sources |
| `uri` | string | Optional: the document identifier in the knowledge base |
| `page` | number | Optional: the page number in the document |
| `metadata` | object | Optional: data specific to each source type |

#### Attachment

| Field | Type | Description |
| - | - | - |
| `name` | string | The file name |
| `mime` | string | The file's content type |
| `size` | number | The file size in bytes |
| `objectKey` | string | The storage object key. Not a download link. Exchange it for a short-lived link through the presign API |

<Note>
  No platform feature currently attaches files to an API channel answer turn, so `attachments` has never appeared on a callback. The field is reserved for future compatibility. Inbound attachments work normally.
</Note>

### Example payloads

**A text message with no buttons**

```json theme={null}
{
  "eventId": "out:run-8891:0",
  "integrationKey": "api_7Kd2xQ9mPz4vR8nLcJt3Aw",
  "conversationId": "7412",
  "runId": "run-8891",
  "from": "bot",
  "user": { "id": "cust-42" },
  "text": "Your order will be delivered tomorrow.",
  "occurredAt": "2026-09-14T09:14:20Z"
}
```

**A message with buttons**

```json theme={null}
{
  "eventId": "out:run-8892:0",
  "integrationKey": "api_7Kd2xQ9mPz4vR8nLcJt3Aw",
  "conversationId": "7412",
  "runId": "run-8892",
  "from": "bot",
  "user": { "id": "cust-42" },
  "text": "What would you like to do next?",
  "buttons": [
    { "type": "postback",   "title": "Track order", "data": "track:SO-7781" },
    { "type": "phone_call", "title": "Call us",     "phone": "+84982123456" },
    { "type": "url",        "title": "Website",     "url": "https://example.com" }
  ],
  "occurredAt": "2026-09-14T09:15:02Z"
}
```

**Quick replies**

```json theme={null}
{
  "eventId": "out:run-8893:0",
  "integrationKey": "api_7Kd2xQ9mPz4vR8nLcJt3Aw",
  "conversationId": "7412",
  "runId": "run-8893",
  "from": "bot",
  "user": { "id": "cust-42" },
  "text": "How can I help?",
  "quick_replies": [
    { "type": "text",       "title": "Returns", "data": "intent:return" },
    { "type": "phone_call", "title": "Call us", "phone": "+84982123456" }
  ],
  "occurredAt": "2026-09-14T09:16:30Z"
}
```

**A carousel**

```json theme={null}
{
  "eventId": "out:run-8894:0",
  "integrationKey": "api_7Kd2xQ9mPz4vR8nLcJt3Aw",
  "conversationId": "7412",
  "runId": "run-8894",
  "from": "bot",
  "user": { "id": "cust-42" },
  "text": "Here are the products that suit you.",
  "carousels": [
    {
      "title": "Helmet A",
      "sub_title": "Meets QCVN 2:2008",
      "image_url": "https://example.com/img/helmet-a.jpg",
      "buttons": [
        { "type": "postback", "title": "Buy", "data": "buy:HELMET-A" },
        { "type": "url", "title": "Details", "url": "https://example.com/helmet-a" }
      ]
    },
    {
      "title": "Helmet B",
      "image_url": "https://example.com/img/helmet-b.jpg",
      "buttons": [
        { "type": "postback", "title": "Buy", "data": "buy:HELMET-B" }
      ]
    }
  ],
  "occurredAt": "2026-09-14T09:18:11Z"
}
```

**An answer with source citations**

```json theme={null}
{
  "eventId": "out:run-8895:0",
  "integrationKey": "api_7Kd2xQ9mPz4vR8nLcJt3Aw",
  "conversationId": "7412",
  "runId": "run-8895",
  "from": "bot",
  "user": { "id": "cust-42" },
  "text": "The returns policy applies for 30 days from delivery.",
  "references": [
    { "id": "r1", "type": "knowledge", "title": "Returns policy",
      "file_name": "policy-2026.pdf", "page": 4 },
    { "id": "r2", "type": "web_search", "title": "Warranty terms",
      "url": "https://example.com/warranty" }
  ],
  "occurredAt": "2026-09-14T09:20:45Z"
}
```

### Handling callbacks

* `text` is always present, even alongside `buttons` or `carousels`. It is the fallback rendering, so a text-only system still gets a coherent conversation.
* Empty lists are absent, not `[]`. `buttons`, `quick_replies`, `carousels` and `references` are omitted entirely when the turn has none.
* `conversationId` and `from` may be absent on callbacks from older platform versions. Treat absence as undetermined.
* Deduplicate on `eventId`, not `runId`.
* Join your data on `conversationId`, not `runId` - a `runId` is only one answer turn.
* Return 200 immediately and process asynchronously. Each call has only 10 seconds; slow processing counts as a failure and triggers a retry.

### When your endpoint fails

| Your response | What the platform does |
| - | - |
| 2xx | Treated as delivered |
| 5xx, 429, 408, timeout, connection error | Retries up to 3 times with backoff (around 1s, 3s, 9s). A `Retry-After` header you send is honoured, up to 30 seconds |
| Any other 4xx, or a redirect | No retry. Treated as permanently rejected |
| Still failing after 3 attempts | Moved to an error queue on the platform side, with an ERROR log line |

<Warning>
  When your endpoint is down for a long stretch, delivery is **at most once**. That is a deliberate trade-off: unlimited retries would clog the shared delivery component. The recovery path is the history API below; the error queue lives on the platform side and is not replayed to you automatically.
</Warning>

### Callback URL requirements

* `https` (http is only usable in internal development).
* No credentials embedded in the URL. You authenticate the platform by signature, not by a secret hidden in a path.
* It must resolve to a public internet address. Loopback, private, link-local, unique-local, multicast and carrier NAT ranges are all rejected.
* The check runs at connection time, so a domain that resolves to an internal address is rejected too.
* At most 2048 characters.

## Reloading conversation history

This is the recovery path for answer turns you never received. Because callbacks are at-most-once, after 3 failures the turn stays in the error queue; without this API that message would be lost to you.

```http theme={null}
GET https://console-agents.fpt.ai/direct-bff/v1/conversations/7412/messages?after=9001&limit=30
Authorization: Bearer <API key>
```

| Parameter | Description | Required |
| - | - | - |
| `conversationId` | From the field of the same name on the callback. A positive integer | Required |
| `after` | The pagination cursor: the id of the last message you have. Omit it to read from the start of the conversation | Optional |
| `limit` | Messages per page. Default 30, maximum 100. Values outside the range are clamped, not rejected | Optional |

A 200 response:

```json theme={null}
{
  "conversationId": "7412",
  "messages": [
    { "id": "9001", "role": "user", "from": "customer",
      "text": "Where is my order?", "createdAt": "2026-09-14T10:30:00Z",
      "attachments": [
        { "name": "invoice.pdf", "type": "application/pdf", "size": 1024 }
      ] },
    { "id": "9002", "role": "assistant", "from": "bot",
      "text": "Let me check that for you.", "createdAt": "2026-09-14T10:30:04Z",
      "buttons": [
        { "type": "postback", "title": "Track order", "data": "track:SO-7781" }
      ] },
    { "id": "9003", "role": "assistant", "from": "operator",
      "text": "Your order has been processed.", "createdAt": "2026-09-14T10:41:00Z" }
  ],
  "limit": 30,
  "hasMore": false
}
```

* Use the organisation API key, not the channel secret. The secret proves your server is forwarding a message; this is you reading your own data. A `visitorToken` can read its own session only; reading another returns 403 `SESSION_FORBIDDEN`.
* Cursor pagination, oldest first. Pass `after` as the id of the last message you hold; `hasMore` says whether there is another page.
* Message ids are strings, because they exceed JavaScript's safe integer range, but they still sort numerically.
* Read `from`, not `role`. A human operator's turn is stored with the same `role` as the agent's. Here `from` has three values: `customer`, `bot`, `operator`.
* `operator` does not occur yet: the platform has no feature for staff to answer in place of the agent on this channel. The field is reserved for future compatibility.
* Every row returned is a real turn. A row only appears when it has `text`, buttons, quick replies, a carousel, citations or an attachment, so you do not have to filter out empty bubbles.
* A malformed `after` returns 400 rather than being silently ignored.
* One 404 covers three cases: the conversation does not exist, it belongs to another organisation, or it is an internal staff conversation. Deliberately indistinguishable.
* Attachments carry only name, type and size, with no object key.
* Reading history does not consume the run quota. It has its own, 120 calls per minute per API key by default.
* When you exceed the history quota, the 429 comes with only a `Retry-After` header.

<Warning>
  This is a recovery path, not an archive. When a customer is deleted under the data retention policy, all their messages go with them. Sync whatever you need to keep into your own systems.
</Warning>

## API keys and usage quotas

### Creating an API key

The organisation API key is used for the history API and the file download link API. Create one on the **API keys** page in Console.

* Keys look like `sk-` followed by 32 random characters, for example `sk-9Kd2xQ…`.
* A key is shown exactly once, at creation. The system only stores a hash, so there is no way to see it again.
* The key list shows the first 12 characters so you can tell them apart.
* A key acts with the rights of whoever created it, so it grants nothing new.

### Revoking and rotating keys

Revocation is a flag, not a deletion: a revoked key id is still resolvable afterwards so the audit trail stays readable. The list shows revoked keys too.

<Warning>
  Revocation takes up to 30 seconds to bite, because keys are cached. The response carries a `revocationDelaySeconds` field stating exactly that. If a key leaks, revoke it at once and allow for the delay.
</Warning>

Rotating an API key is two steps in this order: create the new key first, revoke the old one second. In between, both work.

### Checking your usage

```http theme={null}
GET https://console-agents.fpt.ai/direct-bff/v1/usage
Authorization: Bearer <API key>
```

```json theme={null}
{
  "day": "2026-09-14",
  "runs": { "used": 42, "limit": 2000, "remaining": 1958,
            "resetsAt": "2026-09-15T00:00:00Z" },
  "rate": { "limit": 20, "window": "1m" }
}
```

* `runs` is the organisation's daily UTC budget, shared across every conversation and every channel. It refills at `resetsAt`, which is 00:00 UTC.
* `rate.limit` is the per-minute limit for each caller.
* Checking usage does not consume quota, so you can call it often.
* API keys only. A `visitorToken` is rejected with 403 `USAGE_FORBIDDEN`.

APIs that count against quota also carry three headers, on successful responses as well as on a 429:

```text theme={null}
X-RateLimit-Limit:     2000
X-RateLimit-Remaining: 1957
X-RateLimit-Reset:     1757894400
```

## Next steps

<CardGroup cols={3}>
  <Card title="Live Chat channel" icon="comments" href="/en/live-chat-channel">
    Use the platform's ready-made chat window instead of building your own.
  </Card>

  <Card title="Mobile SDK" icon="mobile-screen" href="/en/mobile-sdk">
    Bring the chat window into Android and iOS apps.
  </Card>

  <Card title="Technical appendix" icon="table-list" href="/en/technical-appendix">
    Error codes, system limits and the pre-production checklist.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.