PelekaPeleka Developers
Guides

Syncing contacts

Keep contacts in sync from an external system, including tags and custom fields, without duplicate-email errors getting in your way.

If you're syncing contacts from a CRM, an e-commerce platform, or your own signup form, you'll hit the same fork in the road on every sync run: is this contact new, or does it already exist? Peleka's POST /contacts doesn't guess for you: it throws a 409 on a duplicate email rather than silently updating the existing record. That's deliberate: a silent upsert can quietly overwrite fields you didn't mean to touch, and this guide covers the create-then-update pattern that handles both cases without that risk.

The core pattern

Try to create. If it already exists, update instead.

async function syncContact(email, fields) {
  const res = await fetch('https://api.peleka.io/api/v1/contacts', {
    method: 'POST',
    headers: {
      'X-API-Key': process.env.PELEKA_API_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ email, ...fields }),
  });

  if (res.status === 409) {
    // Already exists: look it up, then patch it instead of creating.
    const existing = await findContactByEmail(email);
    return updateContact(existing.id, fields);
  }

  return res.json();
}

There's no GET /contacts?email= filter built for a single exact lookup. List with a search param and take the first match, or, if you're storing Peleka's contact ID on your side after the first sync, skip the lookup entirely and go straight to PATCH /contacts/{id} on subsequent runs. That second option is faster and is what most integrations settle on once the initial backfill is done.

Attaching tags

Tags go in as an array on both create and update: tags: ["uuid-or-name"]. You can pass either a tag's ID or its exact name; Peleka resolves whichever it recognizes.

One thing that trips people up: this only attaches existing tags. If you pass a name that doesn't match any tag in the workspace, it's silently skipped: no error, no tag applied. If your integration invents tag names on the fly (say, imported-2026-08), create the tag first:

curl -X POST https://api.peleka.io/api/v1/tags \
  -H "X-API-Key: pel_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "name": "imported-2026-08" }'

Then reference it by name (or by the id the create call returned) when you create or update the contact. If you're tagging a batch of contacts with several tags at once, POST /tags/bulk accepts a names array and creates any that don't already exist, so you don't need one request per tag.

Custom fields

customFields is a flat object; whatever key/value pairs your workspace has defined show up under this field on the contact:

{
  "email": "[email protected]",
  "customFields": {
    "plan": "pro",
    "lifetime_value": 1240,
    "signup_source": "referral"
  }
}

Custom fields aren't auto-created from an API call. They need to already exist as fields in the workspace (Settings → Custom Fields, or POST /custom-fields). Sending a key that isn't defined doesn't error, but it also doesn't show up anywhere in the dashboard, so it's easy to think a sync succeeded when the field was actually just discarded. Worth checking the custom-fields list once before your first real sync run.

Putting it together

A full sync of one contact, tags and custom fields included, looks like this:

async function syncContact(email, { firstName, tags, customFields }) {
  const payload = { email, firstName, tags, customFields };

  const createRes = await fetch('https://api.peleka.io/api/v1/contacts', {
    method: 'POST',
    headers: { 'X-API-Key': process.env.PELEKA_API_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });

  if (createRes.status === 201) {
    return (await createRes.json()).data;
  }

  if (createRes.status === 409) {
    const { data: matches } = await fetch(
      `https://api.peleka.io/api/v1/contacts?search=${encodeURIComponent(email)}`,
      { headers: { 'X-API-Key': process.env.PELEKA_API_KEY } },
    ).then((r) => r.json());

    const existing = matches.find((c) => c.email === email);
    const updateRes = await fetch(`https://api.peleka.io/api/v1/contacts/${existing.id}`, {
      method: 'PATCH',
      headers: { 'X-API-Key': process.env.PELEKA_API_KEY, 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });
    return (await updateRes.json()).data;
  }

  throw new Error(`Sync failed for ${email}: ${createRes.status}`);
}

Run this per contact in your source system, and rerunning the whole batch is safe — the second pass just becomes a series of updates.

On this page