POST /v1/score dev · no auth

Bestie Scoring API

Score a cosmetic product's ingredient list on a 0–100 hazard scale (higher = more hazardous). One endpoint, two interchangeable engines: a fast deterministic mode backed by regulatory data, or a full LLM mode with concentration reasoning, grounded citations, a written summary, and personal-preference weighting.

Scale
0–100
Modes
2
LLM latency
~2.5s
LLM cost/scan
~$0.0005

Quickstart

Send a list of ingredients to the live API. No key needed for default mode.

curl -s https://bestie.trevormil.com/v1/score \
  -H 'content-type: application/json' \
  -d '{
    "ingredients": "Water, Triethanolamine, DMDM Hydantoin, Fragrance",
    "mode": "default"
  }'

Modes

Both modes return the same response shape — only the depth of analysis differs.

defaultdeterministic · no LLM

Per-ingredient max of the regulatory risk score + cross-ingredient (INCI) combination rules. Free, instant, reproducible.

  • ✓ same input → same score
  • ✓ no API key, no per-call cost
  • ✗ no concentration %, narrative, or preferences
llmfull analysis

Estimates each ingredient's concentration, judges hazard at use level, finds interactions, and writes a cited summary — plus preference weighting.

  • ✓ concentration %, interactions, narrative
  • ✓ allergy / preference personalization
  • ✓ grounded [n] citations

POST/v1/score

Score one product — by ingredients or productId (exactly one). Body is JSON. Requires an X-Device-Id header (every scan is saved to history). Returns 200 with the unified score response, or 400 on bad input.

Request body

FieldTypeDescription
ingredients one ofstring[] | stringINCI names as an array, or one comma-separated string. Provide this or productId (exactly one).
productId one ofstringResolve ingredients (and productDescriptor) from a known product instead. Provide this or ingredients.
mode"default" | "llm"Scoring engine. Defaults to "default".
productDescriptorstringProduct type (e.g. "leave-on serum"). llm only; auto-filled from the product when scoring by productId.
preferencesTextstringFree-text preferences / allergies. llm only; defaults to the saved profile when omitted.
allergiesstring[]Structured allergens. llm only; defaults to the saved profile when omitted.
modelstringOpenRouter model id. Defaults to openai/gpt-4o-mini. llm only.
{
  "ingredients": ["Water", "Triethanolamine", "DMDM Hydantoin", "Fragrance"],
  "mode": "llm",
  "productDescriptor": "leave-on lotion",
  "preferencesText": "allergic to fragrance, pregnant",
  "allergies": ["fragrance"],
  "model": "openai/gpt-4o-mini"
}

Response

Both modes share this shape. In default mode, preferences, narrative, telemetry are null, citations is [], and ingredients omit estimatedConcentrationPct.

FieldTypeDescription
scorenumberProduct hazard 0–100 (higher = more hazardous).
productIdstring | nullEchoed when the request used productId; null when scored from ingredients.
modestringWhich engine ran.
summarystringOne-line headline.
ingredients[]object[]input, hazardScore, hazardFlags, note; llm adds estimatedConcentrationPct, citationIds.
interactions[]object[]Cross-ingredient concerns: ingredients, concern, severity.
coverageobjectmatched / total / unmatched[] — ingredients not found in the corpus.
preferencesobject | nullWeighting: adjustment (points added), notes[], allergenFlags[].
narrativestring | nullConsumer summary with inline [n] markers indexing citations (1-based).
citations[]object[]Numbered evidence: id, kind, label, url?.
telemetryobject | nullcalls, totalTokens, costUsd, latencyMs.
{
  "mode": "llm",
  "score": 85,
  "summary": "Contains DMDM Hydantoin, a formaldehyde releaser, plus a nitrosamine interaction.",
  "ingredients": [
    {
      "input": "DMDM Hydantoin",
      "hazardScore": 90,
      "estimatedConcentrationPct": 0.5,
      "hazardFlags": ["formaldehyde_releaser"],
      "note": "Formaldehyde-releasing preservative.",
      "citationIds": ["risk:ing_..."]
    }
  ],
  "interactions": [
    {
      "ingredients": ["Triethanolamine", "DMDM Hydantoin"],
      "concern": "Amine + releaser can form genotoxic nitrosamines.",
      "severity": "high"
    }
  ],
  "coverage": { "matched": 4, "total": 4, "unmatched": [] },
  "preferences": {
    "adjustment": 50,
    "notes": [
      { "preference": "fragrance allergy", "verdict": "conflict",
        "ingredients": ["Fragrance"], "rationale": "Fragrance present." }
    ],
    "allergenFlags": [{ "allergen": "fragrance", "ingredients": ["Fragrance"] }]
  },
  "narrative": "This product is hazardous, driven by a formaldehyde-releasing preservative [1] and a nitrosamine-forming combination [2]. It also conflicts with your fragrance allergy.",
  "citations": [
    { "id": "risk:ing_a", "kind": "risk", "label": "CIR: formaldehyde releaser" },
    { "id": "rule:xref", "kind": "rule", "label": "SCCS nitrosamine guidance" }
  ],
  "telemetry": { "calls": 3, "totalTokens": 4800, "costUsd": 0.0006, "latencyMs": 2500 }
}
{
  "mode": "default",
  "score": 78,
  "summary": "Deterministic hazard 78/100 driven by DMDM Hydantoin (+18 from interactions).",
  "ingredients": [
    { "input": "DMDM Hydantoin", "hazardScore": 60,
      "hazardFlags": ["formaldehyde_releaser"], "note": "CIR: formaldehyde releaser" }
  ],
  "interactions": [
    { "ingredients": ["Triethanolamine", "DMDM Hydantoin"],
      "concern": "Amine + releaser can form genotoxic N-nitrosamines.", "severity": "high" }
  ],
  "coverage": { "matched": 4, "total": 4, "unmatched": [] },
  "preferences": null,
  "narrative": null,
  "citations": [],
  "telemetry": null
}

