Supplier Catalog API
Push your product catalogue to Olfactorian and keep it in sync from your own systems. Your prices, packs and stock, live on your storefront — and matched into formulas perfumers are already writing.
Introduction
This API is for suppliers — you sell perfumery materials and you want your catalogue on Olfactorian without maintaining it by hand. You push products; we render them on your storefront and link them to the materials perfumers use.
It is a write API over your own catalogue. It is not the formulas API: if you are building an app that reads published formulas and the public material catalog, you want the Formulas & Materials API instead.
What you can do
Create and update products, set their packs and prices, mark stock, archive what you no longer sell, and read a feed of every change made to your catalogue — including changes made by your own team in the workspace.
Products are archived, never deleted
There is no delete. Archiving takes a product off your storefront and stops its links resolving, while the history of what people clicked stays intact — so your own reporting does not develop holes when you retire a line. Archiving is reversible by sending the product again.
Credentials
Credentials are self-serve. An owner of your workspace creates them under Settings → API credentials. You get a pair:
| Parameter | Type | Description |
|---|---|---|
client_id | string | Identifies your integration. Safe to log. |
client_secret | string | Shown once, at creation. We store only a hash of it, so it cannot be recovered or shown again — save it when you create it. |
Rotating a credential
Create the new one, move your integration onto it, then disable the old one. That order matters: disabling takes effect immediately, so disabling first takes your integration down.
Limits
Up to 5 active credentials per workspace. Disabling one frees a slot straight away, so create one per integration rather than one per deployment. Both catalog scopes are granted automatically; they are not selectable.
Disabling is immediate. A disabled credential stops working on the very next call, not when its token would have expired — so it is a real kill switch if one leaks.
Authentication
Exchange your credential for a short-lived access token, then send that token as a bearer token. Tokens last one hour; mint on demand and cache in memory.
/oauth/tokenThe token endpoint is standard OAuth 2.0, and two things about it differ from the rest of this reference: it lives at /oauth/token — not under /api/v1 — and the body is form-encoded, not JSON.
curl -sS -X POST https://olfactorian.com/oauth/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d grant_type=client_credentials \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET \
-d 'scope=catalog.read catalog.write'{
"access_token": "olf_at_…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "catalog.read catalog.write"
}Scopes
| Parameter | Type | Description |
|---|---|---|
catalog.read | scope | Read your products and your change feed. |
catalog.write | scope | Create, update and archive your products. |
A token only ever carries scopes your credential still holds. If a scope is taken away, tokens minted afterwards do not carry it — and neither does a refreshed one.
Quick start
Token, then one product, then read it back. One thing first: the product URL must be on a domain registered to your workspace — replace your-shop.example below with your own registered domain or the request is refused (the refusal names the domains you can use).
TOKEN=$(curl -sS -X POST https://olfactorian.com/oauth/token \
-d grant_type=client_credentials -d client_id="$CLIENT_ID" \
-d client_secret="$CLIENT_SECRET" -d 'scope=catalog.read catalog.write' \
| jq -r .access_token)
curl -sS -X PUT https://olfactorian.com/api/v1/supplier/products/AC-1042 \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"title": "Iso E Super",
"url": "https://your-shop.example/products/iso-e-super",
"status": "active",
"variants": [
{ "title": "10 ml", "price": 6.50, "currency": "EUR", "ml": 10, "available": true },
{ "title": "50 ml", "price": 24.00, "currency": "EUR", "ml": 50, "available": true }
]
}'
curl -sS https://olfactorian.com/api/v1/supplier/products/AC-1042 \
-H "Authorization: Bearer $TOKEN"Requests & responses
Everything is JSON, and every response is uncached. You identify each product by your own id — the external_id in the path is whatever your systems already call it (a SKU, a product id). We never ask you to store an id of ours.
Successful responses carry a data object. Writes also report what happened, as action: inserted, updated or unchanged.
Replaying is safe
A product is identified by your external_id, so sending the same request twice converges on the same result. There is no idempotency key to manage. A nightly feed that changed nothing is cheap and leaves your catalogue alone.
The receipt is honest about packs too: a write that changes only a price or a pack list reports updated, and replaying the identical request converges back to unchanged.
Errors
Errors carry a stable code and a human message. Match on the code, not the message. There are two envelope shapes: the token endpoint answers in the flat OAuth 2.0 form the RFC prescribes, and every catalogue endpoint nests under error.
{
"error": {
"code": "invalid_request",
"message": "The payload failed validation.",
"issues": [ { "field": "url", "message": "url must be absolute https on: your-shop.example" } ]
}
}| Parameter | Type | Description |
|---|---|---|
invalid_request | 400 | The payload or a parameter is malformed. Fix and retry — retrying unchanged will not help. |
missing_or_malformed_authorization_header | 401 | No usable Authorization header. Send "Authorization: Bearer <token>". |
invalid_token | 401 | Expired or revoked token. Mint a new one; if it fails again, your credential may be disabled. |
insufficient_scope | 403 | Your credential does not carry the scope this call needs. |
not_found | 404 | No such product in your catalogue. Note that updating something that does not exist is a 404 — create it first. |
invalid_request | 413 | A bulk body over 50 MB. Split the feed. |
invalid_request | 422 | The payload failed validation. The response names what failed, per record for a bulk upload. |
rate_limit_exceeded | 429 | Slow down. Honour Retry-After and back off. |
internal_error | 500 | An unexpected failure on our side, in the same envelope. Retry with backoff. |
The token endpoint's own codes are the OAuth standard set — invalid_request, invalid_client, unauthorized_client, unsupported_grant_type, invalid_scope — in the flat shape above.
When a write fails validation, nothing is written. If a request dies mid-flight — a network fault, a timeout — resend it: every write path converges on the same result, so a replay is always the fix.
Pagination
List endpoints are cursor-paged. Pass limit, then follow nextCursor until it comes back null. Do not construct a cursor yourself — treat it as opaque.
curl -sS "https://olfactorian.com/api/v1/supplier/products?limit=250&cursor=$CURSOR" \
-H "Authorization: Bearer $TOKEN"{
"data": [ { "external_id": "AC-1042", "title": "Iso E Super", … } ],
"nextCursor": "U1EwNDc4MjU2"
}Rate limits
| Parameter | Type | Description |
|---|---|---|
Reads | 600 / hour | GET on products and the change feed. |
Writes | 60 / hour | PUT, PATCH, archive, and bulk upload. |
Bulk upload exists so a full catalogue refresh costs one write, not two thousand — use it rather than looping single writes.
A 429 carries Retry-After. Other responses carry no quota headers, so budget your calls rather than probing for remaining allowance.
The product
A product carries your own fields, and a list of packs.
| Parameter | Type | Description |
|---|---|---|
external_idrequired | string | Your id for this product. Set from the path. |
titlerequired | string | The product name as you sell it. |
urlrequired | string | The page on your shop. Must be on a domain you have registered with us. |
status | enum | "active", "draft" or "archived". Defaults to active on create. |
description | string | Plain text or simple markup. |
vendor | string | The manufacturer, when it differs from you. |
product_type | string | Your own category label. |
tags | string[] | Your own tags. |
images | object[] | Product photos, as { src, position }. src must be absolute https. |
handle | string | URL-ish slug. Derived from the title when the product is first created, then stable — send it only to override. |
attributes | object | Your own key-value facts (max 100 keys, 32 KB). PATCH merges key-by-key. The "olfactorian" key is reserved. |
variantsrequired | object[] | The packs you sell — at least 1, at most 100. See below. |
Packs
| Parameter | Type | Description |
|---|---|---|
title | string | The pack label, e.g. "10 ml". Omitted, it is derived from the measure ("5 ml", "250 g"). |
sku | string | Your SKU for this pack. |
price | number | Price in the pack’s own currency. Omit if you do not publish a price. |
currency | string | ISO 4217. Packs may differ — we render each pack in its own currency. |
ml | number | Volume, when the pack is a liquid measure. |
grams | number | Weight, when it is not. A pack is one or the other — sending both is refused. |
available | boolean | In stock. Defaults to true when omitted — send false explicitly or your whole feed reads as in stock. |
external_id | string | Your own id for the pack, if you keep one. |
compare_at_price | number | A was-price, when the pack is discounted. |
inventory_quantity | number | Units on hand, if you publish it. |
position | number | Display order. Defaults to the order you send. |
Reads also return two read-only timestamps: synced_at, the last time any feed or write touched the product, and updated_at, the last time a write landed. Every catalogue write moves both — including one whose receipt says unchanged — while archiving moves only updated_at. Key change detection on the action receipts or the change feed, not on timestamps.
Your shop URL must be on a domain you have registered with us. A URL on any other domain is refused, and the refusal names the domains you can use. Your registered domains are listed in your workspace settings — register additional ones there before sending products that point at them.
List products
/api/v1/supplier/productsscope catalog.read| Parameter | Type | Description |
|---|---|---|
status | string | Filter by status. |
limit | number | Page size. Default 50; above 250 is refused, not clamped. |
cursor | string | From a previous response’s nextCursor. |
Get a product
/api/v1/supplier/products/{external_id}scope catalog.read{
"data": {
"external_id": "AC-1042",
"handle": "iso-e-super",
"title": "Iso E Super",
"description": null,
"vendor": null,
"product_type": null,
"tags": [],
"status": "active",
"url": "https://your-shop.example/products/iso-e-super",
"images": [],
"attributes": {},
"synced_at": "2026-09-01T11:09:17.276+00:00",
"updated_at": "2026-09-01T11:09:17.276+00:00",
"variants": [
{ "sku": null, "title": "10 ml", "price": 6.5, "currency": "EUR", "ml": 10, "available": true, "position": 1 },
{ "sku": null, "title": "50 ml", "price": 24, "currency": "EUR", "ml": 50, "available": true, "position": 2 }
]
}
}Create or replace
/api/v1/supplier/products/{external_id}scope catalog.writePUT replaces the whole product — every field, packs included. The variants array you send is the pack list afterwards — a pack you leave out is removed — and any optional field you omit is cleared too: description, vendor, tags, images, attributes all reset when absent (only the handle survives omission). This is the single most common way to lose data on this API: a full feed must carry the full product. If you mean to change one field, use PATCH.
Returns 201 when the product is new, 200 when it already existed.
Update fields
/api/v1/supplier/products/{external_id}scope catalog.writeFields you send are updated; fields you omit are left alone. The product must already exist — PATCH on an unknown id is a 404 rather than a create.
One field is coarser than the rest: variants. When you send it, the array you send becomes the whole pack list — exactly like PUT. Omit it and your packs are untouched; there is no way to patch a single pack in place.
curl -sS -X PATCH https://olfactorian.com/api/v1/supplier/products/AC-1042 \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "status": "draft" }'Archive
/api/v1/supplier/products/{external_id}/archivescope catalog.writeTakes the product off your storefront and stops its links resolving. Nothing is deleted and your click history stays intact. Send the product again to bring it back. (Setting status to "archived" in a write does the same thing; this endpoint just says what it means.)
{ "data": { "external_id": "AC-1042", "status": "archived", "changed": true } }Bulk upload
/api/v1/supplier/products/bulkscope catalog.writeUp to 2,000 products in one request, applied all-or-nothing: if any record fails validation, nothing is written and the response names every failing record by index. Fix the feed and resend the whole thing — you never have to work out which half landed. Each record is a full product, replacing what is stored, exactly like PUT.
curl -sS -X POST https://olfactorian.com/api/v1/supplier/products/bulk \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "products": [ {
"external_id": "AC-1042",
"title": "Iso E Super",
"url": "https://your-shop.example/p/iso-e",
"variants": [ { "title": "10 ml", "price": 6.50, "currency": "EUR", "ml": 10 } ]
} ] }'{
"data": {
"created": 1, "updated": 0, "unchanged": 0,
"results": [ { "external_id": "AC-1042", "action": "inserted" } ]
}
}Change feed
/api/v1/supplier/eventsscope catalog.readEvery change to your catalogue is recorded and readable here — whether it came from this API, from a CSV import, or from someone on your team editing in the workspace. Poll it to reconcile what changed and when.
| Parameter | Type | Description |
|---|---|---|
actor | "api" | This API, authenticated as one of your credentials. |
actor | "system" | An automated run rather than a person. |
actor | "member" | A signed-in person. |
Event types
| Parameter | Type | Description |
|---|---|---|
import_batch_applied | event | A write batch landed (API, CSV or feed). after carries created / updated / unchanged counts and the changed external_ids. |
product_status_changed | event | The archive endpoint or a workspace status action. A status sent inside a PUT/PATCH/bulk write rides that write’s import_batch_applied instead. before carries the prior per-product statuses; after the new status and external_ids. |
product_field_changed | event | A single field edited in the workspace. before/after carry the field and values. |
Other event types may appear as the platform grows — ignore types you do not recognise rather than failing on them. A batch that changed nothing still emits its event with unchanged counted and an empty external_ids.
{
"data": [ {
"event_type": "import_batch_applied",
"actor": "api",
"before": null,
"after": { "source": "api", "created": 0, "updated": 1, "unchanged": 0, "external_ids": ["AC-1042"], "external_ids_total": 1 },
"created_at": "2026-09-01T11:09:17.533+00:00"
} ],
"nextCursor": "MjAyNi0wOS0…"
}Events never contain personal identifiers — the actor is a label, never a person. Page it with limit and nextCursor like any other list.
Integration checklist
Before you go live:
| Parameter | Type | Description |
|---|---|---|
Token caching | do | Mint on demand, cache in memory under an hour, re-mint on a 401. |
Secret storage | do | Keep the secret out of source control and out of logs. Rotate by creating the new one first. |
Bulk over loops | do | Use bulk upload for a full refresh; single writes for incremental edits. |
Pack lists replace | watch | PUT — and any write that sends variants — replaces the whole pack list. Send every pack, every time. |
Domains | watch | Register a shop domain before sending URLs on it. |
Backoff | do | Respect 429 and retry 5xx with backoff. |