Bulk Import (CSV / JSON)
Bulk Import lets you load data into Joryio from a file: user profiles, historical events, or rows for your custom entities. Open Data → Bulk Import (/users/import) in the dashboard, drop a CSV or JSON file, map its columns, and Joryio processes it in the background while you watch the progress.
This page covers file uploads. The separate Data Import connector under Data → Data Import pulls user data from data warehouses (Snowflake, BigQuery) on a connection you configure once. Use Bulk Import for one-off files - a CRM export, a legacy list, an event backfill; use Data Import when the source of truth lives in a warehouse.
File requirements
| Formats | .csv (first row = column headers; comma, semicolon or tab delimited; UTF-8, with or without a BOM) or .json (an array of objects - anything else is rejected). |
| Upload size | Up to 100 MB per file from the dashboard. |
| Rows | Up to 1,000,000 rows per import (server-side; rows beyond the cap are not processed). |
| Concurrency | One import at a time per workspace - starting a second while one is processing is rejected. |
Files upload directly to storage via a presigned URL, then a background worker processes the job. Uploaded files are deleted after processing and expire after 24 hours regardless.
Import types
Pick the type before uploading - it changes the available mapping targets:
- Users - create or update contact profiles.
- Events - backfill historical events into the analytics store.
- Entities - load rows into one of your custom entities (you pick which).
Column mapping
Importing is two steps.
Step 1 - Choose file. Pick the import type (and, for entities, the entity), drop or browse to a .csv or .json file, and click Next. Joryio reads the column names in your browser: the header row of a CSV, or the keys of the first 50 objects of a JSON array. A file with no header row, or JSON that is not an array of objects, shows an error here instead of moving on.
Step 2 - Map columns. One row per column in your file, with up to three sample values from the first rows and an Import as dropdown. Every column starts pre-matched by its name (see the table below). A column whose name is not recognised starts as a Custom attribute named after the column - a dot in the name becomes _, because a dot would store a nested path. Change any match, rename a custom attribute, or pick Don't import to leave a column out. Back returns to step 1 and keeps your file and your choices.
Start Import stays disabled until the mapping can import something:
- a user import maps a column to Email or External ID - rows without either are skipped, so a phone-only mapping would import nothing;
- an event import maps Event Name and either User ID or Email;
- an entity import maps every required field;
- no two columns go to the same target, and a custom name is not empty, has no
.and does not start with$.
Only the columns you import are sent: a column set to Don't import is not written.
Recognised column names
Names are compared ignoring case, spaces, _ and -, so First Name, first_name, first-name and FirstName are the same name.
| Column name | Imported as |
|---|---|
email, e-mail, email_address | |
phone, phone_number, mobile, mobile_number, mobile_phone, cell, cell_phone, telephone, tel | Phone |
external_id, externalId, user_id, userId | External ID |
first_name, firstname, given_name, fname, forename | First Name (attributes.firstName) |
last_name, lastname, surname, family_name, lname | Last Name (attributes.lastName) |
email_subscribe, email_subscription_status, sms_opt_in, whatsapp_consent, viber_status, ... | The channel's subscription status - a channel name (email, sms, whatsapp, push, viber) followed by a status word (status, subscribe, subscription, subscribed, consent, opt_in, opted_in) |
A column whose name says the opposite - email_unsubscribe, sms_opt_out, email_opted_out - is never detected as a subscription status. In such a column "yes" means the person opted out, so reading it as consent would subscribe them. It imports as a custom attribute; to apply it, first change its values to statuses (for example unsubscribed), then map it to the channel's subscription.
name and full_name are not split into a first and last name - no rule splits every name correctly. They import as a custom attribute ({{ user.full_name }} in a message). When a file has two columns for the same field (say phone and mobile), the spelling listed earlier in the table wins and the other column stays a custom attribute, so no value is lost.
Put names in First Name and Last Name: those are what the standard {{ user.firstName }} and {{ user.lastName }} merge tags, the people search and the profile read. A name stored as a custom attribute such as first_name renders only as {{ user.first_name }}.
The same names are recognised when the Imports API is called without a fieldMapping.
Targets for user imports
| Target | Meaning |
|---|---|
externalId | Your stable user ID - the preferred identifier. |
email / phone | Contact identifiers. Email is lowercased and trimmed; phone is normalized to E.164 - see Phone numbers. |
attributes.firstName, attributes.lastName, attributes.<anything> | Profile attributes. Pick Custom attribute and edit the name for attributes.<anything>. |
subscriptions.email / sms / whatsapp / push / viber | Channel-level consent status. |
Group: <name> (<channel>) | Per-group consent - sets the contact's status in a specific subscription group / consent category (stored as subscriptions.list.<listId>.<channel>). See Subscription management. |
Phone numbers
Always send phone numbers in full international format (E.164): +, the country code, then the number - for example +972501234567. A number without + is read using the workspace's default country for phone numbers; if none is set, or the digits can be read two ways, it is saved as unverified and not used for messages. How phone numbers are handled
A phone written with a country code (+972501234567) is imported as is. One without (0501234567) is read in the workspace's default country for phone numbers; with none set, it is not converted. A phone that is not a valid number is kept, not dropped: it is saved as the contact's unverified number (phoneUnverified), the contact's existing phone is not changed, and the import report lists a notice for the row. Unverified numbers are never messaged. Warehouse syncs follow the same rules. A contact export lists the unverified number in a last column, phoneUnverified.
How users are matched (dedup / identify)
Every user row must carry an externalId or an email - rows with neither are skipped and listed in the error report. A phone number alone is not enough: a row with only a phone is skipped too.
externalIdfirst. If the row has anexternalId, Joryio matches on it alone, within the workspace. The row's email is written to that contact but is not used to find it. No contact with thatexternalId: a new contact is created.- Otherwise
email. The row matches on the normalized (lowercased, trimmed)email. No contact with that email: a new contact is created. Exactly one: it is updated. Several: see below. - Phone never matches. A
phonecolumn is written to the contact the row matched or created, but never used to find one. - A match updates the existing profile (only the fields present in the row are written - attributes not in your file are left untouched).
The job's summary reports both outcomes separately as Created and Updated. Re-importing the same file is safe: rows just resolve to updates.
When several contacts share an email
An email address is not unique in a workspace - a couple or a family can share one, or one person can exist under two user IDs. When a row has an email but no externalId, and more than one contact has that email:
- Exactly one contact is updated: the most recently active one - the contact whose profile changed most recently (on a tie, the most recently created). The same file picks the same contact every time.
- The others are left as they are. An import never merges contacts and never updates all of them - a name in the row would otherwise overwrite another person's.
- An unsubscribe in the row applies to every contact with that email, as described in Duplicate contacts.
- A subscribe in the row applies to the updated contact only. A re-subscribe is per contact; the other contacts keep their own status.
- The import summary shows how many rows matched several contacts, and the downloadable report lists each of them as a
notice- not an error, because the row was imported.
To update one exact contact, include an externalId column: a row with an externalId always matches that one contact. Warehouse syncs (Data Import) match rows the same way.
Subscription statuses and consent safety
Status cells are parsed liberally: subscribed / true / yes / 1 mean subscribed; unsubscribed / false / no / 0 / opt_out mean unsubscribed; opted_in / double_opt_in / confirmed mean a confirmed opt-in. Blank or unrecognized values leave the channel untouched.
By default (checkbox off), consent-protective rules apply per channel and per group:
- Unsubscribes in the file always apply.
- A prior opt-out is never silently re-subscribed by an import.
- A confirmed opt-in is never downgraded to plain subscribed.
Ticking "Override unsubscribes" (admins and owners only, after a warning) treats the file as the source of truth - it can re-subscribe prior opt-outs and downgrade opt-ins. You are confirming you have consent to contact those users. Either way, every change an import makes is written to the contact's subscription history with source import.
Duplicate contacts and "Override unsubscribes"
Imports follow the rules in Contacts that share an email or phone:
- "Override unsubscribes" is off by default. Without it, a
subscribedvalue for a contact who previously unsubscribed - or whose email or phone is blocked by another contact's opt-out - is ignored and counted as kept unsubscribed. - With the option ticked, such a contact is re-subscribed - that contact only. Other contacts with the same email or phone stay unsubscribed.
- With the option ticked, a
STOPopt-out is overridden too - for that contact only, recorded in its history with the import job. For SMS, the import page warns next to the checkbox and the summary counts these contacts: This contact opted out by texting STOP. Your SMS provider may keep blocking messages until they text START. - An import never lifts a hard bounce or spam complaint, or an organization-wide block (only Joryio support lifts those). Those rows are counted separately.
- Unsubscribes in the file reach every duplicate. Every contact in the workspace with the same email or phone is unsubscribed too, with a history entry.
When the import finishes, its summary shows how many contacts were overridden (and, of those, how many were SMS contacts who had texted STOP), and how many were kept unsubscribed, kept by a hard bounce or complaint, or kept by an organization-wide block. Warehouse syncs follow the same rules. Store syncs (Shopify, WooCommerce, Magento) never re-subscribe anyone.
Event imports
Target fields: userId (matched against externalId), email, eventName, timestamp, and properties.<key> for event properties. Rules the worker enforces:
- Every row needs an
eventNameand auserIdoremail- and the user must already exist in the workspace, or the row is skipped (import users first). timestampis optional; an unparseable value fails the row, and implausible values (more than ~2 years past or 1 hour future) are clamped to now - the same rule as live event tracking.- Properties get the same guardrails as the live
/trackpath: size/key-count/depth limits and PII masking. - Imports are retry-safe: a re-run of the same job cannot double-insert events.
Imported events land in the same event stream as live ones, so they are visible to segments and analytics.
Entity imports
Choose the target entity first; its fields become the mapping targets. Without mappings, columns are matched to entity field names directly. Rows missing a required entity field are skipped with an error. If a row carries an _id / id column the row upserts (updates the existing record or creates it); otherwise it inserts a new record. Versioning and soft-delete follow the entity's own settings.
Monitoring an import
The Recent Imports table shows every job with its type, format, progress bar, status, and who started it, refreshing automatically while anything is running. Statuses:
| Status | Meaning |
|---|---|
| Pending / Processing | Queued, then working through the file in batches. |
| Completed | Finished - check the per-row counters. |
| Failed | The job itself failed (e.g. the uploaded file wasn't found); the error message is shown. Failed jobs are retried once automatically before landing here. |
| Expired | An old completed job whose file and error report have been cleaned up (24 hours). |
Click View on a job for the detail modal: total rows, Created / Updated / Skipped / Failed counters, the number of rows that matched several contacts, and - when any rows had problems or notices - a Download Error Report link (Download report when it also lists notices). The report has one line per row: its number, its type (error - the row was not written, or notice - it was, but is worth a look) and the reason; identifier values are redacted. The report link is available for 24 hours after completion.
A row is Skipped when it fails validation (missing identifier, unknown user on an event import, missing required entity field); Failed counts real write errors.
Segment the contacts of an import
Every contact a contact import creates or updates is marked with that import. Open a completed contact import with View and click Create segment: Joryio creates a segment named Imported: <file name> (<date>) with a single condition - Came from import is that import - and opens it, ready to preview, refine or use as an audience. The button is shown only to people who can create segments.
The same condition is available in every segment, campaign audience and journey audience, where you can pick several imports at once or exclude them - see Came from import.
Contacts imported before this feature was released are not marked - the system did not record which import touched them - so a segment of an older import matches nobody; import the file again to mark its contacts. Event and entity imports do not mark contacts.
Related pages
- Data Import (warehouses) - scheduled sync from Snowflake / BigQuery.
- Segments - imported attributes are immediately available to segment on.
- Subscription management - groups and consent categories that per-group import targets write to.