Skip to main content
The canonical import schema, the CSV template shape, a worked example from a realistic foreign roster export, and the partner API sequence for bulk migration. If your data looks like this, you migrate to 4D like this.
User Import is 4D’s self-serve path for bringing an existing member base onto the platform - a tenant admin’s console wizard (template → upload → map columns → dry-run → commit) and a partner backend’s bulk API, both driven by the same import engine and the same guarantees.

The guarantee

Every import, console or API, makes three promises up front:
  • Never overwrites. Existing values are never replaced. A matched row only fills fields and attributes that are currently empty (“fill-gaps-only”); anything already set on a member stays exactly as it is.
  • Never emails. Imports are silent. No invitation, welcome, or notification email is triggered as a side effect of any import job - that stays a deliberate, separate step your org owns.
  • Never deletes. A file only adds and enriches members; it is never treated as the full membership list, and nothing already in 4D is removed because it’s absent from an uploaded file.
Imported members are otherwise ordinary 4D members from the moment they’re created: same identity model, same wallet provisioning, same audit trail as a member created one at a time via the API or the console. They are not visually or functionally distinguishable from anyone else. They don’t receive consent rows at import time - like every member, they hit the consent gate the first time they sign in - and no role beyond the default member role is ever granted by an import.

Canonical row schema

Every row in an import - whichever way it arrives - is one member record. Column names in your source file don’t matter; only the target fields below do, since mapping is what routes your columns onto them. Field rules, exactly:
  • email is the only required field, and is the row’s identity key - matching is by email, case-insensitively (trimmed and lowercased on write).
  • country, if present, must be an officially assigned ISO 3166-1 alpha-2 code. Case doesn’t matter on input (pt and PT both work); it’s stored uppercase. An unassigned two-letter pair (e.g. XX) is rejected.
  • dob, if present, must be a real ISO calendar date: it has to be an actual day (2024-02-30 doesn’t round-trip and is rejected), strictly in the past, and no earlier than the year 1900.
  • locale is free-form text - not validated against a fixed locale list.
  • attributes is a flat object keyed by attribute-registry key (see below). Any column not routed to a core field or an attribute key must be explicitly marked ignored in the mapping - there is no free-form bag left on the member record.
These are exactly the field rules the platform’s shared import schema enforces - reusing the same validators as single-record user creation (Create a user) - so import-time and create-time validation can never drift apart.

Attribute columns (the per-tenant part)

Beyond the fixed core fields above, every org has its own enabled set of Tier-B attributes - the same registry that drives your org’s profile fields. Attribute values are addressed by their key inside the attributes object, typed and validated per that key’s definition: A single_choice/multi_choice attribute rejects any value outside its configured choice list - this is not a Zod-level check the wire schema can enforce (values are typed unknown on the wire), so it’s caught during dry-run validation against your org’s live registry, not at upload time. Only attributes your org has enabled (org_attribute_config) are valid mapping targets; an attribute key that’s unknown or disabled for your org fails the same way. The platform ships with a starter catalog you can enable per org - phone, gender, address.line1/address.city/address.postcode, favourite_team, shirt_size, emergency_contact - plus whatever your org has added.

The CSV template

The console wizard’s Import members → Download template button generates a CSV specific to your org: one column per core field, plus one column per attribute your org currently has enabled, a header-description row, and one filled-in example row so the expected shape is self-evident without reading this document. It’s regenerated from your live configuration every time you download it, so it can’t go stale the way a static example file would. The template is the fallback when you don’t want to map columns by hand: fill it in directly and every column already routes to the right field.

Caps and retention

The one-active-job limit exists to keep fill-gaps semantics and queue fairness simple - finish or abandon one import before starting the next for the same org. Retention is a fixed PII-minimization default: staged rows, mapping, and generated error reports are deleted 30 days after the job is created, whether or not it completed.

Matching, fill-gaps, and attach semantics

For every row, the import engine runs the same create-or-attach path a single POST /v1/users call uses:
  1. No existing user with that email → a new user and member are created, with every field/attribute in the row written as given.
  2. An existing user with that email, not yet a member of your org → they are attached as a member of your org.
  3. An existing member of your org with that email → the row updates only what’s currently empty on that member - never a value that’s already set. external_id is backfilled only if it was null. Core profile fields (first_name, last_name, country, dob) are backfilled only if currently null/empty. Attribute values are written with an explicit fill_gaps write mode - if a value is already present for that attribute key, the row’s value for it is silently skipped, never overwritten - and stamped with provenance import so every value an import ever wrote is auditable as such.
Re-uploading the same (or a corrected) file is always safe: rows are matched by email every time, so nothing is ever duplicated, and existing data is never clobbered by a stale re-import.

Worked example: Clube Vega’s roster

Clube Vega is a small five-a-side club exporting members from their old spreadsheet system to onboard onto 4D. Their export, as it left that system: Portuguese headers, day/month/year dates, and a free-text country name - none of it matches 4D’s field names or formats yet. That’s expected; that’s what the mapping step is for.

Step 1 - fix what mapping can’t compute

