Skip to main content

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.

Not the same as Data Import

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) or .json (an array of objects - anything else is rejected).
Upload sizeUp to 100 MB per file from the dashboard.
RowsUp to 1,000,000 rows per import (server-side; rows beyond the cap are not processed).
ConcurrencyOne 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

After selecting a file you can add field mappings: each row maps a source column (typed exactly as it appears in your file) to a target field (picked from a dropdown).

If you add no mappings, user imports auto-detect: externalId / external_id / userId / user_id become the external ID, email and phone map to themselves, columns that look like subscription-status columns (a channel prefix plus a status-ish suffix, e.g. email_subscription_status, sms_opt_in) set channel consent, and every other column becomes a custom attribute under its own name.

With explicit mappings, only mapped columns are imported. Target fields for user imports:

TargetMeaning
externalIdYour stable user ID - the preferred identifier.
email / phoneContact identifiers. Email is lowercased and trimmed; phone is normalized to E.164.
attributes.firstName, attributes.lastName, attributes.<anything>Profile attributes (pick Custom Attribute... and type the key).
subscriptions.email / sms / whatsapp / pushChannel-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.

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.

  • If the row has an externalId, Joryio matches on it (within the workspace).
  • Otherwise it matches on the normalized email.
  • A match updates the existing profile (only the fields present in the row are written - attributes not in your file are left untouched); no match creates a new contact.

The job's summary reports both outcomes separately as Created and Updated. Re-importing the same file is safe: rows just resolve to updates.

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 "Apply subscription status to all rows..." 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.

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 eventName and a userId or email - and the user must already exist in the workspace, or the row is skipped (import users first).
  • timestamp is 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 /track path: 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:

StatusMeaning
Pending / ProcessingQueued, then working through the file in batches.
CompletedFinished - check the per-row counters.
FailedThe 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.
ExpiredAn 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, and - when any rows had problems - a Download errors link with each failing row number and the reason (identifier values are redacted). The error 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.