Collections
Collections are named groups of products. The AI assistant uses them to narrow recommendations to a subset of your catalog, "show me bestsellers", "what's in the summer 2026 drop", "anything from the gift guide". Push your merchandising structure once and the assistant will respect it everywhere it surfaces products.
The endpoints here cover create, read, update, replace, archive, and batch operations on collections, including their product memberships and embedded translations.
The Collection object
| Field | Type | Required | Description |
|---|---|---|---|
external_id | string | Yes (on create) | Your unique identifier for this collection. Free-form, must be unique per company. Used as the upsert key, posting again with the same value updates the existing collection. |
humind_id | string | Returned only | 24-char hex ObjectId Humind assigns. Stable for the lifetime of the collection. |
title | string | Yes | Display title in default_language. |
handle | string | No | URL-safe slug, lowercase, hyphen-separated. Unique per company when set — assigning a handle that already belongs to a different collection (matched on a different external_id) returns 409 handle_already_used. Upserts on the same external_id keep their existing handle unchanged. |
description | string | No | Plain-text description. |
description_html | string | No | HTML description. Sanitized server-side, see HTML content. |
status | 'active' | 'archived' | No | Defaults to active. archived hides the collection from the assistant. |
default_language | string | No | ISO 639-1 code for the language of the top-level fields. |
source | 'public-api' | 'shopify' | 'supersmart' | No | Defaults to public-api. Tag the collection with the integration that owns this data — useful when you're pushing data through this API that you also maintain in another system. The same value drives the upsert lookup, so re-posting with the same source + external_id updates the same row. Any other value is rejected with a 400 validation_failed error on source. |
products | object[] | No | Product memberships. See Linking products by external_id. |
translations | object[] | No | Per-locale overrides. See Translations. |
created_at | ISO 8601 | Returned only | Creation timestamp (UTC). |
updated_at | ISO 8601 | Returned only | Last modification timestamp (UTC). |
Product membership
Each entry in products describes one product that belongs to the collection.
| Field | Type | Required | Description |
|---|---|---|---|
external_id | string | Yes | The external_id of an existing product. The server resolves it to the matching humind_id at write time. See Linking products by external_id. |
include_all_variants | boolean | No | Defaults to true. When true, every variant of the product is part of the collection. |
included_variants | string[] | No | Required if include_all_variants is false. List of variant external_ids to include. Variants not listed are excluded from the collection. |
Translations
translations is a list of per-locale overrides keyed by language:
{
"default_language": "fr",
"title": "Nos meilleures ventes",
"description": "Les produits les plus aimés de la saison.",
"translations": [
{
"language": "en",
"title": "Bestsellers",
"description": "Our most-loved products this season."
}
]
}Per-locale fields you can override:
| Field | Type |
|---|---|
title | string |
description | string |
description_html | string |
Locales not listed in translations fall back to the top-level (default_language) value.
You can patch one locale at a time without resending the full collection via the dedicated Translations sub-resource.
HTML content
The description_html field accepts HTML. Humind sanitizes HTML server-side before storing it, using 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 /collections creates a new collection, 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/collections \
-H "Authorization: Bearer hmd_live_..." \
-H "Idempotency-Key: 3f1a8d92-7c4b-4e6f-b2a1-d5e9c8f7a3b4" \
-H "Content-Type: application/json" \
-d '{
"external_id": "col-bestsellers",
"handle": "bestsellers",
"title": "Bestsellers",
"description": "Our most-loved products this season.",
"status": "active",
"default_language": "en",
"products": [
{ "external_id": "SKU-123", "include_all_variants": true },
{ "external_id": "SKU-456", "include_all_variants": false, "included_variants": ["SKU-456-A"] }
],
"translations": [
{
"language": "fr",
"title": "Nos meilleures ventes",
"description": "Les produits les plus aimés de la saison."
}
]
}'Response 201 Created (new collection) or 200 OK (existing collection updated)
{
"external_id": "col-bestsellers",
"humind_id": "65f1ab9c8e7d4a2b1c3d4e5f",
"handle": "bestsellers",
"title": "Bestsellers",
"description": "Our most-loved products this season.",
"status": "active",
"default_language": "en",
"products": [
{ "external_id": "SKU-123", "include_all_variants": true },
{ "external_id": "SKU-456", "include_all_variants": false, "included_variants": ["SKU-456-A"] }
],
"translations": [
{
"language": "fr",
"title": "Nos meilleures ventes",
"description": "Les produits les plus aimés de la saison."
}
],
"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 collection (first time the API saw this external_id). A 200 OK means the collection already existed and was updated in place. Both are success.
List collections
GET /collections returns the collections 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 collection by exact handle match. |
Request
curl https://api.thehumind.com/public/v1/collections?limit=50 \
-H "Authorization: Bearer hmd_live_..."To fetch the next page:
curl "https://api.thehumind.com/public/v1/collections?limit=50&cursor=eyJpZCI6IjY1ZjEuLi4ifQ" \
-H "Authorization: Bearer hmd_live_..."Response 200 OK
{
"data": [
{
"external_id": "col-bestsellers",
"humind_id": "65f1ab9c8e7d4a2b1c3d4e5f",
"handle": "bestsellers",
"title": "Bestsellers",
"status": "active",
"default_language": "en",
"products": [
{ "external_id": "SKU-123", "include_all_variants": true }
],
"created_at": "2026-04-25T14:30:00Z",
"updated_at": "2026-04-25T14:30:00Z"
}
],
"next_cursor": "eyJpZCI6IjY1ZjFjZDllOGU3ZDRhMmIxYzNkNGU2MCJ9"
}When next_cursor is null or missing, you've reached the last page.
Retrieve a collection
GET /collections/{id} returns a single collection. The {id} accepts either form documented in Identifiers.
Required scope: catalog:read
Request, by external_id
curl https://api.thehumind.com/public/v1/collections/api:col-bestsellers \
-H "Authorization: Bearer hmd_live_..."Request, by humind_id
curl https://api.thehumind.com/public/v1/collections/65f1ab9c8e7d4a2b1c3d4e5f \
-H "Authorization: Bearer hmd_live_..."Response 200 OK
Same shape as the create response.
Replace a collection
PUT /collections/{id} performs a full replace: the request body becomes the entire collection. Fields you don't include are reset to their defaults (or unset where applicable). The products array you send replaces the current membership in full.
Use this when you have a complete representation of the collection 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/collections/api:col-bestsellers \
-H "Authorization: Bearer hmd_live_..." \
-H "Idempotency-Key: 7a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d" \
-H "Content-Type: application/json" \
-d '{
"external_id": "col-bestsellers",
"title": "Bestsellers, April 2026",
"handle": "bestsellers",
"status": "active",
"default_language": "en",
"products": [
{ "external_id": "SKU-123", "include_all_variants": true },
{ "external_id": "SKU-789", "include_all_variants": true }
]
}'Response 200 OK
Returns the full collection object, same shape as the create response.
Replace is destructive
PUT removes any field, product membership, or translation not present in the body. To add or remove a single product, use PATCH instead.
Update a collection
PATCH /collections/{id} performs a partial update: only the fields present in the body are modified. Everything else stays as it was.
When you send a products array in PATCH, it replaces the membership list as a whole, there's no per-entry merge. To add a product, send the existing list plus the new entry. To remove one, send the existing list minus that entry.
Required scope: catalog:write
Request, rename without touching membership
curl -X PATCH https://api.thehumind.com/public/v1/collections/api:col-bestsellers \
-H "Authorization: Bearer hmd_live_..." \
-H "Idempotency-Key: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d" \
-H "Content-Type: application/json" \
-d '{
"title": "Bestsellers, April 2026"
}'Request, add a product to the collection
curl -X PATCH https://api.thehumind.com/public/v1/collections/api:col-bestsellers \
-H "Authorization: Bearer hmd_live_..." \
-H "Idempotency-Key: 2c3d4e5f-6789-4abc-9def-0123456789ab" \
-H "Content-Type: application/json" \
-d '{
"products": [
{ "external_id": "SKU-123", "include_all_variants": true },
{ "external_id": "SKU-456", "include_all_variants": false, "included_variants": ["SKU-456-A"] },
{ "external_id": "SKU-999", "include_all_variants": true }
]
}'Response 200 OK
Returns the full updated collection object.
Archive (or delete) a collection
DELETE /collections/{id} performs a soft delete by default: the collection's status is set to archived and the assistant stops using it for recommendations. 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 collection 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 collection gone (e.g. test catalog cleanup, or a collection 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 collection. Default is soft delete. |
Request — soft delete (default)
curl -X DELETE https://api.thehumind.com/public/v1/collections/api:bestsellers \
-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/collections/api:bestsellers?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: the products inside the collection stay intact, you can un-archive in one PATCH, and chat history that grounded recommendations on this collection stays coherent.
Hard delete (?force=true) is for catalog cleanup — typically a test or sandbox collection you want fully gone. Soft delete first triggers the archive routine; ?force=true then permanently removes the resource. Products that were grouped by this collection are not affected; only the grouping itself disappears.
Batch upsert
POST /collections/batch accepts up to 500 collections in a single call and returns a 207 Multi-Status response with one entry per item. Use this for the initial sync of your merchandising structure or any bulk update.
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/collections/batch \
-H "Authorization: Bearer hmd_live_..." \
-H "Idempotency-Key: 4d3c2b1a-9f8e-4d7c-6b5a-4f3e2d1c0b9a" \
-H "Content-Type: application/json" \
-d '{
"items": [
{
"external_id": "col-bestsellers",
"title": "Bestsellers",
"default_language": "en",
"products": [
{ "external_id": "SKU-123", "include_all_variants": true }
]
},
{
"external_id": "col-new-arrivals",
"title": "New arrivals",
"default_language": "en",
"products": [
{ "external_id": "SKU-456", "include_all_variants": true }
]
},
{
"external_id": "col-bad",
"default_language": "en"
}
]
}'Response 207 Multi-Status
{
"results": [
{
"external_id": "col-bestsellers",
"status": "created",
"humind_id": "65f1ab9c8e7d4a2b1c3d4e5f"
},
{
"external_id": "col-new-arrivals",
"status": "updated",
"humind_id": "65f1cd9e8e7d4a2b1c3d4e60",
"unresolved_products": ["SKU-456"]
},
{
"external_id": "col-bad",
"status": "failed",
"error": {
"code": "validation_failed",
"message": "Invalid collection payload.",
"details": {
"issues": [
{ "path": ["title"], "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].unresolved_products | string[] | Optional. Lists product external_ids that didn't match any product owned by your company at write time. Not an error. See below. |
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.
Linking products by external_id
When you push a collection, each entry in products references a product by your external_id, not by humind_id. The server resolves the reference at write time:
- If the
external_idmatches a product owned by your company, the membership is recorded with the correspondinghumind_idunder the hood. - If the
external_iddoes not match any product, the membership is still recorded, but the unmatchedexternal_idis reported back inunresolved_productson the response. This is a warning, not an error: the request succeeds.
The collection automatically picks up unresolved products as soon as you push them. You don't need to re-PATCH the collection, the next time you create the missing product, Humind links it back to the collection.
Push products before collections
The cleanest order is: push your products first, then your collections. That avoids unresolved_products entirely. If you can't (e.g. you're rebuilding both at once), it's still safe, collections and products converge once both sides are in.
The single-resource endpoints (POST, PUT, PATCH /collections/{id}) follow the same rule. The response body includes unresolved_products: [...] whenever any entry didn't resolve, top-level alongside the collection object:
{
"external_id": "col-bestsellers",
"humind_id": "65f1ab9c8e7d4a2b1c3d4e5f",
"products": [
{ "external_id": "SKU-123", "include_all_variants": true },
{ "external_id": "SKU-DOES-NOT-EXIST-YET", "include_all_variants": true }
],
"unresolved_products": ["SKU-DOES-NOT-EXIST-YET"]
}Translations sub-resource
For collections 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 /collections/{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. |
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/collections/api:col-bestsellers/translations/fr \
-H "Authorization: Bearer hmd_live_..." \
-H "Idempotency-Key: 5f6a7b8c-9d0e-4f1a-2b3c-4d5e6f7a8b9c" \
-H "Content-Type: application/json" \
-d '{
"title": "Nos meilleures ventes",
"description": "Les produits les plus aimés de la saison."
}'Response 200 OK
Returns the full collection 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 /collections/{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/collections/api:col-bestsellers/translations/fr \
-H "Authorization: Bearer hmd_live_..." \
-H "Idempotency-Key: 6a7b8c9d-0e1f-4a2b-3c4d-5e6f7a8b9c0d"Response 204 No Content
No body.
Common errors
The collection 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 collection owned by your company. | Confirm the external_id or humind_id. Cross-tenant lookups also return 404. |
409 | idempotency_conflict | Same Idempotency-Key reused with a different body. | Generate a fresh UUID. |
409 | handle_already_used | The submitted handle is already used by another collection in this company (matched on a different external_id). | Pick a unique handle, or PATCH the existing collection on its external_id instead of creating a new one. |
400 | validation_failed | Body fails the Collection 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. |
unresolved_products is not an error
You'll see this field on 200/201/207 responses when at least one entry in products[] couldn't be matched to an existing product. It's a heads-up, not a failure, the rest of the request was applied normally and Humind links the missing references the moment those products show up.