Bestie API
Score a cosmetic product's ingredients on a 0–100 hazard scale, look up competitor prices, store per-device preferences and scan history, and crowdsource missing product data. Base URL https://bestie.trevormil.com.
Device identity (X-Device-Id)
No accounts. The client generates a UUID once (stored in the iOS Keychain) and sends it as the X-Device-Id header. It keys profiles, history, rate limits, and contributions. It's an identifier, not a credential. A 🔑 below marks routes that require it.
Endpoints at a glance
| Method | Endpoint | What it does |
|---|---|---|
| GET | /health | Liveness probe. |
| POST | /v1/score | Score a product by ingredients or productId. 🔑 |
| GET | /v1/schema | JSON Schema for the score request/response + product. |
| POST | /v1/prices | Competitor prices via SerpApi. 🔑 |
| GET | /v1/products/:id | Product info by barcode (OBF catalog). |
| GET | /v1/products/:id/exists | Cheap pre-check before scoring (exists/hasIngredients). |
| POST | /v1/products/:id/ingredients | Crowdsource an ingredient list. 🔑 |
| POST | /v1/products/:id/image | Upload a label photo. 🔑 |
| GET | /v1/products/:id/contributions | Submitted ingredient lists. |
| GET | /v1/products/:id/images | Uploaded image metadata. |
| GET | /v1/history | This device's past scans (paginated). 🔑 |
| GET | /v1/profile | Saved preferences/allergens. 🔑 |
| PUT | /v1/profile | Upsert preferences (partial). 🔑 |
| DEL | /v1/profile | Forget this device's data. 🔑 |
GET/health
Liveness probe. No X-Device-Id.
curl -s https://bestie.trevormil.com/health
# → { "ok": true }
POST/v1/scoreX-Device-Id
Score one product by ingredients or productId (exactly one). Two engines: default (deterministic, free, instant) and llm (concentration reasoning, interactions, grounded citations, narrative, preference weighting). Both return the same shape. Every call is saved to history.
Request body
| Field | Type | Description |
|---|---|---|
ingredients one of | string[] | string | INCI names (array or comma-separated). Provide this or productId. |
productId one of | string | Barcode/UPC — resolves ingredients (and descriptor) from the catalog. |
lastSeenProductId | string | Optional context — the last product the user had scanned. Stored verbatim with the request in history; no scoring logic. |
mode | "default" | "llm" | Engine. Defaults to "default". |
productDescriptor | string | Product type. llm; auto-filled from the product when using productId. |
preferencesText | string | Free-text preferences. llm; defaults to the saved profile. |
allergies | string[] | Allergens. llm; defaults to the saved profile. |
language | string | ISO code for prose. llm; defaults to the saved profile. |
model | string | OpenRouter model id. llm; default openai/gpt-4o-mini. |
Response
| Field | Type | Description |
|---|---|---|
mode | string | Which engine ran. |
productId | string | null | Echoed when scored by productId; null from ingredients. |
product | object | null | The full looked-up product (productId, name, ingredients[], descriptor, brand, imageUrl — same shape as GET /v1/products/:id) when scored by productId; null from ingredients. |
score | number | Hazard 0–100 (higher = worse). |
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[]. |
preferences | object | null | Preference weighting (llm with prefs). |
narrative | string | null | Consumer summary with [n] citation markers (llm). |
citations[] | object[] | Numbered evidence. |
telemetry | object | null | calls, totalTokens, costUsd, latencyMs (llm). |
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"}'
curl -s https://bestie.trevormil.com/v1/score \
-H 'content-type: application/json' \
-H 'X-Device-Id: 11111111-2222-4333-8444-555555555555' \
-d '{"productId":"8410757001090"}'
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","preferencesText":"allergic to fragrance"}'
{
"mode": "llm",
"productId": null,
"product": null,
"score": 85,
"summary": "Contains DMDM Hydantoin, a formaldehyde releaser.",
"ingredients": [{ "input": "DMDM Hydantoin", "hazardScore": 90, "hazardFlags": ["formaldehyde_releaser"], "note": "..." }],
"interactions": [],
"coverage": { "matched": 3, "total": 3, "unmatched": [] },
"preferences": null,
"narrative": "This product...[1]",
"citations": [{ "id": "...", "kind": "risk", "label": "..." }],
"telemetry": { "calls": 2, "totalTokens": 2993, "costUsd": 0.00066, "latencyMs": 5511 }
}
// when the request used "productId", the full product is included
// (same shape as GET /v1/products/:id) — and is stored in /v1/history too
{
"productId": "8410757001090",
"product": {
"productId": "8410757001090",
"name": "Hydrating Serum",
"ingredients": ["Aqua", "Glycerin", "..."],
"descriptor": "serum",
"brand": "Acme",
"imageUrl": "https://images.openbeautyfacts.org/.../front.jpg"
}
}
Errors: 400 (bad body / both|neither ids), 401 (no X-Device-Id), 404 (productId unknown), 422 (productId found but no ingredients), 429 (llm rate limit).
GET/v1/schema
JSON Schema (generated from zod) for the score request and response, plus the product shape. No X-Device-Id.
curl -s https://bestie.trevormil.com/v1/schema
# → { "request": {…}, "response": {…}, "product": {…} }
POST/v1/pricesX-Device-Id
Competitor-price lookup via SerpApi. engine: "shopping" (prices/sellers) or "organic" (web results). Query is a product name or barcode/UPC. Rate-limited per device; each live call spends a SerpApi credit (mock rows when no key is configured).
| Field | Type | Default |
|---|---|---|
query required | string | — |
engine | "shopping" | "organic" | "shopping" |
limit | integer 1–50 | 10 |
country | ISO-3166 a2 | "us" |
language | ISO-639 | "en" |
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}'
{
"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, "rating": 4.8, "reviews": 1203, "delivery": "Free delivery", "link": "…", "thumbnail": "…", "snippet": null }]
}
Errors: 401 (no X-Device-Id), 429 (rate limit + Retry-After), 502 (SerpApi error).
GET/v1/products/:id
Product info by barcode/UPC from the Postgres Open Beauty Facts catalog (~40k products), with crowdsourced uploads filling gaps. Same lookup that backs scoring by productId. No X-Device-Id.
curl -s https://bestie.trevormil.com/v1/products/8410757001090
{
"productId": "8410757001090",
"name": "Crema Manos",
"ingredients": ["aqua", "glycerin", "parfum"],
"descriptor": "creams",
"brand": "S'nonas",
"imageUrl": null
}
Errors: 404 (barcode not in the catalog).
GET/v1/products/:id/exists
A cheap pre-check — run this before POST /v1/score to skip the expensive scoring call for a barcode we can't resolve. Reads only existence flags (catalog + crowdsourced contributions), never the full product payload. No X-Device-Id.
| Query | Type | Default |
|---|---|---|
withIngredients | "true" | off — also report hasIngredients |
exists: false predicts a 404 from score; hasIngredients: false predicts a 422 (found, but no ingredients to score).
curl -s 'https://bestie.trevormil.com/v1/products/8410757001090/exists?withIngredients=true'
{
"productId": "8410757001090",
"exists": true,
"hasIngredients": true
}
POST/v1/products/:id/ingredientsX-Device-Id
Crowdsource an ingredient list for a barcode that has none on file (the ~2-in-3 case). Stored as status: "pending"; the next scan of that barcode then resolves from the contribution. Body: { ingredients: string[] | string, rawText?, lastSeenProductId? }. lastSeenProductId is optional context stored verbatim — no logic.
curl -s -X POST https://bestie.trevormil.com/v1/products/3606000537128/ingredients \
-H 'content-type: application/json' \
-H 'X-Device-Id: 11111111-2222-4333-8444-555555555555' \
-d '{"ingredients":["Aqua","Glycerin","Niacinamide","Phenoxyethanol"],"lastSeenProductId":"3606000537128"}'
{
"id": "1",
"barcode": "3606000537128",
"deviceId": "11111111-2222-4333-8444-555555555555",
"ingredients": ["aqua", "glycerin", "niacinamide", "phenoxyethanol"],
"rawText": null,
"lastSeenProductId": "3606000537128",
"status": "pending",
"createdAt": "2026-06-21T20:00:00.000Z"
}
Errors: 400 (empty list), 401 (no X-Device-Id).
POST/v1/products/:id/imageX-Device-Id
Upload a product label photo (raw image bytes in the body) for OCR/review. Content-Type: image/*, ≤ 6MB. Stored in object storage (DO Spaces); the response returns its public url.
curl -s -X POST https://bestie.trevormil.com/v1/products/3606000537128/image \ -H 'X-Device-Id: 11111111-2222-4333-8444-555555555555' \ -H 'content-type: image/jpeg' \ --data-binary @label.jpg
{
"id": "1",
"barcode": "3606000537128",
"deviceId": "11111111-2222-4333-8444-555555555555",
"contentType": "image/jpeg",
"byteSize": 248173,
"url": "https://bestie-uploads.sfo3.digitaloceanspaces.com/product-labels/3606000537128/.jpg",
"createdAt": "2026-06-21T20:00:00.000Z"
}
Errors: 401 (no X-Device-Id), 413 (> 6MB), 415 (non-image), 503 (storage not configured).
GET/v1/products/:id/contributions
Submitted ingredient lists for a barcode, newest first. No X-Device-Id.
curl -s https://bestie.trevormil.com/v1/products/3606000537128/contributions
# → { "items": [ { "id", "barcode", "ingredients", "status", "createdAt", … } ] }
GET/v1/products/:id/images
Uploaded image metadata for a barcode (no bytes), newest first. No X-Device-Id.
curl -s https://bestie.trevormil.com/v1/products/3606000537128/images
# → { "items": [ { "id", "barcode", "contentType", "byteSize", "createdAt" } ] }
GET/v1/historyX-Device-Id
This device's past /v1/score scans (request + response), newest first. Keyset-paginated — pass the returned nextCursor as ?cursor= for the next (older) page. Each stored response carries the full product (when scored by productId), so scan history renders without re-fetching the catalog.
| Query | Type | Default |
|---|---|---|
limit | integer 1–100 | 20 |
cursor | string (id) | — |
curl -s 'https://bestie.trevormil.com/v1/history?limit=20' \ -H 'X-Device-Id: 11111111-2222-4333-8444-555555555555'
{
"items": [{ "id": "42", "request": {…}, "response": {…}, "mode": "default", "score": 10, "createdAt": "2026-06-21T19:30:00.000Z" }],
"nextCursor": "41"
}
Errors: 400 (bad cursor), 401 (no X-Device-Id).
GET/v1/profileX-Device-Id
This device's saved preferences/allergens. Returns an empty default if none stored. These auto-fill /v1/score (llm) when the request omits them.
curl -s https://bestie.trevormil.com/v1/profile \ -H 'X-Device-Id: 11111111-2222-4333-8444-555555555555'
{
"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"
}
Errors: 401 (no X-Device-Id).
PUT/v1/profileX-Device-Id
Upsert preferences. Partial — only sent fields change; null clears one. Body: { preferencesText?, allergies?, language? } (mirrors /v1/score).
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"}'
Returns the saved profile (same shape as GET). Errors: 400 (bad body), 401 (no X-Device-Id).
DELETE/v1/profileX-Device-Id
Forget this device's stored preferences/allergens.
curl -s -X DELETE https://bestie.trevormil.com/v1/profile \
-H 'X-Device-Id: 11111111-2222-4333-8444-555555555555'
# → { "deleted": true }
Errors: 401 (no X-Device-Id).
Errors
Errors return JSON { "error": "..." }; some carry a machine-readable code (and productId) — e.g. 422 → { "error": "...", "code": "no_ingredients", "productId": "..." }. Secrets are redacted.
| Status | When |
|---|---|
| 400 | Malformed body; both/neither of ingredients+productId; empty list; or mode:"llm" without an LLM key. |
| 401 | Missing or malformed X-Device-Id on a route that requires it. |
| 404 | Product not found (unknown barcode), or unknown route. |
| 413 | Uploaded image exceeds the 6MB limit. |
| 415 | Image upload with a non-image/* Content-Type. |
| 422 | Product found but has no ingredients (code: "no_ingredients") — prompt the user to contribute. |
| 429 | Per-device rate limit (/v1/prices, /v1/score llm). Includes Retry-After. |
| 502 | Upstream failure (e.g. SerpApi on /v1/prices). |
Running it
Bun server. Needs Postgres (ingredient corpus, OBF catalog, device data); llm mode needs an OpenRouter key; /v1/prices uses a SerpApi key (mock without one).
export POSTGRES_URL=postgres://... # corpus + OBF catalog + device data export OPENROUTER_API_KEY=sk-or-... # llm mode only export OPENROUTER_MODEL=openai/gpt-4o-mini # optional (this is the default) export SERPAPI_API_KEY=... # /v1/prices live data (else mock) bun run api # http://localhost:4190