---
title: "Connect a channel"
method: POST
path: "/v1/projects/{project_id}/channels"
tags: ["Channels"]
---

# Connect a channel

`POST /v1/projects/{project_id}/channels`

## Request

- Base URL: `https://api.naturali.ai`
- URL: `https://api.naturali.ai/v1/projects/{project_id}/channels`
- Auth: one of:
  - HTTP bearer
  - OAuth 2 (authorization code, authorization URL `https://api.naturali.ai/authorize`, token URL `https://api.naturali.ai/token`, scopes: `mcp:access`)

Connect a channel. The required fields depend on `channel`; no credential is ever returned.
**Discord** (`channel: discord`) — supply `application_id` and `bot_token`, plus the `modes` selecting which Gateway flows to serve (direct messages, and/or @mention-opens-a-thread). The token is checked with Discord first: `400 invalid_bot_token` when Discord rejects it, `400 application_mismatch` when it belongs to another application. Returns `501` when the deployment has no `CHANNEL_TOKEN_KEY` configured.
**WhatsApp** (default) — one of two credential paths, both filling the same write-only secret:
* **BYOT** (`credential_source: byot`, default) — supply
  `phone_number_id` and `access_token` from your own Meta app.

* **Embedded signup** (`credential_source: embedded_signup`) — supply
  `code` and `waba_id` from the Meta popup; naturali exchanges the
  `code` for the token, subscribes its app to the WABA and registers the
  number for sending when it is not yet, so the customer never hands
  over a token. Returns `501` on deployments where Meta App credentials
  are not configured.


The project's plan limits how many channels it may hold. At the limit this responds `403` with `plan_limit_reached`, whose `details` carry the `plan` and the `limit`, before any credential is acquired. Only `active` channels count, so disabling one frees allowance without deleting it.

## Request body

