# Sync contacts

> Upsert contacts from a CRM, database, or spreadsheet into your Caramel audience without creating duplicates.

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

- [Authenticate](../get-started/authentication) and obtain an access token.
- Confirm your business is on the Business tier or above. See the [tier requirement](#tier-requirement) section.

## The upsert pattern

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

```bash
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.

## Tier requirement

> **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.

## 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

```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

```py
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
```

### 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

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

```js
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

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

Send only changed records:

```js
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

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).

## 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.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:

```js
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

> **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 ship** — `caramel.v1.contact.upsert` will accept `marketing_consent` in a future version.

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

## 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

| 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

After import, check usage counts:

```bash
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

- [Rate limits](../reference/rate-limits) — per-bearer caps, `Retry-After` behavior, and backoff strategy.
- [Build with Lovable](build-with-lovable) — for AI-builder workflows.
- [Authentication](../get-started/authentication) — token refresh and multi-business access.
