# Upload items to a dataset

`POST https://stylor.ai/api/v1/datasets/{datasetId}/items/upload`

- Authentication: private API key, sent as `Authorization: Bearer sgpt-sk-…::…`
- Rate-limit resource: `api.v1.datasets.items.upload`
- Demo key: not accepted
- Web page: https://stylor.ai/guides/rest/v1/upload-dataset-items

Queues up to 5,000 products for processing in one request. Nothing is
searchable yet when this call returns: each product becomes a
`pending` queue entry, and a background worker turns it into a
`DatasetItem` — downloading and storing its images, extracting style
metadata and computing embeddings. Track progress with the queue
endpoints; the `itemId` of a queue entry is the `itemId` of the item it
produces.

`disableGenerativeAssets` is accepted for compatibility but ignored:
generative thumbnails are disabled platform-wide and every entry is
stored with the value `true`.

## Validation

Validation is all-or-nothing. Every product is checked before any is
queued; if one fails, the whole request is rejected and nothing is
written. Failures come back in `details` as `{ index, path, message }`
where `index` is the product's position in `items` (or `-1` for
request-wide SKU problems) and `path` is the offending field. The
per-field messages are:

- `Product must be an object.`
- `Expected string, got <type>` — one of `name`, `handle`, `description`, `publishedAt`, `createdAt`, `updatedAt`, `productType`, `sku` is not a string
- `Price must be a non-negative number.`
- `SKU must not be empty.` — `sku` is blank or only whitespace
- `Unparseable date: <value>` — `publishedAt`, `createdAt` or `updatedAt` cannot be parsed by `Date`
- `Expected an array.` — `tags`, `images`, `options` or `variants` is not an array
- `Expected <type>, got <type>` — an element of those arrays has the wrong type (`tags` must hold strings; the others objects)
- `Expected string.` — a variant's `sku`, `name`, `createdAt`, `updatedAt` or `productId` is not a string
- `Price must be non-negative number.` — a variant's `price`
- `Expected boolean.` — a variant's `available`
- `Expected one of mens, womens, unisex, or null.` — `gender` is present and not one of those

Dates are normalised to ISO 8601 UTC before they are stored. An empty
`publishedAt` is accepted and means the product is unpublished. Fields
outside the documented shape are dropped.

A few rules are enforced only by the database when the batch is
written, and break the whole request with the shared
`500 Internal server error.` (nothing is queued): `name` and `handle`
must be non-empty; each variant's `name` and `productId` must be
non-empty; every `images` entry needs a `src`; `tags` and option
`values` must not contain empty strings. A `variants` value that is
neither an array nor omitted, or a `null` element inside `variants`,
also returns the shared `500 Internal server error.`

## Re-uploading a product

SKUs are matched ignoring case and surrounding whitespace, the same way
a data source sync matches them. A SKU must appear only once in the
request. When it already has a queue entry in the dataset (in any
status except `archived`), the upload updates that entry instead of
creating a second one:

- `updatedAt` differs from the stored product → the entry is re-queued.
  If the image URLs changed it goes through full processing again
  (`pending`); otherwise only name, description, price and sizes are
  refreshed (`partial_update`).
- The entry is `failed` → it is re-queued for full processing.
- Otherwise nothing is written; the product is counted as `unchanged`.

A SKU whose only entry is `archived` brings that entry back, with the
same `itemId`, and is counted as `revived`. If it was fully processed
and its image URLs, name and product type are unchanged, it returns
through `partial_update`; otherwise it is processed again (`pending`).

This makes the endpoint safe to retry, and usable for price and stock
refreshes as long as `updatedAt` moves with the change.

## Example request

