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

# Channels

> Send a message, a WhatsApp message template, a WhatsApp reaction, or an email from an agent, and read delivery records

The channels routes send outbound messages from an agent on a connected [channel](/concepts/channels) and expose the delivery record every send creates. They are the REST twin of the [`Channels`](/reference/sdk/channels) runtime object: same bodies, same `deliveryId`, same statuses.

*Verified against lua-cli 3.33.0.*

## Base URL and authentication

Every route needs `channels:send` on the agent; the host, the bearer header, and the error envelope are on the [REST API overview](/reference/rest/overview). Each send route honors an `X-Idempotency-Key` header: replaying a key returns the first send's stored outcome instead of sending again, for as long as the delivery record lives; a key whose first attempt failed replays the failure; the same key from another agent is a different key.

## Recipients and delivery records

A recipient is addressed by exactly one of `userId`, `phoneNumber`, `email`, or `conversationId`. `userId` works on every channel and resolves the address from the end user's channel history; a raw `phoneNumber` only cold-starts `whatsapp` and `sms`, a raw `email` only `email`. `teams`, `instagram`, and `messenger` are warm-only: the end user must have written first, so address them by `userId`, or on Teams by `conversationId` to post into a shared conversation.

Every send answers the same shape.

<ResponseField name="deliveryId" type="string">
  Id of the delivery record; stable for the send's whole life and accepted by `GET .../deliveries/:deliveryId`.
</ResponseField>

<ResponseField name="status" type="string">
  Where the delivery stands as the call returns: `accepted` on a live send, or `queued` when the WhatsApp window was closed; receipts move it to `sent`, `delivered`, `read`, `failed`, or `expired` later. A replayed key returns wherever the first send has got to, so any status is possible there.
</ResponseField>

<ResponseField name="delivered" type="boolean">
  The vendor accepted the message.
</ResponseField>

<ResponseField name="persisted" type="boolean">
  The message was written to the agent's memory. `false` for a conversation-scoped send, which has no single end user to record against, and on a memory write that failed after a delivered send; that case is still `200`, with `warning` set to `message delivered but not recorded to agent memory`.
</ResponseField>

<ResponseField name="queued" type="boolean">
  `true` when the WhatsApp 24-hour window was closed and the text waits for the end user's reply; `delivered` is `false` until then.
</ResponseField>

<ResponseField name="userId, identifier, messageId, warning" type="string">
  The end user the message was recorded against, the channel-native address used, the vendor message id when the channel returns one, and a warning when something non-fatal happened.
</ResponseField>

## Endpoints

### POST /developer/agents/:agentId/channels/send

Sends a text message on any supported channel.

<ParamField path="agentId" type="string" required>The sending agent.</ParamField>
<ParamField header="X-Idempotency-Key" type="string">Replay key, see [Base URL and authentication](#base-url-and-authentication).</ParamField>

<ParamField body="channel" type="string" required>
  One of `whatsapp`, `sms`, `email`, `webchat`, `teams`, `instagram`, `messenger`.
</ParamField>

<ParamField body="to" type="object" required>
  Exactly one of `userId`, `phoneNumber`, `email`, `conversationId`. `webchat` requires `userId`; `conversationId` is Teams only.
</ParamField>

<ParamField body="text" type="string" required>
  The message. It may contain `:::` [formatting component](/channels/formatting/overview) blocks, which each channel renders its own way.
</ParamField>

<ParamField body="options" type="object">
  `channelIdentifier` pins one of the agent's channel configurations. `whatsapp.onClosedWindow` is `queue` (default: send the platform's message-request template, queue the text, deliver when the end user replies) or `fail` (answer `400` so you can send your own approved WhatsApp message template). A queued send to a `+1` number answers with a `warning`, because Meta blocks the message-request template to US numbers.
</ParamField>

**Response**

