Data migration

Bulk imports

Move a merchant's historical customers or orders into Throttle from a file their old platform exported. Four steps: upload, map, dry run, commit. The dry run is not optional — a commit rewrites order and customer history, so Throttle refuses to run one until you have been shown what it would do.

1

Create the import, then upload the file

POST /api/v1/imports returns a presigned PUT. Send the file body straight to that URL — it never passes through the Throttle API.

2

Inspect and confirm the mapping

POST /api/v1/imports/{id}/inspect sniffs the dialect, headers and a 20-row sample and suggests a target field per column. PATCH /api/v1/imports/{id}/mapping saves what you confirmed.

3

Validate — the dry run

POST /api/v1/imports/{id}/validate reads the whole file under that mapping and reports what it WOULD create, update, skip and reject. It writes nothing.

4

Commit

POST /api/v1/imports/{id}/commit runs the real write pass. It is refused unless a dry run has passed.

Before you start

  • CSV and TSV up to 500 MB and 500,000 rows. The extension on fileName selects the parser, so it has to be accurate — it is chosen before a single byte is uploaded.
  • Excel up to 10 MB and 65,536 rows (.xlsx, .xlsm; .xls up to 25 MB). The lower ceiling is not arbitrary: a .xlsx is a ZIP whose directory sits at the END of the file, so a sheet cannot be decoded until the workbook, shared-strings and styles entries have all been read — it is parsed whole rather than streamed a row at a time. 65,536 is the .xls format's own ceiling, reused for both so there is one number rather than two. Past it, a sheet is refused with a message naming the cap; save it as CSV, which handles 500,000 rows.
  • Spreadsheet dates cannot be misread. A date cell is a number plus a format, not text, so it is imported as ISO-8601 and the dateOrder choice cannot move it. That ambiguity is real only for CSV, where 03/04/2024 is two different days.
  • A workbook with several sheets reads the first by default. POST /api/v1/imports/{id}/inspect returns every sheet in sheets, and re-POSTing with { "sheetName": "Orders" } reads that one instead. Changing the sheet re-saves the dialect, which retracts any dry run — the preview described a different grid.
  • Naming a provider pre-fills the mapping. bigcommerce and shopify both ship export templates; omit it and the header-name matcher runs alone. The provider also selects the status vocabulary, which is not cosmetic: BigCommerce exports one status column that implies both position and payment, while Shopify splits it across Fulfillment Status and Financial Status and needs both read together — an order can be fulfilled but unpaid, or paid but unfulfilled.
  • Shopify blanks its order columns on continuation rows. Where BigCommerce repeats the order-level values on every line of a multi-item order, Shopify writes them on the first line only. The Shopify template declares that shape, so the blanks are read as the format rather than as an inconsistent export. Its single Billing Name column is split into first and last on the last space.
  • One entity per file. entityType is customer or order.
  • Scopes. Reads need imports:read; every verb that creates, maps, runs or undoes an import needs imports:write. Publishable (pk_) keys cannot hold either, and extensions are not granted them.
  • Imports are scoped to one workspace environment. An import belongs to the application and environment its credential resolves to, and reading one from a different environment is 403 environment_mismatch.
The file never passes through the API
POST /api/v1/imports hands back a presigned PUT valid for one hour and you upload straight to storage. That is why the size ceiling is checked twice: once against the fileBytes you declare (before the URL is issued) and again against the object's real size at inspect time, because a presigned PUT signs the key and the content type — never the body length.

1. Create and upload

POST /api/v1/imports
curl -X POST https://api.usethrottle.dev/api/v1/imports \
  -H "x-api-key: $THROTTLE_SECRET_KEY" \
  -H "content-type: application/json" \
  -d '{
    "entityType": "order",
    "fileName": "orders-2024.csv",
    "fileBytes": 4823901,
    "provider": "bigcommerce"
  }'

