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

Email campaign to contacts

Difficulty: medium | Scopes: crm, mail | Stack: cURL / JavaScript

Send a personalized email to every CRM contact selected by a filter. Emails go out from a mailbox connected to your Bitrix24 account, so client replies land in that same mailbox and reach Bitrix24.

What you need

  • A Vibecode API key with the crm and mail scopes
  • A mailbox connected to the Bitrix24 account
  • Node.js 18 or newer

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. Find the connected mailbox and an allowed sender address.
  2. Select contacts by filter, keeping only those with an email address.
  3. Insert the contact name into the template and send the email.

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

Step 1. Mailbox and sender address

The list of connected mailboxes is returned by GET /v1/mail/mailboxes.

cURL

Terminal
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/mail/mailboxes"

JavaScript

javascript
const res = await fetch(`${VIBE_URL}/v1/mail/mailboxes`, {
  headers: { 'X-Api-Key': VIBE_API_KEY },
})
const { data: mailboxes } = await res.json()
const mailboxId = mailboxes[0].id
JSON
{
  "success": true,
  "data": [
    { "id": 5, "name": "sales@example.com", "email": "sales@example.com", "senderName": "Sales department" }
  ]
}

The sender address cannot be set arbitrarily — it comes from the mailbox address list returned by GET /v1/mail/mailboxes/:id/senders.

cURL

Terminal
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/mail/mailboxes/5/senders"

JavaScript

javascript
const res = await fetch(`${VIBE_URL}/v1/mail/mailboxes/${mailboxId}/senders`, {
  headers: { 'X-Api-Key': VIBE_API_KEY },
})
const { data } = await res.json()
const sender = data.items[0].sender
JSON
{
  "success": true,
  "data": {
    "items": [
      { "email": "sales@example.com", "name": "Sales department", "sender": "Sales department <sales@example.com>" }
    ]
  }
}

The email uses the ready-made string from the sender field — together with the name and the angle brackets.

Step 2. Contacts with an address

POST /v1/contacts/search selects contacts by filter. The "!email": null condition keeps only those with an address filled in — without it the script iterates over contacts that have no address to send to.

cURL

Terminal
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/contacts/search" \
  -d '{
    "filter": { "typeId": "CLIENT", "!email": null },
    "select": ["id", "name", "lastName", "email"],
    "limit": 50
  }'

JavaScript

javascript
const res = await fetch(`${VIBE_URL}/v1/contacts/search`, {
  method: 'POST',
  headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    filter: { typeId: 'CLIENT', '!email': null },
    select: ['id', 'name', 'lastName', 'email'],
    limit: 50,
  }),
})
const { data: contacts } = await res.json()
JSON
{
  "success": true,
  "data": [
    { "id": 17, "name": "Maria", "lastName": "Wilson", "email": "maria@example.com" }
  ],
  "meta": { "total": 128, "hasMore": true, "durationMs": 412 }
}

The email field arrives as a string. If a contact has several addresses, select returns the primary one.

How many contacts matched the filter is stated by meta.total, and whether the selection did not fit into a single page by meta.hasMore. Both values sit next to data, not inside it. Pages must be walked by meta.hasMore: a data length equal to limit does not rule out the last page.

Step 3. Sending the email

POST /v1/mail/messages sends the email. from, to, subject and body are required.

cURL

Terminal
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/mail/messages" \
  -d '{
    "from": "Sales department <sales@example.com>",
    "to": ["maria@example.com"],
    "subject": "New partnership terms",
    "body": "Hello, Maria!\n\nWe have updated our partnership terms starting in November."
  }'

JavaScript

javascript
function personalize(template, contact) {
  const name = [contact.name, contact.lastName].filter(Boolean).join(' ') || 'colleagues'
  return template.replaceAll('{{NAME}}', name)
}

await fetch(`${VIBE_URL}/v1/mail/messages`, {
  method: 'POST',
  headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    from: sender,
    to: [contact.email],
    subject: 'New partnership terms',
    body: personalize(TEMPLATE, contact),
  }),
})

The response confirms the send and lists the recipients.

JSON
{ "success": true, "data": { "success": true, "to": ["maria@example.com"] } }

Each email is sent by a separate call. There is no bulk mode with a single request for all recipients, and that is for the best: the name substitution differs for every recipient, and one undelivered address does not break the whole campaign.

Limitations

Sender address from the list. The sender address must be one of those returned by GET /v1/mail/mailboxes/:id/senders. The email will not be sent if the from field contains an arbitrary string.

On behalf of an employee. Emails go out on behalf of a real employee or department, not the system. The client sees the address from step 1 and replies to it — warn the mailbox owner before you start.

A log against repeats. The script keeps a log of the addresses it has written to and skips anyone already in it. Without such a log, an interruption halfway means the next run emails everyone who already received the message a second time. The log belongs to one campaign — start a new file for the next one, otherwise it reaches nobody.

