Skip to main content

Imports API

Bulk-load contacts, events or custom entity records from a CSV or JSON file.

All endpoints on this page are relative to the base URL: https://api-eu1.joryio.com/api - see API Overview.

Overview​

An import is three calls, not one. You ask for a upload location, you upload the file straight to storage, and then you tell Joryio to process it:

  1. POST /users/import/upload-url - returns a presigned URL and the fileKey that names your upload.
  2. PUT the file to that URL - direct to storage, so a 200 MB file never passes through the API.
  3. POST /users/import - creates the job. Returns immediately; the file is processed in the background.
  4. GET /users/import - poll for progress and the final counts.

Step 2 succeeding tells you nothing about step 3. The upload is just a file landing in a bucket; validation happens when the job is created and again per row as it runs.

Authentication​

Authorization: Bearer YOUR_API_KEY

Every endpoint here needs users:write. Setting overrideConsent additionally needs compliance:write - users:write alone gets a 403, and the import does not start.

1. Request an upload URL​

POST /users/import/upload-url
Content-Type: application/json

{
"filename": "customers-2026-08.csv"
}
{
"success": true,
"fileKey": "imports/<organizationId>/<workspaceId>/customers-2026-08.csv",
"uploadUrl": "https://storage.example.com/..."
}

The fileKey is scoped to your organization and workspace. Pass it back verbatim in step 3 - a key outside your own prefix is rejected with a 400.

2. Upload the file​

curl -X PUT --upload-file customers-2026-08.csv "$UPLOAD_URL"

A 200 here means the bytes are stored. It does not mean the import will succeed.

3. Create the import job​

POST /users/import
Content-Type: application/json

{
"format": "csv",
"fileKey": "imports/<organizationId>/<workspaceId>/customers-2026-08.csv",
"importType": "users",
"originalFilename": "customers-2026-08.csv",
"fieldMapping": {
"Email Address": "email",
"First Name": "firstName",
"Loyalty Tier": "attributes.loyaltyTier"
}
}
FieldTypeNotes
formatcsv | jsonRequired
fileKeystringRequired. Exactly what step 1 returned
importTypeusers | events | entitiesDefaults to users
originalFilenamestring?Shown in the import history
fieldMappingobject?Source column → target field path. Omit to auto-detect from the header row
overrideConsentboolean?See below. Requires compliance:write
smsAlreadyConfirmedboolean?SMS double opt-in only. See below. Requires compliance:write
maxBackdatingDaysint?1-3650. How far back an imported event may be dated
entityNamestring?Required when importType is entities

Unknown fields are rejected with a 400 rather than ignored, so a typo in a field name fails loudly instead of silently doing nothing.

fieldMapping holds columns, and only columns​

Every key in fieldMapping is one of your own column names. Import options are separate, typed fields - overrideConsent, entityName, maxBackdatingDays - and never keys in this map.

This matters if you have a column literally named _overrideConsent: it is a column, it maps like any other, and it does not turn on consent override. Only the typed field does that.

Leave fieldMapping out entirely and the columns are matched from the file's header row. Provide it and it is trusted exactly as given - anything unmapped is ignored.

Without a fieldMapping, common header spellings map to the standard fields - for example first_name, First Name or given_name to attributes.firstName, surname or last_name to attributes.lastName, e-mail to email and mobile to phone; every other column becomes a custom attribute under its own name. The full list is in Bulk Import. full_name is not split. CSV files may be comma, semicolon or tab delimited - the delimiter is read from the header row.

JSON files​

Both shapes are accepted:

[ { "email": "[email protected]" }, { "email": "[email protected]" } ]
{ "users": [ { "email": "[email protected]" } ] }

overrideConsent​

By default an import can suppress but not grant: an unsubscribed row in your file unsubscribes that contact, while a subscribed row does not re-subscribe someone who previously opted out.

overrideConsent: true treats the file as authoritative in both directions. It is a compliance action, so it needs compliance:write on top of users:write.

Note the asymmetry: unsubscribes apply either way. You do not need this flag to sync opt-outs out of a CRM - only to re-subscribe people who had opted out.

The value must be a JSON boolean. A string, a 1, or a "yes" is rejected with a 400 rather than quietly read as false - being told is better than believing an override ran when it did not.