```bash
curl -X POST "https://stylor.ai/api/v1/datasets/YOUR_DATASET_ID/items/upload" \
  -H "Authorization: Bearer $STYLOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "items": [
    {
      "name": "Classic Crew Tee",
      "handle": "classic-crew-tee",
      "description": "<p>A heavyweight organic cotton tee with a relaxed fit.</p>",
      "publishedAt": "2024-11-02T09:15:00Z",
      "createdAt": "2024-11-02T09:15:00Z",
      "updatedAt": "2025-02-10T12:00:00Z",
      "productType": "T-Shirts",
      "sku": "A1A2P-BB2J",
      "price": 49,
      "tags": [
        "organic",
        "basics"
      ],
      "images": [
        {
          "src": "https://cdn.shopify.com/s/files/1/0001/products/tee-front.jpg"
        },
        {
          "src": "https://cdn.shopify.com/s/files/1/0001/products/tee-back.jpg"
        }
      ],
      "options": [
        {
          "name": "Size",
          "values": [
            "Medium",
            "Large"
          ]
        }
      ],
      "variants": [
        {
          "sku": "A1A2P-BB2J-M",
          "name": "Medium",
          "available": true,
          "price": 49,
          "createdAt": "2024-11-02T09:15:00Z",
          "updatedAt": "2025-02-10T12:00:00Z",
          "productId": "8412345678901"
        },
        {
          "sku": "A1A2P-BB2J-L",
          "name": "Large",
          "available": false,
          "price": 49,
          "createdAt": "2024-11-02T09:15:00Z",
          "updatedAt": "2025-02-10T12:00:00Z",
          "productId": "8412345678901"
        }
      ]
    }
  ]
}'
```

## Path parameters

- `datasetId` (string (uuid), required): The dataset's id, as returned when it was created.

## Request body

Content type `application/json`, required.

- `items` (array<IngestProduct>, required): Products to queue. Between 1 and 5,000 entries. [min items 1; max items 5000]
  - `name` (string, required): Product title. Must be non-empty. [example `"Classic Crew Tee"`]
  - `handle` (string, required): URL slug. The product URL is built as `<store URL>/products/<handle>`. Must be non-empty. [example `"classic-crew-tee"`]
  - `description` (string, required): Product description; HTML is allowed. May be an empty string. [example `"<p>A heavyweight organic cotton tee with a relaxed fit.</p>"`]
  - `publishedAt` (string, required): Any string `Date` can parse (ISO 8601 recommended). [example `"2024-11-02T09:15:00Z"`]
  - `createdAt` (string, required): Any string `Date` can parse. [example `"2024-11-02T09:15:00Z"`]
  - `updatedAt` (string, required): Any string `Date` can parse. [example `"2025-02-10T12:00:00Z"`]
  - `productType` (string, required): Merchant category. May be an empty string. [example `"T-Shirts"`]
  - `sku` (string, required): Product-level SKU. Must be non-empty, unique within the request, and not already present in the dataset's queue. [example `"A1A2P-BB2J"`]
  - `price` (number, required): Product price. Must be a non-negative number. [minimum 0; example `49`]
  - `tags` (array<string>, required): Free-form tags. Every element must be a non-empty string.
  - `images` (array<IngestProductImage>, required): Images to download. Every element must be an object with `src`.
    - `src` (string (uri), required): Publicly fetchable image URL. Required — an entry without `src` fails the upload with a database validation error. [example `"https://cdn.shopify.com/s/files/1/0001/products/tee-front.jpg"`]
  - `options` (array<IngestProductOption>, required): Product options. Every element must be an object.
    - `name` (string, required): Option name. [example `"Size"`]
    - `values` (array<string>, required): Option values. Empty strings fail the upload at the database.
  - `variants` (array<IngestProductVariant>, required): Purchasable variants. Every element must be an object; a `null` element fails the request with the shared `500`.
    - `sku` (string, required): Variant SKU. Must be a string; an empty string is accepted. [example `"A1A2P-BB2J-M"`]
    - `name` (string, required): Variant title. Becomes the size `label`. Must be non-empty — an empty string fails the upload at the database with a `500`. [example `"Medium"`]
    - `available` (boolean, required): Whether the variant is in stock. [example `true`]
    - `price` (number, required): Variant price. Must be a non-negative number. [minimum 0; example `49`]
    - `createdAt` (string, required): Any string `Date` can parse (ISO 8601 recommended). Stored normalised to ISO 8601 UTC. [example `"2024-11-02T09:15:00Z"`]
    - `updatedAt` (string, required): Any string `Date` can parse. Stored normalised to ISO 8601 UTC. [example `"2025-02-10T12:00:00Z"`]
    - `productId` (string, required): Your identifier for the parent product. Must be non-empty. [example `"8412345678901"`]
  - `gender` (string, nullable): Optional. Who you sell the product to, when you know. It is stored as the item's `metadata.gender` in place of the one the labeller would choose, ahead of any department the title or tags name and of the dataset's `targetGender`. Products that are not worn (home goods, beauty, gift cards) are always `unisex`. Omit it or send `null` to let the labeller decide. Changing it on an existing SKU relabels the product. [one of `"mens"`, `"womens"`, `"unisex"`, `null`; example `"womens"`]
