Skip to content

Products

Push your product catalog to Humind so the AI assistant can recommend the right items, ground its answers in your data, and surface accurate prices and availability.

The endpoints here cover create, read, update, replace, archive, and batch operations on products, including their variants and embedded translations.

The Product object

FieldTypeRequiredDescription
external_idstringYes (on create)Your unique identifier for this product. Free-form, must be unique per company. Used as the upsert key, posting again with the same value updates the existing product.
humind_idstringReturned only24-char hex ObjectId Humind assigns. Stable for the lifetime of the product.
titlestringYesDisplay title in default_language.
descriptionstringNoPlain-text description.
description_htmlstringNoHTML description. Sanitized server-side, see HTML content.
handlestringNoURL-safe slug, lowercase, hyphen-separated. Optional — when omitted the server derives one from title (lowercase ASCII, non-alphanumerics replaced by hyphens). Not required to be unique within a store; lookup keys are external_id / humind_id, not handle.
type'product' | 'kit'NoDefaults to product. Use kit for items that bundle several SKUs sold as one (rare). Any other value is rejected with a 400 validation_failed error on type.
status'active' | 'archived' | 'draft'NoDefaults to active. archived hides the product from the assistant. draft is for staging unpublished products.
online_store_urlstringNoPublic URL of the product on your storefront. The assistant uses this to deep-link shoppers from chat.
default_languagestringNoBCP 47 short tag (en, fr, pt-BR). The two-letter primary subtag is required; the optional region subtag must be uppercase. When omitted, the company's primary language is used. Other shapes are rejected with validation_failed.
available_for_salebooleanReturned onlyComputed automatically from variants: true when status is active AND at least one variant has available_for_sale: true. Any value sent on the product itself is ignored. To make a product unavailable, set every variant's available_for_sale to false, or archive the product (status: archived).
brandobjectNoSee Brand.
categoriesstring[]NoFree-form category names. Used for soft grouping; doesn't have to match Humind collections.
imagesobject[]NoSee Image. The first image is the primary one shown in chat.
variantsobject[]Yes (1–250)See Variant. Even single-SKU products need one variant carrying the price and currency. Variant external_ids must be unique within the array; duplicates are rejected with validation_failed.
translationsobjectNoMap of ISO 639-1 codes to per-locale overrides. See Translations.
created_atISO 8601Returned onlyCreation timestamp (UTC).
updated_atISO 8601Returned onlyLast modification timestamp (UTC).

Brand

FieldTypeRequiredDescription
namestringYesBrand display name.
domainstringNoBrand's canonical web domain (no protocol). Used for deduplication and assistant context.

Image

FieldTypeRequiredDescription
urlstringYesPublicly reachable HTTPS URL. Humind fetches and caches the image; ensure the URL is stable.
altstringNoAlt text for accessibility and the assistant's understanding of the image.

Variant

FieldTypeRequiredDescription
external_idstringYesYour unique variant identifier (e.g. SKU). Unique within the product.
titlestringNoVariant label (e.g. "50 ml", "Black / Medium"). Optional — for single-SKU products you can omit it and the variant inherits the product title. Required in practice as soon as a product carries more than one variant, otherwise shoppers can't tell them apart.
skustringNoStock-keeping unit. Surface to shoppers when relevant.
pricenumberYesSelling price as a decimal with at most 2 fractional digits (e.g. 29.90). Must be ≥ 0 and ≤ 1,000,000,000. Values with more than 2 explicit fractional digits (e.g. 29.999) are rejected with validation_failed. Floating-point artefacts that round to ≤2 fractional digits (e.g. 0.300000000000000040.3) are accepted and stored as the rounded value, so retrieving the product back returns the cleaned-up price.
compare_at_pricenumberNoStrike-through "before" price for discounts. Same format as price. Must be strictly greater than price if set — compare_at_price <= price returns validation_failed.
currencystringYesISO 4217 code (e.g. EUR, USD, GBP). Validated against the live ISO 4217 list — unknown codes are rejected with validation_failed.
available_for_salebooleanNoDefaults to true.
inventory_quantityintegerNoStock count. Omit to mark as untracked.
regional_pricingobjectNoMap of ISO 3166-1 alpha-2 country codes to {currency, price, compare_at_price?} for region-specific pricing. See example below.
cart_actionobjectNoWhat happens when the assistant offers an "Add to cart" CTA for this variant. Defaults to { "type": "noop" } when omitted. See Cart action.

