For AI agents: markdown of this page — /docs-content-en/recipes/telegram-bot.md documentation index — /llms.txt

Telegram bot for CRM

Difficulty: medium | Scopes: crm | Stack: cURL / JavaScript (Node.js, grammy)

The bot watches for new deals in a pipeline and sends the manager a deal card in Telegram: title, amount, contact name. Buttons under the message move the deal to another stage — the manager handles the request without opening Bitrix24.

What you need

  • A Vibecode API key with the crm scope
  • A Telegram bot token — issued by @BotFather
  • The identifier of the chat the bot writes to
  • Node.js 18 or newer, the grammy and node-cron packages

In all examples $VIBE_URL is the base address https://vibecode.bitrix24.com, $VIBE_API_KEY is your API key.

How the solution works

  1. Remember the identifier of the most recent deal — this is the starting cursor position.
  2. Every two minutes request deals with an identifier greater than the stored one.
  3. For every deal found, pull the contact name and send a message to Telegram.
  4. A button under the message moves the deal to the selected stage.
  5. The cursor is kept in a file next to the script, so a restart neither skips deals nor sends them twice.

The step examples show individual calls. The ready-to-run script is in the "Full code" section.

Step 1. Starting cursor position

On the first run the bot must not send every deal in the Bitrix24 account. POST /v1/deals/search with descending sort by identifier and limit: 1 returns the last created deal — its identifier becomes the cursor.

cURL

Terminal
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/deals/search" \
  -d '{
    "filter": { "categoryId": 0 },
    "select": ["id"],
    "sort": { "id": "desc" },
    "limit": 1
  }'

JavaScript

javascript
const res = await fetch(`${VIBE_URL}/v1/deals/search`, {
  method: 'POST',
  headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    filter: { categoryId: 0 },
    select: ['id'],
    sort: { id: 'desc' },
    limit: 1,
  }),
})
const { data } = await res.json()
const cursor = data[0]?.id ?? 0
JSON
{
  "success": true,
  "data": [{ "id": 7999 }],
  "meta": { "total": 1218, "hasMore": true, "durationMs": 1614 }
}

The meta.total field is the number of deals matching the filter, not the length of the data array. The cursor needs only data[0].id.

Step 2. New deals

The "id": { "$gt": <cursor> } condition selects deals created after the last poll. Ascending sort by identifier is needed so that messages go out in the order the deals appeared and the cursor moves forward.

cURL

Terminal
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/deals/search" \
  -d '{
    "filter": { "id": { "$gt": 7995 }, "categoryId": 0 },
    "select": ["id", "title", "amount", "currency", "contactId", "assignedById", "createdAt"],
    "sort": { "id": "asc" },
    "limit": 50
  }'

JavaScript

javascript
const res = await fetch(`${VIBE_URL}/v1/deals/search`, {
  method: 'POST',
  headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    filter: { id: { $gt: cursor }, categoryId: 0 },
    select: ['id', 'title', 'amount', 'currency', 'contactId', 'assignedById', 'createdAt'],
    sort: { id: 'asc' },
    limit: 50,
  }),
})
const { data: deals } = await res.json()
JSON
{
  "success": true,
  "data": [
    {
      "id": 7999,
      "title": "Request from the website",
      "amount": 0,
      "currency": "USD",
      "contactId": 0,
      "assignedById": 1,
      "createdAt": "2026-07-21T08:55:32.000Z"
    }
  ],
  "meta": { "total": 1, "hasMore": false, "durationMs": 1080 }
}

The categoryId: 0 filter limits the selection to the main pipeline. Remove it if the bot watches all pipelines at once.

Step 3. Contact name

The deal holds only the contact identifier. The name is returned by GET /v1/contacts/:id.

cURL

Terminal
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/contacts/67"

JavaScript

javascript
async function contactName(contactId) {
  if (!contactId) return 'not specified'
  const res = await fetch(`${VIBE_URL}/v1/contacts/${contactId}`, {
    headers: { 'X-Api-Key': VIBE_API_KEY },
  })
  if (!res.ok) return 'not specified'
  const { data } = await res.json()
  return [data.name, data.lastName].filter(Boolean).join(' ') || `Contact ${contactId}`
}
JSON
{
  "success": true,
  "data": { "id": 67, "name": "Maria", "lastName": "Wilson", "typeId": "CLIENT", "companyId": 7 }
}