A pause between emails. Add a pause between sends. The constraint here is not API speed, but the fact that the recipients' mail providers treat a sudden stream of identical emails from one address as unwanted mail, and the address ends up on blocklists.

Recipient consent. Send only to those who agreed to receive emails. The typeId: "CLIENT" selection is a record type in the CRM, not confirmed consent to receive your emails.

A test on your own address. Before the first production run, send an email to your own address and check how the name substitution looks.

The account queue. A Bitrix24 account handles a limited number of API 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. The QUEUE_OVERFLOW code means the queue is full and the request was rejected immediately, QUEUE_TIMEOUT means the request waited for a slot longer than 30 seconds and never reached Bitrix24, so it is safe to repeat. The recommended pause arrives in the Retry-After header and is duplicated in error.retryAfter. Repeat with an increasing delay rather than instantly — a ready-made retry function is in Limits and optimization.

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

The full list of error codes — Errors.

Full code

javascript
// mail-blast.js — personalized campaign to CRM contacts
import { readFileSync, appendFileSync } from 'node:fs'
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 MAILBOX_ID = 5
const DELAY_MS = 2000
const PAGE = 50
// The log of addresses already written to. The emails go to real people, so a run
// after an interruption must continue where it stopped instead of resending everything.
const SENT_LOG = process.env.SENT_LOG ?? './sent.log'

function loadSent() {
  try {
    return new Set(readFileSync(SENT_LOG, 'utf8').split('\n').filter(Boolean))
  } catch {
    return new Set()
  }
}

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

const SUBJECT = 'New partnership terms'
const TEMPLATE = `Hello, {{NAME}}!

We have updated our partnership terms. The new rates take effect on the first day of next month.

Best regards, the sales department`

const sleep = ms => new Promise(r => setTimeout(r, ms))

// Any 429 refusal (QUEUE_OVERFLOW, QUEUE_TIMEOUT, RATE_LIMITED) means the request never reached
// Bitrix24 — repeating it is safe, the email will not go out twice. How long to wait
// is stated by the Retry-After header, and when it is absent by the error.retryAfter
// field. A random fraction of a second is added to the pause so that parallel
// copies of the script do not retry at the same moment.
const MAX_RETRIES = 5

async function apiCall(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) {
      // Honor the advised pause as-is: scaling it by the attempt number would
      // turn Retry-After: 3 into 48 seconds on the fifth attempt. The
      // exponent is only for the case where the server named no pause.
      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
  }
}

async function resolveSender() {
  const body = await apiCall(`${VIBE_URL}/v1/mail/mailboxes/${MAILBOX_ID}/senders`, { headers })
  const sender = body.data.items?.[0]?.sender
  if (!sender) throw new Error(`mailbox ${MAILBOX_ID} has no confirmed sender`)
  return sender
}

async function fetchContacts() {
  const all = []
  for (let offset = 0; ; offset += PAGE) {
    const body = await apiCall(`${VIBE_URL}/v1/contacts/search`, {
      method: 'POST',
      headers,
      body: JSON.stringify({
        filter: { typeId: 'CLIENT', '!email': null },
        select: ['id', 'name', 'lastName', 'email'],
        limit: PAGE,
        offset,
      }),
    })
    all.push(...body.data)
    if (!body.meta?.hasMore) return all
  }
}

function personalize(template, contact) {
  const name = [contact.name, contact.lastName].filter(Boolean).join(' ') || 'colleagues'
  return template.replaceAll('{{NAME}}', name)
}

async function sendTo(sender, contact) {
  await apiCall(`${VIBE_URL}/v1/mail/messages`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      from: sender,
      to: [contact.email],
      subject: SUBJECT,
      body: personalize(TEMPLATE, contact),
    }),
  })
}

async function main() {
  const [sender, contacts] = await Promise.all([resolveSender(), fetchContacts()])
  console.log(`Sender: ${sender}`)
  console.log(`Recipients: ${contacts.length}`)

  const alreadySent = loadSent()
  let sent = 0
  let skipped = 0
  const failed = []

  for (const contact of contacts) {
    if (alreadySent.has(contact.email)) {
      skipped++
      continue
    }
    try {
      await sendTo(sender, contact)
      appendFileSync(SENT_LOG, contact.email + '\n')
      sent++
      console.log(`  ${contact.email} — sent`)
    } catch (error) {
      failed.push({ email: contact.email, reason: error.message })
      console.log(`  ${contact.email} — error: ${error.message}`)
    }
    await sleep(DELAY_MS)
  }

  console.log(`Sent: ${sent}, skipped as already sent: ${skipped}, failed: ${failed.length}`)
  for (const f of failed) console.log(`  ${f.email}: ${f.reason}`)
}

main().catch(console.error)

See also