Import a previewed CSV
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, dots and parentheses. A value starting with
+or00is international and must then be a dialable E.164 number. A bare run of digits is completed with the workspace country when it has that country’s national length (in a Colombian workspace3001234567becomes+573001234567; a leading trunk0is dropped), or accepted with a+when it already starts with the code of Colombia, Mexico, the United States, Brazil, Argentina, Chile or Peru at that country’s full length. Anything else rejects the row with a Spanish message quoting the value. The workspace country is the one chosen at onboarding, or else the single country of the workspace’s active numbers; a country outside those seven completes nothing. - A phone or WhatsApp cell that is filled in but unusable rejects the row, even if the row has an email. Otherwise a row needs a phone, an email or a WhatsApp number, and emails must be well-formed (they are lowercased).
- Columns mapped to
metadataare stored under their header (or the name given inmetadataKeys). 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
errorsand count inskipped.
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. The status says how it went:
- 200,
partial: false: every chunk was written. - 207 Multi-Status,
partial: true: some chunks failed. Each failed chunk adds oneFailed to import rows X-Y: <reason>entry, where X and Y are the real file rows it held, and its rows count infailed. The reason is the refusal’s own message (for example the plan’s contact cap), or a generic Spanish message for a server error. - Every chunk failed: the request fails with the first chunk’s error, for example 404
CAMPAIGN_NOT_FOUNDfor an unknown campaign or 403BILLING_ERRORover the contact cap. - A file with no valid row answers 200 with
imported: 0.
imported + skipped + failed can be lower than total, because duplicates and existing contacts
are in none of them.
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. contact.created for each new contact row ({ contact_id, campaign_id, name, phone_number, email, whatsapp_number, status, source, created_at }, with source csv); not sent when an existing contact is returned or a removed contact is restored; one event per inserted row. Delivered only to webhooks that name contact.created (it is opt-in only: the "*" wildcard does not include it). Also audit.log_recorded for webhooks subscribed to it. See Webhooks.
Access
- Required scope:
write(orfull). - Rate limit: Contacts import — 5 requests/min per workspace, shared only by
/bulk,/upload-previewand/upload-confirm, on top of the general API limit. See Rate limits. - Plan: Refused with 403
BILLING_ERRORwhen the contact cap is already reached. Otherwise each 500-row chunk is checked against the cap (Starter 500, Growth 2,000, Business 20,000); chunks over it are reported inerrorswith a 207, or fail the request when no chunk fits.
Authentication
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.
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.
Path parameters
UUID of the campaign that receives the contacts. A value that is not a UUID answers 400; an unknown or foreign campaign answers 404, because every chunk fails.
Request
The same CSV file sent to upload-preview (.csv, max 10 MB). Its SHA-256 must equal fileHash.
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.
Optional JSON object renaming metadata columns, from CSV header to the key stored on the contact. Unlisted metadata columns keep their header as key.
The fileHash returned by upload-preview (case-insensitive hex).
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.
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
Every valid row was written (partial: false). Rows rejected before import are in errors and skipped.
Always true on 200 and 207. Read the status or data.partial to know whether every row landed.

