> ## Documentation Index
> Fetch the complete documentation index at: https://docs.genlook.app/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Upsert Product (optional)

> Explicit catalog endpoint. Optional — most callers should use the inline form of POST /try-on instead.

<Note>
  **You probably don't need this endpoint.** The recommended path is the inline `product` field on [`POST /try-on`](/tryon-api/endpoints/create-try-on) — same upsert semantics, one less call. Use this endpoint only when you want:

  * a **lifetime TTL by default** (inline upserts default to 15 days),
  * the product **listed in [`GET /products`](/tryon-api/endpoints/list-products)** and visible in the dashboard,
  * to pre-register a catalog **without immediately running a generation**.
</Note>

Creates or updates a product, matched on `(account, externalId)`. The same endpoint handles both — pass full fields the first time, then partial fields to edit later. Products created via this endpoint default to **lifetime** (no expiry); pass `validForDays` to opt into a TTL.

## Content types

* **`application/json`** — when all images are URLs.
* **`multipart/form-data`** — when you ship image bytes inline. The JSON payload arrives in a `data` form field; each `images[].fileKey` references a file field by name.

## Request

<ParamField body="externalId" type="string" required>
  Your product ID. Reused on subsequent calls to update the same product. Don't start it with `_anon_` — that prefix is
  reserved for IDs we generate.
</ParamField>

<ParamField body="title" type="string">
  Optional. Helps the AI classify the product category more accurately. Preserved on updates if omitted.
</ParamField>

<ParamField body="description" type="string">
  Optional. Helps the AI classify the product category more accurately. Preserved on updates if omitted.
</ParamField>

<ParamField body="images" type="array">
  Replaces the full image list on update. Each entry has a `source` object holding **exactly one** of:

  * `url` — remote URL.
  * `fileKey` — name of a multipart file field in the same request.

  An optional `classifications` object can sit alongside `source`.

  <Note>The old flat shape — `{ url }` / `{ fileKey, classifications }` with the fields at the top level — is still accepted but **deprecated**. Prefer `{ source: { url | fileKey }, classifications? }`.</Note>
</ParamField>

<ParamField body="validForDays" type="integer | null">
  How long the product lives between uses, in days. Defaults to `null` (kept forever) when created via this endpoint.
  Pass a number (1–365) to set a custom lifetime, `null` to opt back into "kept forever" on an existing product, or omit
  to preserve the existing value.
</ParamField>

<ParamField body="metadata" type="object">
  Free-form JSON returned on [`GET /products/:externalId`](/tryon-api/endpoints/list-products).
</ParamField>

<ParamField body="imageUrls" type="string[]" deprecated>
  Legacy alias for `images: [{ source: { url } }, …]`. Still accepted; will be removed in the next major. Prefer `images`.
</ParamField>

<RequestExample>
  ```bash JSON (URL images) theme={null}
  curl -X POST "https://api.genlook.app/tryon/v1/products" \
    -H "x-api-key: gk_your_api_key" \
    -H "Content-Type: application/json" \
    -d '{
      "externalId": "shirt-42",
      "title": "Red tee",
      "description": "Soft cotton regular fit",
      "images": [
        { "source": { "url": "https://cdn.example/front.jpg" } },
        { "source": { "url": "https://cdn.example/back.jpg" } }
      ]
    }'
  ```

  ```bash Multipart (URL + uploaded bytes) theme={null}
  curl -X POST "https://api.genlook.app/tryon/v1/products" \
    -H "x-api-key: gk_your_api_key" \
    -F 'data={
          "externalId": "shirt-42",
          "title": "Red tee",
          "description": "Soft cotton regular fit",
          "images": [
            { "source": { "url": "https://cdn.example/front.jpg" } },
            { "source": { "fileKey": "back" } }
          ]
        }' \
    -F "back=@back.jpg"
  ```

  ```bash Partial update (existing product) theme={null}
  # Update only the title. Images, description, TTL stay as-is.
  curl -X POST "https://api.genlook.app/tryon/v1/products" \
    -H "x-api-key: gk_your_api_key" \
    -H "Content-Type: application/json" \
    -d '{ "externalId": "shirt-42", "title": "Red tee — restocked" }'
  ```

  ```bash Opt-in TTL theme={null}
  curl -X POST "https://api.genlook.app/tryon/v1/products" \
    -H "x-api-key: gk_your_api_key" \
    -H "Content-Type: application/json" \
    -d '{
      "externalId": "seasonal-001",
      "title": "Summer collection",
      "description": "...",
      "images": [{ "source": { "url": "https://cdn.example/summer.jpg" } }],
      "validForDays": 90
    }'
  ```

  ```ts Node SDK theme={null}
  import { Genlook } from "@genlook/api";

  const client = new Genlook({ apiKey: process.env.GENLOOK_API_KEY! });

  // Create or update — same call, keyed on externalId.
  const product = await client.products.upsert({
    externalId: "shirt-42",
    title: "Red tee",
    description: "Soft cotton regular fit",
    images: [
      { source: { url: "https://cdn.example.com/front.jpg" } },
      { source: { url: "https://cdn.example.com/back.jpg" } },
    ],
    // validForDays: 90,  // opt into a TTL; omit for lifetime
  });

  // Multipart with byte uploads:
  import { readFile } from "node:fs/promises";
  await client.products.upsert({
    externalId: "shirt-42",
    title: "Red tee",
    description: "Soft cotton",
    images: [{ source: { url: "https://cdn.example.com/front.jpg" } }, { source: { fileKey: "back" } }],
    files: {
      back: { data: await readFile("./back.jpg"), mimeType: "image/jpeg" },
    },
  });
  ```
</RequestExample>

## Response

Returns the full product state after the upsert. See [List Products](/tryon-api/endpoints/list-products#response-shape) for the field-by-field reference.

<ResponseExample>
  ```json Success theme={null}
  {
    "externalId": "shirt-42",
    "title": "Red tee",
    "description": "Soft cotton regular fit",
    "metadata": null,
    "validForDays": null,
    "expiresAt": null,
    "lastUsedAt": null,
    "createdAt": "2026-05-13T10:00:00.000Z",
    "updatedAt": "2026-05-13T10:00:00.000Z",
    "images": [
      { "sourceUrl": "https://cdn.example/front.jpg", "order": 0 }
    ]
  }
  ```

  ```json Reserved prefix (409) theme={null}
  {
    "code": "RESERVED_EXTERNAL_ID",
    "message": "externalId '_anon_foo' is reserved — the '_anon_' prefix is for server-generated IDs.",
    "status": 409
  }
  ```

  ```json Missing image on create (400) theme={null}
  {
    "code": "PRODUCT_IMAGES_REQUIRED",
    "message": "Product 'shirt-42' does not exist yet; at least one image is required to create it.",
    "status": 400
  }
  ```
</ResponseExample>
