Step 2 of the CSV import. Send the same file again as multipart/form-data, together with
the confirmed mapping and the fileHash from POST .../upload-preview. A file whose SHA-256
differs is refused with 400 File changed between preview and confirm. Because the request is
multipart, mapping, metadataKeys and consent are JSON-encoded strings (each at most 1 MB
and 5 levels deep).
How rows become contacts (every row of the file, not only the first 1,000):
- Phone and WhatsApp values lose spaces, dashes and parentheses, and get a leading
+ if they
have none. No country code is added, so 3001234567 becomes +3001234567. Include the
country code in the file.
- A row needs a phone, an email or a WhatsApp number. Phones must then be E.164, and emails
well-formed and lowercased.
- Columns mapped to
metadata are stored under their header (or the name given in
metadataKeys). Card-like and 9-digit ID-like numbers are redacted. The 20-key and key-format
rules of the JSON endpoints do not apply.
- Rows that fail go to
errors and count in skipped.
Valid rows are then imported in chunks of 500 with the exact rules of
POST /api/campaigns/{campaignId}/contacts/bulk: duplicates collapse, existing phones are skipped,
removed ones are restored. A chunk that fails does not fail the request. A campaign that is
unknown or completed, or a chunk that would cross the plan’s contact cap, adds one
Failed to import rows X-Y: … entry to errors, and the other chunks still import. Always read
imported and errors; a 200 does not mean every row landed. imported + skipped can be lower
than the row count, because duplicates, existing contacts and failed chunks are in neither.
Consent. Without consent, every imported contact is recorded as having no consent evidence
(import:csv). With it, the source is recorded as import:csv:<source> together with the evidence
and the uploader’s IP.
Side effects. Inserts contacts, writes consent provenance rows and an audit entry. Nobody is
contacted. Contacts added to an active campaign are not queued until it is paused and activated
again.
Idempotency. Safe to retry with the same file: phones already in the campaign are skipped, so a
retry imports only what did not land (imported counts only new rows).
Webhook events. audit.log_recorded for webhooks subscribed to it. See Webhooks.
Access
- Required scope:
write (or full).
- Rate limit: Contacts import — 5 requests/min per workspace, shared by every route under
/api/campaigns/{campaignId}/contacts, on top of the general API limit. See Rate limits.
- Plan: Refused with 403
BILLING_ERROR when the contact cap is already reached. Otherwise each 500-row chunk is checked against the cap (Starter 500, Growth 2,000, Business 20,000), and chunks over it are reported in errors.