# Syncing contacts with an ERP system

**Difficulty:** advanced | **Scopes:** crm | **Stack:** cURL / JavaScript

Move contacts between an ERP system and Bitrix24: new records are created on both sides, edits to existing ones travel from the ERP system into Bitrix24, and a repeated run does not duplicate clients. The exchange runs on a schedule and processes only what changed since the previous run.

## What you need

- A Vibecode API key with the `crm` scope
- Access to the API of your ERP system
- Node.js 18 or newer

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

**The ERP side is not described in this recipe.** Different ERP systems and custom databases use different protocols, so everything that happens on their side is extracted into five stub functions: `fetchErpContacts` and `createErpContact` read and write records, `findErpContactByKey` looks an existing record up by key, `loadSyncCursor` and `saveSyncCursor` store the mark of the last run. You replace these five functions with your own code. Everything else — the Vibecode calls — works as shown.

## How the solution works

1. Choose a matching key. Records are linked by a value that exists in both systems and does not change — a tax number, an external client code or an email address.
2. Read from Bitrix24 the contacts changed after the previous run, not the whole database.
3. Match them against the ERP export and split them into three groups: matched, only in Bitrix24, only in the ERP system.
4. Create the missing contacts in Bitrix24 with a single batch call, and update the differences in the matched ones.
5. Save the run timestamp — the next cycle counts from it.

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

## Step 1. Matching key and the field for the external code

Records cannot be linked by name — «John Brown» occurs many times. You need an external client code, a tax number or an email address.

The external code is stored in a custom contact field. Their names are specific to each Bitrix24 account and look like `ufCrm_1729594209` — the list is returned by [`GET /v1/contacts/fields`](/docs/entities/contacts/fields).

### cURL

```bash
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/contacts/fields"
```

### JavaScript

```javascript
const res = await fetch(`${VIBE_URL}/v1/contacts/fields`, {
  headers: { 'X-Api-Key': VIBE_API_KEY },
})
const { data } = await res.json()
const custom = Object.keys(data.fields).filter(name => name.startsWith('ufCrm_'))
```

```json
{
  "success": true,
  "data": {
    "fields": {
      "id": { "type": "integer", "isReadOnly": true },
      "email": { "type": "crm_multifield", "isMultiple": true },
      "ufCrm_1729594209": { "type": "string", "title": "Client code in the ERP system", "isMultiple": false }
    }
  }
}
```

Custom fields differ from system ones by the `ufCrm_` prefix in the name. The field caption is in `title` — it is what identifies the field holding the external code among several.

The field name is determined once and written into the script configuration. If there is no external code, the email address becomes the key — the filter works on it without extra setup.

## Step 2. What changed since the previous run

A full database export on every cycle is unnecessary. [`POST /v1/contacts/search`](/docs/entities/contacts/search) with a filter on `updatedTime` returns only the records changed after the mark of the previous run. Operator syntax — [filtering](/docs/filtering).

### 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": { ">updatedTime": "2026-07-20T00:00:00Z" },
    "select": ["id", "name", "lastName", "email", "phone", "updatedTime"],
    "sort": { "updatedTime": "asc" },
    "limit": 50,
    "offset": 0
  }'
```

### 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: { '>updatedTime': since },
    select: ['id', 'name', 'lastName', 'email', 'phone', 'updatedTime'],
    sort: { updatedTime: 'asc' },
    limit: 50,
    offset,
  }),
})
const { data, meta } = await res.json()
```

```json
{
  "success": true,
  "data": [
    {
      "id": 2521,
      "name": "Probe",
      "lastName": "Recipe",
      "email": "probe@example.com",
      "phone": "+12025550123",
      "updatedTime": "2026-07-21T08:59:22.000Z"
    }
  ],
  "meta": { "total": 1, "hasMore": false, "durationMs": 574 }
}
```

The contact timestamp is carried by the `updatedTime` field, not `updatedAt` — on deals this field is named differently. Whether more pages remain is reported by `meta.hasMore`.

Pages are traversed with the `offset` parameter, and its step is a multiple of 50: `offset: 25` returns the same first page as `offset: 0`. Use a `limit` that is a multiple of 50 and increase `offset` by the same amount.

Addresses and phones come back as strings — `"email": "probe@example.com"`. The full set of contact values with types is in the `fm` field.

## Step 3. Matching and the three groups

Matching is ordinary code on the script side: build an index by the key and walk the ERP export. There are no API calls in this step, so there is no cURL example here — there would be nothing to show.

