# Import members from another platform (/docs/use/imports)



Moving your community from another quest platform? A member import brings your members over with their point balances, the quests they already finished and who invited whom, so nobody starts from zero.

What members see:

* **They sign in as before.** Each member is matched by the account they'll sign in with: their login in your app, their wallet, or their X account. Signing in reaches their imported points and progress.
* **Their points are there.** Opening balances count on balance leaderboards, but not on boards that rank points earned or net points.
* **Finished quests stay finished.** A member who already did "Welcome" doesn't do it again, and quests that need it are unlocked. Imported progress pays no rewards.
* **Invited friends stay theirs.** An inviter keeps the friends they brought, and earns their referral program's share of the points those friends earn from now on.
* **Their marketing consent comes along.** A member who opted in to email on the old platform stays opted in, with the time and the wording they agreed to, so your record of their opt-in comes with them.

You check the import first, which changes nothing and shows what would happen, and then apply it. Your developer, or a coding agent with the Domino skill, prepares the file and runs the check with the CLI. Console cannot start an import, but it shows the imports you prepared. In live, a person with **Publish** permission approves the import in Console.

```mermaid
flowchart LR
    File["JSONL file"] --> Rows["Import rows"]
    Rows --> Check["Check (dry run)"]
    Check --> Summary["Summary and refused rows"]
    Summary -->|test: CLI| Apply["Apply"]
    Summary -->|live: Console approval| Apply
    Apply --> Members["Members, balances, progress, referrals"]
```

## Prepare the file [#prepare-the-file]

Write one member per line, as JSON. `ref` is the member's ID on the platform they come from; it must be unique in the file. The [member imports reference](/docs/reference/imports) has every row field and refusal code.

```json
{"ref": "legacy-1", "identity": {"kind": "app:legacy", "subject": "u1"}, "accounts": [{"kind": "x", "subject": "1001"}, {"kind": "telegram", "subject": "5551234"}, {"kind": "wallet", "chain": "solana", "address": "So11111111111111111111111111111111111111112"}], "points": [{"ledger": "xp", "balance": 1200}], "quests": [{"quest": "daily-check-in", "actions": [{"at": "2026-09-01T10:00:00Z"}, {"at": "2026-09-02T10:00:00Z"}]}]}
```