When no contact is linked to the deal, contactId arrives as zero, not null. The if (!contactId) check covers both values. The lastName and secondName fields may be empty — assemble the name from the non-empty parts.

Step 4. Changing the stage from the chat

The button under the message calls PATCH /v1/deals/:id with a new stageId. The response contains the previousStageId field — it shows which stage the deal left.

cURL

Terminal
curl -s -X PATCH -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/deals/7999" \
  -d '{ "stageId": "EXECUTING" }'

JavaScript

javascript
async function moveDeal(dealId, stageId) {
  const res = await fetch(`${VIBE_URL}/v1/deals/${dealId}`, {
    method: 'PATCH',
    headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ stageId }),
  })
  const { data } = await res.json()
  return data.stageId
}
JSON
{
  "success": true,
  "data": { "id": 7999, "stageId": "EXECUTING", "previousStageId": "NEW", "movedTime": "2026-07-21T08:56:02.000Z" }
}

Stage names for the button labels come from the Bitrix24 account — GET /v1/statuses with the entityId=DEAL_STAGE filter. The Bitrix24 account administrator renames stages and adds new ones, so labels must not be kept in the code.

JSON
{
  "success": true,
  "data": [
    { "id": 1042, "statusId": "NEW", "name": "New", "sort": 10, "entityId": "DEAL_STAGE" },
    { "id": 1044, "statusId": "PREPARATION", "name": "Document preparation", "sort": 20, "entityId": "DEAL_STAGE" }
  ],
  "meta": { "total": 2, "hasMore": false }
}

Three fields of the response are needed: statusId goes into stageId when the deal is moved, name becomes the button label, sort sets the order of the buttons. The id identifier is the directory record, it is not passed into the deal.

Step 5. A cursor that survives a restart

A cursor held in a process variable is lost on restart: the bot takes the starting value from step 1 again and skips everything that appeared while it was down. A file next to the script removes this limitation without external dependencies. This is file-system work, not an API call, so there is no cURL example here.

javascript
import { readFileSync, writeFileSync, renameSync } from 'node:fs'

const CURSOR_FILE = './cursor.json'

function readCursor() {
  try {
    return JSON.parse(readFileSync(CURSOR_FILE, 'utf8')).lastDealId ?? 0
  } catch {
    return 0
  }
}

function writeCursor(lastDealId) {
  writeFileSync(`${CURSOR_FILE}.tmp`, JSON.stringify({ lastDealId }))
  renameSync(`${CURSOR_FILE}.tmp`, CURSOR_FILE)
}

The write goes to a temporary file followed by a rename. A rename within one file system is atomic, so stopping the process mid-write leaves no truncated file that would reset the cursor to zero.

The cursor moves only after the message has gone out to Telegram. If the send fails, the deal is picked up again by the next poll — that is better than losing a request.

Limitations

Polling, not events. The bot asks the Bitrix24 account rather than receiving notifications. Up to two minutes pass between the creation of a deal and the chat message — the interval is set by the node-cron schedule.

Only new deals are visible. The cursor runs by identifier, and the identifier of an existing deal never changes. A new amount, a new assignee, a stage change — all of it passes the bot by. To catch changes you need a cursor over movedTime or updatedTime, as in the Task automation recipe.

One running instance. The cursor file belongs to its own process. Two copies of the bot on different machines read their own files and will send the same deal twice. To run several instances, move the cursor to shared storage and leave the polling to one designated instance.

Stages belong to their pipeline. In the main pipeline a stage looks like NEW, in an additional one like C9:NEW with the pipeline number in the prefix. The bot requests the stage list of the pipeline it watches and puts identifiers from that list into the buttons.