Even with overrideConsent: true, a re-subscribe applies only to the contact in the row - other contacts with the same email or phone stay unsubscribed - a STOP opt-out is overridden only with the flag (recorded in the contact's history with the import job), and an import never lifts a hard bounce, a spam complaint or an organization-wide block. Without it, a subscribed value for a blocked contact is ignored. An unsubscribed value reaches every contact in the workspace with the same email or phone. See Contacts that share an email or phone.

The finished job carries consentSummary:

{ "overridden": 12, "overriddenSmsStop": 3, "keptUnsubscribed": 40, "keptDeliverability": 1, "keptOrganization": 0 }

overridden counts re-subscribes made with overrideConsent, and overriddenSmsStop how many of those were SMS contacts who had texted STOP (their SMS provider may keep blocking messages until they text START); keptUnsubscribed counts subscribed values ignored because the flag was off; keptDeliverability and keptOrganization count the ones no import may lift. consentSummary is null for imports that finished before it existed.

smsAlreadyConfirmed​

Matters only when the workspace's SMS double opt-in is on. Then each new SMS opt-in in the file (a contact with no SMS status) is stored as pending and gets one "Reply YES" text, sent within about a minute; consentSummary.awaitingSmsConfirmation counts them. smsAlreadyConfirmed: true states you hold proof these people already confirmed their SMS opt-in: they are stored subscribed with no text. It is a compliance claim, so it needs compliance:write like overrideConsent.

With overrideConsent: true (and SMS double opt-in on), an opted-out SMS contact in the file is treated like a new sign-up: pending, ONE confirmation text, no reminder, subscribed only after YES, back to unsubscribed if unanswered. Without it, opted-out contacts are left unchanged and get no text. smsAlreadyConfirmed: true writes them subscribed instead (with overrideConsent). A contact already pending is never confirmed by a file.

4. Poll for progress​

GET /users/import

Returns the workspace's import history, newest first, with status, totalRows, processedRows, createdCount, updatedCount, skippedCount, failedCount and, when rows failed, a bounded sample of the errors with their source row numbers. matchedSeveralCount counts rows that had an email but no externalId while several contacts share that email: each updated only the most recently active of them, an unsubscribe in it reached all of them, and the error report lists it as a notice. See When several contacts share an email; add externalId to your rows to match one contact exactly.

status moves pending → processing → completed or failed.

5. List imports a segment can target​

GET /users/import/sources?limit=100

Needs users:read. Returns the workspace's finished contact (users) imports, newest first - the list the Came from import segment condition picks from:

{
"imports": [
{
"id": "6f1c2a9e-4b7d-4c1e-9a53-2f0d8e7b1c44",
"fileName": "customers-2026-08.csv",
"status": "completed",
"createdAt": "2026-10-08T09:00:00.000Z",
"completedAt": "2026-10-08T09:05:12.000Z",
"rowCount": 1200,
"createdCount": 1000,
"updatedCount": 150
}
]
}

limit defaults to 100 and is at most 200. ids (comma-separated, up to 100) returns exactly those imports, whatever their age - use it to name the imports a saved condition points at. status is completed, expired (finished; its error report has been cleaned up) or failed - a failed import is listed only when it got as far as processing rows, because the contacts it reached are marked. Event and entity imports are not listed.

Every contact a contact import creates or updates is marked with the import's id, so a segment can target the import:

{ "type": "contact_import", "operator": "any_of", "value": ["6f1c2a9e-4b7d-4c1e-9a53-2f0d8e7b1c44"] }

See the contact_import filter in the Segments API. Contacts imported before this feature was released carry no mark.

One import at a time​

A workspace may have one pending or processing import. A second request while one is live returns:

{ "statusCode": 400, "message": "An import is already in progress for this workspace. Please wait for it to complete." }

Poll step 4 until the first finishes rather than retrying immediately.

Errors​

StatusMeaning
400Unknown field, bad overrideConsent value, fileKey outside your prefix, entities import with no entityName, or an import already running
403overrideConsent requested without compliance:write
403smsAlreadyConfirmed requested without compliance:write
404The fileKey names no uploaded object - the job is created and then fails with "The specified key does not exist"

Rows that fail individually do not fail the job. They are counted in failedCount and sampled in the error report, so a file with three bad rows out of fifty thousand still imports the other 49,997.