Skip to content

Sync contacts

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.