# Integrating with CRM systems

**Availability: Preview**

The most common integration connects RelationsAI to a CRM: a new or updated lead triggers a call, and the call's outcome, summary and transcript flow back to the lead record. This guide describes that pattern end to end.

It works with any CRM that can send an HTTP request (or be polled) when a record changes, and that you can write to through its own API. Nothing here depends on a particular CRM product.

## Before you begin

| You need | For |
|---|---|
| A live key and a test key, each with `contacts:write`, `calls:write`, `calls:read`, `analysis:read`, `dnc:write` | Your integration service (one key per mode) |
| `transcripts:read` (optional) | Copying transcripts to the record |
| A webhook endpoint subscribed to `call.ended` and `call.settled` | Writing results back ([Receiving webhooks](/guides/receiving-webhooks)) |
| A ready agent | The calls ([Creating and preparing agents](/guides/creating-and-preparing-agents)) |

Keep the API key in your integration service, never in the CRM's browser-side scripts: live keys are refused from browsers.

## The architecture

```text
CRM record changes ──▶ your integration service
                          1. compliance first: do-not-call flag ─▶ POST /dnc-entries, stop
                          2. POST /contacts/upsert     (name, consent, custom fields)
                          3. POST /calls               (metadata: crm_record_id, crm_event_id;
                                                         skipped if this CRM event id was handled)
RelationsAI ──webhook──▶ your integration service
                          4. call.ended    ─▶ status on the record ("No answer", "Busy")
                          5. call.settled  ─▶ GET /calls/{id}/analysis ─▶ summary + fields to the record
Every hour             ─▶ 6. reconcile: GET /calls?created_after=... ─▶ anything the webhooks missed
```

Your integration service is the only component that holds RelationsAI keys and the CRM's credentials. It records which CRM events it has handled and which calls it has written back, so every step can safely run twice.

## Sync the contact

Upsert the contact by phone number with the fields the agent needs ([`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number)). Map CRM fields to the contact's `name`, `language` and [custom fields](/guides/managing-contacts#custom-fields) (defined in the console), so the agent can use them as `{{field_name}}`. Send `consent_granted` only as your CRM records it.

## Trigger the call

Place the call with the CRM record id and the CRM event id in `metadata`. CRM automations commonly fire twice for one change, and webhook deliveries from your CRM can be retried too, so **record each CRM event id you handled and skip one you have already seen**. Every request to RelationsAI is executed, so this check is what prevents a second call. You can also ask RelationsAI: list the calls to that number from the last day ([`GET /calls`](/api/calls#list-calls) with `to` and `created_after`) and look for one whose `metadata.crm_event_id` matches, as the complete example does. For extra safety you can also send an optional `Idempotency-Key` derived from the CRM event, for example `crm-<event id>-call`: a duplicate then replays the first answer (see [Idempotency](/fundamentals/idempotency)).

One CRM event becomes this sequence of requests (compliance, upsert, call):

**1. Upsert the contact** from the CRM record. Send `consent_granted` only as the CRM records it:

```bash
curl -X POST https://voice-api.relationsai.com/v1/contacts/upsert \
     -H "Authorization: Bearer <token>" \
     -H "Content-Type: application/json" \
     -d '{
  "phone_number": "+12025550100",
  "name": "Asha"
}'
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/contacts/upsert", {
  method: "POST",
  headers: {
    "Authorization": "Bearer <token>",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "phone_number": "+12025550100",
    "name": "Asha"
  }),
});
const contact = await response.json();
```
```python

response = requests.post(
    "https://voice-api.relationsai.com/v1/contacts/upsert",
    headers={
        "Authorization": "Bearer <token>",
    },
    json={
        "phone_number": "+12025550100",
        "name": "Asha",
    },
    timeout=30,
)
contact = response.json()
```

**2. Place the call** with the CRM ids in `metadata`. Handle each CRM event id once; an optional `Idempotency-Key` such as `crm-E-9001-call` adds a second guard:

```bash
curl -X POST https://voice-api.relationsai.com/v1/calls \
     -H "Authorization: Bearer <token>" \
     -H "Content-Type: application/json" \
     -d '{
  "agent_id": "agent_1ZltiWzxrXmMprGD6q63",
  "to": "+12025550100",
  "variables": {
    "first_name": "Asha"
  },
  "metadata": {
    "crm_record_id": "L-1042",
    "crm_event_id": "E-9001"
  }
}'
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/calls", {
  method: "POST",
  headers: {
    "Authorization": "Bearer <token>",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "agent_id": "agent_1ZltiWzxrXmMprGD6q63",
    "to": "+12025550100",
    "variables": {
      "first_name": "Asha"
    },
    "metadata": {
      "crm_record_id": "L-1042",
      "crm_event_id": "E-9001"
    }
  }),
});
const call = await response.json();
```
```python

response = requests.post(
    "https://voice-api.relationsai.com/v1/calls",
    headers={
        "Authorization": "Bearer <token>",
    },
    json={
        "agent_id": "agent_1ZltiWzxrXmMprGD6q63",
        "to": "+12025550100",
        "variables": {
            "first_name": "Asha",
        },
        "metadata": {
            "crm_record_id": "L-1042",
            "crm_event_id": "E-9001",
        },
    },
    timeout=30,
)
call = response.json()
```

If the CRM marks the person do-not-call, send this instead of steps 1 and 2, and place no call:

```bash
curl -X POST https://voice-api.relationsai.com/v1/dnc-entries \
     -H "Authorization: Bearer <token>" \
     -H "Content-Type: application/json" \
     -d '{
  "phone_number": "+12025550100"
}'
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/dnc-entries", {
  method: "POST",
  headers: {
    "Authorization": "Bearer <token>",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "phone_number": "+12025550100"
  }),
});
const entry = await response.json();
```
```python