Examples

curl -s https://bestie.trevormil.com/v1/score \
  -H 'content-type: application/json' \
  -H 'X-Device-Id: 11111111-2222-4333-8444-555555555555' \
  -d '{"ingredients":"Water, Triethanolamine, DMDM Hydantoin, Fragrance","mode":"default"}'
curl -s https://bestie.trevormil.com/v1/score \
  -H 'content-type: application/json' \
  -H 'X-Device-Id: 11111111-2222-4333-8444-555555555555' \
  -d '{"productId":"prod_abc123"}'
curl -s https://bestie.trevormil.com/v1/score \
  -H 'content-type: application/json' \
  -H 'X-Device-Id: 11111111-2222-4333-8444-555555555555' \
  -d '{
    "ingredients": ["Aqua", "Parfum", "Linalool"],
    "mode": "llm",
    "productDescriptor": "serum",
    "preferencesText": "allergic to fragrance"
  }'
const res = await fetch("https://bestie.trevormil.com/v1/score", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    ingredients: ["Water", "Salicylic Acid", "Retinol"],
    mode: "llm",
  }),
});
const report = await res.json();
console.log(report.score, report.narrative);

Errors

Errors return JSON { "error": "..." } with the status below. Secrets are redacted from messages.

StatusWhen
400Empty ingredients, malformed body, or mode:"llm" without OPENROUTER_API_KEY.
404Unknown route.

POST /v1/prices

Competitor-price lookup proxied through SerpApi. Input is plaintext — a product name or a barcode/UPC (Google resolves most GTINs). Ingredients alone won't match; there's no product to search on.

shoppinggoogle_shopping

Seller listings with prices, ratings, delivery. Use for price comparison.

organicgoogle

Web results with title, link, snippet. No prices.

FieldTypeDefaultNotes
querystringRequired. Product name or barcode/UPC.
engine"shopping" | "organic""shopping"Result type.
limitinteger 1–5010Max rows returned.
countryISO-3166 alpha-2"us"Market (SerpApi gl).
languageISO-639"en"Language (SerpApi hl).
curl -s https://bestie.trevormil.com/v1/prices \
  -H 'content-type: application/json' \
  -H 'X-Device-Id: 11111111-2222-4333-8444-555555555555' \
  -d '{"query":"cerave moisturizing cream","engine":"shopping","limit":5}'
curl -s https://bestie.trevormil.com/v1/prices \
  -H 'content-type: application/json' \
  -H 'X-Device-Id: 11111111-2222-4333-8444-555555555555' \
  -d '{"query":"3606000537128","engine":"organic"}'
{
  "query": "cerave moisturizing cream",
  "engine": "shopping",
  "source": "serpapi",
  "count": 5,
  "fetchedAt": "2026-06-21T18:00:00.000Z",
  "results": [
    {
      "title": "CeraVe Moisturizing Cream",
      "source": "Walmart",
      "price": "$17.97",
      "extractedPrice": 17.97,
      "link": "https://...",
      "thumbnail": null,
      "rating": 4.8,
      "reviews": 1203,
      "delivery": "Free delivery",
      "snippet": null
    }
  ]
}

