ToldUntold Client API v1 — Integration Guide
On this page
Machine-to-machine REST API for managing a brand's ToldUntold catalog — products, variant axes, variants and stock — from a PIM, ERP or OMS.
Formal contract:
openapi.yaml(OpenAPI 3.1). This guide is the narrative companion; the OpenAPI file is authoritative for exact shapes.
1. Overview & audience
The Client API (/v1/client/catalog/*) is the programmatic surface a brand's
back-office systems use to keep the ToldUntold catalog in sync. It covers:
- Products — create, read (superset detail), partial update, archive.
- Axes — the brand's variant-axis library (e.g.
size,color) and their values. - Product axis selection — which axes/values a product exposes.
- Variants — per-product SKUs, created one-by-one or generated in bulk.
- Stock — locations, absolute-quantity adjustments, the movement ledger, a flat full-sync levels view, and bulk snapshot feeds.
It is designed to feel familiar to anyone who has integrated Shopify Admin or
Stripe: Bearer auth, scoped keys, flat JSON errors with stable machine codes,
page/per_page pagination, and idempotent writes.
Audience: back-end engineers integrating a brand's inventory/product systems with ToldUntold. Everything here is server-to-server; there is no browser/OAuth flow and no end-user login.
Other domains. This guide covers the catalog domain (
/v1/client/catalog/*). The analytics domain (/v1/client/analytics/*) — daily/content/user learning analytics for BI/ERP/HRIS — is documented in the analytics integration guide. Both surfaces share the sametuk_keys, auth, error envelope and rate-limit machinery; a single key can carry catalog and analytics scopes together.
Base URL:
https://api.<environment>.tolduntold.com
Replace <environment> with the value ToldUntold gives you (staging,
production, …). All paths below are relative to this base.
2. Authentication
2.1 Client API keys (tuk_)
Every request authenticates with a Client API key in the Authorization
header:
Authorization: Bearer tuk_<key_id>.<secret>
A key looks like tuk_9f2c1a4b6d8e0f2a4c6e8b0d.<40-char-secret>. The part before
the dot (tuk_<key_id>) is the public key id (safe to log); the part after
the dot is the secret, shown only once at creation and stored only as a
SHA-256 hash on our side. If you lose it, rotate the key (§2.3) — it cannot be
recovered.
Each key carries exactly one brand and a set of scopes (§3). The brand is never sent in the request — it is resolved from the key. This is why there is no brand id in any path.
Feed tokens (
cfk_) are NOT valid here. Acfk_token on/v1/client/*returns401 unauthenticatedwith a message pointing you to the legacy stock feed endpoint. See §12 for the relationship between the two.No HMAC in v1. Unlike the legacy
cfk_stock feed (which supports an optionalX-Catalog-SignatureHMAC header), the Client API does not use HMAC. Security rests on Bearer credentials over TLS plus idempotency. Do not sendX-Catalog-Signature; it is ignored. (Request signing is reserved for future webhooks — see §11.)
2.2 Key lifecycle (super-admin managed)
Client API keys are provisioned by a ToldUntold super-admin, not self-service in v1. Key management lives on ToldUntold's internal admin surface, outside this integrator API — but the semantics you should know are:
- Create — a super-admin issues a key for your brand with a chosen name,
scope set and optional expiry (
expires_at). The plaintext key is returned once at creation. Store it in a secret manager. - List — masked keys only; the secret is never shown again.
- Revoke — sets
revoked_at; the key immediately stops authenticating (401). - Delete — hard-removes the key record.
To obtain, rotate or revoke a key, contact your ToldUntold administrator.
2.3 Rotation
Rotation mints a successor key (same brand, name, scopes and expiry), links
it to its predecessor (rotated_from_id), returns the new plaintext once, and
revokes the old key atomically. Use it for scheduled secret rotation or after a
suspected leak. Because the old key is revoked as part of the same operation,
deploy the new secret before (or immediately after) rotating to avoid a gap.
2.4 Smoke test
The fastest credential check is GET /v1/client/catalog/status — it needs a
valid key but no particular scope, and echoes the resolved brand, key id, name,
granted scopes and expiry:
curl -s https://api.staging.tolduntold.com/v1/client/catalog/status \
-H "Authorization: Bearer tuk_9f2c1a4b6d8e0f2a4c6e8b0d.$SECRET"
{
"brand_id": 8347,
"key_id": "tuk_9f2c1a4b6d8e0f2a4c6e8b0d",
"name": "ERP production",
"scopes": ["catalog.products:read", "catalog.stock:write"],
"expires_at": null
}
3. Scopes
Scopes follow a {domain}.{resource}:{action} grammar and are carried on the
key. There are no wildcards in v1.
| Scope | Grants |
|---|---|
catalog.products:read |
All catalog reads — products, axes, product axis selection, variants. |
catalog.products:write |
Create / update / archive products. |
catalog.axes:write |
Create / update / delete brand axes and their values. |
catalog.variants:write |
Create / update / delete / generate variants and set a product's axis selection. |
catalog.stock:read |
Read stock: locations, levels, movements, per-product stock; gates additive stock fields on product/variant reads (see §3.1). |
catalog.stock:write |
Create / update stock locations; adjust stock quantities. |
catalog.stock:ingest |
Submit bulk stock feeds. |
A key may hold any combination. Choose the minimum a given system needs (an ERP
that only pushes stock might hold just catalog.stock:ingest + catalog.stock:read).
3.1 The stock:read gate on product reads
catalog.products:read returns products and variants including the merchandising
availability.status (in_stock / low_stock / out_of_stock / stale). The
operational quantities are gated on catalog.stock:read:
- the product
stock_summaryobject, and - each
variants[].availability.quantity
are only present when the key also carries catalog.stock:read. Without it,
stock_summary is omitted and availability.quantity is stripped (the status
stays). Plan your key scopes accordingly.
3.2 ANY-OF scopes
Most endpoints require exactly one scope. A few accept any one of several (ANY-OF). The only ANY-OF endpoint in v1 is the stock feed report:
GET /v1/client/catalog/stock/feeds/{feed_id}
readable by a key holding either catalog.stock:read or
catalog.stock:ingest — so an ingest-only key can post a feed and read its own
report without also holding the read scope.
A missing scope is 403 insufficient_scope; the message lists the scope(s) the
endpoint accepts.
4. Errors
All errors use a flat envelope:
{ "code": "validation_failed", "message": "The given data was invalid.", "errors": { "sku": ["The sku field is required."] } }
code— a stable, machine-readable string. Branch your logic on this.message— human-readable; may change, do not parse it.errors— present on422 validation_failedonly: a map of field → messages.
This is additive to the standard {message, errors} shape that cfk_ integrators
already parse: the message/errors keys keep their meaning, and code is new.
4.1 Error code catalogue
| HTTP | code |
Meaning |
|---|---|---|
| 400 | idempotency_key_required |
A required Idempotency-Key header was omitted (product create, variant create/generate, stock adjust). |
| 401 | unauthenticated |
Missing, malformed, expired or revoked key — or a cfk_ feed token presented here. |
| 403 | insufficient_scope |
Valid key, but it lacks the required scope. |
| 404 | not_found |
The resource does not exist or belongs to another brand. Also: writing the __default__ location. |
| 405 | method_not_allowed |
The HTTP method is not allowed on this path. |
| 409 | duplicate_sku |
The SKU is already taken by a product or a variant of the brand. |
| 409 | duplicate_axis_code |
An axis with this code already exists for the brand. |
| 409 | duplicate_axis_value |
A value with this code already exists on the axis. |
| 409 | axis_in_use |
Deleting an axis attached to a product / used by a variant, or detaching an axis a live variant uses. |
| 409 | value_in_use |
Deleting a value used by a variant or selected by a product. |
| 409 | conflict |
A variant axis combination already exists; a SKU race during generate; a duplicate stock location; a revoked-key rotate. |
| 409 | stock_version_conflict |
expected_quantity did not match the current level. Carries current_quantity. |
| 409 | rejected_stale |
A stock adjust was rejected on a conflicting timestamp; no change was made. |
| 409 | idempotency_key_reuse |
The Idempotency-Key was already used with a different request body. |
| 409 | idempotency_conflict |
A request with this Idempotency-Key is currently in flight. |
| 422 | validation_failed |
Body/query validation failed. Carries errors. |
| 422 | no_axes_selected |
generate was called on a product with no axis selection. |
| 422 | variant_cap_exceeded |
The generate expansion exceeds 500 combinations. |
| 429 | rate_limited |
A rate limit was hit. Carries Retry-After and X-RateLimit-* headers. |
| 500 | server_error |
Unexpected server error (opaque; never leaks internals). |
no_axes_selectedandvariant_cap_exceededare HTTP422but carry their own machine code (notvalidation_failed) and have noerrorsmap — they are semantic pre-conditions, not field validation.
5. Pagination, sorting & filtering
5.1 Lists
List endpoints return an envelope:
{
"data": [ /* items */ ],
"meta": { "current_page": 1, "per_page": 50, "total": 137, "last_page": 3 }
}
Single-object endpoints return a flat JSON body (no data/meta).
page— 1-based, default1.per_page— default50, hard cap 200. A larger value is silently clamped to 200 (not rejected).
(The stock feed report's lines block keeps its own page/per_page, default
100, cap 500.)
5.2 Sorting
Product lists accept sort= with a comma-separated list of fields; a leading -
means descending. Allowed fields: sku, created_at, updated_at. Unknown
fields are ignored; the default order is -updated_at.
GET /v1/client/catalog/products?sort=-updated_at,sku
Other lists use a stable deterministic order (documented per endpoint in the
OpenAPI file); sort= is wired on products in v1.
5.3 Filtering & incremental sync
Every list supports updated_since for incremental polling:
GET /products?updated_since=<iso8601>— products changed at/after the instant.GET /stock/levels?updated_since=<iso8601>— levels whosesource_updated_atis at/after the instant.
Other filters per resource: products by sku, is_active, has_variants;
variants by is_active, sku; axes and locations by is_active; stock levels by
sku and location; per-product stock and movements by location and variant.
6. Idempotency
Mutating requests may carry an Idempotency-Key header (a client-generated
token, max 191 chars):
Idempotency-Key: 2026-07-08T09:00Z-create-tee-classic
Semantics:
- Replay — a completed request replayed with the same key and identical
body returns the original response verbatim, plus
Idempotency-Replayed: true. - Reuse conflict — the same key with a different body is
409 idempotency_key_reuse. - In-flight conflict — the same key while the first request is still running
is
409 idempotency_conflict. - Retention — settled keys are retained for 24 hours, after which the key may be reused. A failed (non-2xx) request releases its key immediately, so you can safely retry with the same key.
6.1 Required vs. recommended
Idempotency-Key is REQUIRED on the creations whose duplicates cannot be
caught by a database constraint (a post-timeout retry would otherwise forge a
real duplicate). Omitting it there returns 400 idempotency_key_required:
| Endpoint | Idempotency-Key |
|---|---|
POST /products |
Required |
POST /products/{id}/variants |
Required |
POST /products/{id}/variants/generate |
Required |
POST /products/{id}/stock/adjust |
Required |
PATCH /products/{id} |
Optional (honored) |
POST /axes, POST /axes/{code}/values |
Optional (honored) |
POST /stock/locations |
Optional (honored) |
Other PATCH / DELETE / PUT |
Not processed — header ignored (naturally re-runnable) |
On the "optional (honored)" endpoints a sent key is replayed like a required one;
sending it is recommended for any write you might retry. On the remaining
PATCH/DELETE/PUT endpoints there is no idempotency layer — the operation is
naturally re-runnable, so an Idempotency-Key header is simply ignored (never an
error).
6.2 Stock feeds use source_batch_id, not this mechanism
POST /stock/feeds does not use the generic idempotency middleware. It keeps
the existing feed batch mechanism: the Idempotency-Key header is required and
folds into source_batch_id. Re-posting the same batch id returns the existing
feed unchanged (200). A missing header there is a 422 validation_failed
(the source_batch_id field is required) — not a 400. See §9.9.
7. Rate limits
Two limiters apply, per minute:
| Bucket | Limit | Notes |
|---|---|---|
| Per-key reads | 300 / min | GET/HEAD. |
| Per-key writes | 60 / min | POST/PATCH/PUT/DELETE. |
| Per-brand aggregate | 600 / min | Across all keys of the brand, all methods. |
Every response carries the headers of the most-constrained bucket applying to the call:
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 297
X-RateLimit-Reset: 1751965200
X-RateLimit-Reset is a Unix epoch (seconds). When a limit is exceeded you get
429 rate_limited with the same X-RateLimit-* trio (remaining 0) plus
Retry-After (seconds). Back off until then.
8. Management-API semantics
This is a management API, not a storefront read API. Two consequences:
- Inactive resources are exposed. Products, axes, values, variants and
locations are returned regardless of their
is_activeflag — a PIM must see everything it manages, active or not. Use theis_activefilters to narrow. is_activeis your visibility switch. Archiving is a soft state, not a delete:PATCH /products/{id}withis_active: falsearchives a product;is_active: truerepublishes it. Variants and locations behave the same. Only variants have a real (soft)DELETE; products are archived, never deleted, in v1.- PATCH is partial, PUT is declarative.
PATCHtouches only the fields you send. The singlePUT(product axis selection) is a full declarative replacement — send the complete desired set; anything omitted is removed.
9. Resource walkthrough (end-to-end)
A full brand-onboarding flow with curl. Set once:
BASE=https://api.staging.tolduntold.com/v1/client/catalog
KEY="tuk_9f2c1a4b6d8e0f2a4c6e8b0d.$SECRET"
auth=(-H "Authorization: Bearer $KEY" -H "Content-Type: application/json")
9.1 Create a product
Idempotency-Key required. is_active defaults to false; sku is required and
brand-unique (against products and variants).
curl -s "$BASE/products" "${auth[@]}" \
-H "Idempotency-Key: onboard-tee-classic-1" \
-d '{
"sku": "TEE-CLASSIC",
"title": "Classic Tee",
"type": "simple",
"price_value": 29.90,
"price_currency": "EUR",
"is_active": true,
"default_language": "en-US",
"versions": { "fr-FR": { "title": "Tee Classique" } }
}'
201 returns the superset detail: core product fields plus has_variants,
price_range, availability, axes, variants, and (with catalog.stock:read)
stock_summary. Capture its tu_id:
PID=$(curl -s "$BASE/products?sku=TEE-CLASSIC" "${auth[@]}" | jq -r '.data[0].tu_id')
9.2 Create axes and values
curl -s "$BASE/axes" "${auth[@]}" -d '{
"code": "size", "label": "Size",
"values": [ {"value":"S","label":"Small"}, {"value":"M","label":"Medium"}, {"value":"L","label":"Large"} ]
}'
curl -s "$BASE/axes/size/values" "${auth[@]}" -d '{ "value": "XL", "label": "Extra Large" }'
code and value are URL-safe and immutable ([a-zA-Z0-9._-]+). A duplicate
is 409 duplicate_axis_code / duplicate_axis_value.
9.3 Select axes on the product (declarative PUT)
Set which axes/values the product exposes. Array order = axis position.
curl -s -X PUT "$BASE/products/$PID/axes" "${auth[@]}" -d '{
"axes": [ { "code": "size", "values": ["S","M","L"] } ]
}'
200 returns the effective selection. An empty axes: [] detaches everything.
Unknown or inactive codes/values → 422; detaching an axis a live variant still
uses → 409 axis_in_use.
9.4 Generate variants (cartesian)
Preview first with dry_run, then create. Idempotency-Key required.
curl -s "$BASE/products/$PID/variants/generate" "${auth[@]}" \
-H "Idempotency-Key: gen-tee-1" \
-d '{ "dry_run": true }'
# → { "total_combinations": 3, "would_create": 3, "skipped_existing": 0 }
curl -s "$BASE/products/$PID/variants/generate" "${auth[@]}" \
-H "Idempotency-Key: gen-tee-1-commit" \
-d '{ "sku_template": "{sku}-{size}", "activate": true }'
201 returns { total_combinations, created, skipped_existing, variants: [...] }.
sku_template supports {sku} (product SKU/tu_id) and {axis_code} placeholders;
existing combinations are skipped. More than 500 combinations →
422 variant_cap_exceeded; no axes selected → 422 no_axes_selected.
Create a single variant instead:
curl -s "$BASE/products/$PID/variants" "${auth[@]}" \
-H "Idempotency-Key: var-tee-xl-1" \
-d '{ "sku": "TEE-CLASSIC-XL", "axis_values": { "size": "XL" }, "price": { "value": 32.00 } }'
axis_values maps each selected axis code to one value. A SKU clash →
409 duplicate_sku; an existing combination → 409 conflict. axis_values is
immutable on PATCH (recreate to change it); price.value: null on PATCH
clears the override so the variant inherits the product price.
9.5 Create a stock location
curl -s "$BASE/stock/locations" "${auth[@]}" \
-d '{ "external_code": "store-paris", "name": "Paris Flagship" }'
external_code is required, URL-safe, brand-unique and immutable. It is the
address used in PATCH /stock/locations/{external_code}. The reserved
__default__ sentinel cannot be created (422) or written (404).
9.6 Push a stock feed (bulk snapshot)
Requires catalog.stock:ingest. The Idempotency-Key header is the batch id.
curl -s "$BASE/stock/feeds" "${auth[@]}" \
-H "Idempotency-Key: 2026-07-08T09:00Z-batch-001" \
-d '{
"source": "erp-eu",
"mode": "snapshot",
"lines": [
{ "sku": "TEE-CLASSIC-S", "location_code": "store-paris", "quantity": 12, "reserved": 2 },
{ "sku": "TEE-CLASSIC-M", "location_code": "store-paris", "quantity": 7 }
]
}'
202 acknowledges and queues processing:
{ "feed_id": "tuf_5e6f7a8b", "status": "queued", "received_lines": 2,
"report_url": "/v1/client/catalog/stock/feeds/tuf_5e6f7a8b" }
Re-posting the same Idempotency-Key returns the existing feed (200). Only
snapshot mode is supported in v1 (delta → 422).
9.7 Read the feed report
Readable with stock:read or stock:ingest:
curl -s "$BASE/stock/feeds/tuf_5e6f7a8b" "${auth[@]}"
{
"feed_id": "tuf_5e6f7a8b", "source": "erp-eu", "status": "completed",
"totals": { "applied": 2 }, "report_url": "/v1/client/catalog/stock/feeds/tuf_5e6f7a8b",
"lines": { "data": [ { "sku": "TEE-CLASSIC-S", "location_code": "store-paris", "result": "applied", "message": null } ] }
}
9.8 Adjust stock (absolute set, optimistic lock)
Requires catalog.stock:write. Idempotency-Key required. quantity is the
absolute value to set; reserved is preserved and not writable. Pass
expected_quantity for an optimistic-lock guard.
# variant is required for a variant-parent product; omit/null for a simple one.
curl -s "$BASE/products/$PID/stock/adjust" "${auth[@]}" \
-H "Idempotency-Key: adj-2026-07-08-1" \
-d '{ "variant": "tuv_1a2b3c4d", "location": "store-paris", "quantity": 42, "expected_quantity": 40 }'
200 returns { level, movement }. If the level changed since you read it →
409 stock_version_conflict carrying current_quantity. A null location
targets the __default__ sentinel (auto-created).
9.9 Read stock back
# Flat, cross-product full-sync view (paginated, filterable, updated_since).
curl -s "$BASE/stock/levels?location=store-paris" "${auth[@]}"
# Per-product view: variants × locations + product-level rows + aggregate availability.
curl -s "$BASE/products/$PID/stock" "${auth[@]}"
# Movement ledger, newest first.
curl -s "$BASE/products/$PID/stock/movements" "${auth[@]}"
Each level carries quantity, reserved, and computed available = max(0,
quantity − reserved).
10. Consistency notes
- 404 over 403 for cross-brand. Any resource outside your key's brand is a
flat
404 not_found— you can never distinguish "does not exist" from "belongs to another brand". There is no cross-brand access. - No implicit resource creation on reads. Reads never mutate.
stock/adjustwith a null location is the one place a__default__location is auto-created. - Superset reads. Product reads return the full ToldUntold superset (core
fields untouched, plus the additive keys). A product you just wrote comes back
in the exact shape a subsequent
GETreturns.
11. Out of scope in v1 & roadmap
Not available in v1 — do not build against these; they are documented so you can plan. Fields marked reserved will be additive (safe to ignore now):
| Area | Status in v1 |
|---|---|
| Product media | Not settable. Roadmap. |
| Categories | Not settable. Roadmap. |
| Markets / audiences | Not settable. Roadmap. |
Product DELETE |
Not available — archive with is_active: false. |
| Upsert by SKU | Roadmap: PUT /products/by-sku/{sku} (v1.1). |
| Webhooks | Reserved. Will use HMAC request signing (the signature scheme not used in v1). |
| Cursor pagination | Reserved. v1 uses page/per_page. |
| Availability per market | Reserved additive field availability_by_market; today availability is a global aggregate. |
| Wildcard scopes | Not supported; enumerate scopes explicitly. |
| POS attachment on locations | Read-only (pointofsale_tu_id); not writable in v1. |
Stock delta feeds |
Not supported; send full snapshot feeds. |
Because errors are a stable {code} contract and reads are additive supersets,
future additions will not break a v1 integration that ignores unknown fields.
12. Relationship with the legacy cfk_ stock feed
Before the Client API, the only machine surface was stock ingestion via
cfk_ feed tokens at POST /v1/catalog/stock/feeds. That path is unchanged and
byte-stable — existing cfk_ integrations keep working exactly as before
(same auth, same body, optional HMAC, same idempotent source_batch_id).
The Client API adds a superset stock feed at
POST /v1/client/catalog/stock/feeds (scope catalog.stock:ingest) with the same
request body, plus everything else (products, axes, variants, adjustments,
reads). New integrations should use tuk_ keys and the Client API; issuance
of new cfk_ tokens is being wound down.
Migration is low-risk: the feed body is identical, so switching a stock push from
cfk_//v1/catalog/stock/feeds to tuk_//v1/client/catalog/stock/feeds is
essentially swapping the token and the URL (and dropping any HMAC header). The
Idempotency-Key = source_batch_id semantics carry over unchanged.
Documented divergence (legacy path). The historical
cfk_documentation stated that feed tokens enforce per-token "abilities"; in practice the legacycfk_middleware does not check abilities. This is a known property of the legacy path and its behaviour is unchanged. The Client API is different: it enforces scopes on every request (§3), so atuk_key genuinely cannot act outside its granted scopes.
Appendix — endpoint quick reference
| Method & path | Scope(s) | Idem-Key |
|---|---|---|
GET /status |
any valid key | – |
GET /products |
products:read |
– |
GET /products/{id} |
products:read |
– |
POST /products |
products:write |
required |
PATCH /products/{id} |
products:write |
optional |
GET /axes |
products:read |
– |
POST /axes |
axes:write |
optional |
PATCH /axes/{code} |
axes:write |
– |
DELETE /axes/{code} |
axes:write |
– |
POST /axes/{code}/values |
axes:write |
optional |
PATCH /axes/{code}/values/{value} |
axes:write |
– |
DELETE /axes/{code}/values/{value} |
axes:write |
– |
GET /products/{id}/axes |
products:read |
– |
PUT /products/{id}/axes |
variants:write |
– |
GET /products/{id}/variants |
products:read |
– |
POST /products/{id}/variants |
variants:write |
required |
POST /products/{id}/variants/generate |
variants:write |
required |
PATCH /variants/{id} |
variants:write |
ignored |
DELETE /variants/{id} |
variants:write |
ignored |
GET /stock/locations |
stock:read |
– |
POST /stock/locations |
stock:write |
optional |
PATCH /stock/locations/{external_code} |
stock:write |
– |
GET /stock/levels |
stock:read |
– |
GET /products/{id}/stock |
stock:read |
– |
GET /products/{id}/stock/movements |
stock:read |
– |
POST /products/{id}/stock/adjust |
stock:write |
required |
POST /stock/feeds |
stock:ingest |
required¹ |
GET /stock/feeds/{id} |
stock:read or stock:ingest |
– |
¹ The feed Idempotency-Key is the source_batch_id (feed mechanism, not the
generic idempotency middleware); a missing header is 422, not 400.