response = requests.post(
    "https://voice-api.relationsai.com/v1/dnc-entries",
    headers={
        "Authorization": "Bearer <token>",
    },
    json={
        "phone_number": "+12025550100",
    },
    timeout=30,
)
entry = response.json()
```

Complete runnable example: [cURL script](/examples/guides/integrating-with-crm-systems/crm-event-to-call.sh) · [JavaScript](/examples/guides/integrating-with-crm-systems/crm-event-to-call.mjs) · [Python](/examples/guides/integrating-with-crm-systems/crm-event-to-call.py)

Decisions in this flow:

- **Compliance first.** If the CRM marks the person do-not-call, add the number to the do-not-call list and stop. Never call to "confirm" an opt-out.
- **A refusal is an outcome, not an error to retry.** `retry: "no"` errors (for example `dnc_listed`, `consent_required`, `outside_calling_hours`) are written to the CRM record as the result of the attempt. Retry only `retry: "yes"` or `"after"` errors, and after a timeout check whether the call exists (by `metadata`) before resending.
- **A `202` is not a result.** Write "call queued" to the record if you like; the outcome comes from the webhooks.

For many records at once (a nightly list, a segment), use a campaign instead of a loop of single calls: see [Common integration patterns](/guides/common-integration-patterns).

## Write the outcome back

On `call.ended`, write the `outcome` early if your users want to see "No answer" or "Busy" quickly. On `call.settled`, everything is final:

1. Read `data.object.metadata.crm_record_id` from the event. No lookup table needed.
2. Fetch [`GET /calls/{call_id}/analysis`](/api/analysis#retrieve-a-call-analysis), and the transcript if you copy it.
3. Write to the record: outcome, duration, the summary, each `ready` extracted field mapped by name, `goal_met`, and the call id (so a person can trace the values back to the conversation).
4. Record the call id as written back. A duplicate `call.settled` then changes nothing.

The [webhook receiver](/guides/receiving-webhooks#a-complete-receiver) does steps 1 and 2 on `call.settled`; replace its log line with your CRM write. Put the CRM write in the background step, never before answering the webhook.

What to write where is your choice, but some advice:

- Store transcripts and recordings only if your users need them and your data-protection obligations allow it. A link back to your own copy is often better than pasting a whole transcript into a note.
- Never store the recording `url`: it expires in about 15 minutes and works without a key. Download the audio instead.
- Write `ready` analysis values only; do not overwrite CRM data with `absent` (`null`) values.

## Keep compliance in sync

Do-not-call and consent must be the same in both systems, in both directions:

| Change | Direction | Do |
|---|---|---|
| The person opts out in the CRM | CRM to RelationsAI | [`POST /dnc-entries`](/api/do-not-call-list#add-a-number-to-the-do-not-call-list) immediately, before any further call |
| Consent recorded or withdrawn in the CRM | CRM to RelationsAI | Upsert the contact with `consent_granted: true` or `false` |
| A call is refused with `dnc_listed` | RelationsAI to CRM | Mark the record do-not-call |
| Someone is added to the list in the console | RelationsAI to CRM | Periodically list [`GET /dnc-entries`](/api/do-not-call-list#list-or-look-up-do-not-call-entries) and mark new numbers in the CRM |

Never remove a do-not-call entry because the CRM still shows the person as callable. A removal is a deliberate act with an audit trail (see [Consent and DNC](/guides/consent-and-dnc)).

## Reconcile

Webhooks can be missed: your endpoint was down for longer than the retry window, a deploy broke verification, an event was acknowledged but not processed. Run a scheduled job that lists recent calls ([`GET /calls`](/api/calls#list-calls) with `created_after` and `status=ended`), and writes back any settled call that is not in your written-back table. The [reconciliation example](/guides/common-integration-patterns#reconciliation) does exactly that.

## Test to live

1. Build and test everything with a **test key** and a **test-mode** webhook endpoint. Test contacts, calls and endpoints never mix with live ones.
2. Use the test numbers to exercise each branch: `+12025550100` (completed, with analysis), `+12025550101` (no answer), `+12025550103` (failed), and a number you add to the test-mode do-not-call list (refused).
3. Create a live key with the same scopes and a live webhook endpoint (it has its own secret). Switch your service's configuration, not its code.
4. Start with a small set of real records and watch the first results in both systems. See [Going live](/guides/going-live).

Ids are mode-specific: a `ct_...` or `call_...` id from test mode does not exist in live mode. Do not copy stored ids from your test setup into production data.

## What can go wrong

- **Two calls for one CRM change**: The same CRM event handled twice, or a blind retry after a timeout. Fix: Record handled CRM event ids; check for the call by `metadata` before resending; optionally send a key derived from the CRM event id.
- **[`idempotency_key_reused`](/errors/idempotency_key_reused)** (only if you send keys): One key used for two different requests (for example two steps sharing one key). Fix: Add the step to the key (`crm-<event>-contact`, `crm-<event>-call`).
- **Results written to the wrong record**: Matching by phone number instead of `metadata`. Fix: Put the record id in `metadata` and match on it.
- **Analysis fields missing in the CRM**: Written on `call.ended` before they were ready. Fix: Write analysis on `call.settled`.
- **The CRM shows a person callable who opted out by phone**: Refusals not written back. Fix: Mark `dnc_listed` refusals and new do-not-call entries in the CRM.
- **[`rate_limited`](/errors/rate_limited) during a bulk sync**: Unpaced loops. Fix: Pace writes and honour `Retry-After`.

## Next steps

[Common integration patterns](/guides/common-integration-patterns).
