> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fourdos.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrate an existing member base

> The canonical import schema, a worked example, and the partner API sequence for bulk migration

> 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          | Type                                 | Required | Example                         |
| -------------- | ------------------------------------ | -------- | ------------------------------- |
| `email`        | string (email)                       | **Yes**  | `mafalda.rocha@example.com`     |
| `external_id`  | string                               | No       | `1042`                          |
| `first_name`   | string                               | No       | `Mafalda`                       |
| `last_name`    | string                               | No       | `Rocha`                         |
| `display_name` | string                               | No       | `Mafalda R.`                    |
| `locale`       | string                               | No       | `pt-PT`                         |
| `country`      | string (ISO 3166-1 alpha-2)          | No       | `PT`                            |
| `dob`          | string (ISO 8601 date, `YYYY-MM-DD`) | No       | `2005-03-14`                    |
| `attributes`   | object (attribute key → value)       | No       | `{"phone": "+351 911 222 333"}` |

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](/walkthroughs/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:

| Type            | Meaning                            | Example values       |
| --------------- | ---------------------------------- | -------------------- |
| `text`          | free-form string                   | `"+351 911 222 333"` |
| `number`        | numeric                            | `12`                 |
| `date`          | ISO date                           | `"2026-07-10"`       |
| `boolean`       | true/false                         | `true`               |
| `single_choice` | exactly one of a fixed choice list | `"m"`                |
| `multi_choice`  | any subset of a fixed choice list  | `["m", "l"]`         |

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

| Limit                                     | Value                                                          |
| ----------------------------------------- | -------------------------------------------------------------- |
| Max rows per import job                   | 250,000                                                        |
| Max rows per staging chunk (one API call) | 5,000                                                          |
| Concurrent import jobs per organization   | 1 (a second job is rejected until the first finishes or fails) |
| Uploaded rows and error reports retained  | 30 days, then purged automatically                             |

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:

| ID Sócio | Nome Próprio | Apelido   | Email                                                             | Data Nascimento | País     | Telemóvel        | Tamanho Camisola | Posição |
| -------- | ------------ | --------- | ----------------------------------------------------------------- | --------------- | -------- | ---------------- | ---------------- | ------- |
| 1042     | Mafalda      | Rocha     | [mafalda.rocha@example.com](mailto:mafalda.rocha@example.com)     | `14/03/2005`    | Portugal | +351 911 222 333 | M                | Ala     |
| 1043     | Diogo        | Fernandes | [diogo.fernandes@example.com](mailto:diogo.fernandes@example.com) | `02/11/1998`    | Portugal | +351 912 333 444 | L                | Pivô    |
| …        |              |           |                                                                   |                 |          |                  |                  |         |

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`](/assets/clube-vega-roster.csv):

```csv theme={null}
ID Sócio,Nome Próprio,Apelido,Email,Data Nascimento,País,Telemóvel,Tamanho Camisola,Posição
1042,Mafalda,Rocha,mafalda.rocha@example.com,2005-03-14,PT,+351 911 222 333,m,Ala
1043,Diogo,Fernandes,diogo.fernandes@example.com,1998-11-02,PT,+351 912 333 444,l,Pivô
1044,Beatriz,Nogueira,beatriz.nogueira@example.com,2001-07-27,PT,+351 913 444 555,s,Guarda-Redes
1045,Rui,Almeida,rui.almeida@example.com,1999-05-30,PT,+351 914 555 666,xg,Ala
1046,Diogo,Fernandes,diogo.fernandes@example.com,1998-11-02,PT,+351 912 333 444,l,Pivô
1047,Sara,Teixeira,sara.teixeira@example.com,2003-09-09,PT,+351 915 666 777,xl,Fixo
```

(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:

```json theme={null}
{
  "columns": [
    { "sourceColumn": "ID Sócio", "target": "core_field", "fieldKey": "external_id" },
    { "sourceColumn": "Nome Próprio", "target": "core_field", "fieldKey": "first_name" },
    { "sourceColumn": "Apelido", "target": "core_field", "fieldKey": "last_name" },
    { "sourceColumn": "Email", "target": "core_field", "fieldKey": "email" },
    { "sourceColumn": "Data Nascimento", "target": "core_field", "fieldKey": "dob" },
    { "sourceColumn": "País", "target": "core_field", "fieldKey": "country" },
    { "sourceColumn": "Telemóvel", "target": "attribute", "attributeKey": "phone" },
    { "sourceColumn": "Tamanho Camisola", "target": "attribute", "attributeKey": "shirt_size" },
    { "sourceColumn": "Posição", "target": "ignored" }
  ]
}
```

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:

| Row             | Outcome      | Detail                                                                                                                                                                            |
| --------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 (Mafalda)     | Would create | new member                                                                                                                                                                        |
| 2 (Diogo, 1043) | Would create | new member                                                                                                                                                                        |
| 3 (Beatriz)     | Would attach | `beatriz.nogueira@example.com` already has a 4D account from a prior self-registration; her existing `country` stays untouched, her (currently empty) `shirt_size` gets filled in |
| 4 (Rui)         | **Invalid**  | `shirt_size: "xg"` is not one of the club's configured choices (`xs`, `s`, `m`, `l`, `xl`, `xxl`)                                                                                 |
| 5 (Diogo, 1046) | **Invalid**  | duplicate email - row 2 already claims `diogo.fernandes@example.com` in this file                                                                                                 |
| 6 (Sara)        | Would create | new member                                                                                                                                                                        |

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:

```csv theme={null}
email,external_id,first_name,last_name,display_name,locale,country,dob,attributes,row_number,error_code,error_message
rui.almeida@example.com,1045,Rui,Almeida,,,PT,1999-05-30,"{""phone"":""+351 914 555 666"",""shirt_size"":""xg""}",4,ROW_ATTR_INVALID_VALUE,shirt_size: must be one of the allowed choices
diogo.fernandes@example.com,1046,Diogo,Fernandes,,,PT,1998-11-02,"{""phone"":""+351 912 333 444"",""shirt_size"":""l""}",5,ROW_DUPLICATE_IN_FILE,email already used by row 2 in this file
```

## 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](/api-reference/overview)

* so this walkthrough can't drift from what the API actually accepts.

### 1. Create the job

```bash theme={null}
curl -X POST "$API_BASE_URL/v1/imports" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: clube-vega-2026-07-10-01" \
  -d '{
    "file_name": "clube-vega-roster.csv",
    "declared_total": 6,
    "mapping": { "columns": [ /* the 9 entries from Step 2 above */ ] }
  }'
