/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.
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.
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
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
| Field | Type | Description |
|---|---|---|
ingredients one of | string[] | string | INCI names as an array, or one comma-separated string. Provide this or productId (exactly one). |
productId one of | string | Resolve ingredients (and productDescriptor) from a known product instead. Provide this or ingredients. |
mode | "default" | "llm" | Scoring engine. Defaults to "default". |
productDescriptor | string | Product type (e.g. "leave-on serum"). llm only; auto-filled from the product when scoring by productId. |
preferencesText | string | Free-text preferences / allergies. llm only; defaults to the saved profile when omitted. |
allergies | string[] | Structured allergens. llm only; defaults to the saved profile when omitted. |
model | string | OpenRouter 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.
| Field | Type | Description |
|---|---|---|
score | number | Product hazard 0–100 (higher = more hazardous). |
productId | string | null | Echoed when the request used productId; null when scored from ingredients. |
mode | string | Which engine ran. |
summary | string | One-line headline. |
ingredients[] | object[] | input, hazardScore, hazardFlags, note; llm adds estimatedConcentrationPct, citationIds. |
interactions[] | object[] | Cross-ingredient concerns: ingredients, concern, severity. |
coverage | object | matched / total / unmatched[] — ingredients not found in the corpus. |
preferences | object | null | Weighting: adjustment (points added), notes[], allergenFlags[]. |
narrative | string | null | Consumer summary with inline [n] markers indexing citations (1-based). |
citations[] | object[] | Numbered evidence: id, kind, label, url?. |
telemetry | object | null | calls, 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.
| Status | When |
|---|---|
| 400 | Empty ingredients, malformed body, or mode:"llm" without OPENROUTER_API_KEY. |
| 404 | Unknown 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.
Seller listings with prices, ratings, delivery. Use for price comparison.
Web results with title, link, snippet. No prices.
| Field | Type | Default | Notes |
|---|---|---|---|
query | string | — | Required. Product name or barcode/UPC. |
engine | "shopping" | "organic" | "shopping" | Result type. |
limit | integer 1–50 | 10 | Max rows returned. |
country | ISO-3166 alpha-2 | "us" | Market (SerpApi gl). |
language | ISO-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
- Required —
POST /v1/score(all modes — every scan is saved),POST /v1/prices,GET /v1/history, and all/v1/profilemethods. - Not required —
GET /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".
| Method | Path | Does |
|---|---|---|
| GET | /v1/profile | Current profile (empty default if none). |
| PUT | /v1/profile | Upsert. Partial — only sent fields change; null clears one. |
| DELETE | /v1/profile | Forget 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.
| Query | Type | Default | Notes |
|---|---|---|---|
limit | integer 1–100 | 20 | Page size. |
cursor | string (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
/v1/schema— JSON Schema for the score request + response and the product shape (generated from zod)./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