v1 device-scoped · no account

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

MethodEndpointWhat it does
GET/healthLiveness probe.
POST/v1/scoreScore a product by ingredients or productId. 🔑
GET/v1/schemaJSON Schema for the score request/response + product.
POST/v1/pricesCompetitor prices via SerpApi. 🔑
GET/v1/products/:idProduct info by barcode (OBF catalog).
GET/v1/products/:id/existsCheap pre-check before scoring (exists/hasIngredients).
POST/v1/products/:id/ingredientsCrowdsource an ingredient list. 🔑
POST/v1/products/:id/imageUpload a label photo. 🔑
GET/v1/products/:id/contributionsSubmitted ingredient lists.
GET/v1/products/:id/imagesUploaded image metadata.
GET/v1/historyThis device's past scans (paginated). 🔑
GET/v1/profileSaved preferences/allergens. 🔑
PUT/v1/profileUpsert preferences (partial). 🔑
DEL/v1/profileForget 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

FieldTypeDescription
ingredients one ofstring[] | stringINCI names (array or comma-separated). Provide this or productId.
productId one ofstringBarcode/UPC — resolves ingredients (and descriptor) from the catalog.
lastSeenProductIdstringOptional 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".
productDescriptorstringProduct type. llm; auto-filled from the product when using productId.
preferencesTextstringFree-text preferences. llm; defaults to the saved profile.
allergiesstring[]Allergens. llm; defaults to the saved profile.
languagestringISO code for prose. llm; defaults to the saved profile.
modelstringOpenRouter model id. llm; default openai/gpt-4o-mini.

Response

FieldTypeDescription
modestringWhich engine ran.
productIdstring | nullEchoed when scored by productId; null from ingredients.
productobject | nullThe full looked-up product (productId, name, ingredients[], descriptor, brand, imageUrl — same shape as GET /v1/products/:id) when scored by productId; null from ingredients.
scorenumberHazard 0–100 (higher = worse).
summarystringOne-line headline.
ingredients[]object[]input, hazardScore, hazardFlags, note; llm adds estimatedConcentrationPct, citationIds.
interactions[]object[]Cross-ingredient concerns (ingredients, concern, severity).
coverageobjectmatched / total / unmatched[].
preferencesobject | nullPreference weighting (llm with prefs).
narrativestring | nullConsumer summary with [n] citation markers (llm).
citations[]object[]Numbered evidence.
telemetryobject | nullcalls, 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).

FieldTypeDefault
query requiredstring
engine"shopping" | "organic""shopping"
limitinteger 1–5010
countryISO-3166 a2"us"
languageISO-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.

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

QueryTypeDefault
limitinteger 1–10020
cursorstring (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.

StatusWhen
400Malformed body; both/neither of ingredients+productId; empty list; or mode:"llm" without an LLM key.
401Missing or malformed X-Device-Id on a route that requires it.
404Product not found (unknown barcode), or unknown route.
413Uploaded image exceeds the 6MB limit.
415Image upload with a non-image/* Content-Type.
422Product found but has no ingredients (code: "no_ingredients") — prompt the user to contribute.
429Per-device rate limit (/v1/prices, /v1/score llm). Includes Retry-After.
502Upstream 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