API
esc

Type to search.

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": [ … ] }
JSON

Each row is one product:

catalogue.jsonl (one line)
{
"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.

400 Bad Request
{
"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 as partial.
  • fail_row: create nothing for that row and report it as failed. 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}
200 OK
{
"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.

GET /v1/imports/{id}/rows?status=failed
{
"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.