Cart action

cart_action is a discriminated union — pick the type that matches your storefront.

type: 'redirect' — send the shopper to a URL

FieldTypeRequiredDescription
type'redirect'YesSend the shopper to url.
urlstringYesThe URL to send the shopper to (typically online_store_url with a variant query param, but anything reachable works).

type: 'noop' — show the variant without a CTA

FieldTypeRequiredDescription
type'noop'YesSurface the variant in chat without an actionable CTA (useful when checkout is gated, e.g. B2B).

type: 'prestashop' — native PrestaShop AJAX add-to-cart

For PrestaShop 1.7+ / 8 stores, push native id_product and id_product_attribute so the widget can POST directly to your /cart endpoint same-origin (no CORS, no new tab, native cart drawer updates inline).

FieldTypeRequiredDescription
type'prestashop'YesNative PrestaShop add-to-cart.
id_productintegerYesPrestaShop's id_product.
id_product_attributeintegerYesPrestaShop's id_product_attribute for the variant. Use 0 for products without combinations.
product_urlstringYesThe product page URL. Used as a fallback redirect target when window.prestashop is unavailable on the visitor's page.

Auto-derive

If your company is configured with catalogIntegrationType: prestashop, you can omit cart_action entirely on every variant — the API auto-derives the prestashop action from your external_id (product) and external_id (variant) fields. Push it explicitly only when you want to override the derivation for specific variants.

Why PrestaShop is allowed but not Shopify/WooCommerce

PrestaShop's id_product and id_product_attribute are public — visible in HTML markup, URLs, and data attributes. Pushing them via the API doesn't leak any secret. By contrast, Shopify GIDs and WooCommerce variation IDs are platform-private and only ever set by our internal adapters, never accepted on the public surface.

Translations

translations is a map of language codes to per-locale field overrides:

json
{
  "default_language": "fr",
  "title": "Crème hydratante",
  "description": "Une crème légère pour le visage.",
  "handle": "creme-hydratante",
  "translations": {
    "en": {
      "title": "Hydrating cream",
      "description": "A lightweight face cream.",
      "handle": "hydrating-cream"
    }
  }
}

Per-locale fields you can override:

FieldType
titlestring
descriptionstring
description_htmlstring
handlestring
online_store_urlstring
ingredientsstring[]

Keys not present in translations fall back to the top-level (default_language) value.

You can patch one locale at a time without resending the full product via the dedicated Translations sub-resource.

HTML content

Several fields accept HTML, most notably description_html, plus content_html on translations. Humind sanitizes HTML server-side before storing it. The sanitizer applies a fixed allowlist:

  • Tags preserved: p, a, br, hr, em, strong, b, i, u, ul, ol, li, h1h6, blockquote, pre, code, table, thead, tbody, tr, th, td, img, span, div.
  • Tags stripped: script, iframe, style, object, embed, form, input, and any other tag not in the list above.
  • Attributes stripped: event handlers (onclick, onerror, …), style, and any javascript: URL.
  • Attributes preserved: href on <a> (only https, http, mailto, and valid data: URIs), src, alt, width, height on <img>, plus a limited set of class values.

HTML is sanitized on input

Don't rely on the API to round-trip your raw HTML. Tags like <script> or attributes like onclick are stripped silently, your stored markup may be a strict subset of what you sent. Read back the resource after a write if you need to see what was kept.


Create or upsert

POST /products creates a new product, or updates an existing one if the external_id already exists for your company.

Required scope: catalog:write

Request

