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.
Before you begin
Section titled “Before you begin”- Authenticate and obtain an access token.
- Confirm your business is on the Business tier or above. See the tier requirement section.
The upsert pattern
Section titled “The upsert pattern”caramel.v1.contact.upsert uses email as the unique key.
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:
- Lowercase before submitting. Caramel lowercases server-side, but doing it client-side keeps your logs clean.
- Phone is not a primary key. 2 records with the same phone but different emails are 2 contacts.
Tier requirement
Section titled “Tier requirement”Plan
caramel.v1.contact.upsertrequires the Business tier or above (including Lifetime). Starter and Growth callers receive403 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.submitvia a dedicated import form. Form submission works on every tier.
One-shot import
Section titled “One-shot import”For a one-time migration of many records, use a sequential loop with a delay to stay under the rate limit.
Node.js
Section titled “Node.js”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 };}Python
Section titled “Python”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, failuresThroughput
Section titled “Throughput”- 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.
Resumable imports
Section titled “Resumable imports”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.
Ongoing sync
Section titled “Ongoing sync”For continuous sync (e.g., mirroring a customers table that updates throughout the day), use change-data-capture or full-table reconciliation.
Change-data-capture
Section titled “Change-data-capture”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.
Full-table reconciliation
Section titled “Full-table reconciliation”When CDC isn’t reliable, reconcile the full table nightly:
- Read all contacts from your source.
- Hash (email + first_name + last_name) per record.
- Compare against your local sync-state table (email + last-synced hash).
- Upsert every record whose hash has changed; update sync state.
Pair CDC (for freshness) with nightly reconciliation (for correctness).
Deduplication
Section titled “Deduplication”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 differences —
Jane@Example.comandjane@example.comhit the same contact. - Trailing whitespace — Caramel trims it, but decode
=20or 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;});Marketing consent
Section titled “Marketing consent”Important
caramel.v1.contact.upsertdoes NOT setmarketing_consent. The default isfalse, which means imported contacts receive no marketing emails until consent is recorded.
If your source contacts opted in, choose one of these approaches:
- Route the initial import through
caramel.v1.form.submitusing a form configured to setmarketing_consent: trueon submit. This creates a clear consent audit trail. - Use the dashboard CSV import, which includes an explicit “these contacts opted in” checkbox.
- Wait for the consent parameter to ship —
caramel.v1.contact.upsertwill acceptmarketing_consentin a future version.
Do not falsely claim consent. CAN-SPAM and GDPR apply.
Journey enrollment
Section titled “Journey enrollment”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.
Error handling
Section titled “Error handling”| Error | Cause | Strategy |
|---|---|---|
email_required (400) | Source record has empty/null email | Skip and log to failure table |
unauthorized (401) | Token expired mid-sync | Refresh proactively every 50 minutes, not reactively |
tier_required (403) | Business is below Business tier | Switch to form-submit strategy |
rate_limited (429) | Rate exceeded | Honor Retry-After, drop to 3 rps after first 429 |
internal_error (500) | Transient issue | Exponential 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.
Verify the sync
Section titled “Verify the sync”After import, check usage counts:
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.
Next steps
Section titled “Next steps”- Rate limits — per-bearer caps,
Retry-Afterbehavior, and backoff strategy. - Build with Lovable — for AI-builder workflows.
- Authentication — token refresh and multi-business access.