Import a previewed CSV

View as Markdown
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](/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](/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`.

Authentication

AuthorizationBearer
Workspace API key: `jl_` followed by 64 lowercase hex characters, created by the workspace owner in the dashboard (**Settings → API Keys**) and sent as `Authorization: Bearer jl_...`. The plaintext is shown once, at creation; Jelliu stores only a SHA-256 hash. A workspace can hold up to 25 active keys. | Scope | GET / HEAD | POST / PUT / PATCH / DELETE | Admin-only routes | | --- | --- | --- | --- | | `read` | Yes | No | No | | `write` | Yes | Yes | No | | `full` | Yes | Yes | Yes | Operations restricted to admins or owners reject keys without the `full` scope with `403`, and say so in their description. No key, whatever its scope, can mint or revoke API keys or rotate a webhook secret — that requires a signed-in owner session. A revoked key stops authenticating within about 10 seconds. See [Authentication](/authentication).

Path parameters

campaignIdstringRequiredformat: "uuid"

UUID of the campaign that receives the contacts. A value that is not a UUID answers 400. An unknown or foreign campaign does not answer 404 here; every chunk is reported as failed in errors.

Request

This endpoint expects a multipart form containing a file.
filefileRequired

The same CSV file sent to upload-preview (.csv, max 10 MB). Its SHA-256 must equal fileHash.

mappingstringRequired

JSON object mapping each CSV header to one of phoneNumber, name, email, whatsappNumber, metadata or ignore. Headers left out are ignored. If several headers map to the same identity field, the last one in the object wins.

metadataKeysstringOptional

Optional JSON object renaming metadata columns, from CSV header to the key stored on the contact. Unlisted metadata columns keep their header as key.

fileHashstringRequiredformat: "^[a-fA-F0-9]{64}$"

The fileHash returned by upload-preview (case-insensitive hex).

skipInvalidbooleanOptionalDefaults to true

Has no effect: invalid rows are always skipped and reported. Do not send it in a multipart request. The form value arrives as the string "true" and fails validation with 400.

consentstringOptional
Optional JSON object `{"source": string (2-80 chars), "evidence": string (3-500 chars)}` declaring the consent basis for the list: `source` is how it was collected (`web_form`, `contract`, `double_opt_in`…), `evidence` something checkable later (a URL, an export id). Both keys are required when it is sent; a basis without evidence is refused with 400. Omit it when there is no evidence.

Response

The import ran. Check errors for rows or chunks that did not land.

successtrue

Always true on 200, even when chunks failed.

dataobject

Errors

400
Bad Request Error
401
Unauthorized Error
403
Forbidden Error
429
Too Many Requests Error