API
Developer documentation

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.

https://olfactorian.com/api/v1OAuth 2.0 · read-only
Read-onlyPublished data onlyPer-user consentRevocable anytime
Getting started

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/json with 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 GET endpoints only. Write-back and webhooks are on the roadmap.
Trust & security

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.

Scope
Public data only
Read-only access to published formulas and the public material catalog. Private drafts are invisible — a request for one returns 404, so their existence never leaks.
Consent
Per-user approval
Every person approves on Olfactorian’s own consent screen. An app only ever acts for the users who connected it — never the whole community.
Credentials
Logins stay private
Apps receive Olfactorian’s own short-lived, scoped tokens. A user’s password and session never leave Olfactorian and are never shared.
Control
Revoke anytime
Only admin-approved apps can connect. Access tokens expire hourly, and any app can be disconnected instantly — its access dies at once.
How a connection works
  1. 1
    The app asks to connect
    It sends the user to Olfactorian with a one-time PKCE challenge — a scrambled secret only the app can later unlock.
    identified by client_id
  2. 2
    The user approves
    They sign in to their own Olfactorian account and approve the exact scopes on our consent screen. No approval, no access.
    per-user consent
  3. 3
    Olfactorian returns a one-time code
    Delivered only to the app’s pre-approved address, and useless to anyone who intercepts it without the original PKCE secret.
    PKCE-protected
  4. 4
    The app exchanges it for a token
    The 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
  5. 5
    The app reads — only what is allowed
    Each call carries the token; Olfactorian returns only published formulas and public materials, for that user, within their granted scopes.
    read-only
Two gates guard every connection. An admin approves what an app may request — its scopes and redirect address — before it can generate credentials. Then each user approves whether it may act for them. An app can never exceed either.
A connected app can
  • Read published formulas — names, notes, full ingredient breakdowns
  • Read the shared, public material catalog
  • Search and page, for the users who connected it
It can never
  • 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.

Getting started

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 from GET /materials/{id}.
  • cas — the CAS Registry Number, a globally recognized chemical identifier. Prefer matching on cas when you reconcile against a third-party or supplier inventory that does not use Olfactorian ids. Note that CAS numbers can be null for proprietary bases and some naturals, so treat identity as “CAS if present, otherwise olfactorianMaterialId.”

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

Getting started

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.

JavaScript
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
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_SECRET

Want to see the whole flow run live? The API playground drives the real consent screen, token exchange, and endpoints end-to-end.

Using the API

Making requests

The API follows a small set of conventions consistently, so once you have made one request you have effectively made them all.

ParameterTypeDescription
Base URLhttpsAll resource paths are relative to https://olfactorian.com/api/v1. HTTPS is mandatory; plain HTTP requests are refused.
AuthorizationheaderEvery request carries Authorization: Bearer <access_token>. There are no unauthenticated endpoints and no API keys in query strings.
FormatjsonResponses 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).
MethodsGETv1 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.
IdentifiersstringPath 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();
Using the API

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

ParameterTypeDescription
GET /oauth/authorizeredirectConsent screen. The user signs in to Olfactorian and approves the requested scopes.
POST /oauth/tokenformExchange an authorization code, or rotate a refresh token, for an access token.
POST /oauth/revokeformRevoke 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.

ParameterTypeDescription
formulas.readscopeRead published formulas: list, search, and retrieve full detail.
materials.readscopeRead 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:

HTTP
Authorization: Bearer olf_at_0k7aD7xcRG5fN5NYuz6DO2zKqgpL6Dor95QNidQhbFs
  • Access tokens last ~1 hour (expires_in is returned in seconds). When one expires, calls return 401 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_token exchange 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
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_SECRET
Using the API

Pagination

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.

ParameterTypeDescription
limitintegerPage size, 1 to 100 (default 25). Controls how many items each page returns.
cursorstringOpaque 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.
nextCursorstring | nullReturned 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;
}
Using the API

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.

HTTP
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 599
X-RateLimit-Reset: 1781467200
ParameterTypeDescription
X-RateLimit-LimitintegerMaximum requests allowed in the current window.
X-RateLimit-RemainingintegerRequests remaining in the current window. Slow down as this approaches 0.
X-RateLimit-ResetintegerUnix 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.

Using the API

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.

JSON
{
  "error": {
    "code": "insufficient_scope",
    "message": "Scope formulas.read is required"
  }
}
ParameterTypeDescription
400invalid_requestMalformed parameter, cursor, or id (e.g. limit out of the 1 to 100 range, or a corrupted cursor).
401unauthorizedMissing, invalid, expired, or revoked access token. Refresh the token, then retry.
403insufficient_scopeThe token is valid but lacks the scope this endpoint requires. Re-authorize with the needed scope.
404not_foundNo such published formula or material. Also returned for private, draft, or deleted formulas so existence is never leaked. Not retryable.
429rate_limit_exceededRate 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.