```

`201 Created`:

```json theme={null}
{
  "id": "0198f2b1-2a00-7c31-9c47-df6a2ee9a001",
  "organization_id": "<your org id>",
  "created_by_id": null,
  "source": "api",
  "state": "staging",
  "mapping": { "columns": [ "..." ] },
  "file_name": "clube-vega-roster.csv",
  "declared_total": 6,
  "staged_rows": 0,
  "processed_rows": 0,
  "valid_rows": 0,
  "invalid_rows": 0,
  "created_count": 0,
  "attached_count": 0,
  "failed_count": 0,
  "expires_at": "2026-08-09T00:00:00.000Z",
  "created_at": "2026-07-10T00:00:00.000Z",
  "updated_at": "2026-07-10T00:00:00.000Z"
}
```

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:

```bash theme={null}
curl -X POST "$API_BASE_URL/v1/imports/0198f2b1-2a00-7c31-9c47-df6a2ee9a001/rows" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "rows": [
      { "rowNumber": 1, "payload": { "email": "mafalda.rocha@example.com", "external_id": "1042", "first_name": "Mafalda", "last_name": "Rocha", "country": "PT", "dob": "2005-03-14", "attributes": { "phone": "+351 911 222 333", "shirt_size": "m" } } },
      { "rowNumber": 2, "payload": { "email": "diogo.fernandes@example.com", "external_id": "1043", "first_name": "Diogo", "last_name": "Fernandes", "country": "PT", "dob": "1998-11-02", "attributes": { "phone": "+351 912 333 444", "shirt_size": "l" } } },
      { "rowNumber": 3, "payload": { "email": "beatriz.nogueira@example.com", "external_id": "1044", "first_name": "Beatriz", "last_name": "Nogueira", "country": "PT", "dob": "2001-07-27", "attributes": { "phone": "+351 913 444 555", "shirt_size": "s" } } },
      { "rowNumber": 4, "payload": { "email": "rui.almeida@example.com", "external_id": "1045", "first_name": "Rui", "last_name": "Almeida", "country": "PT", "dob": "1999-05-30", "attributes": { "phone": "+351 914 555 666", "shirt_size": "xg" } } },
      { "rowNumber": 5, "payload": { "email": "diogo.fernandes@example.com", "external_id": "1046", "first_name": "Diogo", "last_name": "Fernandes", "country": "PT", "dob": "1998-11-02", "attributes": { "phone": "+351 912 333 444", "shirt_size": "l" } } },
      { "rowNumber": 6, "payload": { "email": "sara.teixeira@example.com", "external_id": "1047", "first_name": "Sara", "last_name": "Teixeira", "country": "PT", "dob": "2003-09-09", "attributes": { "phone": "+351 915 666 777", "shirt_size": "xl" } } }
    ]
  }'
