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.