```javascript
// bitrixKey and erpKey must extract THE SAME attribute from the two sides:
// either the external key on both, or the email on both. An index built on email
// against a lookup by external key never matches — the exchange starts duplicating.
function match(bitrixContacts, erpContacts, bitrixKey, erpKey) {
  const index = new Map(bitrixContacts.map(c => [bitrixKey(c), c]).filter(([k]) => k))
  const paired = []
  const onlyInErp = []
  const pairedIds = new Set()

  for (const erp of erpContacts) {
    const found = index.get(erpKey(erp))
    if (found) {
      paired.push({ bitrix: found, erp })
      pairedIds.add(found.id)
    } else {
      onlyInErp.push(erp)
    }
  }

  const onlyInBitrix = bitrixContacts.filter(c => !pairedIds.has(c.id))
  return { paired, onlyInBitrix, onlyInErp }
}
```

Normalize the key before comparing — lowercase the address, strip spaces and dashes from tax numbers and phones. Otherwise `Smith@Example.com` and `smith@example.com` split into two records.

## Step 4. Creating the missing contacts in a batch

Creating them one by one is unnecessary. [`POST /v1/contacts/batch`](/docs/batch) accepts an `items` array and returns the result for each element separately.

### cURL

```bash
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/contacts/batch" \
  -d '{
    "action": "create",
    "items": [
      {
        "name": "John",
        "lastName": "Brown",
        "email": [{ "value": "brown@example.com", "typeId": "WORK" }],
        "phone": [{ "value": "+12025550124", "typeId": "WORK" }],
        "sourceId": "OTHER",
        "sourceDescription": "Import from the ERP system"
      },
      {
        "name": "Maria",
        "lastName": "Davis",
        "email": [{ "value": "davis@example.com", "typeId": "WORK" }]
      }
    ]
  }'
```

### JavaScript

```javascript
const res = await fetch(`${VIBE_URL}/v1/contacts/batch`, {
  method: 'POST',
  headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    action: 'create',
    items: onlyInErp.map(erp => ({
      name: erp.name,
      lastName: erp.lastName,
      email: [{ value: erp.email, typeId: 'WORK' }],
      phone: [{ value: erp.phone, typeId: 'WORK' }],
      sourceId: 'OTHER',
      sourceDescription: 'Import from the ERP system',
    })),
  }),
})
const { data } = await res.json()
```

```json
{
  "success": true,
  "data": {
    "results": [
      { "index": 0, "success": true, "id": 2523 },
      { "index": 1, "success": true, "id": 2525 }
    ],
    "summary": { "total": 2, "succeeded": 2, "failed": 0 }
  }
}
```

The `index` field points to the position in the submitted `items` array — it tells you which ERP record got which `id` in Bitrix24. Store these pairs on your side, otherwise the next cycle has to rebuild the matching from scratch.

A failure on one record does not cancel the rest. An unsuccessful element arrives in the same `results` array in the form `{ "index": 3, "success": false, "error": "<code>", "message": "<explanation>" }` — parse the whole array, not just `summary`.

The address and the phone are passed as `[{ "value": …, "typeId": "WORK" }]`. The form with the `VALUE` and `VALUE_TYPE` keys is rejected with `INVALID_MULTIFIELD_SHAPE`. The endpoint also accepts a single string.

Updates and deletions go through the same endpoint — only `action` changes:

```json
{ "action": "update", "items": [{ "id": 2523, "fields": { "post": "Director" } }] }
```

```json
{ "action": "delete", "ids": [2523, 2525] }
```

## Step 5. Updating matched records

Differences in matched pairs are fixed field by field — [`PATCH /v1/contacts/:id`](/docs/entities/contacts/update) accepts only the changed fields.

### cURL

```bash
curl -s -X PATCH -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/contacts/2521" \
  -d '{ "post": "Director" }'
```

### JavaScript

```javascript
function diff(bitrix, erp) {
  const changes = {}
  if (erp.name && bitrix.name !== erp.name) changes.name = erp.name
  if (erp.lastName && bitrix.lastName !== erp.lastName) changes.lastName = erp.lastName
  if (erp.post && bitrix.post !== erp.post) changes.post = erp.post
  return changes
}

const changes = diff(pair.bitrix, pair.erp)
if (Object.keys(changes).length > 0) {
  await fetch(`${VIBE_URL}/v1/contacts/${pair.bitrix.id}`, {
    method: 'PATCH',
    headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify(changes),
  })
}
```

```json
{
  "success": true,
  "data": {
    "id": 2521,
    "name": "Probe",
    "lastName": "Recipe",
    "post": "Director",
    "updatedTime": "2026-07-21T09:03:14.000Z"
  }
}
```

