Skip to content

Sync contacts

Last updated
View as Markdown

When your customer data already lives in another system, mirror it into Caramel using the upsert pattern. The same call creates a new contact or updates an existing one — no separate create and update endpoints.

caramel.v1.contact.upsert uses email as the unique key.

Terminal window
curl -X POST https://app.caramelme.com/api/functions/caramel-mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "tools/call",
"id": 1,
"params": {
"name": "caramel.v1.contact.upsert",
"arguments": {
"business_id": "bus_01h7m9k0aabcdefghj1234567",
"email": "jane@example.com",
"first_name": "Jane",
"last_name": "Adamson"
}
}
}'

The response is the contact object (created or updated). There’s no distinction between insert and update in the response.

Two rules to know:

  1. Lowercase before submitting. Caramel lowercases server-side, but doing it client-side keeps your logs clean.
  2. Phone is not a primary key. 2 records with the same phone but different emails are 2 contacts.

Plan caramel.v1.contact.upsert requires the Business tier or above (including Lifetime). Starter and Growth callers receive 403 tier_required.

If your business is on Starter or Growth:

  • For initial imports, use Customers → Import in the dashboard — CSV import works on every tier.
  • For ongoing API sync, submit contacts through caramel.v1.form.submit via a dedicated import form. Form submission works on every tier.

For a one-time migration of many records, use a sequential loop with a delay to stay under the rate limit.

