Migrate Existing Users
Move an existing user base into Canopy with the password hashes it already has, so nobody has to reset a password on day one.
What carries over
A migration creates each identity through the public API with the password hash the old system stored, instead of a password. Canopy keeps the hash as it is, with the algorithm it is in, and verifies a sign-in the way that system did. The person signs in with the password they already have; the first sign-in that proves it replaces the hash with Canopy's own argon2id hash, so within one sign-in per active user the whole base is on Canopy's hashing. Names, email addresses, an external_id for the old system's identifier, metadata, and a starting role all travel in the same request.
Hashes travel from your backend to Canopy's API and nowhere else: password_hash is taken by API Create and its bulk endpoint only, never by the Console, the Admin Workspace or the CSV importer. A hash in a spreadsheet is a leak waiting to happen.
The hashes Canopy accepts
Send the stored hash string as it is, with password_hash_algorithm naming its format. Canopy refuses a hash that is not written the way its algorithm writes one (400 identity.password_hash_invalid), so a bad export fails at import rather than at every sign-in after it.
A hash the old system made with a pepper of its own cannot be verified without that pepper, and MD5, SHA-1 and unsalted hashes are not accepted at all: they are too weak to keep. Those users take the rolling reset below.
Run the migration
A migration is an export, an API key, and a loop over the bulk endpoint.
Five steps
identity.manage in the target Environment (Console → Integrations → API Keys). The key names the Environment, so nothing else is sent.POST /api/v1/identities/bulk-create in chunks of up to 200, each row carrying password_hash and password_hash_algorithm in place of password, and external_id for the old identifier. Each row succeeds or fails on its own, and an Idempotency-Key header makes a retried chunk safe.POST /api/v1/identities/:id/verify-email. Until an address is verified, a sign-in answers 401 auth.email_not_verified and Hosted Login mails a verification link.A bulk row with an imported hash, the hash string exported as PASSWORD_HASH
curl -X POST https://auth.canopy-io.com/api/v1/identities/bulk-create \ -H "X-API-Key: $CANOPY_API_KEY" \ -H "Idempotency-Key: migration-chunk-0001" \ -H "Content-Type: application/json" \ -d '{ "identities": [ { "email": "alex@acme.com", "first_name": "Alex", "last_name": "Singh", "external_id": "legacy:42", "password_hash": "'"$PASSWORD_HASH"'", "password_hash_algorithm": "bcrypt" } ] }'
A row that fails answers in the same results array as the rest, with its code: identity.password_hash_invalid for a hash not written its algorithm's way, identity.email_conflict for an address already in the Account. Fix the export and resend those rows.
What the first sign-in does
Canopy never sees a plaintext password at import, so two things the create would have done wait for the first sign-in that proves the password:
When a hash cannot move
When the old system cannot hand over a usable hash, create the identities without a password and let people set one at their own pace. Nothing is lost but the password itself.
Tell us how we can improve this guide.