The response returns the whole contact with a new `updatedTime` — it shows that the edit really was applied.

Send the request only when `changes` is non-empty. An update without changes still moves `updatedTime`, and on the next cycle the contact falls into the selection of changed records again — the cycle starts feeding on itself.

## Limitations

**Account queue.** The loop of lookups and batch writes hits the account queue before any other refusal. A Bitrix24 account runs 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 cycle that sends requests back to back sooner or later receives 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 repeating it is safe.

```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 recommended pause arrives in the `Retry-After` header and is duplicated in `error.retryAfter`. Repeat with a growing delay instead of instantly — a ready-made retry function is in [Limits and optimization](/docs/optimization).

**Offset paging and windows.** Paging with `offset` is incompatible with window traversal of a wide date range: a `>updatedTime` filter with an old cursor spans more than 14 days, the window turns on automatically, and the second page comes back as `400 UNSTABLE_OFFSET_PAGINATION`. That is why the request carries `autoWindow: false` and sorts by `id`.

**Batch size limit.** The batch call is limited by the number of elements in one request. The exact value is set by the platform administrator, and exceeding it rejects the whole batch with the `BATCH_LIMIT_EXCEEDED` code. Split the export into portions and send them one after another.

**Duplicates on incremental reads.** An incremental read answers «what changed», not «what is missing». The matching index is built from a change slice, so a record whose counterpart has not changed since last time is absent from it — and looks new. It must not be created: the script first looks such a record up in Bitrix24 by key with a separate request, and only creates it when that comes back empty. Without that check the exchange breeds duplicates on every run, and a symmetric window on both sides does not save it.

**Edits go one way.** Edits to existing records travel in one direction only — from the ERP system into Bitrix24. New records, meanwhile, are created on both sides. Sending edits back from Bitrix24 into the ERP system is out of scope for this recipe: it needs a per-field conflict-resolution rule, and without one the same record would be rewritten back and forth on every run.

**Who wins a conflict.** The direction of the edit on a conflict is decided by the script author. In the example the ERP system wins the conflict: its values overwrite the Bitrix24 fields. If a manager corrected the phone in the card while the ERP system still holds the old one, the manager's edit is lost. Another option is to compare the timestamps on both sides and take the fresher one.

**Last-run mark.** The mark of the last run must survive a process restart. Keeping it in a variable inside the script is not an option: after a server reboot the cycle starts from zero and re-reads the whole database.

**Mapping lives outside Vibecode.** The «ERP record — Bitrix24 contact» mapping is stored outside Vibecode. If you lose it, a repeated run matches the records again by key, and for those with an empty key it creates duplicates.

**No exchange log.** The script keeps no exchange log. The batch call response gives `index`, `success` and `id` for each record — store these rows on your side, otherwise after a failure you cannot tell which records went through and which did not.

**Deletion is not transferred.** Deletion is not synchronized. A contact deleted in the ERP system stays in Bitrix24 — the exchange transfers creation and modification.

**BLACKHOLE server sleep.** If the exchange runs on a BLACKHOLE server, turn off automatic sleep. The idle timer counts **incoming** requests to the server, while the exchange makes only outgoing calls — from the timer's point of view the server is idle and falls asleep together with the schedule. It is turned off through [`PATCH /v1/infra/servers/:id/sleep`](/docs/infra/lifecycle/sleep) with the value `sleepAfterMinutes: null`. On preemptible plans (`bc-agent`, `bc-micro`) that is not enough: the cloud restarts the machine roughly once a day anyway. The mark of the last run survives a restart, but while a server sleeps the difference keeps accumulating, so the first cycle after waking goes as a large batch — size your portion for that case, not for an ordinary hour.

The full list of error codes — [Errors](/docs/errors).

## Full code

```javascript
// erp-sync.js — contact exchange between the ERP system and Bitrix24
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 PAGE = 50
const BATCH = 50
// The name of the custom field holding the external key, found in step 1.
// Empty — the email address serves as the matching key.
const EXTERNAL_KEY_FIELD = process.env.ERP_KEY_FIELD ?? null

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

// Any 429 refusal (QUEUE_OVERFLOW, QUEUE_TIMEOUT, RATE_LIMITED) means the request never reached
// Bitrix24 — repeating it is safe, contacts will not be duplicated. How long to
// wait is told by the Retry-After header, and in its absence by the
// error.retryAfter field. A random fraction of a second is added to the pause
// so that parallel copies of the sync do not retry in unison. Without this retry a long export breaks off at the first busy queue.
const MAX_RETRIES = 5

