Shopify Migration Guide
Note
If you want to keep Shopify as the storefront and use Descope for authentication, you can configure Descope as an OIDC identity provider in Shopify Plus.
You can migrate from Shopify to Descope in two main ways:
- Full Migration — export customers (CSV or Admin API) and import them into Descope with the migration script.
- JIT Migration — provision users in Descope on first sign-in.
After enabling just-in-time (JIT) provisioning, users must log in with their email address as the login ID the first time. Shopify does not allow phone numbers as login IDs. If a phone number is associated with the Shopify account, you can verify it and add it as a login ID in Descope for future logins.
Shopify and Descope Terminology
| Shopify | Descope |
|---|---|
| Customer | User — identity with login IDs, profile, and custom attributes. |
| Customer email / phone | Login IDs (email, phone) on the Descope user. |
| Customer tags | Multi-select custom attribute (shopifyTags). |
| Customer note, spend, orders | Custom attributes (shopifyNote, shopifyTotalSpent, shopifyTotalOrders). |
| Shopify Admin API | Used by the migration script (--from-api) or a Generic HTTP Connector in JIT flows. |
| Shopify Plus OIDC / customer accounts | Descope as a Federated App IdP — see Descope with Shopify Plus. |
Full Migration
Note
Shopify does not export customer passwords. A full migration is always a without-passwords migration.
After import, configure a Descope flow (magic link, OTP, and so on) so users can authenticate the first time, then optionally set a new password.
In a full migration you move customer records into Descope with the descope-shopify-migration script, then authenticate those users with Descope going forward.
Prerequisites
Ensure you have the following before starting:
- Access to your Shopify Admin (Customers export and/or Admin API)
- A Descope project and Management Key
- Familiarity with your current Shopify customer data
Important notes
Customer CSV export cap (15 MB)
Shopify caps customer CSV exports at 15 MB. For large stores:
- Export multiple CSVs with segment or date-range filters, then pass them all to
--customers— duplicates are deduplicated automatically. - Or use
--from-api, which fetches all customers via the GraphQL API with automatic pagination (recommended for large stores).
Accounts with no contact info
Shopify allows customer accounts with only a first and last name — no email or phone. Descope requires a login ID, so those accounts are skipped during migration and appear as skipped entries in the log.
1. Importing from Shopify
Configure local environment
- Clone the repo:
git clone https://github.com/descope/descope-shopify-migration.git
cd descope-shopify-migration- Create a virtual environment:
python3 -m venv venv
source venv/bin/activate- Install dependencies:
pip3 install -r requirements.txt- Set up environment variables.
Copy .env.example to .env and fill in your credentials:
cp .env.example .env# Descope credentials (always required)
# Project ID: https://app.descope.com/settings/project
DESCOPE_PROJECT_ID=
# Management Key: https://app.descope.com/settings/company/managementkeys
DESCOPE_MANAGEMENT_KEY=
# Shopify credentials (only required when using --from-api)
# Your store URL, e.g. my-store.myshopify.com (no https://)
SHOPIFY_SHOP_URL=
# --- Option 1: static access token ---
# If you already have a Shopify Admin API access token, set it here directly.
# The script will use it as-is and skip the OAuth flow.
SHOPIFY_ACCESS_TOKEN=
# --- Option 2: OAuth flow ---
# If you don't have a token, the script can obtain one automatically via OAuth.
# Create an app in the Shopify Dev Dashboard (https://shopify.dev/docs/apps/build/dev-dashboard),
# add the read_customers scope and http://localhost:3000/callback as an allowed redirect URI, and paste the
# Client ID and Client secret below. Leave SHOPIFY_ACCESS_TOKEN blank.
# After the first successful run the token will be saved to this file automatically.
SHOPIFY_CLIENT_ID=
SHOPIFY_CLIENT_SECRET=
# Optional: change the local port used for the OAuth callback (default: 3000).
# If you change this, update the redirect URI in your Shopify app accordingly.
# SHOPIFY_OAUTH_PORT=3000Descope credentials
- Project ID: Project Settings
- Management Key: Company Settings → Management Keys
Shopify credentials (--from-api only)
SHOPIFY_SHOP_URL is your store's .myshopify.com domain — no https:// prefix.
For the access token:
- Option 1 (static token): set
SHOPIFY_ACCESS_TOKENand leave the OAuth fields blank. - Option 2 (OAuth flow, recommended): create an app in the Shopify Dev Dashboard with the
read_customersscope, addhttp://localhost:3000/callbackas an allowed redirect URI, install the app on your store, paste the Client ID and Client secret into.env, and leaveSHOPIFY_ACCESS_TOKENblank. Run with--from-api; the script opens the browser, completes the flow, and saves the token.
Note
If your store is part of the Shopify Partner program, you must request access from Shopify to protected customer data before using the API pathway. See Shopify's guide on requesting access to protected customer data.
Note
The migration tool source code is on GitHub.
Export Shopify data (CSV mode only)
From Shopify Admin: Customers → Export → Plain CSV file.
Shopify caps the export at 15 MB. For large stores, export in multiple parts with segment or date filters, then pass all files to --customers. Alternatively, use --from-api to fetch customers via the GraphQL API with automatic pagination.
Required custom user attributes
Before running the migration, create the shopifyTags attribute in Descope Console → Users → Custom Attributes:
| Attribute name | Type |
|---|---|
shopifyTags | Multi Select |
Add an option for each tag used in your Shopify store. Tags will not migrate correctly if this attribute does not exist before the script runs.
The other Shopify attributes (shopifyCustomerId, shopifyTotalSpent, shopifyTotalOrders, and shopifyNote) are created automatically by the script.
2. Running the migration script
You can use the -v or --verbose flags for more detailed output on both dry runs and live migrations.
Dry run (recommended first)
Preview what will be migrated without making changes:
# From CSV
python3 src/main.py --customers shopify-exports/customers_export.csv --dry-run
# From API
python3 src/main.py --from-api --dry-runLive migration
# Single CSV
python3 src/main.py --customers shopify-exports/customers_export.csv
# Multiple CSVs (15 MB export cap workaround)
python3 src/main.py --customers shopify-exports/customers_1.csv \
shopify-exports/customers_2.csv
# From Shopify GraphQL API
python3 src/main.py --from-apiExample output
Fetching customers from Shopify GraphQL API...
Loaded: 523 customers.
Ensuring custom attributes exist in Descope...
Starting migration of 523 customers...
Still working, migrated 50 customers...
...
============================================================
MIGRATION SUMMARY
============================================================
── Customers ────────────────────────────────────────────
Total processed : 523
Created : 520
Merged into existing : 2
Skipped (no login ID) : 1
Failed : 0
Migration complete. Full log written to logs/
============================================================What gets migrated
Customers
Each customer is created as a Descope user with:
- Email as the primary login ID; phone as an additional login ID when present
- Active status
- Custom attributes from Shopify (see table below)
Customers with no email and no phone are skipped — they cannot be given a login ID.
Custom attributes
The following custom attributes are created in your Descope project before migration begins (except shopifyTags, which you create manually):
| Attribute name | Type | Notes |
|---|---|---|
shopifyCustomerId | Numeric | Shopify customer ID |
shopifyTotalSpent | Numeric | Lifetime spend |
shopifyTotalOrders | Numeric | Total order count |
shopifyTags | Multi Select | Customer tags from Shopify (create manually first) |
shopifyNote | Text | Internal Shopify note |
Existing Descope users
If a customer's email already exists in Descope, the migration merges in the Shopify custom attributes and, if the Shopify record has a phone number not already on the account, adds it as an additional login ID. Activation state and other existing data are left untouched.
Testing
python3 -m unittest tests.test_migrationLogs
A timestamped log file is written to logs/migration_log_<timestamp>.log on each run. Failed users are listed in both the log and the terminal summary.
JIT Migration
Note
Shopify customers with no email and no phone cannot be migrated — Descope requires a login identifier. Those users are skipped.
With Just-In-Time (JIT) migration, you do not bulk-export customers. Users are provisioned in Descope when they sign in. When a user authenticates and is not yet in Descope (or has not been marked as migrated), the flow looks them up in Shopify, then applies Shopify attributes to the account.
How it works
User enters email or phone
↓
Credential check happens normally
↓
Already migrated?
├─ Yes → end of flow
└─ No → Shopify is queried by email or phone
↓
User exists in Shopify?
├─ No → mark user as migrated → end of flow
└─ Yes → add Shopify attribute values to the user
↓
mark user as migrated → end of flowPrerequisites
- A Descope project
- A Shopify Admin API access token with the
read_customersscope (you can generate one with the batch migration script in--dry-runmode using--from-api)
1. Create the Shopify HTTP connector
In Descope Console → Connectors → HTTP, create a new connector with:
- Base URL:
https://<your-store>.myshopify.com/admin/api/2026-07/graphql.json - Headers:
X-Shopify-Access-Token: <your token>(store as a secret)Content-Type: application/json
2. Create custom attributes in Descope
Before users are provisioned, create these custom attributes under Users → Custom Attributes:
| Attribute name | Type | Notes |
|---|---|---|
shopifyCustomerId | Numeric | |
shopifyTotalSpent | Numeric | |
shopifyTotalOrders | Numeric | |
shopifyTags | Multi Select | Create options for every tag you use in Shopify |
shopifyNote | Text | |
migratedFromShopify | Boolean | Used to skip migration on later sign-ins |
3. Configure the Descope flow
Step 1 — Check if the user has already been migrated
Open the login flow you want to extend in Descope Console → Flows. After credentials are checked (typically near the end of the flow), add a condition that checks whether user.customAttributes.migratedFromShopify is true. The True branch should skip the migration logic.
Step 2 — Build the Shopify query string (Scriptlet)
Add a Scriptlet action. Shopify's customer search requires an email: or phone: prefix on the search term, so this step builds the query string before calling Shopify.
Add an argument loginId with value form.externalId, and use this script:
return {
shopifyQuery: loginId.startsWith("+") ? `phone:${loginId}` : `email:${loginId}`
};The output key shopifyQuery is used in the next step.
Step 3 — Look up the customer in Shopify (HTTP Connector)
Add an HTTP Connector POST action and select the Shopify connector from step 1. Set the connector action Context Key to shopifyResult, then use this payload. The scriptlet substitutes shopifyQuery into {{scripts.scriptletResult.shopifyQuery}}:
{
"query": "query($q: String!) { customers(first: 1, query: $q) { edges { node { id firstName lastName defaultEmailAddress { emailAddress } defaultPhoneNumber { phoneNumber } numberOfOrders amountSpent { amount } tags note } } } }",
"variables": { "q": "{{scripts.scriptletResult.shopifyQuery}}" }
}Step 4 — Parse the Shopify response
Add a Scriptlet action. Set the context key to parsedShopify, add an argument shopifyResult with value connectors.shopifyResult.body, and use:
const customer = shopifyResult?.data?.customers?.edges?.[0]?.node;
if (!customer) {
return {
found: false,
customerId: "",
email: "",
phone: "",
firstName: "",
lastName: "",
numberOfOrders: "",
amountSpent: "",
tags: "",
note: ""
};
}
return {
found: true,
customerId: Number(customer.id.split("/").pop()) || 0,
email: customer.defaultEmailAddress?.emailAddress || "",
phone: customer.defaultPhoneNumber?.phoneNumber || "",
firstName: customer.firstName || "",
lastName: customer.lastName || "",
numberOfOrders: Number(String(customer.numberOfOrders ?? "0")),
amountSpent: Number(String(customer.amountSpent?.amount ?? "0")),
tags: Array.isArray(customer.tags) ? customer.tags.join(",") : "",
note: customer.note || ""
};Then add a condition: if scripts.parsedShopify.found is false, skip applying Shopify attributes and go straight to marking the user as migrated — they do not have a Shopify account.
Step 5 — Apply Shopify attributes (Update User / Attributes)
Add an Update User / Attributes action and map:
| Attribute | Source value |
|---|---|
shopifyCustomerId | scripts.parsedShopify.customerId |
shopifyTotalSpent | scripts.parsedShopify.amountSpent |
shopifyTotalOrders | scripts.parsedShopify.numberOfOrders |
shopifyTags | scripts.parsedShopify.tags |
shopifyNote | scripts.parsedShopify.note |
Phone | scripts.parsedShopify.phone |
Step 6 — Mark the user as migrated
Add another Update User / Attributes action. Set migratedFromShopify to Boolean True.
Optional — Verify migrated phone numbers
Shopify allows unverified phone numbers, so you should not add a migrated phone as a login ID until it is verified. Use OTP (or another phone method such as magic link, enchanted link, or nOTP) to verify first, then add it as a login ID.
To do this in the JIT flow, add a condition right after the Update User / Attributes action that applies Shopify values. Check that user.phone is not empty and user.verifiedPhone is false, then run phone verification before treating the number as a login ID.