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). Any column not routed to a core field or an attribute key must be explicitly markedignoredin the mapping - there is no free-form bag left on the member record.
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 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 (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 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:
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 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.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 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).
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:
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 (badshirt_sizechoice, 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:
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.