async function apiCall(url, init = {}, onError) {
  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) {
      if (onError) return onError(body, res)
      throw new Error(body?.error?.message ?? `request rejected (${res.status})`)
    }
    return body
  }
}

// --- ERP side: replace with your own code -----------------------------------
async function fetchErpContacts(since) {
  // Return an array of the form
  // { externalKey, name, lastName, email, phone, post },
  // changed after the `since` mark — through THE SAME window as the Bitrix24 side.
  // A full export here against an incremental read there means records whose
  // counterpart has not changed since last time are missing from the index and
  // get created anew on every run.
  return []
}
async function createErpContact(contact) { return { externalKey: String(contact.id) } }
// The mirror of the lookup for the ERP side: return the existing record by key,
// or null. Without it a contact changed in Bitrix24 is exported again every time
// its counterpart in the ERP system has not changed.
async function findErpContactByKey(key) { return null }
async function loadSyncCursor() { return process.env.SYNC_SINCE ?? '1970-01-01T00:00:00Z' }
async function saveSyncCursor(iso) { console.log('new mark:', iso) }
// ---------------------------------------------------------------------------

const normalize = value => String(value ?? '').trim().toLowerCase()

async function fetchChangedContacts(since) {
  const all = []
  for (let offset = 0; ; offset += PAGE) {
    const body = await apiCall(`${VIBE_URL}/v1/contacts/search`, {
      method: 'POST',
      headers,
      body: JSON.stringify({
        filter: { '>updatedTime': since },
        select: EXTERNAL_KEY_FIELD
          ? ['id', 'name', 'lastName', 'post', 'email', 'phone', 'updatedTime', EXTERNAL_KEY_FIELD]
          : ['id', 'name', 'lastName', 'post', 'email', 'phone', 'updatedTime'],
        // A date filter spanning more than 14 days turns on window traversal,
        // which is incompatible with offset: the second page comes back as
        // 400 UNSTABLE_OFFSET_PAGINATION. Turn the window off and sort by id —
        // that gives plain pagination that tolerates offset.
        autoWindow: false,
        sort: { id: 'asc' },
        limit: PAGE,
        offset,
      }),
    })
    all.push(...body.data)
    if (!body.meta?.hasMore) return all
  }
}

// Both sides are keyed by the same attribute. They must not be mixed: an index
// built on email will never match an external key, so every run would treat all
// records as new and create duplicates.
const bitrixKey = contact => normalize(EXTERNAL_KEY_FIELD ? contact[EXTERNAL_KEY_FIELD] : contact.email)
const erpKey = erp => normalize(EXTERNAL_KEY_FIELD ? erp.externalKey : erp.email)

function match(bitrixContacts, erpContacts) {
  const index = new Map()
  for (const contact of bitrixContacts) {
    const key = bitrixKey(contact)
    if (key) index.set(key, contact)
  }

  const paired = []
  const onlyInErp = []
  const pairedIds = new Set()

  for (const erp of erpContacts) {
    const key = erpKey(erp)
    const found = key ? index.get(key) : undefined
    if (found) {
      paired.push({ bitrix: found, erp })
      pairedIds.add(found.id)
    } else {
      onlyInErp.push(erp)
    }
  }

  return {
    paired,
    onlyInErp,
    onlyInBitrix: bitrixContacts.filter(c => !pairedIds.has(c.id)),
  }
}

async function findInBitrixByKey(erp) {
  // The filter gets the RAW value, not the normalized one: lowercasing is for the
  // in-memory comparison, while the account compares the value as stored. An external
  // key «A-1001» saved in upper case is not found by «a-1001», the lookup comes back
  // empty — and creates the very duplicate it was added to prevent.
  const key = EXTERNAL_KEY_FIELD ? erp.externalKey : erp.email
  if (!key) return null
  const body = await apiCall(`${VIBE_URL}/v1/contacts/search`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      filter: EXTERNAL_KEY_FIELD ? { [EXTERNAL_KEY_FIELD]: key } : { email: key },
      select: ['id', 'name', 'lastName', 'post', 'email', 'phone'],
      limit: 1,
    }),
  })
  return body.data[0] ?? null
}