```

`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)

```bash theme={null}
curl -X POST "$API_BASE_URL/v1/imports/0198f2b1-2a00-7c31-9c47-df6a2ee9a001/validate" \
  -H "Authorization: Bearer $API_KEY"
```

`202 Accepted` - the job moves to `validating` and runs asynchronously; poll
`GET /v1/imports/{id}` until `state` is `validated`:

```json theme={null}
{ "state": "validated", "staged_rows": 6, "processed_rows": 6, "valid_rows": 4, "invalid_rows": 2, "...": "..." }
```

Fetch the two problem rows directly:

```bash theme={null}
curl "$API_BASE_URL/v1/imports/0198f2b1-2a00-7c31-9c47-df6a2ee9a001/rows?state=invalid" \
  -H "Authorization: Bearer $API_KEY"
```

```json theme={null}
{
  "rows": [
    { "id": "...", "job_id": "...", "row_number": 4, "state": "invalid", "error_code": "ROW_ATTR_INVALID_VALUE", "error_message": "shirt_size: must be one of the allowed choices", "user_id": null, "...": "..." },
    { "id": "...", "job_id": "...", "row_number": 5, "state": "invalid", "error_code": "ROW_DUPLICATE_IN_FILE", "error_message": "email already used by row 2 in this file", "user_id": null, "...": "..." }
  ],
  "next_cursor": null
}
```

`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

```bash theme={null}
curl -X POST "$API_BASE_URL/v1/imports/0198f2b1-2a00-7c31-9c47-df6a2ee9a001/commit" \
  -H "Authorization: Bearer $API_KEY"
```

`202 Accepted` - moves to `committing`, then asynchronously to `completed`.
Poll `GET /v1/imports/{id}` again:

```json theme={null}
{ "state": "completed", "valid_rows": 4, "created_count": 3, "attached_count": 1, "failed_count": 0, "...": "..." }
```

`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

```bash theme={null}
curl "$API_BASE_URL/v1/imports/0198f2b1-2a00-7c31-9c47-df6a2ee9a001/error-report" \
  -H "Authorization: Bearer $API_KEY"
```

`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

| Method | Path                            | Purpose                                   | Success |
| ------ | ------------------------------- | ----------------------------------------- | ------- |
| `POST` | `/v1/imports`                   | Create a job (starts `staging`)           | `201`   |
| `POST` | `/v1/imports/{id}/rows`         | Stage a chunk (≤5,000 rows)               | `200`   |
| `POST` | `/v1/imports/{id}/validate`     | Seal + start async dry-run                | `202`   |
| `POST` | `/v1/imports/{id}/commit`       | Commit a validated job                    | `202`   |
| `GET`  | `/v1/imports/{id}`              | Status + counts                           | `200`   |
| `GET`  | `/v1/imports/{id}/rows`         | Per-row outcomes, paged, `?state=` filter | `200`   |
| `GET`  | `/v1/imports/{id}/error-report` | Invalid + failed rows as CSV              | `200`   |

## See also

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