- ChannelCreate: Connect a channel. The required fields depend on `channel`: * **whatsapp** — `phone_number_id`, plus a credential by `credential_source`: `byot` (default) requires `access_token`; `embedded_signup` requires `code` and `waba_id`. * **discord** — `application_id` and `bot_token` (write-only), plus the optional `modes` opting into the gateway flows. Discord runs over the Gateway, so the bot token is kept in naturali's encrypted store (the worker must present it to authenticate its WebSocket) rather than as a secret on the runtime; deployments without `CHANNEL_TOKEN_KEY` configured return `501`. No credential value is ever returned. `default` is required (CHANNELS-ROUTING.md §3.3.1) — connecting a channel without saying what it does when nothing else matches is the misconfiguration, so it is caught here rather than discovered as silence in production.
  - `idempotency_key` string: Deduplication key, unique within your account, that makes a retry of an ambiguous failure safe. The first request under a key performs the write and answers `201`; any later request carrying the same key answers `200` with that same result, so a timeout you cannot interpret can simply be retried. The key is claimed for as long as the record exists and never silently expires. Reusing one with a different request body is `409 idempotency_key_reused` — a key names one request, so changing the body and keeping the key is a bug rather than a retry. A retry that arrives while the original is still in flight is `409 idempotency_request_in_progress`.
  - `default` object, required: `agent_id` is required when `action` is `agent` or `oneshot`, `text` when it is `message`; `repeat` is only valid alongside `action: message`.
    - `action` 'agent' | 'oneshot' | 'message' | 'silence' | 'null', nullable, required: What an inbound message becomes; `null` clears an address's own action, and is refused for a channel default or a route. `agent` is a dialogue: the identity behind the message gets a session, and every later message on it continues the same one. `oneshot` runs the agent once and keeps nothing — no address, no actor, no session, no conversation — so each message is answered on its own. Choose it for work that is filed rather than discussed; choose `agent` when the answer depends on what came before. `message` delivers fixed text without running an agent, and `silence` answers nothing.
    - `agent_id` string
    - `text` string
    - `repeat` union
      - 'every' | 'once'
      - object
        - `after_seconds` integer, required
    - `language` string
    - `config` object, nullable: Conversation config bag — media handling, persona overrides, the Discord allowlist, and how a `oneshot` answers. `discord.reply` picks the delivery: `react` (the default) adds `discord.reaction` — `\u2705` unless named — to the message that triggered the run, `mention` answers beside it addressing whoever asked, `react_or_mention` reacts when the agent writes nothing and answers like `mention` when it writes a line, `thread` opens a thread and answers inside it, and `none` delivers nothing at all. A run that fails delivers nothing in every mode, so a reaction always means the work was done; there is no failure marker. A mention with nothing else in it is ignored unless the action says otherwise, for any action. `discord.use_replied_message: true` takes the text of the message it replies to, so replying to a message with only the mention files that message. `discord.empty_mention_text` answers any other bare mention with that fixed text, addressing the sender, without running the agent. `discord.forward_user_id: true` hands the sender's Discord user id to the agent's tools as the `discord_user_id` tool context, which a tool's `headers` read as `{{context:discord_user_id}}`. It is the id Discord reported for the message's author, never text the model wrote, so a tool can act for whoever sent it. Off unless set, because tool context reaches every tool the agent has. Read only for `oneshot`. A conversational `agent` in a guild *is* its thread — that is what its session is keyed on — so it always opens one.
  - `max_context_messages` integer, nullable: The most recent messages each conversation's session sends the model, taking precedence over the agent's own `max_context_messages`. An open conversation picks a change up on its next message. Null defers to the agent.
  - `channel` 'whatsapp' | 'discord'
  - `phone_number_id` string: Required for `whatsapp`.
  - `credential_source` 'byot' | 'embedded_signup'
  - `access_token` string: Write-only. Required for `byot`. The WhatsApp access token from your own Meta app. Accepted on write, never returned.
  - `code` string: Required for `embedded_signup`. The short-lived authorization code from the Meta popup; naturali exchanges it for the access token server-side. Never stored or returned.
  - `pin` string: Optional (`embedded_signup` only). The two-step verification PIN used if naturali registers the number, which it does only when the number is not yet registered for sending. Needed only when the number already has one; otherwise naturali generates a PIN and does not keep it. Never stored or returned.
  - `waba_id` string: Required for `embedded_signup`; optional for `byot`.
  - `application_id` string: Required for `discord`. The Discord application id.
  - `bot_token` string: Write-only. Required for `discord`. The bot token, sealed in naturali's encrypted store so the gateway worker can authenticate its WebSocket. Accepted on write, never returned.
  - `modes` DiscordModes, nullable: Which Discord Gateway flows this channel serves; null for non-Discord channels. At least one must be enabled. * `dms` — the bot converses 1:1 in direct messages (the WhatsApp shape). Needs only the non-privileged `DIRECT_MESSAGES` intent. * `mention_threads` — an @mention in a server opens a thread, and the whole thread is one conversation. Reading the follow-up messages in the thread needs the **privileged** `MESSAGE_CONTENT` intent, which you enable on your own Discord app (Discord requires bot verification past ~100 servers), so it is off unless you ask for it.
    - `dms` boolean
    - `mention_threads` boolean

## Response `201`

Channel created.

