Implementation guideCRM APIs & sync

How to Build a CRM API Integration That Survives Production

Calling a CRM API takes an afternoon. Keeping a sync correct for years means designing for expired tokens, shifting pages, duplicate webhooks, rate limits and partial failures from the first commit. This guide walks through each of those decisions in the order you meet them.

You leave withA working design for a CRM connector: auth, incremental reads, safe writes, verified webhooks, rate-aware retries, tests and run-time checks.

Before you start
  • Access to the CRM’s API documentation and a sandbox or test account
  • A written list of objects and fields that must move, and in which direction
  • Somewhere to run a small service and keep state (a database or key-value store)
By
Comtau Editorial
Published
Reading
12 min · 9 steps
Diagram: pages of records read from one system, a worker holding an idempotency key, a validated write to the target system, and a dashed red retry loop with growing backoff.

In short

  • Decide what moves, in which direction, and who owns each field before writing code. Most integration bugs are ownership bugs.
  • Authenticate as a dedicated integration identity with the narrowest scopes, and keep secrets out of code and logs.
  • Read with a cursor you persist, re-read a small overlap window, and make every handler idempotent.
  • Write with upserts by external ID or idempotency keys, so a retry can never create a duplicate.
  • Treat webhooks as hints that must be verified, deduplicated and queued, not as the source of truth.

A CRM API is the programmatic interface a CRM exposes so other software can read and write its records: create a contact, move a deal to a new stage, attach a note, or subscribe to changes. Most modern CRMs offer a REST or REST-like API, OAuth 2.0 authentication and some form of change notification. That much is in every vendor’s documentation.

What the documentation rarely spells out is what happens on the thousandth call. This guide is vendor-neutral: the examples use a fictional CRM at api.example-crm.test, and every snippet is plain JavaScript for Node.js that was run against a mocked API. When you apply them to a real platform, replace the endpoints, headers and limits with the ones in that platform’s current documentation. For one platform worked through in depth, see Salesforce integration patterns, APIs and events.

Scope the integration before you open the API docs

The first deliverable of a CRM API integration is not code. It is a one-page specification that answers four questions per object:

  1. Which system owns it? The owner is the only system allowed to create the record and edit its owned fields. Others hold a copy. Ownership can be split by field; the ERP vs CRM guide shows how.
  2. Which direction does it move? One-way, or two-way with field-level ownership. A two-way sync without field ownership turns every edit into a potential conflict.
  3. What triggers a move? An event (a webhook or change event), a schedule, or a user action. The trigger sets your latency and your failure handling.
  4. How are records matched? By a stored external ID, or by a natural key such as a normalized email. Never by display name.
Hypothetical scope: ERP customers to CRM contacts, one way, with one field flowing back
ERP fieldCRM fieldTransformationOwner
customer.numbercontact.erp_customer_idStored as the external ID; never edited in the CRMERP
customer.emailcontact.emailTrimmed and lower-cased before matchingERP
customer.credit_holdcontact.credit_holdBoolean; read-only in the CRM layoutERP
customer.sales_repcontact.owner_idMapped through a rep code table; unknown codes go to reviewERP
(none)contact.marketing_consentCaptured in the CRM; flows back to the ERP nightlyCRM

Write the volumes next to each object: how many records exist, how many change per day, and how many change in the worst hour (a price update, a bulk reassignment, a migration). Rate-limit design starts from the worst hour, not the average.

If the answer to “which system owns it?” is unclear for core customer data, stop here and settle it with the people who use both systems. An API cannot resolve a disagreement about ownership; it can only make it happen faster.

Authenticate as a service, not as a person

An integration that runs under an employee’s login breaks when that person leaves, changes their password or loses a permission. It also makes every automated change look like that person’s work in the audit trail. Use a dedicated integration identity:

  • A service account or integration user created for this integration only, with a role that grants exactly the objects and fields in your scope.
  • A server-to-server OAuth flow where the CRM supports one, typically the client credentials grant or a JWT bearer grant. Use the authorization code flow only when the integration genuinely acts on behalf of different users.
  • The narrowest scopes the platform offers. Read-only scopes for objects you only read.

Tokens expire, and some are revoked early (a password reset, an admin action, a security event). Cache the access token, refresh it before it expires, and when a call returns 401, discard the cached token and retry once.

// Fictional CRM at api.example-crm.test. Credentials come from the environment, never from code.
let cached = null;

