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.
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:
emailis 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 (ptandPTboth 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-30doesn’t round-trip and is rejected), strictly in the past, and no earlier than the year 1900.localeis free-form text - not validated against a fixed locale list.attributesis a flat object keyed by attribute-registry key (see below).labelsholds up to 20 labels per row. Each label is trimmed, runs of whitespace collapse to one space, and it is lowercased; after that it must be 1 to 64 characters, with no;and no control characters. An import only ever adds labels: a label a member already has is kept, and none is removed.- Any column not routed to a core field, an attribute key or
labelsmust be explicitly markedignoredin the mapping - there is no free-form bag left on the member record.
email, country and dob with the same validators as
single-record user creation (Create a user),
so a value that creation accepts, an import accepts too.
Attribute columns (the per-tenant part)
Beyond the fixed core fields above, every org has its own enabled set of attributes - the same registry that drives your org’s profile fields. Attribute values are addressed by theirkey 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 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, job_title, 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 singlePOST /v1/users call uses:
- No existing user with that email → a new user and member are created, with every field/attribute in the row written as given.
- An existing user with that email, not yet a member of your org → they are attached as a member of your org.
- 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_idis 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 explicitfill_gapswrite 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 provenanceimportso every value an import ever wrote is auditable as such.
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/2005 → 2005-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:
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 ashirt_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:
A
fieldKey outside the core fields, or a target outside these four, is
rejected as an invalid mapping, so a typo’d target surfaces immediately
instead of silently dropping a column. An attributeKey that is unknown or
not enabled for your org fails at the dry-run, as ROW_ATTR_UNKNOWN on each
row that carries it. Over the API you send rows already shaped by your
mapping, so labels arrives as an array in each row’s payload.
Step 3 - upload and dry-run
The wizard parses the CSV in the browser, applies this mapping, and stages all six rows. Staging does not judge the values: a malformed email, date or country is staged like any other row, and the dry-run marks that row invalid with its own error code (see Row error codes). Nothing has been written to any member yet. Running the dry-run validates each staged row’s email, date and country, checks for duplicate emails and external ids within the file, and checks attributes against the club’s live attribute registry. Two of the six rows already known to have problems (from Step 1) surface here:
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 theirphone/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. The report carries the core fields andattributes; it has no labels column. error_code is the
stable contract, and every code is listed in
Row error codes. 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 takesAuthorization: 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).
Every import endpoint below is in the API reference,
generated from the OpenAPI document the API serves. Use it for the complete
request and response shapes.
1. Create the job
201 Created:
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 }.
Staging checks the shape of each payload, not its values. A field name
outside the schema table, a field of the wrong JSON type, a rowNumber that is
not a positive integer, or a label that breaks the label rules rejects the
whole chunk with 422 VALIDATION_ERROR (field-level details included)
before anything is staged. A chunk of more than 5,000 rows, or one that would
take the job past 250,000 rows, is 422 IMPORT_ROW_LIMIT. A malformed email,
date or country is staged and then fails its own row at validation. The two
problems in this example (bad shirt_size choice, duplicate email) are
dry-run findings too - 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:
error_code is the stable contract here too - the error_message wording
(like the CSV example in Step 5 above) is illustrative and may change.
Row error codes
A row error fails that row only, never the job. Rows the dry-run rejects havestate: "invalid"; rows that fail while committing have state: "failed".
Both appear in the error report.
The dry-run checks a row in this order and records the first problem only:
email, date, country, duplicate, attributes, labels.
The job-level errors reject the whole request instead:
409 IMPORT_JOB_ACTIVE_EXISTS, 409 IMPORT_JOB_INVALID_STATE and 422 IMPORT_ROW_LIMIT.
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 complete request and response shapes for every import endpoint above.
- 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.