async function createInBitrix(erpContacts) {
  const created = []
  for (let i = 0; i < erpContacts.length; i += BATCH) {
    const chunk = erpContacts.slice(i, i + BATCH)
    const body = await apiCall(`${VIBE_URL}/v1/contacts/batch`, {
      method: 'POST',
      headers,
      body: JSON.stringify({
        action: 'create',
        items: chunk.map(erp => ({
          name: erp.name,
          lastName: erp.lastName,
          post: erp.post,
          email: erp.email ? [{ value: erp.email, typeId: 'WORK' }] : undefined,
          phone: erp.phone ? [{ value: erp.phone, typeId: 'WORK' }] : undefined,
          sourceId: 'OTHER',
          sourceDescription: 'Import from the ERP system',
          // Without writing the external key back, the new contact will not match on the next run.
          ...(EXTERNAL_KEY_FIELD ? { [EXTERNAL_KEY_FIELD]: erp.externalKey } : {}),
        })),
      }),
    })

    for (const row of body.data.results) {
      const source = chunk[row.index]
      if (row.success) created.push({ externalKey: source.externalKey, bitrixId: row.id })
      else console.log(`not created ${source.externalKey}: ${row.error} — ${row.message}`)
    }
  }
  return created
}

function diff(bitrix, erp) {
  const changes = {}
  if (erp.name && bitrix.name !== erp.name) changes.name = erp.name
  if (erp.lastName && bitrix.lastName !== erp.lastName) changes.lastName = erp.lastName
  if (erp.post && bitrix.post !== erp.post) changes.post = erp.post
  return changes
}

async function updateInBitrix(pairs) {
  let updated = 0
  for (const pair of pairs) {
    const changes = diff(pair.bitrix, pair.erp)
    if (Object.keys(changes).length === 0) continue

    // The third argument disables the exception: one edit that did not go through
    // must not bring the whole exchange down. The wrapper still retries on a 429.
    const body = await apiCall(`${VIBE_URL}/v1/contacts/${pair.bitrix.id}`, {
      method: 'PATCH',
      headers,
      body: JSON.stringify(changes),
    }, failed => failed)
    if (body?.success) updated++
    else console.log(`not updated #${pair.bitrix.id}: ${body?.error?.message}`)
  }
  return updated
}

async function sync() {
  const startedAt = new Date().toISOString()
  const since = await loadSyncCursor()
  console.log(`changes since ${since}`)

  const [bitrixContacts, erpContacts] = await Promise.all([
    fetchChangedContacts(since),
    fetchErpContacts(since),
  ])
  console.log(`Bitrix24: ${bitrixContacts.length}, ERP system: ${erpContacts.length}`)

  const { paired, onlyInBitrix, onlyInErp } = match(bitrixContacts, erpContacts)

  // An incremental read answers "what changed", NOT "what is missing": absence
  // from a change slice does not mean the record is absent from Bitrix24. So every
  // unmatched record is looked up by key first — without this the exchange creates
  // a duplicate on every run.
  const trulyNew = []
  for (const erp of onlyInErp) {
    const found = await findInBitrixByKey(erp)
    if (found) paired.push({ bitrix: found, erp })
    else trulyNew.push(erp)
  }

  const created = await createInBitrix(trulyNew)
  const updated = await updateInBitrix(paired)

  let exported = 0
  for (const contact of onlyInBitrix) {
    // The same logic as on import: absence from a change slice does not mean
    // absence from the ERP system, so look the record up by key first.
    if (await findErpContactByKey(bitrixKey(contact))) continue

    const { externalKey } = await createErpContact(contact)
    // The external key goes back into the Bitrix24 contact. Without this write the
    // contact is unmatched again on the next run and gets exported a second time.
    if (EXTERNAL_KEY_FIELD && externalKey) {
      const body = await apiCall(`${VIBE_URL}/v1/contacts/${contact.id}`, {
        method: 'PATCH',
        headers,
        body: JSON.stringify({ [EXTERNAL_KEY_FIELD]: externalKey }),
      }, failed => failed)
      // A failed write silently breaks the mapping: the contact is exported again
      // on the next run, so it does not count as exported.
      if (!body?.success) {
        console.log(`  ${contact.id}: external key not written — ${body?.error?.message}`)
        continue
      }
    }
    exported++
  }

  await saveSyncCursor(startedAt)
  console.log(`created ${created.length}, updated ${updated}, exported ${exported}`)
}

sync().catch(error => {
  console.error('exchange interrupted:', error.message)
  process.exitCode = 1
})
```

Running on a schedule is a job for the operating system scheduler. A single `cron` line like `0 * * * * node /opt/sync/erp-sync.js` starts the exchange every hour and survives a server reboot.

## See also

- [Working with files in CRM fields](/docs/recipes/crm-files)
- [Search contacts](/docs/entities/contacts/search)
- [Update contact](/docs/entities/contacts/update)
- [Contact fields](/docs/entities/contacts/fields)
- [Batch calls](/docs/batch)
- [Filtering syntax](/docs/filtering)