export async function accessToken() {
  if (cached && cached.expiresAt - 60_000 > Date.now()) return cached.token;
  const res = await fetch('https://auth.example-crm.test/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: process.env.CRM_CLIENT_ID,
      client_secret: process.env.CRM_CLIENT_SECRET,
    }),
  });
  // Log the status, never the response body: it can contain the token.
  if (!res.ok) throw new Error(`token request failed with ${res.status}`);
  const body = await res.json();
  cached = { token: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 };
  return cached.token;
}

export function forgetToken() {
  cached = null;
}

Store the client secret, private keys and refresh tokens in a secret manager, not in environment files committed to a repository. Plan rotation from the start: a secret that cannot be rotated without downtime will not be rotated. Redact Authorization headers, tokens and personal data from logs and error reports; an integration log that contains every synchronized email address is a second customer database with weaker protection.

Model the remote API behind one client

Put every call to the CRM behind a single function or class. It is the one place that adds authentication, sets timeouts, classifies errors, and decides what is safe to retry. Business code then reads like the mapping specification instead of like HTTP.

import { accessToken, forgetToken } from './crm-client.mjs';

const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
const MAX_ATTEMPTS = 6;

export class CrmError extends Error {
  constructor(status, body) {
    super(`CRM API responded ${status}`);
    this.status = status;
    this.body = body;
  }
}