JavaScript
// 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();
}
Formulas

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.

GET/api/v1/formulasscope formulas.read

Query parameters

ParameterTypeDescription
qstringFull-text search across name, summary, tags, and material names. Omit to list everything.
tagstringFilter to formulas carrying this tag (e.g. Woody, Gourmand). Combine with q to search within a category.
limitintegerPage size, 1 to 100 (default 25).
cursorstringOpaque 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:

ParameterTypeDescription
publicIdstringStable, URL-safe identifier. Use it to retrieve the full formula and as your foreign key.
namestringDisplay name. Human-facing and mutable; do not key off it.
summarystringShort author-written description of the scent.
tagsstring[]Olfactory and style tags, e.g. ["Woody", "Green", "Citrus"].
imageUrlstringURL of the cover image. May be an empty string if the author set none.
materialCountintegerNumber of ingredients in the formula.
publishedAtstringISO 8601 timestamp (UTC) of when the formula was published.
contributorsobject[]Credited authors, each { uid, displayName }.
nextCursorstring | nullTop-level field. Pass back as cursor for the next page; null when the list is exhausted.
Example response
JSON
{
  "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="
}
Formulas

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

GET/api/v1/formulas/{publicId}scope formulas.read
ParameterTypeDescription
publicIdrequiredstringPath 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:

ParameterTypeDescription
ingredientsobject[]The full recipe, one entry per material (see the ingredient fields below).
ingredients[].namestringDisplay name of the material.
ingredients[].casstring | nullCAS Registry Number. null for proprietary bases and some naturals.
ingredients[].olfactorianMaterialIdstringCanonical material id. Join key to the materials catalog and GET /materials/{id}.
ingredients[].dilutionPctnumberStrength of the material in solution, as a percentage; 100 means neat / undiluted. Defaults to 100 when a formula does not specify a dilution.
ingredients[].amountGramsnumberWeight of the diluted material added to the batch, in grams.
ingredients[].notesstring?Optional author note for this ingredient. Absent when there is none.
Example response
JSON
{
  "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
    }
  ]
}
Materials

List materials

Search the material catalog by name, CAS number, or synonym.

GET/api/v1/materialsscope materials.read
ParameterTypeDescription
qstringSearch by name, CAS, or synonym.
limitintegerPage size, 1–100 (default 25).
cursorstringOpaque cursor for the next page.
cURL
curl "https://olfactorian.com/api/v1/materials?q=vanillin" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
Response
JSON
{
  "data": [
    {
      "id": "vanillin",
      "name": "Vanillin",
      "cas": "121-33-5",
      "materialType": "aroma_chemical",
      "odorType": "gourmand"
    }
  ],
  "nextCursor": null
}
Materials

Retrieve a material

Fetch one material by id, with its odor profile and chemistry.

GET/api/v1/materials/{id}scope materials.read
cURL
curl https://olfactorian.com/api/v1/materials/vanillin \
  -H "Authorization: Bearer $ACCESS_TOKEN"
Response
JSON
{
  "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"]
}
Reference

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 publicId and a material’s id are permanent and never reused, so they’re safe to store.
Reference

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.updated instead of polling.

Need one of these sooner? Tell us — it helps us sequence the work.

Reference

FAQ

The questions perfumers and developers ask most — especially about whether opening an API puts anyone’s formulas at risk.

Can a connected app see a user’s private or unpublished formulas?+
No. The API is strictly read-only and returns only published formulas. Drafts and private work are invisible to it — a request for one comes back as 404, so the API never even reveals that private work exists.
Does an app ever receive a user’s Olfactorian password or login?+
Never. Apps receive Olfactorian’s own short-lived, scoped access tokens, issued only after a user approves them. A user’s password and session stay inside Olfactorian and are never handed out.
What can a connected app actually read?+
The same published, public information the website shows anyone: a formula’s name, notes, tags, and ingredient breakdown, plus the shared material catalog — and only for the users who connected it.
Can an app change, publish, or delete formulas?+
No. v1 has no write, edit, publish, or delete endpoints at all. It can read public data and nothing more.
Can a user disconnect an app later?+
Yes, instantly. Revoking an app tears down all of its tokens immediately, and access tokens expire on their own every hour regardless.
Who is allowed to build an app against the API?+
Anyone can request access, but an admin reviews and approves each app — including the exact scopes and redirect address it may use — before it can generate any credentials.
What happens if an app’s secret leaks?+
Tokens are scoped, expiring, and revocable: the developer rotates the secret or disables the app and its live tokens die at once. And if a one-time refresh token is ever replayed, Olfactorian auto-revokes that whole session as a precaution.
Olfactorian API — Developer Documentation