Bulk product imports
Bulk import is a two-phase workflow. ARC first records the import and gives you an upload URL, you upload the file, and ARC stages it and exposes a preview for a human or integration to inspect before the commit is enqueued.
create → upload file → stage → poll until PREVIEW → preview → approve → poll until COMPLETED
↘ cancel (any time before approve)
↘ rollback a COMPLETED import
Prepare the file
The file is a CSV, XLSX, or XLS spreadsheet with one row per variant and a header row. ARC matches headers by name, ignoring case, spaces and underscores, and looks for the header row in the first 15 rows (so a title banner above it is fine).
A minimal file that creates one product:
sku,name,price,uom
EX-1001,Example widget,12.50,Each
| Column (any accepted header) | Required | Notes |
|---|---|---|
sku (also code, variant sku, ean) | Yes | Identifies the variant. In UPSERT mode an existing SKU is updated. Missing → Missing SKU. |
name (also product name, product, model) | Yes | Product name. Missing → Missing product name. Rows with the same name become variants of one product. |
uom (also unit, unit of measure) | Yes, unless the workspace has a default unit | Must match a unit name in the workspace; otherwise Unknown unit of measure "…". Without it and without a workspace default unit, the row fails with A base unit is required…. |
price (also list price, unit price) | No | Selling price, in the workspace's base currency. |
sale price, cost | No | Also base-currency amounts. |
variant name (also variant) | No | Name of this variant within the product. |
brand, category | No | Found by name; a name the workspace does not have yet is created. |
description, barcode (also upc, gtin), tags | No | |
images (also image url) | No | Image URLs. |
status | No | ACTIVE, INACTIVE or DISCONTINUED. |
weight, length, width, height | No | |
currency | No | ISO code of the row's prices. A row in another currency may carry only tier prices (below), never price, cost or sale price — ARC never converts a price. |
tier1_min_qty, tier1_price, tier2_min_qty, … | No | A quantity price ladder: tier N applies from N's minimum quantity. |
attr_<name> (for example attr_size) | No | A variant attribute; attr_pack_size becomes the attribute pack size. |
Unrecognised columns are ignored. The preview (below) shows how many rows will be created, updated or rejected, with sample errors, before anything is written.
Create the import
curl --fail-with-body --request POST "$ARC_API_URL/api/service/products/bulk" \
--header "Authorization: Bearer $ARC_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"filename": "products-2026-09-18.csv",
"mode": "UPSERT"
}'
The filename must end in .csv, .xlsx, or .xls. mode defaults to CREATE_ONLY when omitted. The response is:
{
"success": true,
"data": {
"importId": "<import-id>",
"uploadUrl": "<presigned-upload-url>",
"fileRef": "<file-ref>"
}
}
The import is created in status UPLOADED, but no file is attached yet. The GraphQL createProductImport operation is the other supported entry point and returns the same three values.
Upload the file
Send the file bytes with an HTTP PUT to uploadUrl. Do not add the API key: the URL is presigned and is valid for one hour.
curl --fail-with-body --request PUT "<presigned-upload-url>" \
--header "Content-Type: text/csv" \
--upload-file products-2026-09-18.csv
Send Content-Type: text/csv for a CSV file and Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet for an XLSX or XLS file. The file must be 64 MB or smaller. A larger file is refused at stage, and the import moves to FAILED.
Stage and preview
curl --fail-with-body --request POST \
"$ARC_API_URL/api/service/products/bulk/<import-id>/stage" \
--header "Authorization: Bearer $ARC_API_KEY"
Stage requires an import in UPLOADED status whose file has been uploaded. It returns { "success": true } as soon as the parse job is queued; parsing and validation run in the background. Poll the status route until data.status leaves the staging states:
curl --fail-with-body \
"$ARC_API_URL/api/service/products/bulk/<import-id>" \
--header "Authorization: Bearer $ARC_API_KEY"
| Status | Meaning |
|---|---|
UPLOADED | Created; waiting for the file and the stage call. |
MAPPING | Stage job is reading the file and mapping columns. |
VALIDATING | Rows are being validated. |
PREVIEW | Ready for preview and approval. |
COMMITTING | Approved; the commit job is writing rows. |
COMPLETED | Commit finished. Terminal. |
FAILED | Stage or commit failed, or the import was cancelled. failureReason says why. Terminal. |
ROLLED_BACK | A completed import was rolled back. Terminal. |
When the status is PREVIEW, read the preview:
curl --fail-with-body \
"$ARC_API_URL/api/service/products/bulk/<import-id>/preview" \
--header "Authorization: Bearer $ARC_API_KEY"
A preview is not a commit. Inspect at least:
- the import
statusandmode; - rows that will be created versus updated (
createCount,updateCount, and thesampleRowssample); - validation errors and warnings (
errorRows,warningRows, and thesampleErrorssample); - the total and valid row counts (
totalRows,validRows); - rules the pipeline applied for you during validation (
autoAppliedRules) and the currency the price tiers will land in (currencySummary).
Approve and monitor
curl --fail-with-body --request POST \
"$ARC_API_URL/api/service/products/bulk/<import-id>/approve" \
--header "Authorization: Bearer $ARC_API_KEY" \
--header "Content-Type: application/json" \
--data '{"modeOverride":"UPSERT"}'
curl --fail-with-body \
"$ARC_API_URL/api/service/products/bulk/<import-id>" \
--header "Authorization: Bearer $ARC_API_KEY"
Approval is accepted only while the import is in PREVIEW status. It moves the import to COMMITTING and enqueues the commit. Poll with a bounded interval and stop when the status is COMPLETED or FAILED. Persist the last observed status and counts so a process restart can resume monitoring without creating another import.
Cancel an import
An import that has not started committing (UPLOADED, MAPPING, VALIDATING or PREVIEW) can be cancelled, which frees the workspace's single import slot. It moves to FAILED with the reason Cancelled by user.
curl --fail-with-body --request POST \
"$ARC_API_URL/api/service/products/bulk/<import-id>/cancel" \
--header "Authorization: Bearer $ARC_API_KEY"
An import that is COMMITTING or COMPLETED cannot be cancelled (400); roll a completed import back instead. Staff users can also cancel from the console or with the GraphQL cancelProductImport(importId:) mutation.
Rollback
curl --fail-with-body --request POST \
"$ARC_API_URL/api/service/products/bulk/<import-id>/rollback" \
--header "Authorization: Bearer $ARC_API_KEY"
Only a COMPLETED import can be rolled back; any other status is refused with 400. Rollback is an operation on the import ledger, not a general-purpose product delete. It removes the products and variants the import created. A created variant that is already used on a document or holds stock is deactivated instead of deleted. Rows the import updated are not reverted. The result reports deletedVariants, deactivatedVariants, deletedProducts, and notRevertedCount. Read it and reconcile before running another import.
Safety rules
- Use a dedicated
READ_WRITEkey with an IP allowlist for CI/import workers. - Keep the import id tied to the external migration job id.
- Never approve a preview that your system did not fetch and validate.
- A workspace can have only one active import at a time (
UPLOADED,MAPPING,VALIDATING,PREVIEW, orCOMMITTING). Creating a second one is refused with400until the first reachesCOMPLETED,FAILED, orROLLED_BACK. An import you created but never finished still counts as active — cancel it instead of leaving it. - An unknown import id returns
400with the messageImport <id> not found, not404. - Do not treat
success: truefromstageorapproveas proof that every row committed; poll status and inspect counts.