# 201
{
  "data": {
    "importId": "8f2c…",
    "uploadUrl": "https://…s3…?X-Amz-Signature=…",
    "fileKey": "workspaces/…/imports/8f2c…/orders-2024.csv",
    "contentType": "text/csv",
    "expiresIn": 3600
  }
}
PUT the file to uploadUrl
# Send exactly the contentType the create call returned. The signature covers
# that header, so a different one fails with SignatureDoesNotMatch.
curl -X PUT "$UPLOAD_URL" \
  -H "content-type: text/csv" \
  --data-binary @orders-2024.csv

A PUT that never happened, or one that stored zero bytes, is caught at the next step as 409 file_not_uploaded rather than becoming an empty mapping screen.

2. Inspect, then map

POST /api/v1/imports/{id}/inspect reads at most the first 1 MB of the file however large it is, so it answers immediately. It returns the sniffed dialect, the headers, a 20-row sample, the platform template it matched, and a suggested target field per header. Those are suggestions — you confirm them.

PATCH /api/v1/imports/{id}/mapping
curl -X PATCH https://api.usethrottle.dev/api/v1/imports/$IMPORT_ID/mapping \
  -H "x-api-key: $THROTTLE_SECRET_KEY" \
  -H "content-type: application/json" \
  -d '{
    "columns": {
      "Order ID": "order.externalId",
      "Email": "order.customerEmail",
      "Total": "order.total",
      "Placed": "order.placedAt"
    },
    "dateOrder": "mdy",
    "emptyPaymentPolicy": "paid",
    "guestSentinels": ["0", ""],
    "createMissingCustomers": true
  }'
  • A column you leave out of columns is ignored. That is how you skip one.
  • Two columns mapped to one field is rejected, with both headers named. The mapper takes the first match and would discard the second silently, which is data loss, not a harmless duplicate.
  • dateOrder is required and never guessed. 03/04/2026 is two different days under dmy and mdy.
  • emptyPaymentPolicy decides what an order with no payment status means — used only when the file states nothing and the order status implies nothing (a "Shipped" row with an empty payment column, for instance). paid records the order as settled and writes a payment record for its full total; unpaid leaves the whole total outstanding. unknown is still accepted for compatibility but is not neutral: the column defaults to pending, so it is unpaid under another name. Choose deliberately — the wrong answer is a balance across an entire migrated history.
Changing the mapping retracts the dry run
A PATCH to the mapping returns the import to status: "mapping" and clears dryRunAt, so you must validate again before you can commit. This is deliberate: a dry run attests to a file and a mapping together. Re-pointing one column can flip create into overwrite, and the attestation has to expire with it.

3. Validate before you commit

POST /api/v1/imports/{id}/validate reads the whole file under the saved mapping and records what it would create, update, skip and reject. It writes no customers and no orders. When it finishes the import is validated and dryRunAt is set.

POST /api/v1/imports/{id}/commit is refused with 409 validation_required for any import that has not passed one. There is no force flag and no way to skip it.

A failed commit can be resumed
A commit that died partway is failed with dryRunAt still set, and committing again resumes it from its checkpoint rather than starting over. A failed validate lands on the same status having written nothing and proven nothing, so it is sent back to a dry run instead — dryRunAt is what tells the two apart.

What a committed order import writes

