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' | 'unlisted' | No | Defaults to active. archived hides a retired product, draft is for staging unpublished products, and unlisted keeps a product commercially available through its direct URL while excluding it from Humind discovery and recommendations. |
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 or unlisted 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. |
metafields | object[] | No | Custom product metadata. See Metafields. Maximum 250 entries. |
group_memberships | object[] | No | Product families this product belongs to. See Product group memberships. Maximum 20 entries. |
variants | object[] | Yes (1 to 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). |
Commercial availability and Humind discovery are separate
An unlisted product can return available_for_sale: true, remain reachable on the merchant's storefront, and still be excluded from Humind indexing. Humind does not generate a search vector or AI questions for it and does not surface it in search or recommendations.
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. |
Metafields
Use metafields for catalog information that does not belong in the standard product schema, such as travel themes, trip duration, material, care instructions, or an internal ranking.
Each metafield has four required string properties:
| Field | Constraints | Description |
|---|---|---|
namespace | 1 to 255 characters; letters, numbers, _, ., - | Groups related keys. Humind does not impose a business naming convention. Use a stable domain such as travel, erp, or content. |
key | 1 to 255 characters; letters, numbers, _, ., - | Stable identifier within the namespace. We recommend snake_case. |
type | 1 to 255 characters; letters, numbers, _, ., - | Describes how the value should be interpreted, for example single_line_text_field, list.single_line_text_field, or html. |
value | String, maximum 100,000 characters | The stored value. Lists must be encoded as a JSON string. |
The (namespace, key) pair must be unique within each product or variant metafield array. A product or variant accepts at most 250 metafields.
{
"metafields": [
{
"namespace": "travel",
"key": "wish_category_titles",
"type": "list.single_line_text_field",
"value": "[\"Art et culture\",\"Voyage en famille\"]"
},
{
"namespace": "travel",
"key": "marketing_description_key_points",
"type": "html",
"value": "<ul><li>Explore the tea fields</li></ul>"
}
]
}Use type: "html" when the value contains HTML. Humind sanitizes it on input and removes scripts, event handlers, inline styles, and unsafe URLs. Values with any other type are stored as opaque strings.
Metafields are accepted by POST /products, POST /products/batch, PUT /products/{id}, PATCH /products/{id}, PUT /products/{id}/variants/{variant_external_id}, and every product line in an NDJSON import. They are also returned by product read and write responses.
For PATCH /products/{id}, omitting metafields preserves the existing product metafields. Sending the property replaces the complete product metafield array, and sending [] clears it. On the variant sub-resource, the optional metafields property also replaces the complete metafield array of that variant.
Push AI-relevant data at product level, not variant level
Variant metafields are stored and returned, but the AI assistant, search filters, and the dashboard's metafield configuration only read product-level metafields. Anything the assistant should know (composition, usage advice, scent notes, …) must be pushed on the product, even when it also exists on variants.
Activate your metafields for the AI
Pushing a metafield stores it; it does not yet feed the assistant. Each metafield must be activated (AI inclusion, search filter, display) either in the dashboard or programmatically via PUT /settings/metafields, which lets a fully automated integration push data and activate it in the same pipeline.
Metafields can be translated per language: see Translations.
Product group memberships
Use group_memberships to connect the product to a family created through the Product groups API. Create the group before writing products that reference it.
| Field | Type | Required | Description |
|---|---|---|---|
group_external_id | string | Yes | external_id of an existing public API product group. Unique within the product's membership array. |
selected_options | object[] | No | Values selected for this product. Each option can appear once. |
selected_options[].option_external_id | string | Yes | external_id of an option defined on the referenced group. |
selected_options[].value | string | Yes | This product's value for the option, for example Blue or Velvet. |
{
"group_memberships": [
{
"group_external_id": "sofa-model-1",
"selected_options": [
{ "option_external_id": "color", "value": "Blue" },
{ "option_external_id": "material", "value": "Velvet" }
]
}
]
}Omitting group_memberships preserves the existing public API memberships. Sending [] clears them. Memberships managed by Shopify or another internal connector remain untouched in both cases.
Unknown references return 422 product_group_not_found or 422 product_group_option_not_found. The same field and semantics apply to synchronous product endpoints and NDJSON imports.
Related and complementary products
Use related_products and complementary_products to give the assistant merchant-curated product recommendations, mirroring the semantics of Shopify Search & Discovery:
related_products: alternatives to this product ("you may also like").complementary_products: products that go well with this one ("pairs well with"), which the assistant proactively suggests as cross-sell when the shopper shows buying intent.
Both fields are arrays of product external_id references, maximum 25 entries each, unique within the array:
{
"external_id": "eau-de-parfum-belle-50ml",
"related_products": ["eau-de-toilette-belle-100ml"],
"complementary_products": ["creme-corps-belle-200ml", "savon-belle"]
}Reference semantics:
- References point to products of your own catalog by their
external_id. Create order does not matter: an id whose product has not been pushed yet is stored as-is and starts resolving as soon as the referenced product exists. - References to products that are archived, draft, or otherwise not surfaced by Humind resolve to nothing; the assistant never recommends them.
- Self-references are silently ignored.
- Same update semantics as metafields: omitting the property preserves the stored list, sending it replaces the whole list,
[]clears it. Accepted by the synchronous product endpoints and every product line of an NDJSON import.
For Shopify merchants, nothing to push: the recommendations configured in the Search & Discovery app (complementary and related products) are imported automatically on every catalog sync.
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. |
metafields | object[] | No | Custom variant metadata. Same format and limits as product metafields. |
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). |
target | 'product_page' | 'add_to_cart' | No | What opening url does, which decides the CTA label. product_page → the CTA reads "View product" and links straight through, since the add happens on your page. add_to_cart → url is a deep link that fills the cart, and the CTA reads "Add to cart". Omitted means add_to_cart: both are valid and indistinguishable from the URL, so set product_page explicitly rather than let a cart CTA land the shopper on a page with nothing added. See Add to cart. |
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. |
type: 'shopify': native Shopify AJAX add-to-cart
For Shopify stores, the widget posts to the storefront's /cart/add.js endpoint same-origin. Merchants on the Humind Shopify app never need to push this: the app attaches it automatically at sync time. Push it explicitly only for catalogs managed outside the app.
| Field | Type | Required | Description |
|---|---|---|---|
type | 'shopify' | Yes | Native Shopify add-to-cart. |
variant_id | integer | Yes | The numeric (legacy) variant id accepted by /cart/add.js. |
product_url | string | No | Fallback product page URL. When omitted, the widget falls back to the product's online_store_url. |
properties | object | No | Line-item properties attached to the cart line (e.g. engraving text, a source tag). String values only. |
type: 'woocommerce': native WooCommerce add-to-cart
For WooCommerce stores, the widget submits the standard add-to-cart request to the product page same-origin, then refreshes the theme's cart fragments.
| Field | Type | Required | Description |
|---|---|---|---|
type | 'woocommerce' | Yes | Native WooCommerce add-to-cart. |
product_id | integer | Yes | WooCommerce's parent product id. |
variation_id | integer | Yes | The variation's id. Use 0 for products without variations. |
product_url | string | Yes | The product page URL the add-to-cart request is submitted to. |
attributes | object | No | The attribute_* values variable products need to disambiguate sibling variations (e.g. { "pa_color": "red", "pa_size": "M" }). String values only. |
type: 'function': call your storefront's own add-to-cart function
For custom storefronts that expose a JavaScript quick-buy function on window (for example injected via your tag manager), the widget calls it directly so shoppers stay in the conversation. See the Add to cart guide for the function contract.
| Field | Type | Required | Description |
|---|---|---|---|
type | 'function' | Yes | Call a merchant-exposed window function. |
name | string | Yes | The function's name on window (e.g. myQuickBuy). Must be a plain JavaScript identifier (letters, digits, _, $, not starting with a digit, max 64 chars): the widget looks it up as a window property and never evaluates an expression. |
args | object | No | Flat string map passed to the function as its single argument, e.g. { "productId": "SKU123" }. Keys max 64 chars, values max 256 chars. |
product_url | string | Yes | Fallback: when the function is not present on the page (visitor outside an AB test bucket, excluded page, tag manager not loaded), the widget redirects the shopper there instead. |
cart_url | string | No | Your cart or checkout page. When set, the confirmation toast shown after a successful add offers a "Go to checkout" button that opens it. When omitted, the toast has no button: the widget cannot know where your cart lives. |
Auto-derive
If your company is configured with catalogIntegrationType: prestashop, shopify or woocommerce (ask us to flag your account), you can omit cart_action entirely on every variant: the API auto-derives the native action from your external_id (product) and external_id (variant) fields. online_store_url is required on the product for the PrestaShop and WooCommerce derivations. Push cart_action explicitly only when you want to override the derivation for specific variants (for example to add WooCommerce attributes or Shopify properties, which are never auto-derived). An explicit cart_action always wins over the derivation.
These ids are public
PrestaShop, Shopify and WooCommerce product and variant ids are public storefront identifiers: visible in HTML markup, URLs, and data attributes. Pushing them via the API doesn't leak any secret.
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[] |
metafields | object[] |
Keys not present in translations fall back to the top-level (default_language) value.
Translated metafields are per-language overrides matched by (namespace, key): when the assistant serves the product in a given language, an entry in translations.<lang>.metafields replaces the value of the base metafield with the same namespace and key. Base metafields without an override keep their default-language value; a translated entry without a base counterpart is stored but never surfaced. The array follows the same format and limits as product metafields, and only the base list drives metafield activation: no per-language configuration needed.
{
"default_language": "fr",
"metafields": [
{ "namespace": "olfactif", "key": "notes_tete", "type": "list.single_line_text_field", "value": "[\"Bergamote\",\"Mandarine\"]" }
],
"translations": {
"en": {
"metafields": [
{ "namespace": "olfactif", "key": "notes_tete", "type": "list.single_line_text_field", "value": "[\"Bergamot\",\"Mandarin\"]" }
]
}
}
}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, h1toh6,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 to 100). Defaults to 50. |
handle | string | Filter to a single product by exact handle match. |
status | 'active' | 'draft' | 'archived' | 'unlisted' | 'all' | Filter by status. When omitted, returns active, draft, and unlisted products. Use archived for soft-deleted products or all to disable the status filter. |
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. |
metafields | object[] | Per-language metafield overrides, matched by (namespace, key). Same format and semantics as translated metafields. Sending the property replaces the whole per-language list; [] clears it. |
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.