Guide

Import contacts

Single, batch, and CSV-driven contact creation. Polling vs webhook follow-up.

Contacts are the people you might message. Each contact has an email plus an arbitrary attributes dict — keys you control, values that show up in mustache placeholders during sends.

Three import shapes

Use case Endpoint
One contact at a time POST /v1/contacts
Up to 1,000 in one call POST /v1/contacts/batch
1k–1M+ from CSV POST /v1/contact_imports (async job)

Pick the shape that matches your volume — the rules below apply to all three.

Idempotency on email

Contacts are idempotent on email within a workspace. Re-POSTing the same email returns the existing contact (HTTP 200, not 201). Use PATCH to update attributes; attributes merges into the existing dict, and setting a key to null removes it.

Bulk batch

import { Clement } from "@clement/sdk";
const c = new Clement({ apiKey: process.env.CLEMENT_API_KEY! });

const result = await c.contacts.batch([
  { email: "alice@acme.com", attributes: { company: "Acme", role: "VP Eng" } },
  { email: "bob@acme.com",   attributes: { company: "Acme", role: "Founder" } },
]);

console.log(result.created, result.updated, result.errors);

The response reports per-row outcomes — successful rows by ID, errored rows with reason codes — rather than failing the whole batch on one bad email.

CSV upload (async)

For 50k-row CSVs and up, use contact_imports. The endpoint accepts multipart/form-data: the CSV file, a column mapping, and the target audience.

curl -X POST "https://api.clement.so/v1/contact_imports" \
  -H "Authorization: Bearer $CLEMENT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -F "file=@./prospects.csv" \
  -F 'column_map={"email":"Email","first_name":"First name","company":"Company"}' \
  -F "audience_id=aud_01HZ8X3J9K7P2QV6R5N4M3T2L3"

You get back an imp_… job:

{
  "id": "imp_01HZ...",
  "object": "contact_import",
  "status": "queued",
  "audience_id": "aud_...",
  "row_count": 50000,
  "rows_processed": 0
}

A 50k-row import finishes in under a minute. 1M+ is supported (chunked, streamed to Postgres COPY).

Two ways to know when it’s done

1. Poll

async function awaitImport(id: string) {
  while (true) {
    const job = await c.contactImports.retrieve(id);
    if (job.status === "succeeded") return job;
    if (job.status === "failed") throw new Error(job.errors?.[0]?.message ?? "import failed");
    await new Promise((r) => setTimeout(r, 2000));
  }
}

Fine for one-off scripts and notebook work. Don’t poll faster than once per second per import — the rate limit will start handing out 429s.

2. Webhook

Subscribe to import.completed and import.failed once, and the dashboard or your worker is notified the moment a job finishes:

curl -X POST "https://api.clement.so/v1/webhooks" \
  -H "Authorization: Bearer $CLEMENT_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{
    "url": "https://your-app.example.com/clement/imports",
    "enabled_events": ["import.completed", "import.failed"]
  }'

The event payload’s data is the same shape as the GET /v1/contact_imports/{id} response — including any per-row errors. See webhooks for signature verification.

Audiences

A bare contact lives in the workspace; assigning it to an audience makes it eligible for campaigns. The CSV importer can either target an existing audience (audience_id) or create one inline by passing audience_name instead.

You can attach existing contacts to an audience in batches of up to 1,000:

await c.audiences.attachContacts("aud_01HZ...", ["con_01...", "con_02..."]);

Suppressions

A separate workspace-global block list — see suppressions. Sends to suppressed addresses are rejected at scheduler time, not at delivery, so an Idempotency-Key retry lands the same send.failed outcome consistently.