Sync contacts from your CRM
Keep contacts and audiences in step with any CRM, and write call results back to it.
This recipe works with any CRM you can read from. You'll update or create contacts by matching on phone and email, use tags to drive audiences, and send call results back with webhooks.
You need a key with write permission. Removing contacts also needs delete, which dashboard keys don't have. See Authentication.
How contacts behave
A few rules shape the sync.
- There is no upsert route. You search first, then call
PATCHorPOST. - Phone and email are unique. Your account can't have two active contacts with the same primary phone, or the same primary email. A duplicate returns
409. - A phone must be dialable. An invalid number returns
400, so log those records and fix them in your CRM. - A phone is required to create a contact. Skip CRM records that don't have one.
- You can't set
external_idthrough the API. Store your CRM's ID inmetadata, under a key of your own such ascrm_id.
PATCH also replaces some values rather than merging them.
| Field | What PATCH does |
|---|---|
phone | Replaces all the contact's phone numbers with this one. |
email | Replaces all the contact's emails with this one. |
metadata | Replaces the whole object, so merge in existing keys first. |
tags | Replaces all tags. Leave it out to keep the current ones. |
Send phone numbers in E.164 format, such as +12025550123. Emails are stored in lowercase.
metadata can have up to 50 keys and be up to 16 KB, nested up to 5 levels deep. A contact can have up to 50 tags. A larger request returns 400.
Update or create a contact
search also matches names and companies by partial text. Always check for an exact match in the results before you treat a contact as the same person.
If a create fails because of a duplicate, the route returns 409 with the code CONFLICT. This can happen when two workers process the same person. The code below searches again once, then updates.
A response with a warnings array means the contact was saved but its tags weren't. The code logs these, so you can send the tags again on the next run.
Save this as sync.mjs or sync.py. Later examples on this page import its api helper. Set SYNTH_API_KEY in your environment first.
// sync.mjs. Requires Node 18 or later.
import { fileURLToPath } from 'node:url';
const BASE = 'https://backend.synthesys.app/api';
const HEADERS = { SYNTH_API_KEY: process.env.SYNTH_API_KEY, 'Content-Type': 'application/json' };
export async function api(method, path, body) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(BASE + path, {
method,
headers: HEADERS,
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 429 && attempt < 5) {
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1000));
continue;
}
const json = await res.json();
if (!res.ok) throw Object.assign(new Error(JSON.stringify(json)), { status: res.status });
return json;
}
}
async function findContact(phone, email) {
for (const term of [phone, email].filter(Boolean)) {
const { data } = await api('GET', `/v1/contacts?search=${encodeURIComponent(term)}&limit=100`);
const match = data.find(
(c) => c.phone_numbers.includes(phone) || (email && c.emails.includes(email.toLowerCase())),
);
if (match) return match;
}
return null;
}
export async function upsertContact(record) {
const fields = {
first_name: record.firstName,
last_name: record.lastName,
phone: record.phone,
email: record.email,
company: record.company,
tags: record.tags,
};
const metadata = { crm_id: record.id };
const update = (contact) =>
api('PATCH', `/v1/contacts/${contact.id}`, {
...fields,
metadata: { ...contact.metadata, ...metadata },
});
const existing = await findContact(record.phone, record.email);
if (existing) return update(existing);
try {
return await api('POST', '/v1/contacts', { ...fields, metadata });
} catch (error) {
const raced = error.status === 409 && (await findContact(record.phone, record.email));
if (raced) return update(raced);
throw error;
}
}
// Replace this with a read from your CRM.
const crmRecords = [
{
id: 'crm_1001',
firstName: 'Jane',
lastName: 'Doe',
phone: '+12025550123',
email: 'jane@example.com',
company: 'Example Inc',
tags: ['Renewal Q4'],
},
];
if (process.argv[1] === fileURLToPath(import.meta.url)) {
for (const record of crmRecords) {
if (!record.phone || !record.firstName) continue;
try {
const { data, warnings } = await upsertContact(record);
console.log('Synced', record.id, data.id, warnings ?? '');
} catch (error) {
console.error('Failed', record.id, error.message);
}
}
}The API limits requests per key, so the helper backs off on 429. Process records one at a time, or in small batches. See Rate limits.
Remove contacts deleted in your CRM
The contact list has no "changed since" filter, so a reconcile walks every page. The list is sorted newest first, and pagination.total is reliable. See Pagination.
Collect all pages before deleting anything, because deleting while paging shifts the offsets. Only delete contacts that carry your crm_id, so you leave contacts from other sources alone.
The DELETE calls need a key with delete permission. A dashboard key gets 403.
import { api } from './sync.mjs';
// Replace this with every ID currently in your CRM.
const crmIds = new Set(['crm_1001']);
const contacts = [];
for (let offset = 0; ; offset += 100) {
const { data, pagination } = await api('GET', `/v1/contacts?limit=100&offset=${offset}`);
contacts.push(...data);
if (offset + 100 >= pagination.total) break;
}
for (const contact of contacts) {
const crmId = contact.metadata?.crm_id;
if (crmId && !crmIds.has(crmId)) {
await api('DELETE', `/v1/contacts/${contact.id}`);
console.log('Deleted', contact.id, crmId);
}
}Sync audiences with tags
Use contact tags to control audience membership from your CRM. A dynamic audience works out its members each time you read it, so changing a contact's tags changes its audiences. The API has no route for adding members to a static audience, so use dynamic audiences here.
- Send tags with each contact
Add the tag to the
tagsarray in your sync, as the code above does. Tags you use for the first time are created for you.Conditions match a normalized form of the tag.
Renewal Q4becomesrenewal_q4: lowercase, with spaces replaced by underscores and other symbols removed.Because
PATCHreplaces all tags, always send the contact's complete list. - Create the audience
POST /v1/audienceswith conditions creates a dynamic audience. Names are unique, so a second run returns409. This code uses that to find the existing audience and update its conditions.import { api } from './sync.mjs'; const name = 'Renewal Q4'; const conditions = { operator: 'and', conditions: [{ field: 'tags', operator: 'contains', value: ['renewal_q4'] }], }; async function findAudience() { for (let offset = 0; ; offset += 100) { const { data, pagination } = await api('GET', `/v1/audiences?limit=100&offset=${offset}`); const match = data.find((audience) => audience.name === name); if (match || offset + 100 >= pagination.total) return match; } } let audience; try { ({ data: audience } = await api('POST', '/v1/audiences', { name, conditions })); } catch (error) { if (error.status !== 409) throw error; const existing = await findAudience(); ({ data: audience } = await api('PATCH', `/v1/audiences/${existing.id}`, { conditions })); } console.log(audience.id, audience.size);Keep the audience's
id. See Conditions for every field and operator. - Read the members
Use this to check the result, or to compare an audience with a list in your CRM.
pagination.totalis the true member count. An audience'ssizefield is only recalculated when its conditions change, so usetotalfor a current figure.import { api } from './sync.mjs'; const audienceId = 'YOUR_AUDIENCE_ID'; const members = []; for (let offset = 0; ; offset += 100) { const { data, pagination } = await api('GET', `/v1/audiences/${audienceId}/members?limit=100&offset=${offset}`); members.push(...data); if (offset + 100 >= pagination.total) break; } console.log(members.length, members.map((m) => m.metadata?.crm_id));Members are contact records with fields such as
id,first_name,phone_numbers,emailsandmetadata. They don't includetags.
See the contacts and audiences references for every field.
Push call results back
Post-call webhooks tell your server when a call ends. The payload has the customer's phone but not the contact ID, so look up the contact by phone to find your crm_id.
- Turn on the webhook for each agent
Webhooks are set per agent, and these routes need
writepermission, even to read. Read the currentversion, then save with it asexpectedVersion. It'snullthe first time. The URL must use HTTPS on port 443.import { api } from './sync.mjs'; const agentId = 'YOUR_AGENT_ID'; const path = `/v1/agents/${agentId}/post-call-webhook`; const { data: current } = await api('GET', path); const { data: saved } = await api('PUT', path, { url: 'https://example.com/synthesys/calls', enabled: true, expectedVersion: current.config?.version ?? null, }); // Queues a test event to your URL. No call is placed. const { data: test } = await api('POST', `${path}/test`, { expectedVersion: saved.config.version }); console.log('Test delivery', test.deliveryId);Copy the signing secret from the dashboard. Open the agent, go to Webhook logs, and select Reveal signing secret in the Post-call webhook section. Only workspace admins can do this, and the API doesn't return the secret.
- Verify the event and write to your CRM
Set
SYNTHESYS_WEBHOOK_SECRETto the signing secret. Check the signature against the raw request body before you parse it. The signature is an HMAC-SHA256 oftimestamp.body, in the formv1=<hex>.Respond within 10 seconds. A timeout, connection error,
429or5xxis retried, up to 5 sends in total. A retry has the same event ID, so use it to skip duplicates.A call can send more than one event.
call.completedcomes first.call.updatedfollows if the call's details change, andcall.recording_readyfollows when a recording is available. Write results keyed oncall.id, so later events overwrite earlier ones.// webhook.mjs. Requires Node 18 or later. import { createServer } from 'node:http'; import { createHmac, timingSafeEqual } from 'node:crypto'; import { api } from './sync.mjs'; const SECRET = process.env.SYNTHESYS_WEBHOOK_SECRET; const seen = new Set(); // Use your database in production. function isValid(rawBody, timestamp, signature) { const age = Math.abs(Date.now() / 1000 - Number(timestamp)); if (!(age < 300) || !signature) return false; const expected = `v1=${createHmac('sha256', SECRET).update(`${timestamp}.${rawBody}`).digest('hex')}`; const a = Buffer.from(expected); const b = Buffer.from(signature); return a.length === b.length && timingSafeEqual(a, b); } // Replace this with a write to your CRM. async function updateCrm(crmId, call) { console.log('Update CRM', crmId, { callId: call.id, status: call.status, endedReason: call.endedReason, durationSeconds: call.durationSeconds, summary: call.summary, transcript: call.transcript, }); } async function handle(event) { const { call } = event; if (event.test || call.isTestCall || !call.customerPhone) return; const { data } = await api('GET', `/v1/contacts?search=${encodeURIComponent(call.customerPhone)}&limit=100`); const contact = data.find((c) => c.phone_numbers.includes(call.customerPhone)); if (contact?.metadata?.crm_id) await updateCrm(contact.metadata.crm_id, call); } createServer(async (req, res) => { let rawBody = ''; for await (const chunk of req) rawBody += chunk; if (!isValid(rawBody, req.headers['x-synthesys-timestamp'], req.headers['x-synthesys-signature'])) { res.writeHead(401).end(); return; } const eventId = req.headers['x-synthesys-event-id']; if (!seen.has(eventId)) { try { await handle(JSON.parse(rawBody)); seen.add(eventId); } catch (error) { console.error(error); res.writeHead(500).end(); // A 5xx asks for a retry. return; } } res.writeHead(200).end(); }).listen(8080);If your CRM write is slow, save the event and reply first, then do the write in a background job.
- Backfill anything you missed
Webhooks only cover calls that end after you turn them on. For older calls, or after an outage, read call logs for the date range.
GET /v1/agents/:id/post-call-webhook/deliveriesshows the 10 most recent deliveries, including status and attempts.
See Receive post-call webhooks for the full event payload, and the post-call webhooks reference for each route.