bash
curl -X POST https://api.thehumind.com/public/v1/products \
  -H "Authorization: Bearer hmd_live_..." \
  -H "Idempotency-Key: 3f1a8d92-7c4b-4e6f-b2a1-d5e9c8f7a3b4" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "SKU-123",
    "title": "Crème hydratante",
    "handle": "creme-hydratante",
    "description": "Une crème légère pour le visage.",
    "status": "active",
    "default_language": "fr",
    "online_store_url": "https://example.com/products/creme-hydratante",
    "brand": { "name": "Acme Skincare", "domain": "acme-skincare.com" },
    "categories": ["Soin", "Hydratation"],
    "images": [
      { "url": "https://cdn.example.com/creme.jpg", "alt": "Crème hydratante 50ml" }
    ],
    "variants": [
      {
        "external_id": "SKU-123-50ML",
        "title": "50 ml",
        "sku": "SKU-123-50ML",
        "price": 29.90,
        "compare_at_price": 34.90,
        "currency": "EUR",
        "available_for_sale": true,
        "inventory_quantity": 42,
        "regional_pricing": {
          "US": { "currency": "USD", "price": 32.00 }
        },
        "cart_action": {
          "type": "redirect",
          "url": "https://example.com/products/creme-hydratante?variant=50ml"
        }
      }
    ],
    "translations": {
      "en": {
        "title": "Hydrating cream",
        "description": "A lightweight face cream.",
        "handle": "hydrating-cream"
      }
    }
  }'

Response 201 Created (new product) or 200 OK (existing product updated)

json
{
  "external_id": "SKU-123",
  "humind_id": "65f1ab9c8e7d4a2b1c3d4e5f",
  "title": "Crème hydratante",
  "handle": "creme-hydratante",
  "description": "Une crème légère pour le visage.",
  "status": "active",
  "default_language": "fr",
  "online_store_url": "https://example.com/products/creme-hydratante",
  "available_for_sale": true,
  "brand": { "name": "Acme Skincare", "domain": "acme-skincare.com" },
  "categories": ["Soin", "Hydratation"],
  "images": [
    { "url": "https://cdn.example.com/creme.jpg", "alt": "Crème hydratante 50ml" }
  ],
  "variants": [
    {
      "external_id": "SKU-123-50ML",
      "title": "50 ml",
      "sku": "SKU-123-50ML",
      "price": 29.90,
      "compare_at_price": 34.90,
      "currency": "EUR",
      "available_for_sale": true,
      "inventory_quantity": 42,
      "regional_pricing": {
        "US": { "currency": "USD", "price": 32.00 }
      },
      "cart_action": {
        "type": "redirect",
        "url": "https://example.com/products/creme-hydratante?variant=50ml"
      }
    }
  ],
  "translations": {
    "en": {
      "title": "Hydrating cream",
      "description": "A lightweight face cream.",
      "handle": "hydrating-cream"
    }
  },
  "created_at": "2026-04-25T14:30:00Z",
  "updated_at": "2026-04-25T14:30:00Z"
}

200 vs 201

A 201 Created means this is a new product (first time the API saw this external_id). A 200 OK means the product already existed and was updated in place. Both are success.


List products

GET /products returns the products that belong to the company the API key is bound to.

Required scope: catalog:read

Query parameterTypeDescription
cursorstringOpaque cursor returned as next_cursor in the previous page. Omit on the first call.
limitintegerItems per page (1100). Defaults to 50.
handlestringFilter to a single product by exact handle match.
status'active' | 'draft' | 'archived'Filter by status. Soft-deleted products live under archived.

Cursor pagination

List endpoints return up to limit items plus a next_cursor you can pass to fetch the following page. When next_cursor is null, you've reached the end. The cursor is opaque — don't parse it; the format may change in a future version.

Request

bash
curl https://api.thehumind.com/public/v1/products \
  -H "Authorization: Bearer hmd_live_..."

To filter by handle:

bash
curl "https://api.thehumind.com/public/v1/products?handle=creme-hydratante" \
  -H "Authorization: Bearer hmd_live_..."

Response 200 OK

