# Sync CRM contacts into your application

Import contacts from a customer's CRM, save them without creating duplicates, and recover when a connection stops working. Start with a one-way import: the CRM owns the source fields; your application owns its annotations and sync state. This workflow does not create or update records in the CRM.

## Explore the demo

**CRM sample app**

Browse CRM leads, companies, contacts, and opportunities after creating a valid session. This app does not demonstrate the complete incremental-sync and recovery implementation in this guide.

First visit? Choose Create a test session, connect a CRM account in Vault, then return to the app and select the integration. The app currently labels a missing session as “Invalid session”; it does not include preconnected sample data.

[Try with sample data](/playground?api=crm&useCase=customer-sync) · [Set up CRM sample app](https://crm.apideck.dev/) · [API endpoints](/apis/crm/reference?useCase=customer-sync)

For example, contact `crm-1042` changes its email address between imports. Your next import updates the same local row because its identity is the source ID, not the email address. Another customer can have a contact with the same ID without either record overwriting the other.

## Your build path

| Step | Build | Verify |
| --- | --- | --- |
| Connect | A customer-specific Vault connection | A known contact is visible to the authorized CRM user |
| Import | A paginated read with durable upserts | Every page commits before the run is marked complete |
| Repeat | Retry the import using the same identity scope | Existing IDs update rather than duplicate |
| Operate | Recovery, change detection, and deletion policy | Failed runs never advance the successful checkpoint |

## Connect the right customer

Start with [Make your first CRM API call](/first-api-call?api=crm&useCase=customer-sync). Use a test workspace and a consumer mapped to that customer. Complete [Vault authorization](/guides/vault?api=crm&useCase=customer-sync), including any required company or workspace settings. Your backend resolves the consumer from the signed-in tenant; never trust a consumer ID supplied by a browser without checking ownership.

Check [Contacts coverage](/apis/crm/coverage?resources=contacts&useCase=customer-sync) for your connector. You need `contactsAll` and pagination. `contactsOne` is useful for verification and webhook-driven refreshes. Confirm field visibility with the actual source user: a successful empty response is not proof that the workspace contains no contacts.

[Open List contacts in Explorer](/apis/crm/api-explorer?operation=contactsAll&useCase=customer-sync) and verify a contact you can also see in the CRM. Selection alone does not execute a request. Keep credentials in Explorer's credential controls, never in the link.

## Run the importer

The downloadable example uses Node.js 24, the TypeScript/JavaScript SDK, and Node's built-in SQLite module. It performs live **read requests** when you run it; it only writes to a local SQLite file. SDK retries handle configured transient failures; the script does not add a second retry loop.

[Download crm-contact-sync.mjs](/samples/crm-contact-sync.mjs) into a new server-side project, then install the pinned SDK:

```bash
npm init -y
npm install @apideck/unify@0.52.2
```

Set these variables in your server environment or secret manager:

| Variable | Value |
| --- | --- |
| `APIDECK_API_KEY` | Your server API key |
| `APIDECK_APP_ID` | The application containing the connection |
| `APIDECK_CONSUMER_ID` | The customer owning that connection |
| `APIDECK_SERVICE_ID` | The selected connector ID |
| `CRM_WORKSPACE_KEY` | Your stable identifier for this source workspace or connection generation |
| `CRM_SYNC_DB` | Optional local database path; defaults to `crm-sync.sqlite` |

```bash
node crm-contact-sync.mjs
```

A successful run prints a summary such as this fictional result, without printing contact data:

```json
{"completed":true,"pages":3,"storedContacts":247}
```

The database contains personal data. Run in a private working directory, exclude the database from version control, and apply your retention policy. Newly created files use owner-only permissions; the script does not repair permissions on an existing file. Apideck's [Zero Data Retention policy](/guides/security-and-data-handling?api=crm) does not govern the copy your application stores.

### How pagination and persistence work

The importer sends `limit: 100`, commits each page, and passes the returned cursor into the next request. It stops only at an explicit `null` next cursor. A missing or repeated cursor fails the run rather than reporting a partial import as complete. A page budget also prevents an unbounded job.

Each database key combines application, consumer, connector, workspace generation, and contact ID. Emails and names can change or be shared, so neither is a safe identity key. If a consumer reconnects the same connector to a different CRM workspace, assign a new workspace key before importing.

The saved `source_json` is an SDK-shaped contact snapshot; JavaScript properties use SDK casing. Keep your application's notes and ownership fields in a separate table. Do not blindly merge omitted fields with explicit `null` values: those represent different source states. See [field mapping](/guides/field-mapping?api=crm&useCase=customer-sync) before deriving your product model.

A failed page rolls back its local transaction. Earlier committed pages remain. Restart the script from page one; the same source IDs are upserted again. The `sync_state.completed_start` timestamp changes only after the final page commits. This is a small, single-worker example: in production, lock or serialize jobs per connection, use durable queue state, and monitor job duration. A cursor is a pagination token, not a long-lived change checkpoint.

## Add incremental updates only after checking support

The unified [Contacts filter schema](/apis/crm/reference/contacts#contactsAll) includes `updated_since`. That does not guarantee your connector supports it. First inspect coverage and test a known change in the source CRM. The downloaded importer deliberately performs full reads and does not enable an unverified filter.

For a connector that supports this filter, the SDK request can include:

```typescript
const result = await client.crm.contacts.list({
  serviceId,
  limit: 100,
  cursor,
  filter: { updatedSince: new Date(since) }
});
```

Use the last successful run's **start time**, with an overlap window suited to the connector's timestamp precision and indexing delay, as `since`. Keep that value fixed for every page of the run. Advance it only after all pages commit. Upserts make overlap safe. Test late-arriving updates and records sharing a timestamp; overlap is a mitigation, not a universal completeness guarantee.

If the connector rejects the filter, do not silently drop it and mark the incremental job successful. Switch explicitly to a scheduled full-read strategy, or verify a connector-specific alternative. Keep periodic reconciliation even when using incremental updates.

### Webhooks and deletions

Where the required events are supported, use [webhooks](/guides/webhooks?api=crm&useCase=customer-sync) to enqueue a refresh of the affected contact. Verify signatures, deduplicate deliveries, and fetch the current record rather than assuming events arrive in order. Use [Get contact](/apis/crm/reference/contacts#contactsOne) for that refresh.

A contact missing from a list may be deleted, hidden by changed permissions, or excluded by a filter. The example never deletes local rows based on absence. Define a deletion policy using verified deletion events or a complete reconciliation with confirmed visibility. A single `404` does not by itself prove deletion. Retain a tombstone or audit record according to your application's policy.

## Recover without losing progress

| Result | Diagnose | Resume |
| --- | --- | --- |
| `401` or credential error | Distinguish an invalid Apideck key from expired downstream authorization using the error type and Logs | Fix server credentials or reconnect the affected customer through Vault; retry the same scope |
| `403` or unexpectedly empty data | Check source permissions, workspace selection, and record sharing with a known contact | Restore access; never treat an access-limited result as permission to delete local records |
| Missing connection settings | Open that connector's setup instructions and complete required settings | Verify the first read before restarting the import |
| Unsupported filter or pagination | Compare the requested operation and filters with connector coverage | Change the sync strategy explicitly; do not claim completeness from one page |
| `429`, network failure, or `5xx` | Check the SDK retry outcome and any rate-limit guidance returned | Requeue with a bounded delay after SDK retries finish; serialize jobs for the connection |
| `402` | Check the application's plan or request allowance | Resolve the limit before requeueing; rapid retries will not help |
| Mapping error or missing ID | Inspect the request ID and downstream call in Logs | Correct the mapping or raise the request with support; do not invent IDs or advance the checkpoint |
| Database or page-budget failure | Check disk space, job size, and the local worker's diagnostics | Repair storage or split the workload; restart safely from the last successful scope |

Use [Logs](/logs?api=crm&useCase=customer-sync) to compare the unified response with downstream requests when available. Record safe operational metadata—status, connector, operation, duration, and request ID—not raw contact payloads in analytics. The sample prints a generic failure summary; production workers should distinguish local storage failures from API errors without exposing SDK error bodies.

## Verify before scheduling

1. Import at least two pages in an authorized test workspace. Match a known contact to the source CRM.
2. Run again without source changes. Confirm the stored row count stays stable.
3. Change a contact's email in the CRM and run again. Confirm the existing ID updates.
4. Interrupt a later page. Confirm earlier rows remain and the successful checkpoint does not move.
5. Revoke access, then reconnect. Confirm the job fails and recovers without changing tenant scope.
6. Run another consumer with overlapping contact IDs. Confirm its rows remain separate.
7. If enabling incremental filters or webhooks, test those independently, including permissions changes and deletions.

The sample can be exercised with synthetic responses to verify SDK compatibility and persistence behavior. That is separate from live connector acceptance; passing local tests does not prove every connector supports the workflow.

## Extend to companies

Once contact import works, add a separate [Companies import](/apis/crm/reference/companies#companiesAll) with its own pagination and completion state. Store company IDs in the same tenant and connector scope. Keep an unresolved relationship when a contact refers to a company that has not arrived yet; do not drop the contact or guess a company by its name. Verify both resources before describing the feature as a complete customer sync.

Return to the [CRM first-call workspace](/first-api-call?api=crm&useCase=customer-sync) to test another connection, or explore the [customer-sync endpoints](/apis/crm/reference?useCase=customer-sync).

## Frequently asked questions

### Should I deduplicate CRM contacts by email address?

No. Use the source contact ID within the consumer and connector scope. Email addresses can change or be shared; a contact whose email changes should update the same local record. See [the import workflow](#your-build-path).

### Does every CRM connector support updated_since?

No. The unified filter schema does not establish connector support. Check coverage and test a known source change before enabling incremental reads. If unsupported, choose an explicit full-read reconciliation strategy. See [incremental updates](#add-incremental-updates-only-after-checking-support).

### Should I delete local contacts that disappear from a list response?

No. Missing contacts may reflect permissions, filters, or an incomplete scan rather than deletion. Use verified deletion events or a completed reconciliation with confirmed visibility. See [Webhooks and deletions](#webhooks-and-deletions).

### Does the CRM sample demonstrate a complete production sync?

No. The sample requires a valid session and lets you explore CRM records; it does not demonstrate the complete incremental-sync and recovery implementation in this guide. Test pagination, tenant isolation, interrupted imports and reconnection before scheduling your own importer.
