ToldUntold Logo
Catalog

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 same tuk_ 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. A cfk_ token on /v1/client/* returns 401 unauthenticated with 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 optional X-Catalog-Signature HMAC header), the Client API does not use HMAC. Security rests on Bearer credentials over TLS plus idempotency. Do not send X-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_summary object, 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 on 422 validation_failed only: 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_selected and variant_cap_exceeded are HTTP 422 but carry their own machine code (not validation_failed) and have no errors map — 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, default 1.
  • per_page — default 50, 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 whose source_updated_at is 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.

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_active flag — a PIM must see everything it manages, active or not. Use the is_active filters to narrow.
  • is_active is your visibility switch. Archiving is a soft state, not a delete: PATCH /products/{id} with is_active: false archives a product; is_active: true republishes 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. PATCH touches only the fields you send. The single PUT (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 (delta422).

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, quantityreserved).


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/adjust with 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 GET returns.

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 legacy cfk_ 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 a tuk_ 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.

Training. Intelligence. Content Production. Visual Merchandising. AI. One platform built for retail performance.