Skip to navigation

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, dots and parentheses. A value starting with + or 00 is 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 workspace 3001234567 becomes +573001234567; a leading trunk 0 is 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 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. 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 one Failed to import rows X-Y: <reason> entry, where X and Y are the real file rows it held, and its rows count in failed. 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_FOUND for an unknown campaign or 403 BILLING_ERROR over 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 (or full).
  • Rate limit: Contacts import — 5 requests/min per workspace, shared only by /bulk, /upload-preview and /upload-confirm, 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); chunks over it are reported in errors with a 207, or fail the request when no chunk fits.

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.

ScopeGET / HEADPOST / PUT / PATCH / DELETEAdmin-only routes
readYesNoNo
writeYesYesNo
fullYesYesYes

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

campaignIdstringRequiredformat: "uuid"

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

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

Every valid row was written (partial: false). Rows rejected before import are in errors and skipped.

successboolean

Always true on 200 and 207. Read the status or data.partial to know whether every row landed.

dataobject

Errors

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