Column mapping is a pick-and-rename operation only - it routes a source column to a target field, it doesn’t transform values. So before uploading, Clube Vega’s admin fixes the two things mapping can’t do for them: reformats “Data Nascimento” to ISO (14/03/20052005-03-14) and replaces “Portugal” with its ISO code (PT) - a spreadsheet find-and-replace on six rows. The ready-to-upload file is available at clube-vega-roster.csv:
(Row 1046 duplicating row 1043’s email, and row 1045’s shirt size xg, are left in deliberately - the export really did contain both mistakes, and the next steps show how 4D handles them.)

Step 2 - declare the mapping

Clube Vega’s admin doesn’t have a shirt_size field to invent; the club already enabled the platform’s starter phone and shirt_size attributes for their org. “Posição” (playing position) isn’t a 4D field at all, so it’s explicitly marked ignored rather than silently dropped:
Every core field in the schema table above and every enabled attribute is a legal fieldKey/attributeKey; anything else is rejected as an invalid mapping, so a typo’d target surfaces immediately instead of silently dropping a column.

Step 3 - upload and dry-run

The wizard parses the CSV in the browser, applies this mapping, and stages all six rows. Because every row is already valid against the core-field rules (a genuinely malformed email, date, or country would have been rejected right here, at upload - before anything is staged), staging succeeds outright. Nothing has been written to any member yet. Running the dry-run validates each staged row against the club’s live attribute registry and checks for duplicates within the file. Two of the six rows already known to have problems (from Step 1) surface here - this is exactly the class of error that can’t be caught by field-format rules alone: Report summary: 6 rows, 4 valid (3 would create, 1 would attach), 2 invalid. Clube Vega’s admin can now either fix rows 4 and 5 in the source file and re-upload, or proceed and handle those two afterward from the error report - nothing forces a perfect file before value starts landing.

Step 4 - commit

Committing processes only the 4 valid rows. The 2 invalid rows are left exactly as flagged - commit never attempts a row the dry-run already rejected. Result: 3 members created, 1 member attached, 0 failed. Mafalda, Diogo, and Sara are new 4D members with their phone/shirt_size attributes set, provenance import. Beatriz’s existing record now additionally has shirt_size filled in - her country and everything else she’d already set is untouched.

Step 5 - the error report

Rows 4 and 5 are downloadable as CSV - original payload columns plus the row number and why it failed - so they can be corrected and re-uploaded as a new job without re-touching the 4 rows that already landed. error_code is the stable, documented contract; the error_message wording below is illustrative of the kind of detail returned and may change:

Partner API migration guide

Everything above is exactly what a partner backend drives programmatically - same engine, same guarantees, same field rules. This section walks the same Clube Vega example through the REST surface. Auth: every endpoint below takes Authorization: Bearer <api-key>, scoped to your organization; the caller must hold an admin role or an org-scoped service API key (identical to the permission model for single-user creation). Idempotency: POST /v1/imports, /validate, and /commit accept an Idempotency-Key header - replaying the same key with the same body returns the original response; the same key with a different body is rejected with IDEMPOTENCY_CONFLICT (409). The full request/response shapes below are generated from the same schemas the server validates against - see the API reference
  • so this walkthrough can’t drift from what the API actually accepts.

1. Create the job

201 Created:
A 409 IMPORT_JOB_ACTIVE_EXISTS means your org already has a job in progress
  • finish or let it finish first.

2. Stage rows

Up to 5,000 rows per call, rowNumber 1-based and stable across chunks (it’s what keys the per-row idempotency and the error report). All 6 fit in one call here:
200 OK: { "staged": 6 }. Every payload here must already satisfy the field rules in the schema table
  • a malformed email, date, or country rejects the whole chunk with 422 VALIDATION_ERROR (field-level details included) before anything is staged. The two problems in this example (bad shirt_size choice, duplicate email) are the kind that can only be caught once your org’s live attribute registry and the rest of the file are known - that’s what the next step is for.

3. Validate (dry-run)

202 Accepted - the job moves to validating and runs asynchronously; poll GET /v1/imports/{id} until state is validated:
Fetch the two problem rows directly:
error_code is the stable, documented contract here too - the error_message wording (like the CSV example in Step 5 above) is illustrative and may change.

4. Commit

202 Accepted - moves to committing, then asynchronously to completed. Poll GET /v1/imports/{id} again:
409 IMPORT_JOB_INVALID_STATE means the job isn’t validated yet - commit only ever runs against a completed dry-run, so an unvalidated row is never written. If you’d rather skip the explicit /validate call, POST /commit with { "dry_run": false } on a still-staging job triggers the same validate-then-commit sequence as one round trip; it still runs both phases internally and still requires a follow-up call (or poll) once validation finishes before the actual commit happens.

5. Fetch the error report

200 OK, Content-Type: text/csv - the same file shown in Step 5 above. This is also the console’s download link (cookie-authenticated instead of an API key; same permission gate).

Endpoint summary

See also

  • API reference - the source of truth for every request/response shape above, generated live from the deployed API.
  • Create a user - the single-record create-or-attach path every import row runs through.
  • Consent - why imported members receive no consent rows until they first sign in.
  • Worked example fixture: clube-vega-roster.csv.