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
| Field | Type | Required | Description |
|---|---|---|---|
external_id | string | Yes (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_id | string | Returned only | 24-char hex ObjectId Humind assigns. Stable for the lifetime of the product. |
title | string | Yes | Display title in default_language. |
description | string | No | Plain-text description. |
description_html | string | No | HTML description. Sanitized server-side, see HTML content. |
handle | string | No | URL-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' | No | Defaults 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' | No | Defaults to active. archived hides the product from the assistant. draft is for staging unpublished products. |
online_store_url | string | No | Public URL of the product on your storefront. The assistant uses this to deep-link shoppers from chat. |
default_language | string | No | BCP 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_sale | boolean | Returned only | Computed 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). |
brand | object | No | See Brand. |
categories | string[] | No | Free-form category names. Used for soft grouping; doesn't have to match Humind collections. |
images | object[] | No | See Image. The first image is the primary one shown in chat. |
variants | object[] | 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. |
translations | object | No | Map of ISO 639-1 codes to per-locale overrides. See Translations. |
created_at | ISO 8601 | Returned only | Creation timestamp (UTC). |
updated_at | ISO 8601 | Returned only | Last modification timestamp (UTC). |
Brand
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Brand display name. |
domain | string | No | Brand's canonical web domain (no protocol). Used for deduplication and assistant context. |
Image
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | Publicly reachable HTTPS URL. Humind fetches and caches the image; ensure the URL is stable. |
alt | string | No | Alt text for accessibility and the assistant's understanding of the image. |
Variant
| Field | Type | Required | Description |
|---|---|---|---|
external_id | string | Yes | Your unique variant identifier (e.g. SKU). Unique within the product. |
title | string | No | Variant 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. |
sku | string | No | Stock-keeping unit. Surface to shoppers when relevant. |
price | number | Yes | Selling 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.30000000000000004 → 0.3) are accepted and stored as the rounded value, so retrieving the product back returns the cleaned-up price. |
compare_at_price | number | No | Strike-through "before" price for discounts. Same format as price. Must be strictly greater than price if set — compare_at_price <= price returns validation_failed. |
currency | string | Yes | ISO 4217 code (e.g. EUR, USD, GBP). Validated against the live ISO 4217 list — unknown codes are rejected with validation_failed. |
available_for_sale | boolean | No | Defaults to true. |
inventory_quantity | integer | No | Stock count. Omit to mark as untracked. |
regional_pricing | object | No | Map of ISO 3166-1 alpha-2 country codes to {currency, price, compare_at_price?} for region-specific pricing. See example below. |
cart_action | object | No | What 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
| Field | Type | Required | Description |
|---|---|---|---|
type | 'redirect' | Yes | Send the shopper to url. |
url | string | Yes | The 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
| Field | Type | Required | Description |
|---|---|---|---|
type | 'noop' | Yes | Surface 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).
| Field | Type | Required | Description |
|---|---|---|---|
type | 'prestashop' | Yes | Native PrestaShop add-to-cart. |
id_product | integer | Yes | PrestaShop's id_product. |
id_product_attribute | integer | Yes | PrestaShop's id_product_attribute for the variant. Use 0 for products without combinations. |
product_url | string | Yes | The 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:
{
"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:
| Field | Type |
|---|---|
title | string |
description | string |
description_html | string |
handle | string |
online_store_url | string |
ingredients | string[] |
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, h1–h6,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 anyjavascript:URL. - Attributes preserved:
hrefon<a>(onlyhttps,http,mailto, and validdata:URIs),src,alt,width,heighton<img>, plus a limited set ofclassvalues.
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
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)
{
"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 parameter | Type | Description |
|---|---|---|
cursor | string | Opaque cursor returned as next_cursor in the previous page. Omit on the first call. |
limit | integer | Items per page (1–100). Defaults to 50. |
handle | string | Filter 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
curl https://api.thehumind.com/public/v1/products \
-H "Authorization: Bearer hmd_live_..."To filter by handle:
curl "https://api.thehumind.com/public/v1/products?handle=creme-hydratante" \
-H "Authorization: Bearer hmd_live_..."Response 200 OK
{
"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
curl https://api.thehumind.com/public/v1/products/api:SKU-123 \
-H "Authorization: Bearer hmd_live_..."Request, by humind_id
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
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
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
| Name | Type | Required | Description |
|---|---|---|---|
force | boolean | No | Truthy 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)
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
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
| Constraint | Value |
|---|---|
| Max items per batch | 500 |
| Max body size | 5 MB |
| Behaviour | Each item is processed independently; one failure doesn't block the rest. |
Request
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
{
"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" }
]
}
}
}
]
}| Field | Type | Description |
|---|---|---|
results[i].external_id | string | The external_id of the input item, echoed for matching. |
results[i].status | string | One of created, updated, failed. |
results[i].humind_id | string | Present on success — the assigned 24-hex Humind id. |
results[i].error | object | Present 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
| Field | Type | Description |
|---|---|---|
title | string | Localized display title. |
description | string | Localized plain-text description. |
description_html | string | Localized HTML description. Sanitized, see HTML content. |
handle | string | Localized URL-safe slug. |
online_store_url | string | Localized public URL on your storefront. |
ingredients | string[] | Localized ingredients list. |
All fields are optional individually, but the body must contain at least one. An empty body returns 400 validation_failed.
Request
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
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
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
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:
| Status | Code | When | Fix |
|---|---|---|---|
401 | missing_credentials, invalid_key_format, invalid_key, revoked | Auth header is missing, malformed, or the key is no longer active. | See Authentication. |
403 | insufficient_scope | Key lacks catalog:read (for GET) or catalog:write (for POST/PUT/PATCH/DELETE). | Create a new key with the right scope. |
404 | not_found | The {id} doesn't match a product owned by your company. | Confirm the external_id or humind_id. Cross-tenant lookups also return 404. |
404 | variant_not_found | The {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. |
409 | idempotency_conflict | Same Idempotency-Key reused with a different body. | Generate a fresh UUID. |
400 | validation_failed | Body fails the Product schema. details points to the bad field. | Fix the field and resend. |
422 | invalid_lang_format | The {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
- Authentication: generate a
catalog:writekey. - Conventions: identifiers, idempotency, dates.
- Errors: full error code reference.