An order is more than its row. The balance the dashboard shows is computed from payment records, and "handed over" from fulfillment records — so an import that wrote only the order would leave a paid, shipped history reading as owing and undelivered. Commit writes the records that make each imported order true everywhere it is read.

  • A payment record for every order whose payment status is captured, for the order total, dated to the order, with processor: "manual" and method: "external" — the same shape POST /api/v1/orders/{id}/payments/record creates for money taken outside Throttle. Manual payments are excluded from billed GMV, so importing years of history never incurs platform fees. Only orders with no payment get one; a re-import never stacks a second, and a payment you recorded yourself is never touched.
  • Nothing for refunded, partially_refunded or disputed. No export column carries the amount returned, so the refund would have to be invented or omitted, and an omitted refund overstates revenue. Those orders keep their full outstanding balance until you record the payment and refund by hand.
  • A completed fulfillment for every fulfilled order, covering the whole order, dated to it, typed from its lines (shipment, service, …, or custom when mixed). No shipment or tracking is created — the file carries none — so the order shows as fulfilled without claiming a carrier or a delivery date. Orders that are partially_fulfilled get none: which lines went out is not in the file. An order that already has a fulfillment gets none either.
  • The status timestamps that belong to the statuscancelledAt, completedAt, closedAt — set to the order date for cancelled, fulfilled and closed respectively. A re-import that changes the status moves them with it.
  • Customers created for orders carry the buyer's name, company and phone from the billing address (or shipping, when billing is absent), not just the email.

Runs are queued, so poll for the outcome

validate, commit and undo all answer 202 Accepted with the import row at its pre-run status. The pass itself runs on a background worker: a 500k-row file takes minutes, which no HTTP request should be holding open. Poll GET /api/v1/imports/{id}, or subscribe a webhook to import.completed.

GET /api/v1/imports/{id}
# validate, commit and undo all answer 202 with the import row at its
# PRE-run status. The run itself happens on a worker — poll for the outcome.
curl -s https://api.usethrottle.dev/api/v1/imports/$IMPORT_ID \
  -H "x-api-key: $THROTTLE_SECRET_KEY"