json
{
  "data": [
    {
      "external_id": "SKU-123",
      "humind_id": "65f1ab9c8e7d4a2b1c3d4e5f",
      "title": "Crème hydratante",
      "handle": "creme-hydratante",
      "status": "active",
      "default_language": "fr",
      "variants": [
        {
          "external_id": "SKU-123-50ML",
          "title": "50 ml",
          "price": 29.90,
          "currency": "EUR"
        }
      ],
      "created_at": "2026-04-25T14:30:00Z",
      "updated_at": "2026-04-25T14:30:00Z"
    }
  ]
}

Retrieve a product

GET /products/{id} returns a single product. The {id} accepts either form documented in Identifiers.

Required scope: catalog:read

Request, by external_id

bash
curl https://api.thehumind.com/public/v1/products/api:SKU-123 \
  -H "Authorization: Bearer hmd_live_..."

Request, by humind_id

bash
curl https://api.thehumind.com/public/v1/products/65f1ab9c8e7d4a2b1c3d4e5f \
  -H "Authorization: Bearer hmd_live_..."

Response 200 OK

Same shape as the create response.


Replace a product

PUT /products/{id} performs a full replace: the request body becomes the entire product. Fields you don't include are reset to their defaults (or unset where applicable).

Use this when you have a complete representation of the product on your side and want to mirror it exactly. For partial updates, use PATCH instead.

Required scope: catalog:write

Request

bash
curl -X PUT https://api.thehumind.com/public/v1/products/api:SKU-123 \
  -H "Authorization: Bearer hmd_live_..." \
  -H "Idempotency-Key: 7a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "SKU-123",
    "title": "Crème hydratante (édition limitée)",
    "handle": "creme-hydratante",
    "status": "active",
    "default_language": "fr",
    "variants": [
      { "external_id": "SKU-123-50ML", "title": "50 ml", "price": 32.00, "currency": "EUR" }
    ]
  }'

Response 200 OK

Returns the full product object, same shape as the create response.

Replace is destructive

PUT removes any field, variant, image, or translation not present in the body. If you want to bump a single variant's price, use PATCH instead.


Update a product

PATCH /products/{id} performs a partial update: only the fields present in the body are modified. Everything else stays as it was.

Required scope: catalog:write

Request, bump a single variant's price

bash
curl -X PATCH https://api.thehumind.com/public/v1/products/api:SKU-123 \
  -H "Authorization: Bearer hmd_live_..." \
  -H "Idempotency-Key: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d" \
  -H "Content-Type: application/json" \
  -d '{
    "variants": [
      { "external_id": "SKU-123-50ML", "price": 32.00 }
    ]
  }'

When you send a variants array in PATCH, each entry is matched on external_id and merged with the existing variant of the same external ID. Variants you don't list are left untouched. To remove a variant, use PUT (full replace) without that variant in the array.

Response 200 OK

Returns the full updated product object.


Archive (or delete) a product

DELETE /products/{id} performs a soft delete by default: the product's status is set to archived, available_for_sale flips to false, and the assistant stops surfacing the product. The record itself stays so you can restore it later by PATCH-ing the status back to active.

Pass ?force=true to hard delete the product instead — the document is removed from the database after the archive cascade runs. There is no un-delete; only use this when you really want the SKU gone (e.g. test catalog cleanup, or a SKU that was created by mistake).

Required scope: catalog:write

Query parameters

NameTypeRequiredDescription
forcebooleanNoTruthy values (any case): true, 1, yes, on. Falsy values: false, 0, no, off, or omitted. Anything else returns 400 validation_failed. When truthy, permanently removes the product. Default is soft delete.

Request — soft delete (default)

bash
curl -X DELETE https://api.thehumind.com/public/v1/products/api:SKU-123 \
  -H "Authorization: Bearer hmd_live_..." \
  -H "Idempotency-Key: 1a2b3c4d-5e6f-4789-9abc-def012345678"

Request — hard delete

bash
curl -X DELETE 'https://api.thehumind.com/public/v1/products/api:SKU-123?force=true' \
  -H "Authorization: Bearer hmd_live_..." \
  -H "Idempotency-Key: 1a2b3c4d-5e6f-4789-9abc-def012345678"

Response 204 No Content

No body.

Soft vs. hard delete

Soft delete is recommended for normal merchant operations: chat conversations that referenced the product stay coherent, you can un-archive in one PATCH, and downstream caches (vector index, dashboards) tear down cleanly via the archive cascade.