- Channel
  - `id` string, required: Public channel ID (chan_ prefix).
  - `project_id` string, required
  - `channel` 'whatsapp' | 'discord', required
  - `status` 'active' | 'disabled', required
  - `credential_source` 'byot' | 'embedded_signup', required: How the credential was obtained.
  - `phone_number_id` string, nullable, required: WhatsApp phone number id; inbound webhooks route on this. Null for non-WhatsApp channels.
  - `waba_id` string, nullable, required: WhatsApp Business Account id, when known.
  - `display_phone_number` string, nullable: The number as Meta displays it, read when the number was linked by embedded signup. Null for other channels and other credential sources.
  - `verified_name` string, nullable: The business name Meta shows for the number, when known; null on the same terms as `display_phone_number`.
  - `application_id` string, nullable, required: Discord application id — the tenant's app, one channel per app. The gateway worker holds one connection per channel. Null for non-Discord channels.
  - `modes` DiscordModes, nullable, required: Which Discord Gateway flows this channel serves; null for non-Discord channels. At least one must be enabled. * `dms` — the bot converses 1:1 in direct messages (the WhatsApp shape). Needs only the non-privileged `DIRECT_MESSAGES` intent. * `mention_threads` — an @mention in a server opens a thread, and the whole thread is one conversation. Reading the follow-up messages in the thread needs the **privileged** `MESSAGE_CONTENT` intent, which you enable on your own Discord app (Discord requires bot verification past ~100 servers), so it is off unless you ask for it.
    - `dms` boolean
    - `mention_threads` boolean
  - `default` ChannelAction, required: What an inbound message resolves to — a channel's `default`, a route, or an address's own action, one shape on all three. For a Discord channel, `config.discord.allowed_role_ids` / `allowed_user_ids` restrict who may invoke the agent in a server — either list admits, empty or absent lists mean everyone, decided before the agent is invoked so an unlisted member costs nothing.
    - `action` 'agent' | 'oneshot' | 'message' | 'silence' | 'null', nullable, required: What an inbound message becomes; `null` only on an address with no action of its own, which the route table then decides. `agent` is a dialogue: the identity behind the message gets a session, and every later message on it continues the same one. `oneshot` runs the agent once and keeps nothing — no address, no actor, no session, no conversation — so each message is answered on its own. Choose it for work that is filed rather than discussed; choose `agent` when the answer depends on what came before. `message` delivers fixed text without running an agent, and `silence` answers nothing.
    - `agent_id` string, nullable, required: The agent that answers, when `action` is `agent` or `oneshot`.
    - `text` string, nullable, required: The fixed text delivered, when `action` is `message`.
    - `repeat` union, required: How often a `message` fires: `every` (default), `once`, or `{ after_seconds }`. Null unless `action` is `message`.
      - 'every' | 'once'
      - object
        - `after_seconds` integer, required
    - `language` string, nullable, required: Preferred reply language; null lets the agent decide.
    - `config` object, nullable, required: Conversation config bag — media handling, persona overrides, the Discord allowlist, and how a `oneshot` answers. `discord.reply` picks the delivery: `react` (the default) adds `discord.reaction` — `\u2705` unless named — to the message that triggered the run, `mention` answers beside it addressing whoever asked, `react_or_mention` reacts when the agent writes nothing and answers like `mention` when it writes a line, `thread` opens a thread and answers inside it, and `none` delivers nothing at all. A run that fails delivers nothing in every mode, so a reaction always means the work was done; there is no failure marker. A mention with nothing else in it is ignored unless the action says otherwise, for any action. `discord.use_replied_message: true` takes the text of the message it replies to, so replying to a message with only the mention files that message. `discord.empty_mention_text` answers any other bare mention with that fixed text, addressing the sender, without running the agent. `discord.forward_user_id: true` hands the sender's Discord user id to the agent's tools as the `discord_user_id` tool context, which a tool's `headers` read as `{{context:discord_user_id}}`. It is the id Discord reported for the message's author, never text the model wrote, so a tool can act for whoever sent it. Off unless set, because tool context reaches every tool the agent has. Read only for `oneshot`. A conversational `agent` in a guild *is* its thread — that is what its session is keyed on — so it always opens one.
  - `max_context_messages` integer, nullable: The most recent messages each conversation's session sends the model, taking precedence over the agent's own `max_context_messages`. Null defers to the agent.
  - `has_credential` boolean, required: Whether an access token is on file (its value is never returned).
  - `gateway_error` object, nullable, required: Why Discord refused this channel's gateway connection, when it did: the worker stops retrying, so the channel stays silent until a `PATCH` changes `bot_token`, `modes` or `status`, which clears it. Null when there is none, and for non-Discord channels.
    - `code` 'invalid_bot_token' | 'disallowed_intents' | 'gateway_rejected', required: `invalid_bot_token` — the token was reset or revoked. `disallowed_intents` — `mention_threads` needs the privileged Message Content intent enabled on the Discord application.
    - `close_code` integer, required: The gateway close code Discord sent.
    - `at` string, date-time, required
  - `created_at` string, date-time, required
  - `updated_at` string, date-time, required

## Other responses

- `400`: The request was malformed or failed validation.
- `401`: Missing or invalid credentials.
- `403`: The credential is scoped to a different project, or the caller's role in the project does not carry this action.
- `404`: The resource does not exist (existence is not leaked).
- `409`: The request conflicts with the resource's current state.
- `501`: The requested path is not enabled on this deployment (e.g. embedded signup before Meta App credentials are configured, or a Discord channel before `CHANNEL_TOKEN_KEY` is set).
- `502`: The upstream runtime, or Meta, could not complete the operation.

---

[API](https://skmtc.dev/naturali/apis/naturali-ai-api.md) · [All operations](https://skmtc.dev/naturali/apis/naturali-ai-api/llms.txt) · [OpenAPI document](https://skmtc.dev/naturali/apis/naturali-ai-api/revisions/e4905b78f422?raw)