The account queue. A Bitrix24 account runs a limited number of requests at a time, the rest wait in a queue. The ceiling comes from the platform settings, so do not rely on a specific number: a loop that fires requests back to back will sooner or later get a 429. QUEUE_OVERFLOW means the queue is full and the request was rejected immediately, QUEUE_TIMEOUT means it waited for a slot longer than 30 seconds. In both cases the request never reached Bitrix24, so a retry is safe.

JSON
{
  "success": false,
  "error": {
    "code": "QUEUE_OVERFLOW",
    "message": "Portal queue overloaded — 128 Bitrix24 calls already pending",
    "userMessage": "Too many concurrent requests to Bitrix24 — retry in a few seconds.",
    "hint": "Honor the Retry-After header. Use exponential backoff with jitter for repeated failures.",
    "retryAfter": 3
  }
}

The recommended pause arrives in the Retry-After header and is duplicated in error.retryAfter. Retry rather than drop the request — that is what "Full code" does. Limits in detail — Limits and optimization, the full list of error codes — Errors.

BLACKHOLE server sleep. The idle timer counts incoming requests to the server, and the bot makes only outgoing calls — from the timer's point of view the server is idle and falls asleep together with the bot. Turn it off with PATCH /v1/infra/servers/:id/sleep and the value sleepAfterMinutes: null. On preemptible plans (bc-agent, bc-micro) that is not enough: the cloud restarts the machine about once a day anyway. For continuous operation take a non-preemptible plan — a cursor kept in a file survives both a restart and a wake-up.

Secrets live in environment variables. The Telegram bot token and the Vibecode API key are read from the environment. The cursor file stores only a numeric identifier.

Full code

javascript
// telegram-crm-bot.js — notifications about new deals in Telegram
import { readFileSync, writeFileSync, renameSync } from 'node:fs'
import { Bot, InlineKeyboard } from 'grammy'
import cron from 'node-cron'

const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.com'
const VIBE_API_KEY = process.env.VIBE_API_KEY
if (!VIBE_API_KEY) throw new Error('The VIBE_API_KEY environment variable is not set')
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN
const CHAT_ID = process.env.TELEGRAM_CHAT_ID
// Without the token and the chat the bot starts but dies on its first Telegram message.
if (!BOT_TOKEN) throw new Error('The TELEGRAM_BOT_TOKEN environment variable is not set')
if (!CHAT_ID) throw new Error('The TELEGRAM_CHAT_ID environment variable is not set')
const CATEGORY_ID = 0
const CURSOR_FILE = './cursor.json'

const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }

// A Bitrix24 account holds a limited number of concurrent requests and answers 429
// when the queue overflows. Without checking the body, destructuring yields
// undefined and the script dies on the first field access instead of a clear error.
// Any 429 refusal means the request never reached Bitrix24, so a repeat is safe.
// The advised pause is honored as-is from the Retry-After header; the exponent is
// only for the case where the server named no pause. Without this retry the first
// QUEUE_OVERFLOW would kill a scheduler tick.
const MAX_RETRIES = 5

async function readData(url, init = {}) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, init)
    const body = await res.json().catch(() => null)

    if (res.status === 429 && attempt < MAX_RETRIES) {
      const advised = res.headers.get('Retry-After') ?? body?.error?.retryAfter ?? null
      // A 60 s ceiling: the advised pause is honored, but an unexpectedly large
      // value from an intermediate proxy must not stall the loop.
      const base = advised === null ? Math.min(2 ** attempt, 30) : Number(advised)
      const wait = Math.min(base, 60) + Math.random()
      console.warn(`The account queue is busy, retrying in ${Math.round(wait)} s`)
      await new Promise(resolve => setTimeout(resolve, wait * 1000))
      continue
    }

    if (!body?.success) throw new Error(body?.error?.message ?? `request rejected (${res.status})`)
    return body.data
  }
}
const bot = new Bot(BOT_TOKEN)

function readCursor() {
  try {
    return JSON.parse(readFileSync(CURSOR_FILE, 'utf8')).lastDealId ?? 0
  } catch {
    return 0
  }
}

function writeCursor(lastDealId) {
  writeFileSync(`${CURSOR_FILE}.tmp`, JSON.stringify({ lastDealId }))
  renameSync(`${CURSOR_FILE}.tmp`, CURSOR_FILE)
}

