Channels
Telegram notifications
Send Ray notifications to Telegram users, groups and channels with your own bot. BotFather setup, finding chat ids, HTML content, pacing and errors.
The telegram_bot channel sends messages with the Telegram Bot API (sendMessage) using a bot you create. Each send targets a chat id: a user who started your bot, a group, or a channel. API calls go to https://ray-api.gege.mn with Authorization: Bearer $RAY_API_KEY.
| Channel type | telegram_bot |
| Template kind | telegram_text |
| Recipient | { "chatId": "..." } |
| Pacing | ratePerMinute, default 1500, max 1600 |
| Suppression | None |
What you need from Telegram
Create a bot
- Open a chat with @BotFather in Telegram and send
/newbot. - Choose a display name and a username ending in
bot. - BotFather replies with the bot token, shaped
<bot_id>:<secret>, for example123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw. Keep it secret.
Find chat ids
Telegram bots can only message chats that allowed it:
- A user must open your bot and press Start (or send it any message) first. Bots cannot start conversations.
- For a group, add the bot to the group.
- For a channel, add the bot as an administrator with permission to post.
Then read the chat id from the bot's recent updates:
curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates"
Look for message.chat.id (users and groups) or channel_post.chat.id (channels) in the response. User ids are positive numbers, group ids negative, and supergroup and channel ids start with -100. A public channel can also be addressed by its username, such as @my_channel. getUpdates returns nothing while the bot has a webhook set; in your app, you will usually capture chat.id when a user starts your bot and store it with the user.
Channel config
Create the config in the dashboard: Channels, New channel, Telegram bot. The token is stored encrypted; after saving, the dashboard only shows the bot id.
| Field | Required | Notes |
|---|---|---|
| Name | yes | Internal label, for example ops-bot. |
Bot token (botToken) |
yes | From BotFather, must match <digits>:<letters, digits, _ or ->. When editing, leave blank to keep the stored token. |
Rate limit, messages per minute (ratePerMinute) |
no | Default 1500, max 1600. Telegram allows about 30 messages per second per bot. |
Find the config's id with GET /channels; its kind is telegram_bot.
Template content
| Field | Type | Notes |
|---|---|---|
text |
string, 1 to 4096 | Sent with parse_mode: HTML. Use Telegram's HTML subset: <b>, <i>, <u>, <s>, <a href="...">, <code>, <pre>. |
disableLinkPreview |
boolean | Optional. true hides the link preview card. |
Escaping: param values are HTML-escaped (&, <, >, ", '), so a value can't add tags or links. Markup belongs in the template text. Literal <, > and & that you write in the template itself must be written as <, > and &, or Telegram rejects the message.
Recipient
{ "chatId": "123456789" }
| Field | Type | Notes |
|---|---|---|
chatId |
string | Required. A numeric chat id as a string ("123456789", "-1001234567890") or a public channel username ("@my_channel"). |
Send a message
curl -X POST https://ray-api.gege.mn/send \
-H "Authorization: Bearer $RAY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: payout-po_8842" \
-d '{
"channelConfigId": "8e9f0a1b-2c3d-4e5f-8a6b-7c8d9e0f1a2b",
"content": {
"text": "<b>Payout sent</b>\nAmount: {{amount}}\n<a href=\"https://app.example.com/payouts/{{payoutId}}\">View payout</a>",
"disableLinkPreview": true
},
"params": { "amount": "$1,250.00", "payoutId": "po_8842" },
"recipient": { "chatId": "123456789" },
"externalUserId": "user_123",
"showInFeed": true
}'
async function telegramNotify(chatId: string, amount: string, payoutId: string) {
const res = await fetch("https://ray-api.gege.mn/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RAY_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `payout-${payoutId}`,
},
body: JSON.stringify({
channelConfigId: "8e9f0a1b-2c3d-4e5f-8a6b-7c8d9e0f1a2b",
templateId: "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a", // a telegram_text template
params: { amount, payoutId },
recipient: { chatId },
}),
});
if (res.status !== 202) throw new Error(`Ray ${res.status}: ${await res.text()}`);
return ((await res.json()) as { sendId: string }).sendId;
}
Broadcast to many chats with targets[] (up to 1000 per request):
{
"channelConfigId": "8e9f0a1b-2c3d-4e5f-8a6b-7c8d9e0f1a2b",
"templateId": "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
"params": { "amount": "$1,250.00", "payoutId": "po_8842" },
"targets": [
{ "recipient": { "chatId": "123456789" }, "externalUserId": "user_1" },
{ "recipient": { "chatId": "987654321" }, "externalUserId": "user_2" }
],
"priority": "low"
}
The delivery row's providerMessageId is Telegram's message_id. delivered means Telegram accepted the message.
Pacing
Ray paces each bot token at ratePerMinute (default 1500), under Telegram's limit of about 30 messages per second per bot. Telegram also limits messages to a single chat (about one per second, lower in groups); a 429 from Telegram is retried with backoff.
Errors
Errors appear on the delivery row as providerError: { "name": "TelegramError", "message": "Telegram sendMessage failed: <code> <description>" }.
| Result | Cases |
|---|---|
failed_terminal (not retried) |
Any 4xx except 429: 400 Bad Request: chat not found, 400 Bad Request: can't parse entities (invalid HTML), 401 Unauthorized (bad token), 403 Forbidden: bot was blocked by the user, 403 Forbidden: bot is not a member of the channel chat |
failed_retryable (retried) |
429 Too Many Requests, 5xx, network errors and timeouts |
Common fixes:
chat not found: the user never started the bot, the id is wrong, or the bot isn't in the group or channel.bot was blocked by the user: the user blocked the bot. Stop messaging that chat id.can't parse entities: unbalanced or unsupported HTML in the template, or a literal<or&in template text. Param values are already escaped.Unauthorized: the token was revoked in BotFather. Paste the new token into the config.
Request errors from POST /send are listed in Errors.