`200` with the send result described under [Recipients and delivery records](#recipients-and-delivery-records).

**Errors**

| Status       | Code or message                                                                               | Meaning                                                                                  | Fix                                        |
| ------------ | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------ |
| `400`        | `channel, to, and text are required`                                                          | A required field is missing                                                              | Send all three                             |
| `400`        | `Unknown channel: <value>`                                                                    | `channel` is not in the list                                                             | Use one of the seven names                 |
| `400`        | `to must have exactly one of userId, phoneNumber, email, or conversationId`                   | Zero or several recipient fields                                                         | Send exactly one                           |
| `400`        | `conversationId is only supported on teams`                                                   | Conversation addressing on another channel                                               | Address a person instead                   |
| `400`        | `<channel> is a warm-only channel; address by userId or conversationId`                       | Cold address on Teams, Instagram, or Messenger                                           | Wait for the end user to write first       |
| `400`        | `phoneNumber cold-start not valid for <channel>; use userId`                                  | A phone number on a channel other than WhatsApp or SMS                                   | Address by `userId`                        |
| `400`        | `WhatsApp send requires a phoneNumber or an existing conversation`                            | Closed window and no way to reach the end user                                           | Send a `phoneNumber`                       |
| `400`        | `WhatsApp 24h window is closed; send an approved template via Channels.whatsapp.sendTemplate` | `onClosedWindow: "fail"` and the window is closed                                        | Send a WhatsApp message template           |
| `401`, `403` | `Invalid or expired token`, `Insufficient permissions`                                        | Credential refused, or no `channels:send` on this agent                                  | Fix the key or its scope                   |
| `422`        | `VENDOR_REJECTED`                                                                             | The webchat vendor refused the event as sent (too large, illegal channel); deterministic | Shrink or fix the event                    |
| `503`        | `VENDOR_UNAVAILABLE`                                                                          | The channel vendor failed; the send may have applied, so no `Retry-After` is given       | Check the delivery record before resending |

Equivalent: `Channels.send()`.

<CodeGroup>
  ```ts TypeScript theme={null}
  const response = await fetch('https://api.heylua.ai/developer/agents/<<YOUR_AGENT_ID>>/channels/send', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer <<YOUR_API_KEY>>',
      'Content-Type': 'application/json',
      'X-Idempotency-Key': 'order-1042-shipped',
    },
    body: JSON.stringify({ channel: 'whatsapp', to: { phoneNumber: '+15551234567' }, text: 'Your order 1042 has shipped.' }),
  });
  const sent: { deliveryId: string; status: string; queued?: boolean } = await response.json();
  console.log(sent.deliveryId, sent.status);
  ```

  ```bash cURL theme={null}
  curl -X POST "https://api.heylua.ai/developer/agents/<<YOUR_AGENT_ID>>/channels/send" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>" \
    -H "Content-Type: application/json" \
    -H "X-Idempotency-Key: order-1042-shipped" \
    -d '{ "channel": "whatsapp", "to": { "phoneNumber": "+15551234567" }, "text": "Your order 1042 has shipped." }'
  ```
</CodeGroup>

### POST /developer/agents/:agentId/channels/whatsapp/template

Sends an approved [WhatsApp message template](/channels/whatsapp) to one recipient, which is the only way to start a conversation outside the 24-hour window. The SDK's bulk `Templates.whatsapp.send()` is a different route (`POST /admin/agents/:agentId/channels/:channelId/whatsapp-templates/:templateId/trigger`): its `results[i]` is Meta's raw per-recipient response, with the message id at `messages[0].id`, not the send result documented here.

<ParamField path="agentId" type="string" required>The sending agent.</ParamField>
<ParamField header="X-Idempotency-Key" type="string">Replay key.</ParamField>

<ParamField body="to" type="object" required>
  `userId` or `phoneNumber`. A `userId` needs an existing WhatsApp conversation with the agent.
</ParamField>

<ParamField body="templateName" type="string" required>The template's name as approved by Meta.</ParamField>
<ParamField body="languageCode" type="string">Template language, for example `en_US`.</ParamField>

<ParamField body="components" type="array">
  Meta template components: the header, body, and button parameter values.
</ParamField>

<ParamField body="messageContext" type="string">
  Text recorded in the agent's memory as what the template said; a placeholder is recorded when omitted.
</ParamField>

<ParamField body="options" type="object">`channelIdentifier` pins a channel configuration.</ParamField>

**Response**

`200` with the send result.

**Errors**

| Status | Code or message                                                            | Meaning                                            | Fix                                        |
| ------ | -------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------ |
| `400`  | `to and templateName are required`                                         | A required field is missing                        | Send both                                  |
| `400`  | `to must provide userId or phoneNumber`, `phoneNumber must contain digits` | Bad recipient                                      | Send a `userId` or a numeric `phoneNumber` |
| `404`  | `No WhatsApp conversation window found for userId <id> on agent <id>`      | The end user never wrote to this agent on WhatsApp | Address by `phoneNumber`                   |

Equivalent: `Channels.whatsapp.sendTemplate()`.

<CodeGroup>
  ```ts TypeScript theme={null}
  const response = await fetch('https://api.heylua.ai/developer/agents/<<YOUR_AGENT_ID>>/channels/whatsapp/template', {
    method: 'POST',
    headers: { Authorization: 'Bearer <<YOUR_API_KEY>>', 'Content-Type': 'application/json' },
    body: JSON.stringify({
      to: { phoneNumber: '+15551234567' },
      templateName: 'order_update',
      languageCode: 'en_US',
      components: [{ type: 'body', parameters: [{ type: 'text', text: '1042' }] }],
      messageContext: 'Order 1042 update sent',
    }),
  });
  const sent: { deliveryId: string; status: string } = await response.json();
  console.log(sent.status);
  ```

  ```bash cURL theme={null}
  curl -X POST "https://api.heylua.ai/developer/agents/<<YOUR_AGENT_ID>>/channels/whatsapp/template" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>" \
    -H "Content-Type: application/json" \
    -d '{
      "to": { "phoneNumber": "+15551234567" },
      "templateName": "order_update",
      "languageCode": "en_US",
      "components": [{ "type": "body", "parameters": [{ "type": "text", "text": "1042" }] }],
      "messageContext": "Order 1042 update sent"
    }'
  ```
</CodeGroup>

### POST /developer/agents/:agentId/channels/whatsapp/reaction

Reacts to a WhatsApp message with one emoji.

<ParamField path="agentId" type="string" required>The sending agent.</ParamField>
<ParamField header="X-Idempotency-Key" type="string">Replay key.</ParamField>
<ParamField body="to" type="object" required>`userId` or `phoneNumber`.</ParamField>

<ParamField body="messageId" type="string" required>
  The vendor id (`wamid...`) of the message to react to, for example an inbound message's id from the conversation history. Meta accepts reactions on messages up to 30 days old.
</ParamField>

<ParamField body="emoji" type="string" required>
  A single emoji. An empty string removes the agent's existing reaction.
</ParamField>

<ParamField body="options" type="object">`channelIdentifier` pins a channel configuration.</ParamField>

**Response**

`200` with the send result.

**Errors**

| Status | Code or message                                                       | Meaning                                                   | Fix                                 |
| ------ | --------------------------------------------------------------------- | --------------------------------------------------------- | ----------------------------------- |
| `400`  | `to, messageId, and emoji are required`                               | A required field is missing                               | Send all three; `emoji` may be `""` |
| `404`  | `No WhatsApp conversation window found for userId <id> on agent <id>` | The `userId` has no WhatsApp conversation with this agent | Address by `phoneNumber`            |

Equivalent: `Channels.whatsapp.sendReaction()`.

<CodeGroup>
  ```ts TypeScript theme={null}
  const response = await fetch('https://api.heylua.ai/developer/agents/<<YOUR_AGENT_ID>>/channels/whatsapp/reaction', {
    method: 'POST',
    headers: { Authorization: 'Bearer <<YOUR_API_KEY>>', 'Content-Type': 'application/json' },
    body: JSON.stringify({ to: { phoneNumber: '+15551234567' }, messageId: 'wamid.HBgL...', emoji: '👍' }),
  });
  const sent: { deliveryId: string } = await response.json();
  console.log(sent.deliveryId);
  ```

  ```bash cURL theme={null}
  curl -X POST "https://api.heylua.ai/developer/agents/<<YOUR_AGENT_ID>>/channels/whatsapp/reaction" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>" \
    -H "Content-Type: application/json" \
    -d '{ "to": { "phoneNumber": "+15551234567" }, "messageId": "wamid.HBgL...", "emoji": "👍" }'
  ```
</CodeGroup>

### POST /developer/agents/:agentId/channels/email/send

Sends an email from the agent's [email channel](/channels/email).

<ParamField path="agentId" type="string" required>The sending agent.</ParamField>
<ParamField header="X-Idempotency-Key" type="string">Replay key.</ParamField>

<ParamField body="to" type="object" required>
  `email` for a cold address, or `userId` to resolve the end user's address from their channel history.
</ParamField>

<ParamField body="text" type="string">Plain-text body, sent as the `text/plain` part.</ParamField>
<ParamField body="html" type="string">Exact HTML body, sent as is with no template wrap.</ParamField>

<ParamField body="richBody" type="string">
  Markdown and `:::` component markers, rendered server-side into the branded email template that the agent's own replies use. Send one of `text`, `html`, or `richBody`.
</ParamField>

<ParamField body="subject" type="string">
  Subject line. An explicit empty string sends a subjectless email; omitting it applies the channel's default.
</ParamField>

<ParamField body="cc" type="string[]">Copied addresses.</ParamField>
<ParamField body="bcc" type="string[]">Blind-copied addresses.</ParamField>

<ParamField body="attachments" type="array">
  `[{ filename, contentType, url }]`. The bytes are fetched server-side from `url`; the attachments of one email may total at most 28 MiB, and a larger total answers `400` with `Attachments exceed the 28MB limit`.
</ParamField>

<ParamField body="inReplyTo" type="string">
  The `Message-ID` of the email this one replies to, so mail clients thread it. Use the inbound email's id, which agent code reads from `webhookPayload.messageId`; angle brackets are optional. Honored on the branded (existing-address) email channel.
</ParamField>

<ParamField body="references" type="string[]">The thread's accumulated `Message-ID` chain.</ParamField>
<ParamField body="options" type="object">`channelIdentifier` pins a channel configuration.</ParamField>

**Response**

`200` with the send result; `messageId` is the mail provider's id.

**Errors**

| Status | Code or message                                                                       | Meaning                               | Fix                                         |
| ------ | ------------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------- |
| `400`  | `to is required`                                                                      | No recipient object                   | Send `to`                                   |
| `400`  | `email send requires to.email or to.userId`                                           | Empty recipient                       | Send an address or a `userId`               |
| `400`  | `email send requires a text, html, or richBody body`                                  | No body field                         | Send one body field                         |
| `400`  | `No email channel configuration found. Ensure the agent has an email channel linked.` | The agent has no email channel        | Connect an [email channel](/channels/email) |
| `400`  | `Attachments exceed the 28MB limit`                                                   | Combined attachment size over the cap | Send smaller or fewer attachments           |

Equivalent: `Channels.email.send()`.

<CodeGroup>
  ```ts TypeScript theme={null}
  const response = await fetch('https://api.heylua.ai/developer/agents/<<YOUR_AGENT_ID>>/channels/email/send', {
    method: 'POST',
    headers: { Authorization: 'Bearer <<YOUR_API_KEY>>', 'Content-Type': 'application/json' },
    body: JSON.stringify({
      to: { email: 'customer@example.com' },
      subject: 'Your invoice for order 1042',
      richBody: 'Thanks for your order. Your invoice is attached.',
      attachments: [
        { filename: 'invoice-1042.pdf', contentType: 'application/pdf', url: 'https://example.com/invoices/1042.pdf' },
      ],
    }),
  });
  const sent: { deliveryId: string; messageId?: string } = await response.json();
  console.log(sent.messageId);
  ```

  ```bash cURL theme={null}
  curl -X POST "https://api.heylua.ai/developer/agents/<<YOUR_AGENT_ID>>/channels/email/send" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>" \
    -H "Content-Type: application/json" \
    -d '{
      "to": { "email": "customer@example.com" },
      "subject": "Your invoice for order 1042",
      "richBody": "Thanks for your order. Your invoice is attached.",
      "attachments": [{ "filename": "invoice-1042.pdf", "contentType": "application/pdf", "url": "https://example.com/invoices/1042.pdf" }]
    }'
  ```
</CodeGroup>

### GET /developer/agents/:agentId/channels/deliveries

Lists the agent's recent outbound deliveries, newest first.

<ParamField path="agentId" type="string" required>The agent.</ParamField>
<ParamField query="userId" type="string">Only deliveries addressed to this end user.</ParamField>

<ParamField query="status" type="string">
  One of `queued`, `accepted`, `sent`, `delivered`, `read`, `failed`, `expired`.
</ParamField>

<ParamField query="channel" type="string">Channel name, for example `whatsapp`, `email`, `sms`.</ParamField>
<ParamField query="since" type="string">ISO 8601 instant; only deliveries created at or after it.</ParamField>
<ParamField query="limit" type="integer" default="50">From 1 to 200.</ParamField>

**Response**

`200` with `{ "success": true, "data": [...] }`, each item a delivery record.

<ResponseField name="id" type="string">The `deliveryId`.</ResponseField>

<ResponseField name="agentId, userId, channel, provider, channelIdentifier, recipient" type="string">
  Who sent it, to whom, on which channel configuration; `provider` is one of `meta`, `vonage`, `bird`, `ses`, `agentmail`, `slack`, `front`, `teams`, `pusher`.
</ResponseField>

<ResponseField name="origin" type="string">
  `channels_send` for these routes; agent replies, templates, queued flushes, and notifications have their own values.
</ResponseField>

<ResponseField name="status" type="string">Current status; `failed` and `expired` are terminal.</ResponseField>

<ResponseField name="error" type="object">
  On a failure: `category` (`window_closed`, `unreachable`, `billing`, `opted_out`, `throttled`, `auth`, `template`, `media`, `invalid_request`, `compliance`, `experiment`, `provider`, `unknown`), `provider`, the vendor `code`, `title`, `detail`, `href`, `retryable`, and `owner` (`customer`, `recipient`, `lua`, `vendor`).
</ResponseField>

<ResponseField name="events" type="array">
  `[{ status, at, source }]`, one per transition; `source` is `send`, `callback`, or `sweep`.
</ResponseField>

<ResponseField name="providerMessageId, templateName, idempotencyKey, pricing" type="various">
  The vendor id, the template used, the replay key you sent, and vendor cost metadata (Meta only).
</ResponseField>

<ResponseField name="createdAt, updatedAt, deliveredAt, readAt, failedAt" type="string">ISO 8601 timestamps.</ResponseField>

`400` on an invalid filter, for example a `limit` outside 1 to 200 or a `since` that is not ISO 8601.

Equivalent: `Channels.listDeliveries()`.

<CodeGroup>
  ```ts TypeScript theme={null}
  const params = new URLSearchParams({ status: 'failed', since: '2026-09-01T00:00:00Z', limit: '50' });
  const response = await fetch(`https://api.heylua.ai/developer/agents/<<YOUR_AGENT_ID>>/channels/deliveries?${params}`, {
    headers: { Authorization: 'Bearer <<YOUR_API_KEY>>' },
  });
  const { data }: { data: Array<{ id: string; status: string; error?: { category: string; title: string } }> } =
    await response.json();
  for (const delivery of data) console.log(delivery.id, delivery.error?.category, delivery.error?.title);
  ```

  ```bash cURL theme={null}
  curl "https://api.heylua.ai/developer/agents/<<YOUR_AGENT_ID>>/channels/deliveries?status=failed&since=2026-09-01T00:00:00Z&limit=50" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>"
  ```
</CodeGroup>

### GET /developer/agents/:agentId/channels/deliveries/:deliveryId

Reads one delivery record by the `deliveryId` a send returned.

<ParamField path="agentId" type="string" required>The agent that sent it.</ParamField>
<ParamField path="deliveryId" type="string" required>The delivery.</ParamField>

**Response**

`200` with `{ "success": true, "data": <delivery> }` in the shape the list route documents; `404` with `No delivery <id> for agent <id>` when it belongs to another agent or does not exist.

Equivalent: `Channels.getStatus()`.

<CodeGroup>
  ```ts TypeScript theme={null}
  const response = await fetch('https://api.heylua.ai/developer/agents/<<YOUR_AGENT_ID>>/channels/deliveries/<<DELIVERY_ID>>', {
    headers: { Authorization: 'Bearer <<YOUR_API_KEY>>' },
  });
  const { data }: { data: { status: string; deliveredAt?: string; readAt?: string } } = await response.json();
  console.log(data.status, data.readAt);
  ```

  ```bash cURL theme={null}
  curl "https://api.heylua.ai/developer/agents/<<YOUR_AGENT_ID>>/channels/deliveries/<<DELIVERY_ID>>" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>"
  ```
</CodeGroup>

## See also

* [`Channels`](/reference/sdk/channels) — the same operations from agent code
* [Send proactive messages](/build/send-proactive-messages) — when to use a template and how the 24-hour window works
* [WhatsApp](/channels/whatsapp) and [Email](/channels/email) — connecting the channels these routes send on
* [REST API overview](/reference/rest/overview) — authentication, scopes, and idempotency