- `disableGenerativeAssets` (boolean): Must be a boolean when present (`null` is rejected). Currently ignored — every entry is stored with `true`. [default `true`]

### Examples

#### One product

Queue one product with its images and two variants.

```json
{
  "items": [
    {
      "name": "Classic Crew Tee",
      "handle": "classic-crew-tee",
      "description": "<p>A heavyweight organic cotton tee with a relaxed fit.</p>",
      "publishedAt": "2024-11-02T09:15:00Z",
      "createdAt": "2024-11-02T09:15:00Z",
      "updatedAt": "2025-02-10T12:00:00Z",
      "productType": "T-Shirts",
      "sku": "A1A2P-BB2J",
      "price": 49,
      "tags": [
        "organic",
        "basics"
      ],
      "images": [
        {
          "src": "https://cdn.shopify.com/s/files/1/0001/products/tee-front.jpg"
        },
        {
          "src": "https://cdn.shopify.com/s/files/1/0001/products/tee-back.jpg"
        }
      ],
      "options": [
        {
          "name": "Size",
          "values": [
            "Medium",
            "Large"
          ]
        }
      ],
      "variants": [
        {
          "sku": "A1A2P-BB2J-M",
          "name": "Medium",
          "available": true,
          "price": 49,
          "createdAt": "2024-11-02T09:15:00Z",
          "updatedAt": "2025-02-10T12:00:00Z",
          "productId": "8412345678901"
        },
        {
          "sku": "A1A2P-BB2J-L",
          "name": "Large",
          "available": false,
          "price": 49,
          "createdAt": "2024-11-02T09:15:00Z",
          "updatedAt": "2025-02-10T12:00:00Z",
          "productId": "8412345678901"
        }
      ]
    }
  ]
}
```

## Responses

### 200

Every product was queued.

Content type `application/json`:

- `success` (boolean, required): Always `true` on this response. [example `true`]
- `uploaded` (integer, required): Number of products accepted — equal to the length of `items`, and to `added + updated + revived + unchanged`. [example `1`]
- `added` (integer): Products queued as new entries. [example `1`]
- `updated` (integer): Existing queue entries re-queued with the new product data. [example `0`]
- `revived` (integer): Archived entries brought back with the new product data, keeping their `itemId`. [example `0`]
- `unchanged` (integer): Products whose queue entry was already up to date; nothing was written for them. [example `0`]
- `warnings` (array<object>): Non-blocking notices about individual products. Reserved; the current validator emits none, so the key is absent.
  - `index` (integer): Position of the product in `items`.
  - `path` (string): Field the notice refers to.
  - `message` (string): The notice.

Example:

```json
{
  "success": true,
  "uploaded": 1,
  "added": 1,
  "updated": 0,
  "unchanged": 0
}
```