| Field      | What it holds                                                                                                                                                                                                                                                                                                                                  |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `identity` | How the member will sign in, so their sign-in reaches the imported member: an `app:<name>` subject your [app login](/docs/build/app-login#link-your-apps-user-ids) exchanges, a wallet (`{"kind": "wallet", "chain", "address"}`, plus `network` for Cosmos), or an X account (`{"kind": "x", "subject": "<numeric user ID>"}`).               |
| `accounts` | Up to 20 accounts your organization vouches belong to the member: wallets in the same shape as `identity`, and at most one each of X (`{"kind": "x", "subject"}`), Telegram (`{"kind": "telegram", "subject"}`) and Discord (`{"kind": "discord", "subject"}`), each by its numeric user ID. They do not sign in.                              |
| `points`   | Opening balances, by point ledger key or ID.                                                                                                                                                                                                                                                                                                   |
| `quests`   | Progress, by quest key or ID: the member's accepted actions, each with its time `at` (epoch milliseconds or an ISO date-time) and, where the quest needs one, a `key`.                                                                                                                                                                         |
| `referral` | Who invited the member and when they accepted: `{"inviter", "acceptedAt", "qualified"}`. `inviter` is the `ref` of another row of the import, or the `identity` of a member who already signs in. `qualified` lists the [referral programs](/docs/concepts/referrals), by key or ID, the member already qualified in, each with its time `at`. |
| `consent`  | The member's [marketing consent](/docs/concepts/consent) on the old platform, one entry per channel: `{"channel": "email", "state": "granted" or "withdrawn", "at", "wording"}`. `at` is when the member chose, in the past. `wording` is the text they agreed to, at most 2,000 characters; a grant needs it.                                 |

A row with the same `ref` as an earlier row of the import replaces it, so you can fix a row and send the file again.

A friend and their inviter can be in the same file, in any order: the inviter's row is applied before their friends'.

```json
{"ref": "legacy-2", "identity": {"kind": "app:legacy", "subject": "u2"}, "referral": {"inviter": "legacy-1", "acceptedAt": "2026-08-01T10:00:00Z", "qualified": [{"program": "invite-friends", "at": "2026-08-03T10:00:00Z"}]}}
```

A member who opted in to your newsletter on the old platform:

```json
{"ref": "legacy-3", "identity": {"kind": "app:legacy", "subject": "u3"}, "consent": [{"channel": "email", "state": "granted", "at": "2025-11-20T09:30:00Z", "wording": "Email me news and offers. Privacy policy: https://example.com/privacy"}]}
```

## Check and apply in test [#check-and-apply-in-test]

### 1. Run a dry run [#1-run-a-dry-run]

```sh
domino import --file members.jsonl --source legacy-domino --dry-run --json
```

The command checks every row against the project and prints the import. Nothing in the project changes. The `summary` says what applying would do:

| Summary field                               | Meaning                                                                                                                                  |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `members.created`, `members.existing`       | Rows that create a member, and rows whose identity already names one. An existing member gets the row's accounts, balances and progress. |
| `accounts`                                  | Identities and accounts the ready rows attach.                                                                                           |
| `points`, `actions`                         | Opening balance totals by ledger, and imported actions by quest.                                                                         |
| `referrals.accepted`, `referrals.qualified` | Accepted referrals the ready rows record, and their qualifications by program.                                                           |
| `refused`, `refusals`                       | Rows that will not be applied, by reason.                                                                                                |

List the refused rows and why with `domino imports rows IMPORT --status refused --json`.

> [!DEV]
> The command uploads the rows in batches of up to 500 rows, 16 MB and 1,000,000 JSON values, well under the 64 MB and 2,000,000 values Relay reads in one `memberImports.addRows` call. A larger row goes in a batch of its own; a row too large for one call (63 MB or 1,990,000 values) is refused before anything is uploaded, with its line number.

### 2. Apply [#2-apply]

Fix refused rows in the file if you need them, then run the import again without `--dry-run`, naming the import to add to it:

```sh
domino import --file members.jsonl --import IMPORT --json
```

In test, the command checks the rows and applies the ready ones. `domino imports apply IMPORT` applies an import that is already checked. Both wait until the import is `applied`.

You can also follow a test import in Console under **Members → Imports**, and check, apply, cancel, stop or revert it there with **Publish** permission.

## Apply a live import [#apply-a-live-import]

Run the dry run with `--environment live`. A live import is applied only by a person signed in to Console with **Publish** permission.

### 1. Check it from the CLI [#1-check-it-from-the-cli]

```sh
domino import --file members.jsonl --source legacy-domino --environment live --dry-run
```

Without `--dry-run`, the command stops before uploading in live.

### 2. Approve it in Console [#2-approve-it-in-console]

In the live environment, open **Members** and select **Imports**. Open the import, review the summary and the refused rows, and select **Approve and apply to live**. A confirmation states how many live members the import creates and links, the opening balances it records, and the referrals it records without referral rewards; select **Approve and apply to live** again to apply it. API keys and CLI sessions can prepare, check, cancel, stop and revert a live import, but applying it with one is refused with `console-approval-required`.

## Stop an import while it applies [#stop-an-import-while-it-applies]

A large import applies one row at a time. To stop one partway, open it in Console under **Members → Imports** and select **Stop import**, then confirm. The row being applied finishes, no further row is applied, and the import shows as **Stopped** with how many rows were applied. Stopping needs **Publish** permission, in test or live.

A stopped import can't be resumed or applied again. To bring over the remaining members, put their rows in a new import. Rows that were already applied stay applied.

> [!DEV]
> Read an import's `state`: `open`, `checking`, `checked`, `applying`, `applied`, `stopped`, `reverting`, `reverted` or `canceled`, and Relay may add states. Its older `status` field reads `applied` for a stopped, reverting or reverted import too, so SDK 1.x code that compares it keeps working.
>
> `domino imports stop IMPORT` or `management.memberImports.stop(id)` stops an `applying` import, which then has `state: "stopped"`, `appliedAt` null and `stoppedAt` set; API keys and CLI sessions may stop a live one. The unapplied rows keep status `ready`, and `progress.applied` counts the applied ones. Stopping a stopped import returns it unchanged; any other status is refused with `import-status`. Like `check`, `apply` and `cancel`, `stop` takes an optional `{ actionId }` before the call options: a retry with the same one answers with the first result.

## Revert an import [#revert-an-import]

To take back an import that was wrong or ran twice, open it in Console under **Members → Imports** and select **Revert import**. Give a reason, then select **Revert import** again. You can revert an **Applied** or **Stopped** import, once, in test or live, with **Publish** permission. The import shows as **Reverting** while the revert goes through its applied rows, one at a time. For each row:

* **Opening balances** are reversed, as far as the member still has the points. Points a member already spent or reserved stay spent: no balance goes below zero, and the result lists those points by member.
* **Imported quest completions** are revoked, so the member can complete those quests again.
* **Wallets, accounts and identities** the import linked are removed, unless the member signed in with one since, or proved an imported wallet themselves. The result lists the links it kept.

Members the import created stay members of the project, and imported referrals stay. When every row is done, the import shows as **Reverted**, with who reverted it, why, and what the revert left: points members had already spent, and links it kept.

An import applied before Relay recorded the links each row creates keeps all its links: reverting it reverses only its balances and completions, and the result says so.

> [!DEV]
> `management.memberImports.revert(id, { reason })` starts reverting an import whose `state` is `applied` or `stopped`; API keys and CLI sessions may revert a live one. Its `state` is `reverting`, then `reverted`, and `revert` is set: `reason`, `requestedAt`, `requestedBy` and `rows` reverted so far, then `revertedAt` and `outcome` when it finishes. Its applied rows keep status `applied` and read `reverted: true` once the revert went through them. `outcome` has `reversed` counts, `unreversedPoints`, `keptLinks` (with `reason` `signed-in` or `proved`) and `linksUntracked`. `domino imports revert IMPORT --reason TEXT` waits for it. Each balance is reversed by a `reverse` entry that names the import, and each completion is revoked with reason `import-reverted`, freeing its occurrence and returning the budget it used. The same `actionId` answers the import again; any other revert of a reverting or reverted import, or of another status, is refused with `import-status`.

## What an import records [#what-an-import-records]

* **Members.** A row whose identity names no member creates one and joins them to the project. A member who later signs in with that identity is the imported member. An import never merges two members.

* **Vouched accounts.** Quests, trackers and wallet checks trust the X, Telegram and Discord accounts and wallets an import attaches like accounts the member proved, from the moment the import attached them, and Console marks them **Imported**. A Telegram or Discord message or reaction from a vouched account counts for its member, so reaction tipping keeps paying without members linking again. When the member links an account of that kind themselves, their link replaces the vouched one, even a different account. Nobody else can link a vouched account while the member holds it, and a vouched Telegram or Discord account does not sign in. If the import attached the wrong account, a reviewer unlinks it on the member's page: an account the member never signed in with or proved is then released, so its owner can link it, and no later import attaches it to that member again. See [operator identity changes](/docs/concepts/participants#operator-identity-changes). For members who join after the import, your app's server can vouch for their accounts under the same rules each time it signs them in: see [vouch for your members' accounts](/docs/build/app-login#vouch-for-your-members-accounts).

* **Opening balances.** Each shows as imported in the member's point history. [Balance leaderboards](/docs/concepts/leaderboards#scores-are-calculated-from-points) count it; earned and net boards do not.

* **Quest progress.** Each action goes through the quest's completion rule as an accepted action, without rewards: a quest that completes once completes on the first action, a keyed quest once per key, a streak counts each action's [calendar](/docs/concepts/calendar) day, and a counter counts distinct keys (an action without a key counts on its own). Imported completions count toward completion limits and prerequisites, and pay nothing. An action past a completion limit records no completion.

* **Referrals.** The friend's inviter and when they accepted, and the programs they already qualified in. An import never replaces an inviter a member already has. Imported referrals pay nothing: no reward for the inviter or the friend, no milestone, and no share of points earned before the import. Each imported qualification counts toward the inviter's qualified friends and milestones from then on, and points the friend earns after the import pay the program's share like any qualified friend's. A share limited to a number of days counts them from the imported qualification time. Console marks the referral and its qualifications **Imported** under **Referrals**. A signup program qualifies a friend only when the row names it.

* **Marketing consent.** Each channel's consent is recorded as the member's, with its original time and wording, and marked **imported** so you can tell it from a choice the member made in your app. If the member already chose on that channel in Domino, their choice stays, whatever the date in the file. Consent from an earlier import is replaced only by a later-dated one. Reverting an import leaves the consent it recorded: it stays the member's record of what they agreed to, and the member changes it in your app.

Applying is resumable: each row is applied once, and checked again first. A row that became invalid after the dry run, such as an account another member linked meanwhile, is refused instead of applied.

> [!DEV]
> An opening balance is an `adjust` entry that names the import, and sends no `points.awarded` webhook. An imported qualification sends no `referral.qualified` webhook. Referral reads name the import in `import` on each relationship and qualification. Each row is applied in its own transaction.

> [!WARNING] A second import adds again
> Importing the same member in a new import credits its opening balance again. The dry run reports such rows under `members.existing`.

## Why a row is refused [#why-a-row-is-refused]

| Code                                                                              | Fix                                                                                                                                                                                |
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `account-linked-elsewhere`                                                        | Another member holds the identity or account. Remove it from the row.                                                                                                              |
| `duplicate-account`                                                               | An earlier row of the import names the same identity or account.                                                                                                                   |
| `identity-unlinked`                                                               | An operator or the member unlinked the identity or wallet, or an operator released a vouched account from this member. Relink it first if Console offers **Relink**, or remove it. |
| `x-account-selected`, `telegram-account-selected`, `discord-account-selected`     | The row names a second account of that kind, or the member already has another one.                                                                                                |
| `limit-reached`                                                                   | The member would hold more than 5 wallets of one chain family besides their sign-in wallet.                                                                                        |
| `unknown-ledger`                                                                  | No active point ledger has that key or ID in this environment.                                                                                                                     |
| `unknown-quest`, `quest-not-published`                                            | No quest has that key or ID, or it has no active release in this environment.                                                                                                      |
| `quest-not-importable`                                                            | The quest completes per content item, which an import cannot record.                                                                                                               |
| `key-required`                                                                    | The quest completes once per key: give every action a `key`.                                                                                                                       |
| `key-used`                                                                        | The quest completes each key once across all members, and another member, or an earlier row of the import, already used one of the row's keys. Remove that action from the row.    |
| `future-action`                                                                   | An action, an acceptance, a qualification or a consent is dated in the future.                                                                                                     |
| `sign-in-disabled`                                                                | The row's sign-in wallet or X account is linked to its member without sign-in.                                                                                                     |
| `duplicate-entry`                                                                 | The row names one point ledger, quest or referral program twice, by any of its names, or one consent channel twice.                                                                |
| `unsupported-consent-channel`                                                     | A consent names a channel Domino doesn't record consent for. Only `email` is recorded.                                                                                             |
| `consent-wording-required`, `consent-wording-too-long`, `consent-wording-invalid` | A granted consent has no wording, its wording is longer than 2,000 characters, or it has characters that aren't plain text.                                                        |
| `unknown-inviter`                                                                 | The `inviter` is no row of the import, and no member of the environment signs in with that identity.                                                                               |
| `self-referral`                                                                   | The row names its own member as the inviter.                                                                                                                                       |
| `inviter-already-accepted`                                                        | The member already has an inviter. Remove `referral` from the row.                                                                                                                 |
| `referral-cycle`                                                                  | The member invited the inviter, directly or through others.                                                                                                                        |
| `inviter-refused`                                                                 | The inviter's row is refused. Fix it, and the friend's row is checked again with it.                                                                                               |
| `unknown-referral-program`                                                        | No referral program has that key or ID in this environment, or it is archived.                                                                                                     |

`domino imports cancel IMPORT` cancels an open or checked import. Applied rows stay applied: correct a balance with a [point reversal](/docs/concepts/points#record-points-as-an-operator) and a completion with a revocation. An imported referral stays too: under **Referrals**, suspend it to stop its payouts, or revoke a qualification. Qualifying a friend again counts like a new qualification, so it can pay a milestone the import reached without paying. An inviter is never replaced, so check the dry run before applying.
