Importing from another platform.
Move a business onto sparx from Shopify, WooCommerce, HubSpot and seventeen others: over the API, in the same four steps the app takes: read what a vendor can give you, check the rows, practice, then run it.
How a migration works
A migration is rows in, jobs out. You send arrays of Record<string, string> (one shape for all twenty vendors) and sparx creates a background ImportJob per entity, processes them in dependency order, and reports what happened row by row.
Nothing about the vendor survives past your request. Reading a products_export.csv into canonical rows happens in @wizeworks/migration, which runs in a browser, in a worker, or in your own script: the API only ever sees rows. That is why one endpoint serves every platform and why your own spreadsheet works exactly as well as a competitor’s export.
runId is an id shared by every job in one migration. There is no migration table, which is why a run can be reported on and canceled, but not edited.
The four calls, in order:
| Endpoint | What it is for |
|---|---|
GET/v1/migration/vendors | What each platform can give you, and which parts this tenant can receive. |
POST/v1/migration/preview | Check rows without writing anything. Pure and synchronous. |
POST/v1/migration/runs | Start the run. Returns 202 with a run id. |
GET/v1/migration/runs/{runId} | Per-entity progress plus the rows that need a person. |
Two more exist for live connections (connect and pull), and
What a vendor can give you
The catalog is computed from the adapters, so it can never advertise something the importer does not do. Each entity is marked available against thistenant’s modules: a locked entity is reported with the module that would unlock it rather than hidden.
curl https://api.sparx.works/v1/migration/vendors \ -H "Authorization: Bearer $SPARX_KEY"
{
"success": true,
"data": {
"vendors": [
{
"slug": "shopify",
"name": "Shopify",
"kind": "commerce",
"hasConnector": true,
"modules": ["commerce", "inventory", "builder"],
"entities": [
{ "entity": "products", "label": "Products", "module": "commerce",
"available": true, "connectorOnly": false },
{ "entity": "collections", "label": "Collections", "module": "commerce",
"available": true, "connectorOnly": true }
],
"sources": [
{
"id": "shopify.products",
"entity": "products",
"label": "Products",
"file": "products_export.csv",
"where": "Products → Export → All products",
"format": "csv"
}
],
"connector": { "slug": "shopify", "fields": [ /* … */ ] }
}
]
}
}sourcesnames the vendor’s own file and the menu it is under, verbatim. Use it in your own UI rather than writing your own copy of it. connectorOnly marks entities that platform has no export for at all; those only arrive through a live connection.
The canonical row
Every entity is a flat Record<string, string>. Strings throughout: numbers, booleans and dates are coerced on the way in, so "24.00", "1,200" and "true" are all fine.
Two rules decide whether a row lands:
- Required fields must be present and non-empty, or the row is skipped.
- At least one natural key must carry a value. The natural key is also what makes an import idempotent: send the same file twice and matching records are updated, not duplicated.
{ "price": "" } and {}mean different things to an upsert: the first says “set the price to nothing”, the second says “leave it alone”. Drop keys you have no value for.
A product spread across several rows (one per variant, which is how every commerce platform exports) is grouped by handle. Send the option matrix on each row (option1_name / option1_value) and the product’s own fields on the first.
Check the rows
curl https://api.sparx.works/v1/migration/preview \
-H "Authorization: Bearer $SPARX_KEY" \
-H "Content-Type: application/json" \
-d '{
"entity": "products",
"rows": [
{ "handle": "merino-beanie", "title": "Merino Beanie", "sku": "BEANIE-S", "price": "24.00" },
{ "handle": "nameless", "sku": "X" }
]
}'{
"success": true,
"data": {
"report": {
"entity": "products",
"rowCount": 2,
"okCount": 1,
"errorCount": 1,
"warningCount": 0,
"blocked": false,
"errorRows": [1],
"issues": [
{
"severity": "error",
"rowIndex": 1,
"column": "title",
"code": "required_missing",
"message": "This product has no title.",
"hint": "A product cannot be created without one. Fill it in, or remove the row."
}
],
"unmappedColumns": [],
"duplicates": []
}
}
}errorRows is uncapped and is the list to act on; issues is capped at 500 for display. blocked means a required column is missing from every row, so the whole set is unusable rather than some of it.
error: this row cannot be written and will be skipped. warning: the row will be written, but something was changed or dropped to make that possible.
Start a run
202 immediately. Requires the editor role.curl https://api.sparx.works/v1/migration/runs \
-H "Authorization: Bearer $SPARX_KEY" \
-H "Content-Type: application/json" \
-d '{
"vendor": "shopify",
"fileName": "products_export.csv",
"dryRun": true,
"upsert": true,
"entities": [
{
"entity": "products",
"rows": [
{ "handle": "merino-beanie", "title": "Merino Beanie", "sku": "BEANIE-S", "price": "24.00" }
]
},
{
"entity": "inventory_levels",
"rows": [
{ "sku": "BEANIE-S", "location": "Studio", "quantity": "9" }
]
}
]
}'{
"success": true,
"data": {
"runId": "576083dd-328e-48d0-9adc-bfa81c8c41dc",
"vendor": "shopify",
"dryRun": true,
"jobs": [
{ "id": "ad87dd2c-…", "entityType": "products", "status": "pending", "rowCount": 1 },
{ "id": "b1f2e3d4-…", "entityType": "inventory_levels", "status": "pending", "rowCount": 1 }
],
"skipped": [],
"reports": { "products": { "okCount": 1, "…": "…" } }
}
}Send everything in one call. Entities are reordered into dependency order (categories before products, products before stock, customers before orders) so stock resolves a SKU that products created. Splitting them across calls hands you that ordering problem instead.
Rows that fail validation are dropped here rather than becoming error rows later, so the count you are given is the count that happens. Entities whose module is off are reported in skipped and the rest of the run proceeds: a WordPress export carrying products for a tenant who only wants the blog imports the blog.
dryRun: true resolves every row against real data and reports what would happen without writing anything. It is a separate read-only path, not a transaction that gets rolled back.
Watch it land
Jobs are processed by a worker, so the run is not finished when the call returns. Poll
status leaves running, or subscribe to webhooks and skip the polling.curl https://api.sparx.works/v1/migration/runs/$RUN_ID \ -H "Authorization: Bearer $SPARX_KEY"
{
"success": true,
"data": {
"run": {
"runId": "576083dd-…",
"vendor": "shopify",
"dryRun": true,
"status": "completed",
"entities": [
{ "entity": "products", "rowCount": 1, "imported": 1,
"updated": 0, "errors": 0, "done": true }
]
},
"jobs": [ /* one per chunk */ ],
"problems": [
{
"entity": "products",
"rowIndex": 83,
"status": "error",
"naturalKey": "BEANIE-XL",
"message": "This product has no title."
}
]
}
}problems is the list worth surfacing to a person: rows that were skipped, and rows that landed but did something worth knowing (an image that had to be linked rather than copied, a location that had to be created). rowIndex is zero-based against the rows you sent for that entity.
Live connections
Three platforms (Shopify, WordPress/WooCommerce and HubSpot) can be read directly instead of exported. A connector fetches; it does not import. It returns the same canonical rows a file produces, and you hand them to /runs exactly as you would rows you parsed yourself.
sparx holds no platform-level Shopify or HubSpot credential and never will. The tenant supplies their own read-only key, it is sent with each call, used, and forgotten. Nothing is written to the database. Ask for read scopes only.
Prove the credentials
curl https://api.sparx.works/v1/migration/connect \
-H "Authorization: Bearer $SPARX_KEY" \
-H "Content-Type: application/json" \
-d '{
"vendor": "shopify",
"credentials": {
"shop": "your-store.myshopify.com",
"accessToken": "shpat_…"
}
}'Page through the data
nextCursor. Hand the cursor back to continue; a null cursor means you have it all.curl https://api.sparx.works/v1/migration/pull \
-H "Authorization: Bearer $SPARX_KEY" \
-H "Content-Type: application/json" \
-d '{
"vendor": "shopify",
"entity": "products",
"cursor": null,
"credentials": { "shop": "your-store.myshopify.com", "accessToken": "shpat_…" }
}'{
"success": true,
"data": {
"entity": "products",
"rows": [
{ "handle": "merino-beanie", "title": "Merino Beanie", "sku": "BEANIE-S",
"price": "24.00", "option1_name": "Size", "option1_value": "Small" }
],
"nextCursor": "eyJsYXN0X2lkIjo0MDcxNzk4NDE5NzR9",
"fetched": 25
}
}fetched counts the vendor’s own records; rows.length counts canonical rows. One product with eight variants is one record and nine rows, so the two differ on purpose.
The whole loop
Pull one entity to exhaustion, then practice, then run it for real.
const key = process.env.SPARX_KEY;
const api = (path, body) =>
fetch(`https://api.sparx.works/v1/migration/${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then((r) => r.json());
const credentials = { shop: "your-store.myshopify.com", accessToken: process.env.SHOPIFY_TOKEN };
// 1. Prove the credentials before doing anything long.
const { data: account } = await api("connect", { vendor: "shopify", credentials });
console.log("connected to", account.account.account);
// 2. Page through one entity until the cursor runs out.
const rows = [];
let cursor = null;
do {
const { data: page } = await api("pull", {
vendor: "shopify", entity: "products", cursor, credentials,
});
rows.push(...page.rows);
cursor = page.nextCursor;
} while (cursor !== null);
// 3. Practice first. Then run it again with dryRun: false.
const { data: run } = await api("runs", {
vendor: "shopify", dryRun: true, entities: [{ entity: "products", rows }],
});
console.log(run.runId, run.jobs.length, "jobs");Pull entities one at a time rather than in parallel. All three platforms rate limit per account, and four concurrent pulls against one store means four pulls all backing off: slower than doing them in order, and far more likely to fail.
Every entity
Seventeen entities. Required fields must be present on every row; natural key is what a re-import matches on, and a row needs at least one of them. A module that is off means the entity is reported as skipped, never an error.
| Entity | Module | Required | Natural key |
|---|---|---|---|
products | commerce | title | handlesku |
inventory_levels | inventory | skuquantity | skulocation |
customers | crm | — | emailphone |
orders | commerce | order_number | order_number |
categories | commerce | name | nameslug |
collections | commerce | name | nameslug |
discounts | commerce | code | code |
content | cms | title | slug |
media | — | url | url |
redirects | builder | fromto | from |
companies | crm | name | namedomain |
deals | crm | name | name |
tickets | crm | subject | subject |
segments | crm | name | name |
suppliers | inventory | name | namecode |
purchase_orders | inventory | po_number | po_number |
b2b_accounts | b2b | company_name | company_name |
The full field list per entity (every optional column, its type and its limit) is ENTITY_FIELDS in @wizeworks/migration, which is what this table is generated from and what the validator checks against.
For the platform-by-platform view (the exact file, the exact menu, and what does not come across) see the switching pages.