## Errors

Every error is a JSON object with an `error` string. Match on the status code; the message is written for people.

| Status | Message | When |
| --- | --- | --- |
| 400 | Request body must be valid JSON. | The request body could not be parsed as JSON. |
| 400 | Request body must be a JSON object. | The request body is empty, or parses to something other than a JSON object (for example an array or `null`). |
| 401 | Organization not found. Please check the organizationId. | The organization encoded in the API key no longer exists. |
| 404 | Dataset not found for this organization. | No dataset with this `datasetId` belongs to the key's organization. |
| 413 | Upload limit exceeded (<count> > 5000). | `items` holds more than 5,000 products. Split the upload into batches. |
| 422 | The 'disableGenerativeAssets' field must be either true or false when provided. | `disableGenerativeAssets` is present but not a boolean (`null` counts as invalid). |
| 422 | `items` must be a non-empty array. | `items` is missing, not an array, or empty. |
| 422 | Duplicate SKUs in payload. | The same `sku` (ignoring case and surrounding whitespace) appears on more than one product in `items`. Checked after per-product validation passes. `details` names each repeat by its position in `items`.Body: `{"error":"Duplicate SKUs in payload.","details":[{"index":3,"path":"sku","message":"A1A2P-BB2J appears earlier in this request."}]}` |
| 422 | Validation failed. | One or more products failed field validation. `details` lists every failure as `{ index, path, message }`; nothing was queued.Body: `{"error":"Validation failed.","details":[{"index":0,"path":"price","message":"Price must be a non-negative number."},{"index":2,"path":"variants[0].available","message":"Expected boolean."}]}` |

### Shared authentication, quota and rate-limit errors

| Status | Message | When |
| --- | --- | --- |
| 401 | API key missing. | The `Authorization` header is absent, is not `Bearer <key>`, or the key does not have three dash-separated segments. |
| 401 | Authentication failed: public key is invalid or not recognized. | A public-key endpoint was called with a key that does not exist. |
| 401 | Authentication failed: private key provided instead of a public key. | A public-key endpoint was called with an `sgpt-sk-…` key. |
| 401 | Authentication failed: token is not recognized. | A private-key endpoint was called with a key whose lookup segment does not exist. |
| 401 | Authentication failed: private key is invalid. | A private-key endpoint was called with a key whose secret does not match. |
| 401 | Authentication failed: public key provided instead of a private key. | A private-key endpoint was called with an `sgpt-pk-…` key. |
| 401 | Authentication failed: bearer token format is invalid. | A private key was sent without its `::` lookup segment. |
| 401 | Authentication failed: API key could not be read. Regenerate the key. | The key's embedded organization data could not be decrypted or parsed. |
| 403 | Demo mode is not available for private-key routes. Demos are only supported with public API keys. | The demo key was sent to a private-key endpoint. |
| 403 | Demo mode is not supported on this API route. Check the documentation for available demo endpoints. | The demo key was sent to a public-key endpoint that opts out of demo mode. |
| 403 | No active subscription for this organization. | The organization that owns the key has no active subscription. |
| 403 | No quota found for organization "<organizationId>". | The organization has no quota record for the current billing period. |
| 403 | No entitlement for "<resource>". | The plan's quota template has no entry for this endpoint. |
| 403 | Access denied to "<resource>". | The plan's quota template turns this endpoint off. |
| 429 | Monthly quota exceeded for "<resource>" (<quota>/<quota> used). | The billing-period quota for this endpoint is exhausted. `Retry-After` gives the seconds until the period resets. |
| 429 | RPM limit exceeded for "<resource>" (<used>/<limit> used). | More than the allowed requests per minute were sent. `Retry-After` is 60. |
| 500 | Internal server error. | An unexpected failure on the server. The message is always this string; details are logged, never returned. Quota consumed by the request is refunded, as it is for every 5xx response. |
