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.
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.
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.
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.
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
fileNameselects 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;.xlsup to 25 MB). The lower ceiling is not arbitrary: a.xlsxis 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.xlsformat'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
dateOrderchoice cannot move it. That ambiguity is real only for CSV, where03/04/2024is two different days. - A workbook with several sheets reads the first by default.
POST /api/v1/imports/{id}/inspectreturns every sheet insheets, 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
providerpre-fills the mapping.bigcommerceandshopifyboth 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 acrossFulfillment StatusandFinancial Statusand 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 Namecolumn is split into first and last on the last space. - One entity per file.
entityTypeiscustomerororder. - Scopes. Reads need
imports:read; every verb that creates, maps, runs or undoes an import needsimports: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.
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
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
}
} # 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.
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
columnsis 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.
-
dateOrderis required and never guessed.03/04/2026is two different days underdmyandmdy. -
emptyPaymentPolicydecides 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).paidrecords the order as settled and writes a payment record for its full total;unpaidleaves the whole total outstanding.unknownis still accepted for compatibility but is not neutral: the column defaults to pending, so it isunpaidunder another name. Choose deliberately — the wrong answer is a balance across an entire migrated history.
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.
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, withprocessor: "manual"andmethod: "external"— the same shapePOST /api/v1/orders/{id}/payments/recordcreates 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_refundedordisputed. 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
fulfilledorder, covering the whole order, dated to it, typed from its lines (shipment,service, …, orcustomwhen 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 arepartially_fulfilledget 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 status —
cancelledAt,completedAt,closedAt— set to the order date forcancelled,fulfilledandclosedrespectively. 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.
# 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.percentisnull, not0, 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.completedfires at the end of any successful pass. Read itsmodefield (validate|commit|undo) to know which one finished — a completed dry run and a completed commit both emit it. -
rowsSkippedcounts 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. -
lastPassModesays which pass wrote the counters, and you need it to read them: a dry run reports what it WOULD create inrowsCreated, so that field alone does not mean a single row exists. -
errorSummarygroups the row errors by code — and carries up to three keys that are not row errors and are never counted inrowsFailed. 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 insiderowsSkipped), andorders_rewritten(a file listing one order's rows non-contiguously). Each entry carries its owncountand a human-readablesample. -
503
queue_unavailablemeans the deployment has no worker configured. Nothing was started.
| status | Meaning |
|---|---|
created | The record exists and the upload URL has been issued. The file is not in storage yet. |
mapping | The file has been inspected. Save or change the mapping from here. |
validating | A dry run is in flight on a worker. |
validated | The dry run finished. This is the only status a commit is accepted from. |
committing | The real write pass is in flight on a worker. |
completed | The commit finished. Undoable. |
failed | The 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. |
undoing | An undo pass is in flight on a worker. |
undone | The 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.
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."
}
]
}
} -
csvis re-importable. It carries the original columns of each failed row plus a_row_number/_error_code/_error_messagetrailer._row_numberis 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_errorsmeans the import recorded no failed rows, including one that has not run yet.
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.
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,fulfillmentsororder_returnsthat 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 survivingorders,carts,invoices,subscriptions,subscription_invoicesorquotes. Counts appear inkeptReasons, 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 undoable —
completed, orfailedpartway after a passing dry run. A dry run wrote nothing, so undoing one is 409undo_not_available; a run still in flight must finish first.
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.
imports:read / imports:write, Webhooks to subscribe import.completed, and Workspace environments for how an import
is scoped.