Hard delete (?force=true) is for catalog cleanup — typically test or sandbox products you want fully gone. Soft delete first triggers the archive routine; ?force=true then permanently removes the resource. Collections that referenced the product simply skip the missing reference; we don't cascade further.


Batch upsert

POST /products/batch accepts up to 500 products in a single call and returns a 207 Multi-Status response with one entry per item. Use this for the initial backfill of a catalog or any bulk update, it's far faster than 500 sequential POSTs.

Required scope: catalog:write

ConstraintValue
Max items per batch500
Max body size5 MB
BehaviourEach item is processed independently; one failure doesn't block the rest.

Request

bash
curl -X POST https://api.thehumind.com/public/v1/products/batch \
  -H "Authorization: Bearer hmd_live_..." \
  -H "Idempotency-Key: 4d3c2b1a-9f8e-4d7c-6b5a-4f3e2d1c0b9a" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      {
        "external_id": "SKU-123",
        "title": "Crème hydratante",
        "handle": "creme-hydratante",
        "default_language": "fr",
        "variants": [
          { "external_id": "SKU-123-50ML", "title": "50 ml", "price": 29.90, "currency": "EUR" }
        ]
      },
      {
        "external_id": "SKU-456",
        "title": "Sérum éclat",
        "handle": "serum-eclat",
        "default_language": "fr",
        "variants": [
          { "external_id": "SKU-456-30ML", "title": "30 ml", "price": 49.00, "currency": "EUR" }
        ]
      },
      {
        "external_id": "SKU-789",
        "title": "Bad data",
        "default_language": "fr"
      }
    ]
  }'

Response 207 Multi-Status

json
{
  "results": [
    {
      "external_id": "SKU-123",
      "status": "created",
      "humind_id": "65f1ab9c8e7d4a2b1c3d4e5f"
    },
    {
      "external_id": "SKU-456",
      "status": "updated",
      "humind_id": "65f1cd9e8e7d4a2b1c3d4e60"
    },
    {
      "external_id": "SKU-789",
      "status": "failed",
      "error": {
        "code": "validation_failed",
        "message": "Invalid product payload.",
        "details": {
          "issues": [
            { "path": ["handle"], "message": "Required", "code": "invalid_type" }
          ]
        }
      }
    }
  ]
}
FieldTypeDescription
results[i].external_idstringThe external_id of the input item, echoed for matching.
results[i].statusstringOne of created, updated, failed.
results[i].humind_idstringPresent on success — the assigned 24-hex Humind id.
results[i].errorobjectPresent on failure. Same shape as a top-level error object.

Duplicate external_id within a batch

If the same external_id appears more than once in items, the first occurrence is processed and later duplicates come back with status: "failed" and code duplicate_external_id_in_batch. Deduplicate client-side before sending.

Wrapper or bare array

The body may be a JSON array as shown above, or wrapped: { "items": [ ... ] }. The API normalises both to the same results response.

Idempotency on batches

The Idempotency-Key applies to the whole batch. A retry of the same batch with the same key replays the entire 207 response, no items are processed twice. Generate a new UUID for a different batch.


Translations sub-resource

For products that already exist, you can patch a single locale without resending the full payload. Use the embedded translations field on POST / PUT / PATCH for the initial seed, then drive ongoing updates through these endpoints.

{lang} is a BCP 47 short tag, en, fr, de, pt-BR, zh-CN, etc. The validation regex is ^[a-z]{2}(-[A-Z]{2})?$. An invalid value returns 422 with code: invalid_lang_format.

Set or update a translation

PUT /products/{id}/translations/{lang} upserts the translation for a single locale. Fields you omit are left untouched on an update; on first write, they default to unset.

Required scope: catalog:write

FieldTypeDescription
titlestringLocalized display title.
descriptionstringLocalized plain-text description.
description_htmlstringLocalized HTML description. Sanitized, see HTML content.
handlestringLocalized URL-safe slug.
online_store_urlstringLocalized public URL on your storefront.
ingredientsstring[]Localized ingredients list.

