Migration Overview
The migration endpoints import an existing user base into the platform in bulk, instead of creating users one by one through Create User. All of them require the user:create permission.
Two wallet flows
Every migrated user gets one wallet, on the chain the row names in chain_id (a chains id, listed by GET /v2/chains). The flows differ in where that wallet comes from:
| Flow | When to use | What the platform records | Endpoints |
|---|---|---|---|
| With wallet | The user already has an address (self-custody, or brought from another system). | A self-custody wallet (custody: false) holding the given address on the given chain, labelled with the user's email (or external_id). | /v2/users/migrate/with-wallet, /with-wallet/bulk, /with-wallet/csv |
| Without wallet | The platform should create a custody wallet for the user. | A custody wallet (custody: true) created asynchronously on the chain's default network. | /v2/users/migrate/without-wallet, /without-wallet/bulk, /without-wallet/csv |
The address is stored on the wallet, not on the user: the user record has no address field. Read the wallet with List User Wallets.
Each flow has three transports:
- Single: JSON body with one user; answers the created user.
- Bulk: JSON body with up to 500 users; answers per-row results.
- CSV: multipart upload (form field
file, at most 5 MB and 500 data rows); answers per-row results.
Identity rules (KVKK-aware)
Personal information (email, name, surname) is optional. Each row needs at least one of:
email: the standard identifier;external_id: an opaque identifier from your system, for when personal data must not be stored (KVKK / GDPR).
email | external_id | Outcome |
|---|---|---|
| ✓ | — | Classic: email, name and surname stored |
| — | ✓ | KVKK: only external_id (and any name/surname you send) stored |
| ✓ | ✓ | Both stored; duplicate checks run on both |
| — | — | Row rejected with missing_email_and_external_id |
external_id is trimmed; an empty value counts as absent.
KYC behaviour (kyc_sent)
kyc_sent | What the platform does |
|---|---|
false (default) | Sends its KYC token(s) to the wallet on every network of the chain, asynchronously. For the without-wallet flow this happens once the wallet has been created. |
true | Sends nothing and records the wallet as already KYC-verified: on every network of the chain (with wallet) or on the chain's default network (without wallet). |
KYC is recorded on the wallet, never on the user: the migrated user's kyc field is always null. Read it with Get User Wallet.
Row checks and reasons
Checks run per row, in this order. A row that fails one is skipped, not failed, so a bad row does not abort a bulk import.
| Reason | Condition |
|---|---|
missing_email_and_external_id | Neither identifier supplied |
duplicate_email | A user with this email already exists |
duplicate_external_id | A user with this external_id already exists |
duplicate_wallet | (with wallet) A user already holds a wallet with this address, compared case-insensitively |
invalid_chain | (with wallet) chain_id names no chain |
A row is failed when something unexpected happens (a store or queue failure, a chain_id that is not a valid id); its reason is then free text.
In the without-wallet flow the chain is resolved only after the user has been stored. A row whose chain_id does not resolve therefore fails with the user already created and no wallet. Do not re-run such a row (it would now be skipped as a duplicate); add the wallet with Create User Wallet.
Bulk response shape
All bulk and CSV endpoints answer 200 with counts and one result per row, in request order:
{
"total": 3,
"success": 2,
"skipped": 1,
"failed": 0,
"results": [
{ "email": "a@example.com", "user_id": "6913872755654191dede37da", "status": "created" },
{ "external_id": "EXT-1", "user_id": "6913872755654191dede37db", "status": "created" },
{ "email": "dup@example.com", "status": "skipped", "reason": "duplicate_email" }
]
}
statusiscreated,skippedorfailed.reasonis present onskippedandfailedrows.user_idis present only oncreatedrows.emailandexternal_idecho the row's identifiers; each is omitted when the row did not send it.
Single mode failures
The single endpoints answer a skipped row with HTTP 400, code 10015, and the reason as the message (for example "message": "duplicate_email"), instead of a per-row report.
See Errors for the error format.