⚠ Requires an X-Device-Id header and is rate-limited per device (see Device identity). Each live call spends a SerpApi search credit; with no SERPAPI_API_KEY configured the endpoint returns deterministic mock rows ("source":"mock").

Device identity & /v1/profile

No accounts. The iOS app generates a UUID once, stores it in the Keychain, and sends it as an X-Device-Id header. That id keys the saved profile (preferences/allergens) and the per-device rate limits. It's an identifier, not a credential — fine for personalization, not for anything sensitive.

Where X-Device-Id is required

  • RequiredPOST /v1/score (all modes — every scan is saved), POST /v1/prices, GET /v1/history, and all /v1/profile methods.
  • Not requiredGET /v1/products/:id, GET /v1/schema, GET /health, and the docs (/, /docs).
  • Missing → 401 "Missing X-Device-Id header"; non-UUID → 401 "Malformed X-Device-Id header".
MethodPathDoes
GET/v1/profileCurrent profile (empty default if none).
PUT/v1/profileUpsert. Partial — only sent fields change; null clears one.
DELETE/v1/profileForget this device's stored data.

Body fields mirror /v1/score: preferencesText, allergies[], language.

curl -s -X PUT https://bestie.trevormil.com/v1/profile \
  -H 'content-type: application/json' \
  -H 'X-Device-Id: 11111111-2222-4333-8444-555555555555' \
  -d '{"preferencesText":"vegan, fragrance-free","allergies":["fragrance","parabens"],"language":"en"}'
{
  "deviceId": "11111111-2222-4333-8444-555555555555",
  "preferencesText": "vegan, fragrance-free",
  "allergies": ["fragrance", "parabens"],
  "language": "en",
  "createdAt": "2026-06-21T19:00:00.000Z",
  "updatedAt": "2026-06-21T19:00:00.000Z"
}

Rate limits (per device, fixed daily window): /v1/prices and /v1/score mode llm. Over-limit returns 429 with a Retry-After header. Default scoring and profile reads aren't limited.

GET /v1/history

A device's past /v1/score scans (request + response), newest first. Requires X-Device-Id. Keyset-paginated: pass the returned nextCursor back as ?cursor= for the next (older) page; nextCursor is null at the end.

QueryTypeDefaultNotes
limitinteger 1–10020Page size.
cursorstring (id)Fetch items older than this id (use nextCursor).
curl -s 'https://bestie.trevormil.com/v1/history?limit=20' \
  -H 'X-Device-Id: 11111111-2222-4333-8444-555555555555'
{
  "items": [
    {
      "id": "42",
      "request": { "productId": "prod_abc123", "mode": "default" },
      "response": { "mode": "default", "productId": "prod_abc123", "score": 10, "summary": "..." },
      "mode": "default",
      "score": 10,
      "createdAt": "2026-06-21T19:30:00.000Z"
    }
  ],
  "nextCursor": "41"
}

GET /v1/products/:id mock

Product info by id: name, ingredient list, and a product descriptor. Mocked placeholder — returns a deterministic sample product for any id until the real product service lands. The same lookup backs scoring by productId. No X-Device-Id needed.

curl -s https://bestie.trevormil.com/v1/products/prod_abc123
{
  "productId": "prod_abc123",
  "name": "Mock Product prod_abc123",
  "ingredients": ["Aqua", "Glycerin", "Niacinamide", "Phenoxyethanol", "Parfum", "Limonene"],
  "descriptor": "leave-on moisturizer",
  "brand": "MockBrand",
  "imageUrl": null
}

The response JSON Schema is exposed under product at GET /v1/schema.

Other routes

GET/v1/schema— JSON Schema for the score request + response and the product shape (generated from zod).
GET/health{ "ok": true }.

Running it

Zero-dependency Bun server. Needs a Postgres connection for the ingredient corpus; llm mode also needs an OpenRouter key.

export POSTGRES_URL=postgres://...        # ingredient corpus + evidence
export OPENROUTER_API_KEY=sk-or-...        # llm mode only
export OPENROUTER_MODEL=openai/gpt-4o-mini # optional (this is the default)

bun run api                               # http://localhost:4190