Olfactorian API
Read the community’s published perfume formulas and the material catalog from a partner application. The open workspace for perfumery, programmatically: GitHub-style access to formulas, for partner apps.
Introduction
Olfactorian is the open workspace for perfumery — a place where perfumers and enthusiasts publish, fork, and refine fragrance formulas, backed by a curated catalog of raw materials. The Olfactorian API opens that same library to partner applications. If Olfactorian is the GitHub of perfumery, the API lets your product be the IDE: browse, search, and import community formulas and material data without sending your users away.
This reference is written for a partner engineer integrating from scratch. It is complete — every endpoint, field, and error you can encounter in v1 is documented here. If you just want to see it work, jump to the quick start; otherwise, read core concepts first to understand how the data is shaped.
- Base URL — all endpoints live under
https://olfactorian.com/api/v1. - JSON over HTTPS — every response is
application/jsonwith camelCase fields. - OAuth 2.0, per user — every request carries an access token obtained on behalf of a connected Olfactorian user.
- Read-only in v1 — the API exposes
GETendpoints only. Write-back and webhooks are on the roadmap.
Security & trust
Opening an API raises a fair question — does this put anyone’s formulas at risk? The short answer is no. The API is read-only, it exposes only the work people have deliberately published, and an app only ever acts for users who personally approved it. Here is exactly how that holds.
- 1The app asks to connectIt sends the user to Olfactorian with a one-time PKCE challenge — a scrambled secret only the app can later unlock.identified by client_id
- 2The user approvesThey sign in to their own Olfactorian account and approve the exact scopes on our consent screen. No approval, no access.per-user consent
- 3Olfactorian returns a one-time codeDelivered only to the app’s pre-approved address, and useless to anyone who intercepts it without the original PKCE secret.PKCE-protected
- 4The app exchanges it for a tokenThe app’s server proves itself with its client_secret and the PKCE secret, and receives an access token stamped to that one user.scoped · expires hourly
- 5The app reads — only what is allowedEach call carries the token; Olfactorian returns only published formulas and public materials, for that user, within their granted scopes.read-only
- ✓Read published formulas — names, notes, full ingredient breakdowns
- ✓Read the shared, public material catalog
- ✓Search and page, for the users who connected it
- ✕See a user’s private or unpublished formulas
- ✕Create, edit, publish, or delete anything
- ✕Get a user’s Olfactorian password or session
- ✕Exceed the scopes a user approved
Prefer the precise mechanics? See authentication for the token lifecycle and core concepts for the published-versus-private boundary.
Core concepts
A short tour of the vocabulary used throughout this reference. Five ideas are worth internalizing before you write any code — they explain how the data is shaped and how to reliably join it to whatever your own application already stores.
Two resources
The API exposes exactly two kinds of object, each with a list endpoint and a detail endpoint:
- Formulas — a perfume recipe: a named composition of materials, each with a dilution and an amount, plus its contributors, tags, and cover image. This is the unit a user creates and publishes on Olfactorian.
- Materials — a single raw ingredient (an aroma chemical, a natural extract, or a base): its name, CAS number, odor profile, and synonyms. Formulas are built out of materials; the material catalog is the shared reference both sides draw from.
“Published” is the visibility boundary
The API only ever returns published formulas. Olfactorian users keep most of their work in a private workspace; publishing is the deliberate act of sharing a formula with the community. A formula that is private, in draft, or unpublished simply does not exist as far as the API is concerned — requesting one returns 404, never 403, so the API never leaks the existence of private work. The material catalog, by contrast, is public reference data and is always available with the materials.read scope.
publicId is a stable identifier
Every published formula has a publicId (for example 100-Hot-Chocolate) — a stable, URL-safe handle that does not change when the author edits the name, summary, or ingredients. Use it as the foreign key when you store a reference to an Olfactorian formula; do not key off the display name, which is mutable and not unique. Materials are identified the same way, by a stable id slug (for example vanillin).
Ingredient identity is how you match your own inventory
Each ingredient inside a formula carries two identifiers so you can reconcile it against the materials your application already knows about:
olfactorianMaterialId— the canonical material id within Olfactorian. If you also read the materials catalog, this is the exact key to join on, and you can fetch the material’s full profile fromGET /materials/{id}.cas— the CAS Registry Number, a globally recognized chemical identifier. Prefer matching oncaswhen you reconcile against a third-party or supplier inventory that does not use Olfactorian ids. Note that CAS numbers can benullfor proprietary bases and some naturals, so treat identity as “CAS if present, otherwiseolfactorianMaterialId.”
Amounts and dilutions
Within a formula, amountGrams is the weight of the diluted material added to the batch and dilutionPct is the strength of that material in solution (100 means neat / undiluted). Together they let you compute the contribution of the pure ingredient. Amounts are the author’s own figures, reproduced faithfully; when a formula omits a material’s dilution, the API reports 100 (neat).
Quick start
Connect a user with OAuth 2.0 (Authorization Code + PKCE), then call the API with the access token. The four steps below are a complete “Connect with Olfactorian” login. You’ll need a registered client_id / client_secret and a pre-registered redirect_uri (HTTPS).
1 · Create a PKCE pair and send the user to consent
Generate a one-time code verifier and its S256 challenge, then redirect the user’s browser to the authorize endpoint. Keep the verifier (and the state) for the callback.
import crypto from 'node:crypto';
// one-time, per login attempt — store verifier + state in the session
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
const state = crypto.randomBytes(16).toString('hex');
const url = new URL('https://olfactorian.com/oauth/authorize');
url.search = new URLSearchParams({
response_type: 'code',
client_id: 'YOUR_CLIENT_ID',
redirect_uri: 'https://yourapp.com/callback',
scope: 'formulas.read materials.read',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
}).toString();
// redirect the user's browser to `url` — they sign in to Olfactorian and approve.
res.redirect(url.toString());2 · Exchange the authorization code for tokens
Olfactorian redirects back to your redirect_uri with ?code and state. Verify the state, then exchange the code from your server.
// on GET https://yourapp.com/callback?code=...&state=...
const r = await fetch('https://olfactorian.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code, // from the query string
client_id: 'YOUR_CLIENT_ID',
client_secret: 'YOUR_CLIENT_SECRET',
redirect_uri: 'https://yourapp.com/callback',
code_verifier: verifier, // the one from step 1
}),
});
const token = await r.json();
// { access_token: 'olf_at_...', token_type: 'Bearer',
// expires_in: 3600, refresh_token: 'olf_rt_...',
// scope: 'formulas.read materials.read' }3 · Call the API as the connected user
const formulas = await fetch(
'https://olfactorian.com/api/v1/formulas?q=rose&limit=10',
{ headers: { Authorization: `Bearer ${token.access_token}` } },
).then((r) => r.json());4 · Refresh when the access token expires
Access tokens last one hour. Rotate with the refresh token — refresh tokens are single-use, so always store the new one returned to you.
curl -X POST https://olfactorian.com/oauth/token \
-d grant_type=refresh_token \
-d refresh_token=$REFRESH_TOKEN \
-d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRETWant to see the whole flow run live? The API playground drives the real consent screen, token exchange, and endpoints end-to-end.
Making requests
The API follows a small set of conventions consistently, so once you have made one request you have effectively made them all.
| Parameter | Type | Description |
|---|---|---|
Base URL | https | All resource paths are relative to https://olfactorian.com/api/v1. HTTPS is mandatory; plain HTTP requests are refused. |
Authorization | header | Every request carries Authorization: Bearer <access_token>. There are no unauthenticated endpoints and no API keys in query strings. |
Format | json | Responses are application/json encoded as UTF-8. All field names are camelCase; timestamps are ISO 8601 strings in UTC (e.g. 2025-07-12T11:19:51.000Z). |
Methods | GET | v1 is read-only, so every endpoint is a GET. Reads are safe and idempotent: repeating a request has no side effects, which makes retries on network errors free of risk. |
Identifiers | string | Path identifiers (publicId, material id) are URL-safe slugs. URL-encode them only if you build paths from untrusted input. |
A minimal request looks like this — a bearer token and a path is all you ever need:
const res = await fetch('https://olfactorian.com/api/v1/formulas?q=rose', {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) {
const { error } = await res.json(); // { code, message }
throw new Error(`${res.status} ${error.code}: ${error.message}`);
}
const { data, nextCursor } = await res.json();Authentication
Olfactorian is the OAuth 2.0 authorization server; your app is the client. We issue our own opaque, revocable tokens — we never expose a user’s Olfactorian session. PKCE (S256) is required, and v1 clients are confidential (they hold a client_secret). The full browser-to-token walkthrough is in the quick start; this section is the specification.
Authorization endpoints
| Parameter | Type | Description |
|---|---|---|
GET /oauth/authorize | redirect | Consent screen. The user signs in to Olfactorian and approves the requested scopes. |
POST /oauth/token | form | Exchange an authorization code, or rotate a refresh token, for an access token. |
POST /oauth/revoke | form | Revoke a token (and its family). Disconnects the integration immediately. |
Scopes
Request only the scopes you need at the authorize step, space-separated. If an endpoint requires a scope the token does not hold, the call fails with 403 insufficient_scope rather than returning partial data.
| Parameter | Type | Description |
|---|---|---|
formulas.read | scope | Read published formulas: list, search, and retrieve full detail. |
materials.read | scope | Read the material catalog: list, search, and retrieve a material. |
Token lifecycle
The token endpoint returns a short-lived access token and a long-lived refresh token. Send the access token on every API request as a bearer token in the Authorization header:
Authorization: Bearer olf_at_0k7aD7xcRG5fN5NYuz6DO2zKqgpL6Dor95QNidQhbFs- Access tokens last ~1 hour (
expires_inis returned in seconds). When one expires, calls return401 unauthorized; mint a fresh one with the refresh token rather than restarting the consent flow. - Refresh tokens rotate and are single-use. Each
grant_type=refresh_tokenexchange returns a new refresh token and invalidates the one you presented. Persist the new token atomically every time, replacing the old value. - Reuse revokes the whole family. If a previously-used (or stolen) refresh token is presented again, Olfactorian treats it as a compromise signal and revokes every token descended from that authorization. The user will have to reconnect. Never run two refreshers concurrently against the same token.
Revoking access
When a user disconnects your integration, revoke their tokens with POST /oauth/revoke. Revoking either token tears down the entire family immediately; the call is idempotent and returns 200 even for an already-revoked token.
curl -X POST https://olfactorian.com/oauth/revoke \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d token=$REFRESH_TOKEN \
-d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRETPagination
The collection endpoints — GET /formulas and GET /materials — are cursor-paginated. Each response contains a data array and a nextCursor. To read the following page, repeat the request with that value in the cursor query parameter. When there are no more results, nextCursor is null.
Following nextCursor walks the complete result set for a query. A single query paginates up to a generous upper bound; to work through a very large result set, narrow it with q or tag rather than relying on deep pagination.
| Parameter | Type | Description |
|---|---|---|
limit | integer | Page size, 1 to 100 (default 25). Controls how many items each page returns. |
cursor | string | Opaque token from the previous response. Treat it as a black box: do not parse, construct, or persist it across sessions. It encodes position only and may expire. |
nextCursor | string | null | Returned in the body. Pass it back as cursor to get the next page; null means you have reached the end. |
Cursor pagination is stable under inserts — you will not skip or double-read items if the catalog changes while you page. To walk an entire result set, loop until nextCursor is null:
async function listAllFormulas(accessToken, query = {}) {
const all = [];
let cursor = null;
do {
const params = new URLSearchParams({ ...query, limit: '100' });
if (cursor) params.set('cursor', cursor);
const res = await fetch(
`https://olfactorian.com/api/v1/formulas?${params}`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const page = await res.json(); // { data, nextCursor }
all.push(...page.data);
cursor = page.nextCursor; // null on the last page → loop ends
} while (cursor);
return all;
}Rate limits
Requests are rate-limited per connected user, not per client — one user’s heavy usage never starves another’s budget. Every response carries the current state of the budget in three headers, so you can throttle proactively rather than waiting to be rejected.
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 599
X-RateLimit-Reset: 1781467200| Parameter | Type | Description |
|---|---|---|
X-RateLimit-Limit | integer | Maximum requests allowed in the current window. |
X-RateLimit-Remaining | integer | Requests remaining in the current window. Slow down as this approaches 0. |
X-RateLimit-Reset | integer | Unix epoch seconds at which the window resets and Remaining returns to Limit. |
Exceeding the budget returns 429 rate_limit_exceeded with a Retry-After header (in seconds). Wait at least that long before retrying — see handling errors for a backoff pattern.
Errors
Every error response uses the same envelope and a conventional HTTP status code. The body always contains a single error object with a machine-readable code and a human-readable message — branch on code, surface message only to developers, and use the HTTP status for coarse handling.
{
"error": {
"code": "insufficient_scope",
"message": "Scope formulas.read is required"
}
}| Parameter | Type | Description |
|---|---|---|
400 | invalid_request | Malformed parameter, cursor, or id (e.g. limit out of the 1 to 100 range, or a corrupted cursor). |
401 | unauthorized | Missing, invalid, expired, or revoked access token. Refresh the token, then retry. |
403 | insufficient_scope | The token is valid but lacks the scope this endpoint requires. Re-authorize with the needed scope. |
404 | not_found | No such published formula or material. Also returned for private, draft, or deleted formulas so existence is never leaked. Not retryable. |
429 | rate_limit_exceeded | Rate limit exhausted. Honor the Retry-After header before retrying. |
Handling errors
Treat 4xx codes as actionable: a 401 means refresh your token, a 403 means request the missing scope, and a 404 is terminal — do not retry it. A 429 (or a transient 5xx) is retryable: back off and try again, honoring Retry-After when present.
// GET with rate-limit-aware retry and exponential backoff
async function apiGet(path, accessToken, attempt = 0) {
const res = await fetch(`https://olfactorian.com/api/v1${path}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (res.status === 429 && attempt < 5) {
const retryAfter = Number(res.headers.get('Retry-After')) || 2 ** attempt;
await new Promise((r) => setTimeout(r, retryAfter * 1000));
return apiGet(path, accessToken, attempt + 1);
}
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${res.status} ${error.code}: ${error.message}`);
}
return res.json();
}List formulas
Search and browse the catalog of published formulas. Pass q for full-text search, tag to filter by category, or neither to page the whole catalog newest-first. Results come back as lightweight FormulaSummary objects — enough to render a list or grid. To get a formula’s ingredients, follow up with retrieve a formula. The response is cursor-paginated.
/api/v1/formulasscope formulas.readQuery parameters
| Parameter | Type | Description |
|---|---|---|
q | string | Full-text search across name, summary, tags, and material names. Omit to list everything. |
tag | string | Filter to formulas carrying this tag (e.g. Woody, Gourmand). Combine with q to search within a category. |
limit | integer | Page size, 1 to 100 (default 25). |
cursor | string | Opaque cursor from a previous response nextCursor. Omit for the first page. |
Request
const params = new URLSearchParams({ q: 'rose', limit: '2' });
const { data, nextCursor } = await fetch(
`https://olfactorian.com/api/v1/formulas?${params}`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
).then((r) => r.json());Response fields
data is an array of FormulaSummary objects:
| Parameter | Type | Description |
|---|---|---|
publicId | string | Stable, URL-safe identifier. Use it to retrieve the full formula and as your foreign key. |
name | string | Display name. Human-facing and mutable; do not key off it. |
summary | string | Short author-written description of the scent. |
tags | string[] | Olfactory and style tags, e.g. ["Woody", "Green", "Citrus"]. |
imageUrl | string | URL of the cover image. May be an empty string if the author set none. |
materialCount | integer | Number of ingredients in the formula. |
publishedAt | string | ISO 8601 timestamp (UTC) of when the formula was published. |
contributors | object[] | Credited authors, each { uid, displayName }. |
nextCursor | string | null | Top-level field. Pass back as cursor for the next page; null when the list is exhausted. |
{
"data": [
{
"publicId": "10-Cannabis-Base",
"name": "Cannabis Base",
"summary": "A unique and potent scent inspired by cannabis...",
"tags": ["Woody", "Green", "Citrus"],
"imageUrl": "https://res.cloudinary.com/.../url_image.jpg",
"materialCount": 22,
"publishedAt": "2025-07-07T19:37:43.000Z",
"contributors": [
{ "uid": "1-aromasdesalazar", "displayName": "Michael Salazar" }
]
}
],
"nextCursor": "MjU="
}Retrieve a formula
Fetch one published formula by its publicId. The response is a FormulaDTO — every field from FormulaSummary plus the full ingredients breakdown. Each ingredient carries both its CAS number and its canonical olfactorianMaterialId, which is how you match it against your own inventory (see core concepts).
/api/v1/formulas/{publicId}scope formulas.read| Parameter | Type | Description |
|---|---|---|
publicIdrequired | string | Path parameter. The stable identifier of the published formula. |
A formula that is private, in draft, deleted, or simply does not exist returns 404 not_found — never 403 — so the API does not reveal whether private work exists.
Request
const formula = await fetch(
'https://olfactorian.com/api/v1/formulas/100-Hot-Chocolate',
{ headers: { Authorization: `Bearer ${accessToken}` } },
).then((r) => {
if (r.status === 404) return null; // not published / unknown
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
});Response fields
All FormulaSummary fields (above), plus:
| Parameter | Type | Description |
|---|---|---|
ingredients | object[] | The full recipe, one entry per material (see the ingredient fields below). |
ingredients[].name | string | Display name of the material. |
ingredients[].cas | string | null | CAS Registry Number. null for proprietary bases and some naturals. |
ingredients[].olfactorianMaterialId | string | Canonical material id. Join key to the materials catalog and GET /materials/{id}. |
ingredients[].dilutionPct | number | Strength of the material in solution, as a percentage; 100 means neat / undiluted. Defaults to 100 when a formula does not specify a dilution. |
ingredients[].amountGrams | number | Weight of the diluted material added to the batch, in grams. |
ingredients[].notes | string? | Optional author note for this ingredient. Absent when there is none. |
{
"publicId": "100-Hot-Chocolate",
"name": "Hot Chocolate",
"summary": "A warm, comforting gourmand built on cocoa and cream...",
"tags": ["Gourmand", "Sweet"],
"imageUrl": "https://res.cloudinary.com/.../url_image.jpg",
"materialCount": 10,
"publishedAt": "2025-07-12T11:19:51.000Z",
"contributors": [
{ "uid": "laime-kiskune", "displayName": "Laime Kiskune" }
],
"ingredients": [
{
"name": "Theobroma Cacao Extract",
"cas": "84649-99-0",
"olfactorianMaterialId": "theobroma-cacao-extract",
"dilutionPct": 100,
"amountGrams": 4.5,
"notes": "The cocoa heart — dose carefully, it dominates."
},
{
"name": "Vanillin",
"cas": "121-33-5",
"olfactorianMaterialId": "vanillin",
"dilutionPct": 10,
"amountGrams": 2.0
}
]
}List materials
Search the material catalog by name, CAS number, or synonym.
/api/v1/materialsscope materials.read| Parameter | Type | Description |
|---|---|---|
q | string | Search by name, CAS, or synonym. |
limit | integer | Page size, 1–100 (default 25). |
cursor | string | Opaque cursor for the next page. |
curl "https://olfactorian.com/api/v1/materials?q=vanillin" \
-H "Authorization: Bearer $ACCESS_TOKEN"{
"data": [
{
"id": "vanillin",
"name": "Vanillin",
"cas": "121-33-5",
"materialType": "aroma_chemical",
"odorType": "gourmand"
}
],
"nextCursor": null
}Retrieve a material
Fetch one material by id, with its odor profile and chemistry.
/api/v1/materials/{id}scope materials.readcurl https://olfactorian.com/api/v1/materials/vanillin \
-H "Authorization: Bearer $ACCESS_TOKEN"{
"id": "vanillin",
"name": "Vanillin",
"cas": "121-33-5",
"materialType": "aroma_chemical",
"odorType": "gourmand",
"odorSummary": "Vanillin has a sweet, creamy vanilla scent...",
"synonyms": ["4-Hydroxy-3-methoxybenzaldehyde"]
}Versioning & stability
The API is versioned in the path (/v1). We add fields and endpoints without bumping the version, so build your integration to ignore unknown response fields rather than break on them. Any breaking change ships under a new path (/v2), and the previous version stays supported for at least six months after its successor is announced.
- Additive (non-breaking) — new response fields, new optional query params, new endpoints. No version bump.
- Breaking — removing or renaming a field, changing a type, tightening validation. Ships as
/v2. - Stable identifiers — a formula’s
publicIdand a material’sidare permanent and never reused, so they’re safe to store.
Roadmap
Now available: self-serve onboarding — request an integration and, once approved, generate and rotate your own credentials under your apps.
v1 is otherwise read-only. The following are planned but not yet available — don’t build against them yet:
- Write-back — create or publish a formula on Olfactorian on behalf of a connected user.
- A user’s own private formulas — read a connected user’s drafts (a new scope, granted with consent).
- Webhooks — subscribe to
formula.published/formula.updatedinstead of polling.
Need one of these sooner? Tell us — it helps us sequence the work.
FAQ
The questions perfumers and developers ask most — especially about whether opening an API puts anyone’s formulas at risk.