All fields are optional individually, but the body must contain at least one. An empty body returns 400 validation_failed.

Request

bash
curl -X PUT https://api.thehumind.com/public/v1/products/api:SKU-123/translations/en \
  -H "Authorization: Bearer hmd_live_..." \
  -H "Idempotency-Key: 5f6a7b8c-9d0e-4f1a-2b3c-4d5e6f7a8b9c" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Hydrating cream",
    "description": "A lightweight face cream.",
    "handle": "hydrating-cream",
    "online_store_url": "https://example.com/en/products/hydrating-cream"
  }'

Response 200 OK

Returns the full product object, the same shape as the create response, so you can see every locale currently stored, not just the one you wrote.

Remove a translation

DELETE /products/{id}/translations/{lang} removes the translation for a single locale. Idempotent: deleting a locale that doesn't exist is a no-op, not an error.

Required scope: catalog:write

Request

bash
curl -X DELETE https://api.thehumind.com/public/v1/products/api:SKU-123/translations/en \
  -H "Authorization: Bearer hmd_live_..." \
  -H "Idempotency-Key: 6a7b8c9d-0e1f-4a2b-3c4d-5e6f7a8b9c0d"

Response 204 No Content

No body.


Variants sub-resource

For products that already exist, you can manage a single variant without resending the full product. The variant is identified by its external_id in the URL path.

{variant_external_id} is the same identifier you set on the variant's external_id field, typically your SKU.

Set or update a variant

PUT /products/{id}/variants/{variant_external_id} upserts a single variant. The body matches the Variant schema, without external_id: that value is taken from the URL.

Required scope: catalog:write

Request

bash
curl -X PUT https://api.thehumind.com/public/v1/products/api:SKU-123/variants/SKU-123-50ML \
  -H "Authorization: Bearer hmd_live_..." \
  -H "Idempotency-Key: 7b8c9d0e-1f2a-4b3c-4d5e-6f7a8b9c0d1e" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "50 ml",
    "sku": "SKU-123-50ML",
    "price": 29.90,
    "compare_at_price": 34.90,
    "currency": "EUR",
    "available_for_sale": true,
    "inventory_quantity": 42
  }'

Response 200 OK

Returns the full product object so you can see the updated variant alongside the rest of the product.

Remove a variant

DELETE /products/{id}/variants/{variant_external_id} removes a single variant from the product. Idempotent: deleting a variant that doesn't exist is a no-op, not an error.

Required scope: catalog:write

A product needs at least one variant

You can't remove the last variant of a product, that would leave it without a price or currency. To fully retire a product, archive it instead.

Request

bash
curl -X DELETE https://api.thehumind.com/public/v1/products/api:SKU-123/variants/SKU-123-50ML \
  -H "Authorization: Bearer hmd_live_..." \
  -H "Idempotency-Key: 8c9d0e1f-2a3b-4c4d-5e6f-7a8b9c0d1e2f"

Response 204 No Content

No body.


Common errors

The product endpoints can return any of the standard HTTP statuses, but these are the ones you'll see most often:

StatusCodeWhenFix
401missing_credentials, invalid_key_format, invalid_key, revokedAuth header is missing, malformed, or the key is no longer active.See Authentication.
403insufficient_scopeKey lacks catalog:read (for GET) or catalog:write (for POST/PUT/PATCH/DELETE).Create a new key with the right scope.
404not_foundThe {id} doesn't match a product owned by your company.Confirm the external_id or humind_id. Cross-tenant lookups also return 404.
404variant_not_foundThe {variant_external_id} on a variant sub-resource call doesn't match any variant on this product.Confirm the variant's external_id. DELETE is idempotent, see notes there before retrying.
409idempotency_conflictSame Idempotency-Key reused with a different body.Generate a fresh UUID.
400validation_failedBody fails the Product schema. details points to the bad field.Fix the field and resend.
422invalid_lang_formatThe {lang} segment of a translation sub-resource URL doesn't match ^[a-z]{2}(-[A-Z]{2})?$.Use a BCP 47 short tag like en, fr, pt-BR.

Next

Released under the proprietary Humind license.