{
  "data": {
    "status": "validated",
    "rowsTotal": 41903,
    "rowsProcessed": 41903,
    "rowsCreated": 41211,
    "rowsUpdated": 0,
    "rowsSkipped": 250,
    "rowsFailed": 442,
    "lastPassMode": "validate",
    "errorSummary": {
      "invalid_amount": { "count": 442, "sample": ""12.4.0" is not a money amount." },
      "total_mismatches": { "count": 3, "sample": "3 order(s) have a total that differs…" }
    },
    "dryRunAt": "2026-09-06T11:02:19.000Z",
    "committedAt": null,
    "progress": { "rowsTotal": 41903, "rowsProcessed": 41903, "percent": 100 }
  }
}
  • progress.percent is null , not 0, while the row total is still unknown — a progress bar stuck at 0% for a whole run is a lie, where "unknown" is at least true.
  • import.completed fires at the end of any successful pass. Read its mode field ( validate | commit | undo) to know which one finished — a completed dry run and a completed commit both emit it.
  • rowsSkipped counts rows merged into another row's order — the second and later lines of a multi-line order — not rows that were discarded. It is always 0 for a customer import, which never groups. Label it for your own users accordingly; "skipped" reads as "did not import", which is the opposite of what happened.
  • lastPassMode says which pass wrote the counters, and you need it to read them: a dry run reports what it WOULD create in rowsCreated, so that field alone does not mean a single row exists.
  • errorSummary groups the row errors by code — and carries up to three keys that are not row errors and are never counted in rowsFailed. Treat them as signals to show, not failures: total_mismatches (orders whose computed total differs from the figure stated in the file — the check that catches a mis-mapped money column, and which over-counts on an order whose rows straddle a batch), rows_appended (rows folded into an order an earlier batch had already written; already inside rowsSkipped), and orders_rewritten (a file listing one order's rows non-contiguously). Each entry carries its own count and a human-readable sample.
  • 503 queue_unavailable means the deployment has no worker configured. Nothing was started.
statusMeaning
createdThe record exists and the upload URL has been issued. The file is not in storage yet.
mappingThe file has been inspected. Save or change the mapping from here.
validatingA dry run is in flight on a worker.
validatedThe dry run finished. This is the only status a commit is accepted from.
committingThe real write pass is in flight on a worker.
completedThe commit finished. Undoable.
failedThe last run failed. failureReason says why. A failed run is committable and undoable only if dryRunAt is set — that is what tells a half-finished commit apart from a validate that proved nothing.
undoingAn undo pass is in flight on a worker.
undoneThe undo removed everything it was allowed to. A partial undo deliberately does not set this, so an import whose rows were blocked stays undoable.

The error report round trip

A run rejects the rows it cannot read and keeps going; it does not abort the file. GET /api/v1/imports/{id}/errors generates the report from the stored row errors on request. Fix the rows, re-import the corrected file, repeat.

GET /api/v1/imports/{id}/errors
curl -s https://api.usethrottle.dev/api/v1/imports/$IMPORT_ID/errors \
  -H "x-api-key: $THROTTLE_SECRET_KEY"

{
  "data": {
    "importId": "8f2c…",
    "rowsFailed": 40000,
    "rowsIncluded": 10000,
    "truncated": true,
    "filename": "8f2c…-errors.csv",
    "csv": "Order ID,Email,Total,_row_number,_error_code,_error_message\n…",
    "errors": [
      {
        "rowNumber": 812,
        "columnName": "Total",
        "code": "invalid_amount",
        "message": "\"12.4.0\" is not a money amount."
      }
    ]
  }
}
  • csv is re-importable. It carries the original columns of each failed row plus a _row_number / _error_code / _error_message trailer. _row_number is the line number in your file, so a corrected re-upload still points at the right row. Column order is first-seen across the report, not the file's — mapping is by header name, not position.
  • 404 no_errors means the import recorded no failed rows, including one that has not run yet.
At most 10,000 row errors are kept per pass
Always read rowsIncluded against rowsFailed before you treat the report as the whole story. rowsFailed is the run's true, uncapped total; rowsIncluded is what this report actually contains. A run with 40,000 failures returns 10,000 of them and truncated: true — correcting only those and re-importing will surface the next batch, not finish the job.

Undo: what it removes, and what it does not

POST /api/v1/imports/{id}/undo deletes the customers and orders this import created. It is queued like the other runs and answers 202; the outcome lands on the import row as undoResult, so it survives a page refresh.

POST /api/v1/imports/{id}/undo
curl -X POST https://api.usethrottle.dev/api/v1/imports/$IMPORT_ID/undo \
  -H "x-api-key: $THROTTLE_SECRET_KEY"
# 202 — queued. The outcome lands on the import row as undoResult.

{
  "data": {
    "status": "undone",
    "undoResult": {
      "ordersDeleted": 41180,
      "customersDeleted": 3902,
      "ordersKept": 31,
      "customersKept": 12,
      "keptReasons": { "payments": 24, "fulfillments": 7, "quotes": 12 }
    }
  }
}
  • Rows the import only matched are never deleted. An import that updated an existing customer, or attached an order to one, did not create that customer — undo leaves it exactly where it was. Only rows stamped with this import's id are candidates.
  • A row something else now depends on is kept, and the reason is named rather than reported as a generic "in use". An order is kept when it has payments, fulfillments or order_returns that arrived by another door. The payment and fulfillment records the import itself wrote (see above) are part of the import: they are deleted with it and never block it. A customer is kept when it has surviving orders, carts, invoices, subscriptions, subscription_invoices or quotes. Counts appear in keptReasons, and each kept row is counted once.
  • Addresses, email preferences, saved payment methods and discount redemptions go with a deleted customer, and line items go with a deleted order. A checkout session loses its customer link rather than blocking the delete.
  • Only a commit that actually ran is undoablecompleted, or failed partway after a passing dry run. A dry run wrote nothing, so undoing one is 409 undo_not_available; a run still in flight must finish first.
Undo is a repair, not a rollback
Anything the merchant has since built on top of imported data — a payment, a fulfillment, a quote — pins that row in place, by design. Read ordersKept and customersKept and expect a partial result on any import that has been live for a while.

Full endpoint reference

Every request field, response field and error code for all nine calls is in the API reference.

Related
Bulk Imports API reference for the endpoint detail, API key scopes for imports:read / imports:write, Webhooks to subscribe import.completed, and Workspace environments for how an import is scoped.