async function fetchStages() {
  const entityId = CATEGORY_ID === 0 ? 'DEAL_STAGE' : `DEAL_STAGE_${CATEGORY_ID}`
  const data = await readData(`${VIBE_URL}/v1/statuses?filter[entityId]=${entityId}`, { headers })
  return data.sort((a, b) => a.sort - b.sort)
}

async function fetchLatestDealId() {
  const data = await readData(`${VIBE_URL}/v1/deals/search`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      filter: { categoryId: CATEGORY_ID },
      select: ['id'],
      sort: { id: 'desc' },
      limit: 1,
    }),
  })
  return data[0]?.id ?? 0
}

async function fetchNewDeals(cursor) {
  return readData(`${VIBE_URL}/v1/deals/search`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      filter: { id: { $gt: cursor }, categoryId: CATEGORY_ID },
      select: ['id', 'title', 'amount', 'currency', 'contactId', 'assignedById', 'createdAt'],
      sort: { id: 'asc' },
      limit: 50,
    }),
  })
}

async function contactName(contactId) {
  if (!contactId) return 'not specified'
  // A missing or inaccessible contact must not kill the card delivery.
  const data = await readData(`${VIBE_URL}/v1/contacts/${contactId}`, { headers })
    .catch(() => null)
  if (!data) return 'not specified'
  return [data.name, data.lastName].filter(Boolean).join(' ') || `Contact ${contactId}`
}

async function moveDeal(dealId, stageId) {
  const data = await readData(`${VIBE_URL}/v1/deals/${dealId}`, {
    method: 'PATCH',
    headers,
    body: JSON.stringify({ stageId }),
  })
  return data.stageId
}

function dealCard(deal, contact) {
  return [
    '<b>New deal</b>',
    '',
    `Title: ${deal.title}`,
    `Amount: ${deal.amount} ${deal.currency}`,
    `Contact: ${contact}`,
    `Created: ${new Date(deal.createdAt).toLocaleString('en-US')}`,
    `Identifier: ${deal.id}`,
  ].join('\n')
}

function stageKeyboard(dealId, stages) {
  const keyboard = new InlineKeyboard()
  for (const stage of stages.filter(s => s.statusId !== 'NEW').slice(0, 3)) {
    keyboard.text(stage.name, `move:${dealId}:${stage.statusId}`)
  }
  return keyboard
}

async function poll(stages) {
  let cursor = readCursor()
  const deals = await fetchNewDeals(cursor)

  for (const deal of deals) {
    const contact = await contactName(deal.contactId)
    await bot.api.sendMessage(CHAT_ID, dealCard(deal, contact), {
      parse_mode: 'HTML',
      reply_markup: stageKeyboard(deal.id, stages),
    })
    cursor = Math.max(cursor, deal.id)
    writeCursor(cursor)
  }

  console.log(`[${new Date().toISOString()}] new deals: ${deals.length}`)
}

async function main() {
  if (readCursor() === 0) writeCursor(await fetchLatestDealId())

  const stages = await fetchStages()
  const stageNames = new Map(stages.map(s => [s.statusId, s.name]))

  bot.callbackQuery(/^move:(\d+):(.+)$/, async ctx => {
    const [, dealId, stageId] = ctx.match
    try {
      const applied = await moveDeal(Number(dealId), stageId)
      await ctx.answerCallbackQuery({ text: `Stage: ${stageNames.get(applied) ?? applied}` })
      await ctx.editMessageReplyMarkup()
    } catch (error) {
      // Without answering the callback the manager keeps a spinner forever.
      await ctx.answerCallbackQuery({ text: `Failed: ${error.message}` })
    }
  })

  bot.command('start', ctx => ctx.reply('The bot is running. New deals arrive in this chat.'))
  bot.command('status', ctx => ctx.reply(`Last processed deal: ${readCursor()}`))

  cron.schedule('*/2 * * * *', () => poll(stages).catch(console.error))
  bot.start()
  console.log('The bot is running')
}

main().catch(console.error)

See also