Who This Is For
This group is for a developer connecting a local Relay instance to an outside messaging surface: Slack, Telegram, or a generic outbound webhook. It covers creating and managing channel configurations, live-testing a channel’s connection, and the inbound endpoints that receive third-party events and turn them into chat turns. Read this if you are wiring Relay into a team chat, receiving messages from a bot, or building an integration that posts into Relay from an external service.
Unlike the other groups in this reference, the two inbound endpoints are genuinely externally facing: they receive requests signed or secret-gated by a third party, not the local app. They are documented with that trust boundary in mind.
Stability
webhook
These routes implement webhook and channel plumbing. The management routes power the Relay Settings screens and may change their request and response shapes between releases, so branch on HTTP status codes rather than on the internal structure of a response body. The two inbound webhook routes follow the Slack and Telegram platform contracts; the shape of what they accept is set by those platforms, not by Relay.
Local Access Model
Relay API routes run inside the local Relay app. Use http://127.0.0.1:<port> in examples, where <port> is the port your instance is bound to (3000 by default).
The management routes (GET/POST/PATCH/DELETE on /api/channels) are local app routes and should not be exposed publicly. The inbound routes (/api/channels/inbound/slack, /api/channels/inbound/telegram) are the exception by design: they must be reachable by Slack or Telegram, so they carry their own request authentication (an HMAC signature for Slack, a shared secret for Telegram) rather than relying on network isolation. Only expose those two paths, and only after a channel is configured with the matching secret.
Conventions For This Group
These behaviors hold across the routes below, so they are stated once here rather than repeated per endpoint:
- Channel types. A channel’s
channelTypeis one ofslack,telegram,webhook. Each type requires specificconfigfields: Slack needswebhookUrl; Telegram needsbotTokenandchatId; a generic webhook needsurl. - Secrets are masked on read, stored in cleartext. Every response that returns a channel serializes its
configthrough a mask: the keysbotToken,signingSecret, andwebhookSecretcome back as****followed by the last four characters, never in full. All other config keys (such aswebhookUrlorchatId) are returned as-is. Note that at rest these secrets are stored in the local database as plaintext JSON; the masking applies only to what the API returns. - Direction. A channel’s
directionisoutboundorbidirectional, defaulting tooutbound. Inbound messages are only processed for abidirectionalchannel; an inbound message to an outbound-only channel is accepted at the HTTP layer but silently dropped by the gateway. - Timestamps.
createdAtandupdatedAtserialize to ISO date strings on the wire. - Inbound acknowledgement semantics. The Slack and Telegram inbound routes return
200 { "ok": true }immediately and process the message in the background, so a200means the event was accepted, not that a reply was sent or that processing succeeded. The Telegram poll route is the exception: it processes updates synchronously and returns counts. - Validation. These routes validate by hand rather than with a shared schema. Error bodies are
{ "error": "<message>" }with the specific message noted per route.
Endpoint Families
channels
Endpoints
| Method(s) | Path | Stability | Source |
|---|---|---|---|
GET, POST | /api/channels | webhook | src/app/api/channels/route.ts |
DELETE, GET, PATCH | /api/channels/{id} | webhook | src/app/api/channels/[id]/route.ts |
POST | /api/channels/{id}/test | webhook | src/app/api/channels/[id]/test/route.ts |
POST | /api/channels/inbound/slack | webhook | src/app/api/channels/inbound/slack/route.ts |
POST | /api/channels/inbound/telegram | webhook | src/app/api/channels/inbound/telegram/route.ts |
POST | /api/channels/inbound/telegram/poll | webhook | src/app/api/channels/inbound/telegram/poll/route.ts |
Endpoint Reference
GET /api/channels
Lists all channel configurations, newest first.
- Request: none.
- Response
200: a JSON array of channel objects, ordered bycreatedAtdescending. Each element:{ "id": "string", "channelType": "slack", "name": "string", "config": "{\"webhookUrl\":\"https://...\"}", "status": "active", "testStatus": "untested", "direction": "outbound", "createdAt": "2026-01-01T00:00:00.000Z", "updatedAt": "2026-01-01T00:00:00.000Z" }configis a JSON string with secret keys masked to****<last4>.channelTypeis one ofslack,telegram,webhook.statusisactiveordisabled.testStatusisuntested,ok, orfailed.directionisoutboundorbidirectional. - Errors: none returned explicitly.
- Side effects: reads only.
POST /api/channels
Creates a channel configuration.
- Request body (JSON):
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Display name for the channel. |
channelType | string | yes | One of slack, telegram, webhook. |
config | object | yes | Type-specific settings (see below). |
Required config keys by type: Slack requires webhookUrl; Telegram requires botToken and chatId; a generic webhook requires url.
- Response
201: the created channel object (same masked shape as aGETelement). Created withstatusactiveandtestStatusuntested;directiondefaults tooutbound. - Errors (all
400):{ "error": "Name is required" }.{ "error": "Invalid channel type. Must be one of: slack, telegram, webhook" }.{ "error": "Config object is required" }.{ "error": "Slack channels require a webhookUrl" }.{ "error": "Telegram channels require botToken and chatId" }.{ "error": "Webhook channels require a url" }.
- Side effects: inserts a row into the channel-config table. The
configis stored as plaintext JSON.
GET /api/channels/{id}
Fetches one channel configuration by id.
- Request: none.
- Response
200: the channel object (masked). - Errors:
404when not found:{ "error": "Channel not found" }. - Side effects: reads only.
PATCH /api/channels/{id}
Partially updates a channel configuration. Every supplied field is optional; only provided fields change.
- Request body (JSON):
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | no | Trimmed. Must not be blank if provided. |
config | object | no | Replaces the stored config. Supplying it also resets testStatus to untested. |
status | string | no | active or disabled. |
direction | string | no | outbound or bidirectional. |
- Response
200: the updated channel object (masked).updatedAtis refreshed on every update. - Errors:
404when not found:{ "error": "Channel not found" }.400whennameis provided but blank:{ "error": "Name cannot be empty" }.400on an invalid status:{ "error": "Invalid status" }.400on an invalid direction:{ "error": "Invalid direction" }.
- Side effects: updates the channel-config row.
DELETE /api/channels/{id}
Deletes a channel configuration by id.
- Request: none.
- Response
200:{ "deleted": true }. - Errors:
404when not found:{ "error": "Channel not found" }. - Side effects: removes the channel-config row.
POST /api/channels/{id}/test
Live-tests a channel by asking its adapter to make a real connection, then persists the result.
- Request: none.
- Response
200:{ "testStatus": "ok" | "failed", "error": "string" }.erroris present only on failure. The route also writes the resultingtestStatusback to the channel row.- Slack sends a test post to the configured
webhookUrl. - Telegram sends a test message via the Bot API using
botTokenandchatId.
- Slack sends a test post to the configured
- Errors:
404when not found:{ "error": "Channel not found" }.500on unparseable stored config:{ "error": "Invalid channel config JSON" }.500when the adapter throws (for example an unknown channel type):{ "testStatus": "failed", "error": "<message>" }.
- Side effects: makes an outbound call to the channel’s platform and updates the row’s
testStatusandupdatedAt.
POST /api/channels/inbound/slack
Receives Slack Events API callbacks. This endpoint is externally facing and verifies Slack’s request signature before doing any work. It handles the one-time URL-verification challenge and ongoing message events.
- Request:
- Query param
configIdidentifies the channel (required except for the URL-verification challenge). - Body is the raw Slack event payload. The handler reads the raw text first so it can verify the signature, then parses it as JSON.
- Signature verification: the stored channel config must contain a
signingSecret. The handler reads thex-slack-request-timestampandx-slack-signatureheaders, rejects timestamps more than five minutes old (replay protection), and compares an HMAC-SHA256 of the raw body against the signature using a constant-time check. - A message event is only processed when it is a real user message: events with a bot id or a subtype, and non-message events, are accepted and ignored.
- Query param
- Response:
- URL-verification challenge:
200{ "challenge": "<value>" }. - Any accepted or ignored event:
200{ "ok": true }, returned immediately. Message handling runs in the background.
- URL-verification challenge:
- Errors:
400on an unparseable body:{ "error": "Invalid JSON" }.400whenconfigIdis missing (and the request is not a challenge):{ "error": "Missing configId" }.404when the channel is not found:{ "error": "Channel not found" }.500on unparseable stored config:{ "error": "Invalid channel config" }.401when the stored config has nosigningSecret, so the request cannot be verified. The error body states that the channel config is missing its signing secret and the request cannot be verified.403on a signature mismatch:{ "error": "Invalid signature" }.
- Side effects: reads the channel config. For an accepted message it dispatches to the inbound gateway without waiting, which may create a conversation, run the chat engine, and post a reply back to Slack. A message to a channel that is not
bidirectionalis dropped by the gateway.
POST /api/channels/inbound/telegram
Receives Telegram Bot API webhook updates. This endpoint is externally facing and authenticates with a shared secret carried in the query string, matching Telegram’s secret_token design.
- Request:
- Query params
configId(required) andsecret(required), wheresecretis compared against the channel’s storedwebhookSecret. - Body is the Telegram update object, parsed as JSON.
- Only text messages are processed. Updates without message text (edits, photos, and similar) are accepted and ignored, as are messages from a bot (loop prevention).
- Because the secret rides in the query string, it can appear in access logs; treat it as sensitive and rotate it if a log is exposed.
- Query params
- Response:
- Any accepted or ignored update:
200{ "ok": true }, returned immediately. Message handling runs in the background.
- Any accepted or ignored update:
- Errors:
400whenconfigIdis missing:{ "error": "Missing configId" }.404when the channel is not found:{ "error": "Channel not found" }.500on unparseable stored config:{ "error": "Invalid channel config" }.401when the stored config has nowebhookSecret, so the request cannot be verified. The error body states that the channel config is missing its webhook secret and the request cannot be verified.403on a secret mismatch:{ "error": "Invalid secret" }.400on an unparseable body:{ "error": "Invalid JSON body" }.
- Side effects: reads the channel config. For an accepted message it dispatches to the inbound gateway without waiting, which may create a conversation, run the chat engine, and reply via the Telegram Bot API. A message to a channel that is not
bidirectionalis dropped by the gateway.
POST /api/channels/inbound/telegram/poll
Polls Telegram for pending updates and processes them, for local development where Telegram cannot reach a localhost webhook. This route is for internal callers only and is driven by the local scheduler, not by a third party.
- Request:
- Requires the header
x-ainative-internalset topoll; any other value is rejected. - Query param
configId(required). - Optional body
{ "offset": number }; a missing or malformed body is tolerated.
- Requires the header
- Response
200:- When there are no updates:
{ "processed": 0, "nextOffset": <offset or null> }. - When updates were processed:
{ "processed": <number>, "total": <number>, "nextOffset": <number> }, wherenextOffsetis the highest update id seen plus one.
- When there are no updates:
- Errors:
401when the internal header is absent or wrong. The error body states that the endpoint is for internal use only.400whenconfigIdis missing:{ "error": "Missing configId" }.404when the channel is not found:{ "error": "Channel not found" }.403when the channel is not active:{ "error": "Channel is not active" }.500on unparseable stored config:{ "error": "Invalid channel config" }.400when the config has no bot token:{ "error": "Missing botToken in config" }.502when Telegram’sgetUpdatesreports failure:{ "error": "Telegram getUpdates failed" }.502when the call to Telegram throws:{ "error": "<message>" }.
- Side effects: calls Telegram’s
getUpdates, processes each non-bot text update synchronously through the inbound gateway (which may run the chat engine and reply), then callsgetUpdatesagain with the advanced offset to acknowledge consumed updates.
Do Not Depend On
- The exact masking format for secrets (
****plus the last four characters) is a display convenience and may change; never parse a masked value back into a usable credential. - The background, fire-and-forget dispatch of inbound Slack and Telegram messages is an implementation detail. Do not assume a
200means a reply was delivered; observe the channel itself for the reply. - The internal poll route (
/telegram/poll) exists for local development and is gated to the scheduler. Do not build against it as a public integration point. - The internal gateway’s rejection of outbound-only channels is silent at the HTTP layer. Set a channel to
bidirectionalexplicitly if you expect inbound handling.