Integrating with CRM systems
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) |
| A ready agent | The calls (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
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). Map CRM fields to the contact's name, language and 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 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).
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:
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:
If the CRM marks the person do-not-call, send this instead of steps 1 and 2, and place no call:
Complete runnable example
Turn one CRM event into a contact update and a call, safely repeatable.
The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.
- cURL script (bash)
crm-event-to-call.sh - JavaScript (Node.js 20+)
crm-event-to-call.mjs - Python 3.9+ (requests)
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 examplednc_listed,consent_required,outside_calling_hours) are written to the CRM record as the result of the attempt. Retry onlyretry: "yes"or"after"errors, and after a timeout check whether the call exists (bymetadata) before resending. - A
202is 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.
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:
- Read
data.object.metadata.crm_record_idfrom the event. No lookup table needed. - Fetch GET
/calls/{call_id}/analysis, and the transcript if you copy it. - Write to the record: outcome, duration, the summary, each
readyextracted field mapped by name,goal_met, and the call id (so a person can trace the values back to the conversation). - Record the call id as written back. A duplicate
call.settledthen changes nothing.
The webhook 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
readyanalysis values only; do not overwrite CRM data withabsent(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 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 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).
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 with created_after and status=ended), and writes back any settled call that is not in your written-back table. The reconciliation example does exactly that.
Test to live
- Build and test everything with a test key and a test-mode webhook endpoint. Test contacts, calls and endpoints never mix with live ones.
- 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). - 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.
- Start with a small set of real records and watch the first results in both systems. See 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
metadatabefore resending; optionally send a key derived from the CRM event id. 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 inmetadataand match on it. - Analysis fields missing in the CRM: Written on
call.endedbefore they were ready. Fix: Write analysis oncall.settled. - The CRM shows a person callable who opted out by phone: Refusals not written back. Fix: Mark
dnc_listedrefusals and new do-not-call entries in the CRM. rate_limitedduring a bulk sync: Unpaced loops. Fix: Pace writes and honourRetry-After.