# 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`](/docs/mail/mailboxes).

### cURL

```bash
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`](/docs/mail/mailboxes/senders).

### cURL

```bash
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`](/docs/entities/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

```bash
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`](/docs/mail/messages/send) sends the email. `from`, `to`, `subject` and `body` are required.

### cURL

```bash
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](/docs/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](/docs/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

- [Send an email](/docs/mail/messages/send)
- [Mailboxes](/docs/mail/mailboxes)
- [Contact search](/docs/entities/contacts/search)
- [Filtering syntax](/docs/filtering)