export async function crmRequest(path, { method = 'GET', body, idempotencyKey } = {}) {
  // A POST is only safe to repeat when the API deduplicates it by key.
  const retrySafe = method !== 'POST' || Boolean(idempotencyKey);
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(`https://api.example-crm.test${path}`, {
      method,
      headers: {
        Authorization: `Bearer ${await accessToken()}`,
        'Content-Type': 'application/json',
        ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
      },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
    if (res.ok) return res.status === 204 ? null : res.json();
    if (res.status === 401 && attempt === 1) {
      forgetToken(); // revoked or rotated: fetch a new token once
      continue;
    }
    if (!retrySafe || !RETRYABLE.has(res.status) || attempt === MAX_ATTEMPTS) {
      throw new CrmError(res.status, await res.text());
    }
    const retryAfter = Number(res.headers.get('Retry-After'));
    const backoff = Math.min(30_000, 500 * 2 ** (attempt - 1));
    const wait = retryAfter > 0 ? retryAfter * 1000 : backoff / 2 + Math.random() * (backoff / 2);
    await new Promise((resolve) => setTimeout(resolve, wait));
  }
}

Three decisions are encoded here. Validation errors (400, 422) and permission errors (403) are never retried: repeating them changes nothing, so they go to a review queue with the response body. Server errors and 429 Too Many Requests are retried with exponential backoff and jitter, honouring Retry-After when the API sends a number of seconds. And a POST is retried only when it carries an idempotency key, because a create that timed out may have succeeded.

Production code adds a request timeout (an AbortSignal), a cap on total retry time, and a structured log line per call with the method, path template, status, attempt and duration, without bodies.

Read incrementally with a cursor you control

A full export on every run works for a few hundred records and fails quietly at a few hundred thousand. Incremental reads fetch only what changed since the last successful run. Most CRM APIs support one of three mechanisms:

Mechanism How it works What to watch
Modified-since filter Query records with updated_at after a stored timestamp, sorted by updated_at then ID Clock skew, late commits, records with identical timestamps
Change feed or event log Read numbered change events from a stored position Retention window: fall behind it and you need a full resync
Webhooks or push events The CRM calls you when something changes Delivery is usually at-least-once and can arrive out of order

With a modified-since filter, the rule that prevents lost records is to persist the position only after the page is processed, and re-read a short overlap window on the next run. Records committed slightly late, or stamped by a server whose clock is a few seconds behind, then land inside the overlap instead of between two runs. The overlap means some records arrive twice, which is fine as long as the handler is idempotent.

import { crmRequest } from './crm-request.mjs';

const OVERLAP_MS = 5 * 60_000;

export async function syncChangedContacts(store, handle) {
  const saved = (await store.get('contacts.updated_after')) ?? '1970-01-01T00:00:00.000Z';
  // Re-read a short window before the saved position: late commits and clock skew
  // must not make a record fall between two runs.
  const since = new Date(Date.parse(saved) - OVERLAP_MS).toISOString();
  let page = `/v1/contacts?updated_after=${encodeURIComponent(since)}&sort=updated_at,id&limit=200`;
  while (page) {
    const { data, next } = await crmRequest(page);
    for (const contact of data) await handle(contact); // must be idempotent: the overlap repeats records
    if (data.length) await store.set('contacts.updated_after', data.at(-1).updated_at);
    page = next; // the API's own next link; never compute page N+1 yourself
  }
}

Prefer cursor-based pagination (an opaque next link or token) over offset pagination. With offsets, a record updated while you page through the results moves to a later position and another record is skipped. If the API only offers offsets, sort by a stable key and re-check the totals at the end.

  1. 02:00:00Connector · ReadStarts from the saved position 01:55:12, minus a five-minute overlap.
  2. 02:00:01CRM API · ReadPage 1 returns 200 contacts sorted by updated_at, then ID, with a next link.
  3. 02:00:02Connector · WriteUpserts 199 contacts; one is unchanged since the last run and is skipped by comparing a hash of mapped fields.
  4. 02:00:02Connector · CheckSaves the position 01:59:58 only after the page is processed.
  5. 02:00:03CRM API · FailurePage 2 returns 429 with Retry-After: 2.
  6. 02:00:05Connector · ReadRetries page 2 after two seconds; 37 contacts, no next link. The run ends.
Fig. 1A hypothetical incremental run. The overlap window re-reads one contact that was already processed; the idempotent handler makes that harmless.

Schedule a periodic full reconciliation as well, weekly or monthly depending on volume: compare record counts and a checksum of key fields between the two systems, and resync what differs. Incremental sync is efficient; reconciliation is what proves it has not drifted.

Make every write safe to retry

Networks fail after the request is sent and before the response arrives. The connector cannot know whether the CRM created the record, so it must be safe to send the same write again. There are two standard ways to achieve that:

  • Upsert by external ID. Store the source system’s identifier in a unique field on the CRM record and write with “create or update by this key”. Repeating the call updates the same record.
  • Idempotency keys. Where the API supports them, send a key derived from the source event (for example the source record ID plus its version). The API returns the original result for a repeated key instead of performing the action twice.
import { crmRequest } from './crm-request.mjs';

export function upsertContact(customer) {
  const key = encodeURIComponent(customer.customerNo);
  return crmRequest(`/v1/contacts/by-external-id/erp_customer_id/${key}`, {
    method: 'PUT',
    body: {
      erp_customer_id: customer.customerNo,
      email: customer.email.trim().toLowerCase(),
      first_name: customer.firstName,
      last_name: customer.lastName,
    },
  });
}
Upsert by external ID (fictional API)PUT/v1/contacts/by-external-id/erp_customer_id/C-1042

Request

PUT /v1/contacts/by-external-id/erp_customer_id/C-1042 HTTP/1.1
Host: api.example-crm.test
Authorization: Bearer <redacted>
Content-Type: application/json

{"erp_customer_id":"C-1042","email":"[email protected]","first_name":"Ana","last_name":"Ortiz"}

Response 200 OK

{
"id": "ct_8f2a91",
"erp_customer_id": "C-1042",
"email": "[email protected]",
"created": false,
"updated_at": "2026-10-28T02:00:02Z"
}

Sending the same request again returns the same record with created: false. Without the external ID, a retried create would produce a second Ana Ortiz.

Records that fail validation go to a dead-letter queue (a table or queue of failed messages) with the payload, the error, the attempt count and a link to the source record. Someone fixes the data at the source and replays the message. A failure that only exists in a log file is a failure nobody will fix.

For multi-step writes, such as creating an account, then its contacts, then a deal, design each step to be repeatable and record progress, so a retry resumes rather than starting over. Where the CRM offers a composite or batch endpoint that applies several operations together, check in its documentation whether the batch is all-or-nothing or can partially succeed, and handle per-record results either way.

Receive webhooks without trusting them

Webhooks cut latency from minutes to seconds and save API calls, but treat each delivery as a hint that something changed, not as an authoritative copy of the record. Deliveries can be duplicated, delayed, reordered, or missing. A robust receiver does four things, in this order:

  1. Verify the signature over the raw request body before parsing it, using the scheme the CRM documents (commonly an HMAC with a shared secret, plus a timestamp to reject replays).
  2. Deduplicate by the event ID the CRM sends, keeping processed IDs for longer than the CRM’s retry window.
  3. Enqueue and acknowledge quickly. Return a 2xx within the time the sender allows; do the work from the queue. Slow endpoints cause the sender to retry, which multiplies load.
  4. Fetch the current record when order matters, instead of applying the payload blindly. The latest state from the API cannot be out of order.
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_MS = 5 * 60_000;

export function verifyWebhook(rawBody, headers, secret, now = Date.now()) {
  const timestamp = headers['x-crm-timestamp'];
  const signature = headers['x-crm-signature'] ?? '';
  if (!timestamp || Math.abs(now - Number(timestamp) * 1000) > TOLERANCE_MS) return false;
  const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest();
  const received = Buffer.from(signature, 'hex');
  return received.length === expected.length && timingSafeEqual(received, expected);
}

The header names and the signed string here belong to the fictional CRM; real platforms each define their own, and some sign with a public key instead of a shared secret. The constant-time comparison and the timestamp tolerance apply to all of them.

  1. 01CRM to Receivercontact.updated evt_71
  2. 02ReceiverVerify signature, check event ID
  3. 03Receiver to QueueEnqueue evt_71
  4. 04Receiver to CRM204 within the timeout
  5. 05CRM to ReceiverSame evt_71 again (sender retry)
  6. 06Receiver to CRM204, already seen: dropped
  7. 07Queue to WorkerDeliver evt_71
  8. 08Worker to CRM APIGET current contact
  9. 09CRM API to WorkerLatest version
Fig. 2Webhook delivery with a duplicate. The receiver acknowledges fast; the worker reads current state, so a late or repeated delivery cannot overwrite newer data.

Keep a scheduled incremental read running even when webhooks are enabled. It catches deliveries that never arrived, and it is your recovery path after the receiver has been down.

Respect rate limits by design

Every CRM limits API usage, typically with some combination of per-second or per-minute request limits, daily allocations shared across all integrations in an account, and limits on concurrent requests. The numbers differ by platform, edition and sometimes by the number of licensed users, and they change, so read them from the platform’s current documentation and, where it exposes one, from its usage or limits endpoint at run time.

  • Budget per integration. If the daily allocation is shared, agree how much each integration may use and alert at a threshold, before another team’s connector starts failing.
  • Use bulk and batch endpoints for large changes. Updating fifty thousand records one call at a time is the usual way integrations exhaust a daily allocation.
  • Throttle in the connector. A token bucket or a queue with a fixed concurrency keeps you under per-second limits instead of discovering them through 429 responses.
  • Back off on 429 and honour Retry-After. Retrying immediately makes the limit last longer.
  • Coalesce changes. Ten updates to the same record within a minute need one write with the final state.

Test against contracts and a sandbox

Unit tests with a mocked HTTP layer cover mapping and error classification, which is where most logic lives. They do not prove the API behaves the way you assumed. Add three more layers:

  • Contract tests that call the CRM’s sandbox for each endpoint you use and assert the fields, types and error shapes your code depends on. Run them on a schedule as well as in CI, so a vendor-side change shows up before it breaks production.
  • Fixture data that is clearly fictional: example domains such as example.com, invented names, no copies of production records. Production data in test environments is a privacy incident waiting to happen.
  • Failure injection: expired tokens, 429 storms, timeouts after the write succeeded, duplicate and out-of-order webhooks, a required field added to the CRM, a picklist value renamed. Each should end in a retry, a review-queue item or an alert, never in silent data loss.

Before the integration goes live

Data

  • Every mapped field has one owner and one direction
  • Records are matched by stored external IDs, with a documented fallback key
  • A backfill of existing records has run and been reconciled

Reliability

  • Writes are upserts or carry idempotency keys
  • Retries use backoff, honour Retry-After and stop at a limit
  • Failed records land in a dead-letter queue that someone owns

Security

  • The integration uses its own identity with minimal scopes
  • Secrets live in a secret manager and can be rotated
  • Logs contain no tokens or full personal records

Operations

  • Dashboards show throughput, error rate, queue age and API usage
  • Alerts fire on sustained failures and on a stalled cursor
  • A runbook explains how to replay, resync and pause the integration

Operate it: logs, metrics, replay and reconciliation

An integration is a small production system, and it needs the same operational basics as any other. The checks below are the ones that catch real problems early:

  • Freshness: the age of the newest processed change per object. A cursor that stops moving is the clearest signal that something is wrong, even when no errors are logged.
  • Failure rate and dead-letter age: a few failures a day may be normal data problems; a review queue that nobody empties is not.
  • API usage against the allocation, per integration.
  • Reconciliation results: counts and checksums per object, with differences listed.

Give operators three controls: pause (stop writing, keep reading), replay (re-run selected failed messages after a fix), and resync (re-read an object from a chosen date). With those, most incidents become a short procedure instead of a data repair project.

When an integration needs more than one connector, such as a CRM, an ERP, a billing system and a support desk, the decisions above repeat for every pair, and it is worth deciding whether to run them on an integration platform or in your own service. The integration platforms vs custom integration framework covers that choice. For delivery help, Comtau’s API integration services cover custom connectors end to end, and CRM integration services cover the wider design of ownership, sync modes and conflict rules.