import { setTimeout as sleep } from 'node:timers/promises';
const TOKEN = process.env.CARAMEL_TOKEN;
const BUSINESS_ID = process.env.CARAMEL_BUSINESS_ID;
const MCP_URL = 'https://app.caramelme.com/api/functions/caramel-mcp';
async function upsertContact(email, firstName, lastName) {
const res = await fetch(MCP_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'tools/call',
id: Date.now(),
params: {
name: 'caramel.v1.contact.upsert',
arguments: {
business_id: BUSINESS_ID,
email: email.toLowerCase().trim(),
first_name: firstName,
last_name: lastName,
},
},
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
if (body.error || body.result?.isError) throw new Error(body.error?.message ?? 'upsert failed');
return body.result;
}
async function importAll(contacts) {
const successes = [];
const failures = [];
for (const c of contacts) {
try {
await upsertContact(c.email, c.first_name, c.last_name);
successes.push(c.email);
} catch (err) {
failures.push({ email: c.email, error: err.message });
}
await sleep(200); // 5 rps — well within the 600/min cap
}
return { successes, failures };
}
import os, time, httpx
TOKEN = os.environ['CARAMEL_TOKEN']
BUSINESS_ID = os.environ['CARAMEL_BUSINESS_ID']
MCP_URL = 'https://app.caramelme.com/api/functions/caramel-mcp'
def upsert_contact(client: httpx.Client, email: str, first: str, last: str) -> dict:
res = client.post(
MCP_URL,
headers={'Authorization': f'Bearer {TOKEN}'},
json={
'jsonrpc': '2.0',
'method': 'tools/call',
'id': int(time.time() * 1000),
'params': {
'name': 'caramel.v1.contact.upsert',
'arguments': {
'business_id': BUSINESS_ID,
'email': email.lower().strip(),
'first_name': first,
'last_name': last,
},
},
},
)
res.raise_for_status()
data = res.json()
if data.get('error') or data.get('result', {}).get('isError'):
raise RuntimeError(data.get('error', {}).get('message', 'upsert failed'))
return data['result']
def import_all(contacts: list[dict]) -> tuple[list, list]:
successes, failures = [], []
with httpx.Client(timeout=30) as client:
for c in contacts:
try:
upsert_contact(client, c['email'], c['first_name'], c['last_name'])
successes.append(c['email'])
except Exception as e:
failures.append({'email': c['email'], 'error': str(e)})
time.sleep(0.2) # 5 rps
return successes, failures
  • 5 rps is a safe sustained rate. The per-bearer cap is 600 rpm (10 rps). Running at 5 rps leaves headroom for retries.
  • 10,000 contacts at 5 rps takes ~33 minutes.
  • You can run 2–3 parallel workers each at 2 rps. More than that risks hitting the cap.

For large imports, add a checkpoint file to survive restarts:

import fs from 'node:fs/promises';
async function importWithCheckpoint(contacts, checkpointPath) {
let done;
try {
const text = await fs.readFile(checkpointPath, 'utf8');
done = new Set(text.split('\n').filter(Boolean));
} catch {
done = new Set();
}
for (const c of contacts) {
if (done.has(c.email)) continue; // already imported
try {
await upsertContact(c.email, c.first_name, c.last_name);
done.add(c.email);
await fs.appendFile(checkpointPath, c.email + '\n');
} catch (err) {
console.error(`Failed: ${c.email}: ${err.message}`);
}
await sleep(200);
}
}

Restart anytime — the checkpoint skips already-processed emails. Upsert is idempotent, so reprocessing a record is safe even without the checkpoint; the checkpoint saves the API call.

For continuous sync (e.g., mirroring a customers table that updates throughout the day), use change-data-capture or full-table reconciliation.

Send only changed records:

async function syncChanges(sinceTimestamp) {
const changes = await db.query(
'SELECT email, first_name, last_name FROM customers WHERE updated_at > $1',
[sinceTimestamp]
);
for (const row of changes.rows) {
try {
await upsertContact(row.email, row.first_name, row.last_name);
await db.query(
'UPDATE customers SET caramel_synced_at = NOW() WHERE email = $1',
[row.email]
);
} catch (err) {
await db.query(
'INSERT INTO caramel_sync_failures (email, error, attempted_at) VALUES ($1, $2, NOW())',
[row.email, err.message]
);
}
await sleep(200);
}
}

Run every 5 minutes via your scheduler. The caramel_synced_at column tracks which records reached Caramel successfully; the failures table lets you replay individual failures.

When CDC isn’t reliable, reconcile the full table nightly:

  1. Read all contacts from your source.
  2. Hash (email + first_name + last_name) per record.
  3. Compare against your local sync-state table (email + last-synced hash).
  4. Upsert every record whose hash has changed; update sync state.

Pair CDC (for freshness) with nightly reconciliation (for correctness).

Caramel deduplicates by lowercased email. Your source system may have duplicates that resolve to the same Caramel contact:

  • Same email, different names — last write wins on first_name/last_name.
  • Capitalization differencesJane@Example.com and jane@example.com hit the same contact.
  • Trailing whitespace — Caramel trims it, but decode =20 or other encoding artifacts on your side.

Collapse your own duplicates before sending:

const seenEmails = new Set();
const deduped = contacts.filter(c => {
const email = c.email.toLowerCase().trim();
if (seenEmails.has(email)) return false;
seenEmails.add(email);
return true;
});

Important caramel.v1.contact.upsert does NOT set marketing_consent. The default is false, which means imported contacts receive no marketing emails until consent is recorded.

If your source contacts opted in, choose one of these approaches:

  1. Route the initial import through caramel.v1.form.submit using a form configured to set marketing_consent: true on submit. This creates a clear consent audit trail.
  2. Use the dashboard CSV import, which includes an explicit “these contacts opted in” checkbox.
  3. Wait for the consent parameter to shipcaramel.v1.contact.upsert will accept marketing_consent in a future version.

Do not falsely claim consent. CAN-SPAM and GDPR apply.

Every upserted contact is immediately evaluated against your active campaign segments. If the contact matches a segment tied to a journey, they’re enrolled automatically.

Important This means importing 10,000 historical contacts can trigger a “welcome series” send to all of them if you have a segment like “new contacts.” To prevent this: pause active journeys before bulk import, or define segments with a recency constraint, or use the dashboard CSV import (which has a “do not enroll in journeys” checkbox).

This is the most common surprise for first-time bulk importers.

ErrorCauseStrategy
email_required (400)Source record has empty/null emailSkip and log to failure table
unauthorized (401)Token expired mid-syncRefresh proactively every 50 minutes, not reactively
tier_required (403)Business is below Business tierSwitch to form-submit strategy
rate_limited (429)Rate exceededHonor Retry-After, drop to 3 rps after first 429
internal_error (500)Transient issueExponential backoff, max 3 retries

For 4xx errors, the record is the problem — skip it, log to the failure table, move on. For 5xx and 429, retry with backoff.

After import, check usage counts:

Terminal window
curl https://gateway.caramelme.com/v1/meta/usage \
-H "Authorization: Bearer $TOKEN" \
-d "{\"business_id\":\"$BUSINESS_ID\"}"

Then open Customers in the dashboard and confirm the count matches your import (minus duplicates). Spot-check a few contacts by email to verify first_name/last_name are correct.