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.

Updated 2026-08-1310 min read

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.

A run is a group of jobs, not a table

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:

EndpointWhat 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

POST/v1/migration/runs/{runId}/cancel
stops anything that has not started.

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.

Request
curl https://api.sparx.works/v1/migration/vendors \
  -H "Authorization: Bearer $SPARX_KEY"
Response
200
{
  "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.
Omit a key rather than sending an empty one

{ "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

POST/v1/migration/preview
validates and writes nothing. It is the same function the app runs in the browser before a byte is uploaded, so a preview and an import can never disagree about what is wrong.

Request
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" }
    ]
  }'
Response
200
{
  "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.

severity has a precise meaning

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

POST/v1/migration/runs
accepts up to 20 entities and 200,000 rows, chunks them into jobs of 5,000, and returns 202 immediately. Requires the editor role.

Request
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" }
        ]
      }
    ]
  }'
Response
202
{
  "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.

Practice first

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

GET/v1/migration/runs/{runId}
until status leaves running, or subscribe to webhooks and skip the polling.

Request
curl https://api.sparx.works/v1/migration/runs/$RUN_ID \
  -H "Authorization: Bearer $SPARX_KEY"
Response
200
{
  "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.

Credentials are yours, and are never stored

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

POST/v1/migration/connect
makes one cheap call to the vendor and tells you whose account you reached, plus which resources those credentials and this tenant’s modules actually allow.

Request
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

POST/v1/migration/pull
returns one page and an opaque nextCursor. Hand the cursor back to continue; a null cursor means you have it all.

Request
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_…" }
  }'
Response
200
{
  "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.

Node
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.

EntityModuleRequiredNatural key
productscommercetitlehandlesku
inventory_levelsinventoryskuquantityskulocation
customerscrm—emailphone
orderscommerceorder_numberorder_number
categoriescommercenamenameslug
collectionscommercenamenameslug
discountscommercecodecode
contentcmstitleslug
media—urlurl
redirectsbuilderfromtofrom
companiescrmnamenamedomain
dealscrmnamename
ticketscrmsubjectsubject
segmentscrmnamename
suppliersinventorynamenamecode
purchase_ordersinventorypo_numberpo_number
b2b_accountsb2bcompany_namecompany_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.

Was this page helpful?