Importing a catalogue
Submit up to 2,000 products in one call, one row per SKU with the URLs of its photos. We fetch the images, validate everything first, and report every row.
An import is a job. You submit rows, get back an import id at once, and the products are created in the background while you poll or wait for a webhook. Nothing is created until every row has passed validation, and once rows are accepted they succeed or fail independently.
Submit
POST/v1/imports
Two body formats carry the same rows.
# One JSON object per line. Options go in the query string.curl "https://api.veeton.com/v1/imports?mode=upsert&on_image_error=skip" \ -H "Authorization: Bearer $VEETON_KEY" \ -H "Content-Type: application/x-ndjson" \ -H "Idempotency-Key: $(uuidgen)" \ --data-binary @catalogue.jsonl# An envelope with the options and a rows array.curl https://api.veeton.com/v1/imports \ -H "Authorization: Bearer $VEETON_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d @- <<'JSON'{ "mode": "upsert", "on_image_error": "skip", "rows": [ … ] }JSONEach row is one product:
{ "reference": "TEE-SAGE-001", "name": "Crewneck Tee — Sage", "description": "Heavyweight organic cotton.", "sex": "female", "images": [ { "url": "https://cdn.example.com/tee-sage-front.jpg", "orientation": "front" }, { "url": "https://cdn.example.com/tee-sage-back.jpg", "orientation": "back" }, { "image_id": "01HX5K2MZ7A3Q4FBNDC0EVDXYS" } ]}| Field | Rules |
|---|---|
reference |
Required. Your product code. Unique within the file and among your API-created products. Up to 100 characters. |
name |
Required. Up to 200 characters. |
description |
Optional. Up to 2,000 characters. |
sex |
Optional. male, female, other or kid. |
images |
1 to 20 entries. Each has exactly one of url or image_id, and an optional orientation (omit it to leave the photo unlabeled for auto-annotation). |
An image url must be https://, on a public host and the default port, without embedded credentials, and fetchable without authentication for at least 24 hours. Presigned S3 URLs and CDN URLs are the usual answer. Up to 10 MB per image; JPEG, PNG and WebP, detected from the bytes. An image_id refers to an image already uploaded with POST /v1/images.
Bodies are limited to 8 MB, which comfortably fits 2,000 rows. Split a larger catalogue across several calls.
Validation is all-or-nothing
Every row is checked before anything is created. If any row is invalid the whole submission is refused with 400 and every problem listed by line, so you fix the file once and resubmit.
{ "errors": [ { "line": 12, "code": "invalid_row", "param": "images.0.url", "message": "`url` must use https" }, { "line": 340, "code": "duplicate_reference_in_file", "param": "reference", "message": "reference \"TEE-SAGE-001\" already appears on line 12. Each row must have a distinct reference." } ]}Modes
mode decides what a row does when a product with the same reference already exists among the products you created through the API. Products created in the dashboard carry no reference and are never matched.
| Mode | Existing reference | New reference |
|---|---|---|
upsert (default) |
Updated in place; the row’s images are appended | Created |
create |
The row fails with reference_already_exists |
Created |
skip_existing |
Left untouched; the row is reported skipped |
Created |
upsert is what makes re-submitting a file after a timeout safe: nothing is duplicated.
When an image cannot be fetched
on_image_error decides what one failing image does to its row.
skip(default): attach the images that did work and report the row aspartial.fail_row: create nothing for that row and report it asfailed. Images that had already been fetched for the row are discarded.
A row whose images all fail is failed in either mode.
Follow the job
GET/v1/imports/{import_id}
{ "id": "01HX5K2MZ7A3Q4FBNDC0EVDXIM", "status": "processing", "mode": "upsert", "on_image_error": "skip", "counts": { "total": 1840, "pending": 412, "succeeded": 1401, "partial": 22, "failed": 5 }, "results_url": null, "created_at": "2026-09-03T14:30:09Z", "started_at": "2026-09-03T14:30:11Z", "finished_at": null}The counters are maintained as rows finish, so polling costs one indexed read whatever the size of the job. While the job is processing the response carries Retry-After: 5; that is the interval to poll at.
| Status | Meaning |
|---|---|
processing |
Rows are being worked through. |
completed |
Every row succeeded. |
completed_with_errors |
The job finished, but some rows are partial or failed. |
failed |
The job itself could not run. No rows were processed. |
Or subscribe: an active webhook on import.completed and import.failed receives the import id when the job reaches a terminal state. An import with some failed rows still completed; the per-row picture lives on the job.
Per-row outcomes
GET/v1/imports/{import_id}/rows
One entry per submitted row, in input order, with what became of it. status=failed narrows to the rows that need attention, which is the fix-and-resubmit loop.
{ "data": [ { "line": 87, "reference": "TEE-OAT-002", "status": "failed", "action": "none", "product_id": null, "images": [ { "index": 0, "source": "https://cdn.example.com/oat-front.jpg", "orientation": "front", "status": "failed", "error": { "code": "http_error", "message": "origin answered 403 (HTTP 403)" } } ], "error": { "code": "no_images", "message": "none of the row's images could be attached" } } ], "next_cursor": null, "has_more": false}Once the job is terminal, results_url on the import points at a JSONL file with the same objects, one per line, in input order. Use the file for a whole-catalogue reconciliation and the endpoint for programmatic loops. The URL is re-signed on every read; do not cache it.
Image error codes are stable: http_error, timeout, too_large, not_an_image, blocked_host, dns_failed, image_not_found, row_failed. Row error codes likewise: reference_already_exists, no_images, image_failed, too_many_attempts.