API
Supplier documentation

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.

Getting started

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.

Getting started

Credentials

Credentials are self-serve. An owner of your workspace creates them under Settings → API credentials. You get a pair:

ParameterTypeDescription
client_idstringIdentifies your integration. Safe to log.
client_secretstringShown 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.

Getting started

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.

POST/oauth/token

The 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
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'
Response
{
  "access_token": "olf_at_…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "catalog.read catalog.write"
}

Scopes

ParameterTypeDescription
catalog.readscopeRead your products and your change feed.
catalog.writescopeCreate, 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.

Getting started

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

cURL
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"
Using the API

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.

Using the API

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" } ]
  }
}
ParameterTypeDescription
invalid_request400The payload or a parameter is malformed. Fix and retry — retrying unchanged will not help.
missing_or_malformed_authorization_header401No usable Authorization header. Send "Authorization: Bearer <token>".
invalid_token401Expired or revoked token. Mint a new one; if it fails again, your credential may be disabled.
insufficient_scope403Your credential does not carry the scope this call needs.
not_found404No such product in your catalogue. Note that updating something that does not exist is a 404 — create it first.
invalid_request413A bulk body over 50 MB. Split the feed.
invalid_request422The payload failed validation. The response names what failed, per record for a bulk upload.
rate_limit_exceeded429Slow down. Honour Retry-After and back off.
internal_error500An 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.

Using the API

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
curl -sS "https://olfactorian.com/api/v1/supplier/products?limit=250&cursor=$CURSOR" \
  -H "Authorization: Bearer $TOKEN"
Response
{
  "data": [ { "external_id": "AC-1042", "title": "Iso E Super", … } ],
  "nextCursor": "U1EwNDc4MjU2"
}
Using the API

Rate limits

ParameterTypeDescription
Reads600 / hourGET on products and the change feed.
Writes60 / hourPUT, 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.

Catalogue

The product

A product carries your own fields, and a list of packs.

ParameterTypeDescription
external_idrequiredstringYour id for this product. Set from the path.
titlerequiredstringThe product name as you sell it.
urlrequiredstringThe page on your shop. Must be on a domain you have registered with us.
statusenum"active", "draft" or "archived". Defaults to active on create.
descriptionstringPlain text or simple markup.
vendorstringThe manufacturer, when it differs from you.
product_typestringYour own category label.
tagsstring[]Your own tags.
imagesobject[]Product photos, as { src, position }. src must be absolute https.
handlestringURL-ish slug. Derived from the title when the product is first created, then stable — send it only to override.
attributesobjectYour own key-value facts (max 100 keys, 32 KB). PATCH merges key-by-key. The "olfactorian" key is reserved.
variantsrequiredobject[]The packs you sell — at least 1, at most 100. See below.

Packs

ParameterTypeDescription
titlestringThe pack label, e.g. "10 ml". Omitted, it is derived from the measure ("5 ml", "250 g").
skustringYour SKU for this pack.
pricenumberPrice in the pack’s own currency. Omit if you do not publish a price.
currencystringISO 4217. Packs may differ — we render each pack in its own currency.
mlnumberVolume, when the pack is a liquid measure.
gramsnumberWeight, when it is not. A pack is one or the other — sending both is refused.
availablebooleanIn stock. Defaults to true when omitted — send false explicitly or your whole feed reads as in stock.
external_idstringYour own id for the pack, if you keep one.
compare_at_pricenumberA was-price, when the pack is discounted.
inventory_quantitynumberUnits on hand, if you publish it.
positionnumberDisplay 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.

Catalogue

List products

GET/api/v1/supplier/productsscope catalog.read
ParameterTypeDescription
statusstringFilter by status.
limitnumberPage size. Default 50; above 250 is refused, not clamped.
cursorstringFrom a previous response’s nextCursor.
Catalogue

Get a product

GET/api/v1/supplier/products/{external_id}scope catalog.read
Response
{
  "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 }
    ]
  }
}
Catalogue

Create or replace

PUT/api/v1/supplier/products/{external_id}scope catalog.write

PUT 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.

Catalogue

Update fields

PATCH/api/v1/supplier/products/{external_id}scope catalog.write

Fields 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
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" }'
Catalogue

Archive

POST/api/v1/supplier/products/{external_id}/archivescope catalog.write

Takes 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.)

Response
{ "data": { "external_id": "AC-1042", "status": "archived", "changed": true } }
Catalogue

Bulk upload

POST/api/v1/supplier/products/bulkscope catalog.write

Up 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
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" } ]
  }
}
Keeping in sync

Change feed

GET/api/v1/supplier/eventsscope catalog.read

Every 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.

ParameterTypeDescription
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

ParameterTypeDescription
import_batch_appliedeventA write batch landed (API, CSV or feed). after carries created / updated / unchanged counts and the changed external_ids.
product_status_changedeventThe 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_changedeventA 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.

Response
{
  "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.

Keeping in sync

Integration checklist

Before you go live:

ParameterTypeDescription
Token cachingdoMint on demand, cache in memory under an hour, re-mint on a 401.
Secret storagedoKeep the secret out of source control and out of logs. Rotate by creating the new one first.
Bulk over loopsdoUse bulk upload for a full refresh; single writes for incremental edits.
Pack lists replacewatchPUT — and any write that sends variants — replaces the whole pack list. Send every pack, every time.
DomainswatchRegister a shop domain before sending URLs on it.
BackoffdoRespect 429 and retry 5xx with backoff.
Supplier Catalog API — Olfactorian