Bestie API
Score a cosmetic product's ingredients on a 0โ100 hazard scale, look up competitor prices, store account preferences and scan history, and crowdsource missing product data. Production base URL https://api.mybestie.io; development base URL https://api-dev.mybestie.io.
Account auth (Authorization: Bearer)
Protected routes require a session token from /v1/auth/register, /v1/auth/login, or /v1/auth/apple. Send it as Authorization: Bearer <token>.
Endpoints at a glance
| Method | Endpoint | What it does |
|---|---|---|
| GET | /health | Liveness probe. |
| POST | /v1/auth/register | Create an email/password account and return a bearer session. |
| POST | /v1/auth/login | Sign in with email/password and return a bearer session. |
| POST | /v1/auth/apple | Sign in with Apple identity token and return a bearer session. |
| GET | /v1/me | Current account. ๐ |
| POST | /v1/score | Score a product by ingredients, productId, or sessionId. ๐ |
| POST | /v1/scan-sessions | Open a streaming scan session. ๐ |
| POST | /v1/scan-sessions/:id/frames | Append a camera frame โ fast, no VLM. ๐ |
| POST | /v1/scan-sessions/:id/extract | The single VLM pass over all frames โ ingredient list. ๐ |
| GET | /v1/scan-sessions/:id | Current scan-session state. ๐ |
| 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/search | Search the catalog by product name/brand (scoreable results). ๐ |
| 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/categories/:id/recommendations | Safer-pick products in a category (no scores). ๐ |
| GET | /v1/categories/:id/distribution | Score distribution for a category (How It Compares). ๐ |
| GET | /v1/history | This account's past scans (paginated). ๐ |
| GET | /v1/profile | Saved preferences/allergens. ๐ |
| PUT | /v1/profile | Upsert preferences (partial). ๐ |
| DEL | /v1/profile | Forget this device's data. ๐ |
POST/v1/auth/register
Create an email/password account and receive a bearer session. Passwords must be at least 12 characters.
curl -s https://api.mybestie.io/v1/auth/register \
-H 'content-type: application/json' \
-d '{
"email": "you@example.com",
"password": "correct-horse-battery-staple"
}'
{
"token": "session-token",
"expiresAt": "2026-07-23T00:00:00.000Z",
"account": {
"id": "account-id",
"email": "you@example.com",
"createdAt": "2026-06-23T00:00:00.000Z",
"updatedAt": "2026-06-23T00:00:00.000Z"
}
}
Errors: 400 duplicate account, 422 invalid body, 429 rate limited.
POST/v1/auth/guest
One-tap guest account: fake credentials, a bearer session, and a tiny lifetime quota on the expensive routes (scoring, read-label, images, image-name, prices, identify). Bound to the calling device via the X-Device-Id header โ one guest per device, so the quota can't be farmed by re-requesting (the same account is returned). When the quota is spent those routes return 429 with code: "guest_limit_reached" (the app surfaces "Guest Account Exhausted โ create a real account"). Account carries isGuest: true.
curl -s -X POST https://api.mybestie.io/v1/auth/guest \ -H 'X-Device-Id: <stable-device-id>'
{
"token": "session-token",
"expiresAt": "2026-07-23T00:00:00.000Z",
"account": { "id": "โฆ", "email": "guest-โฆ@guest.bestie", "isGuest": true, "createdAt": "โฆ", "updatedAt": "โฆ" }
}
Errors: 400 missing X-Device-Id, 429 rate limited. Exhausted-quota responses on protected routes carry code: "guest_limit_reached".
POST/v1/auth/login
Sign in with email/password and receive the same bearer session response as registration.
curl -s https://api.mybestie.io/v1/auth/login \
-H 'content-type: application/json' \
-d '{
"email": "you@example.com",
"password": "correct-horse-battery-staple"
}'
Errors: 401 invalid credentials, 422 invalid body, 429 rate limited.
POST/v1/auth/apple
Sign in with an Apple identity token. Bestie verifies the token signature, issuer, audience, expiry, and email claim, then returns a bearer session.
curl -s https://api.mybestie.io/v1/auth/apple \
-H 'content-type: application/json' \
-d '{
"identityToken": "apple-id-token-jwt"
}'
Errors: 400 missing Apple email, 401 invalid Apple token, 422 invalid body, 429 rate limited, 503 Apple auth not configured.
GET/v1/meAuthorization
Return the account for the current bearer session.
curl -s https://api.mybestie.io/v1/me \ -H 'Authorization: Bearer <token>'
{
"account": {
"id": "account-id",
"email": "you@example.com",
"createdAt": "2026-06-23T00:00:00.000Z",
"updatedAt": "2026-06-23T00:00:00.000Z"
}
}
Protected endpoints require a real account session token from register/login/Apple โ pass it as Authorization: Bearer <token>. There is no shared demo token.
POST/v1/auth/logoutAuthorization
Revoke the current session token.
curl -s -X POST https://api.mybestie.io/v1/auth/logout \ -H 'Authorization: Bearer <token>'
{
"revoked": true
}
Errors: 401 missing or invalid bearer token.
POST/v1/auth/forgot-password
Start a password reset. The response is intentionally opaque โ always 202 whether or not the email exists (anti-enumeration; the send is fire-and-forget so timing can't leak account existence). A single-use token (30-min TTL) is emailed only when a matching account is found, then consumed via /v1/auth/reset-password. Rate-limited to 5/hour per email.
Feature gate: both reset routes return 503 until PASSWORD_RESET_ENABLED=true and email delivery (Resend) is configured. Handlers + tests are production-ready; held back only until email shipping is live.
curl -s -X POST https://api.mybestie.io/v1/auth/forgot-password \
-H 'content-type: application/json' \
-d '{"email":"user@example.com"}'
{ "status": "accepted" }
Body: { email: string }. Errors: 422 (invalid email), 429 (rate limited), 503 (feature gate off / email unconfigured).
POST/v1/auth/reset-password
Complete a reset with the single-use emailed token. The new password is screened by zxcvbn (strength) and Have I Been Pwned (breach check); on success the token is consumed, the password is set, and all existing sessions are revoked in one atomic transaction (a stolen session can't outlive a reset). A weak password is rejected before the token is consumed, so the same link can be retried. Rate-limited to 10/hour per email.
curl -s -X POST https://api.mybestie.io/v1/auth/reset-password \
-H 'content-type: application/json' \
-d '{"token":"<from email link>","newPassword":"correct-horse-battery-staple"}'
{ "reset": true }
Body: { token: string, newPassword: string } (12โ1024 chars; must pass zxcvbn strength โฅ 3 and not be a known breach). Errors: 400 (invalid/expired token), 422 (bad body or weak/breached password), 429 (rate limited), 503 (feature gate off).
GET/health
Liveness probe. No Authorization.
curl -s https://api.mybestie.io/health
# โ { "ok": true }
POST/v1/scoreAuthorization
Score one product by ingredients, productId, or sessionId (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. When scoring by sessionId, if the scan session has frames but no ingredients yet, the score auto-runs /extract first โ so a client can stream frames โ score without an explicit extract call.
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. |
sessionId one of | string | A scan session id โ scores the ingredients it captured from streamed frames (auto-extracts first if needed). Provide this or ingredients or productId. |
lastSeenProductId | string | Optional context โ the last product the user had scanned. Stored verbatim with the request in history; no scoring logic. |
labelImageId | string | Optional โ id of a private scan photo from POST /v1/images to link to this scan. Surfaces as the presigned imageUrl in history. |
ingredientsImageId | string | Optional โ id of the ingredients-panel photo from POST /v1/images. Same linking as labelImageId. |
productImage | string | Optional โ an inline product photo as a base64 data URI (e.g. data:image/jpeg;base64,โฆ), uploaded directly with the score request instead of pre-uploading via POST /v1/images. Capped at ~6.7MB. Accepted now; extraction/linking is wired separately. |
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 language code for user-facing display/prose fields. Defaults to profile language, then en; canonical IDs remain stable. |
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, category, categoryDisplayText, categoryConfidence, categoryEvidence; categoryDisplayText and prose fields follow language when supported; unknown categories return categoryDisplayText: ""; 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://api.mybestie.io/v1/score \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"ingredients":"Water, Triethanolamine, DMDM Hydantoin, Fragrance","language":"en"}'
curl -s https://api.mybestie.io/v1/score \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"productId":"8410757001090"}'
curl -s https://api.mybestie.io/v1/score \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"ingredients":["Aqua","Parfum","Linalool"],"mode":"llm","preferencesText":"allergic to fragrance"}'
curl -s https://api.mybestie.io/v1/score \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"sessionId":"<scan-session-id>"}'
{
"mode": "llm",
"language": "en",
"productId": null,
"product": null,
"score": 85,
"summary": "Contains DMDM Hydantoin, a formaldehyde releaser.",
"ingredients": [{ "input": "DMDM Hydantoin", "hazardScore": 90, "hazardFlags": ["formaldehyde_releaser"], "note": "...", "category": "preservative", "categoryDisplayText": "Preservative" }],
"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 / not exactly one of ingredientsยทproductIdยทsessionId), 401 (missing or invalid bearer token), 403 (sessionId owned by another account), 404 (productId unknown, or sessionId not found / expired), 422 (productId found but no ingredients, or sessionId has no ingredients yet โ code: "no_ingredients"), 429 (llm rate limit).
POST/v1/read-labelAuthorization
A vision model reads a product-label photo and returns a structured INCI ingredient list โ the authoritative ingredient read, separate from /v1/score. Send one image (a base64 data-URI montage of the curved label) or several images as a legibility fallback. Optional barcodeHint lets a confident read self-heal the catalog (it is contributed for that barcode so future scans resolve for free); language hints productName; model overrides the VLM per request. Rate-limited to 50/day per account.
curl -s -X POST https://api.mybestie.io/v1/read-label \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"image":"data:image/jpeg;base64,/9j/4AAQ...","barcodeHint":"3606000537128","language":"en"}'
{
"ingredients": ["Aqua", "Glycerin", "Niacinamide", "Phenoxyethanol"],
"productName": "Hydrating Serum",
"confidence": 0.9,
"model": "openai/gpt-4o-mini",
"unreadable": false
}
Body: { image? | images?, barcodeHint?, language?, model? } โ provide image or images (max 6 frames, ~6MB each). When no legible list is found, returns unreadable: true with ingredients: [].
Errors: 400 (no image/images), 401 (missing or invalid bearer token), 413 (too many / too-large images), 429 (rate limit), 502 (model failed or returned invalid JSON), 503 (no model configured).
Scan flow
A streaming scan session turns a live camera pan over a curved label into a converging ingredient list, then a hazard score. Open a session, then append frames one at a time as the user pans โ frame upload is append-only and instant (no vision model runs). The live ingredient list the user sees while panning comes from the client's on-device OCR. When the user stops, run /extract for the single vision-model pass over all accumulated frames, then score by sessionId. (You can skip the explicit extract โ /v1/score auto-extracts a session that has frames but no ingredients yet.) Sessions are account-scoped and expire 30 minutes after creation.
POST /v1/scan-sessionsโ open a session, getsessionId.POST /v1/scan-sessions/:id/framesโ repeat as the user pans (append-only, no VLM; up to 6 frames).POST /v1/scan-sessions/:id/extractโ the single VLM pass over all frames โ ingredient list.POST /v1/scorewith{ sessionId }โ hazard score (auto-extracts first if you skipped step 3).
TOKEN='<token>'; BASE=https://api.mybestie.io
# 1. open a session
SID=$(curl -s -X POST $BASE/v1/scan-sessions \
-H "Authorization: Bearer $TOKEN" | jq -r .sessionId)
# 2. append frames as the user pans โ instant, no VLM (the live list is client OCR)
curl -s -X POST $BASE/v1/scan-sessions/$SID/frames \
-H 'content-type: application/json' -H "Authorization: Bearer $TOKEN" \
-d '{"image":"data:image/jpeg;base64,/9j/4AAQ...frame1..."}'
# โ { "frameCount": 1, "ingredients": [] } โ stored list, empty until extract
curl -s -X POST $BASE/v1/scan-sessions/$SID/frames \
-H 'content-type: application/json' -H "Authorization: Bearer $TOKEN" \
-d '{"image":"data:image/jpeg;base64,/9j/4AAQ...frame2..."}'
# โ { "frameCount": 2, "ingredients": [] }
# 3. extract once โ the single VLM pass over all accumulated frames
curl -s -X POST $BASE/v1/scan-sessions/$SID/extract \
-H 'content-type: application/json' -H "Authorization: Bearer $TOKEN" \
-d '{"barcodeHint":"3606000537128","language":"en"}'
# โ { "frameCount": 2, "ingredients": ["Aqua","Glycerin","Niacinamide","Phenoxyethanol"], ... }
# 4. score the captured ingredients (or skip step 3 โ /score auto-extracts)
curl -s -X POST $BASE/v1/score \
-H 'content-type: application/json' -H "Authorization: Bearer $TOKEN" \
-d "{\"sessionId\":\"$SID\"}"
Why the perceived latency is instant
The vision model runs exactly once per scan โ on /extract (or lazily from /score) โ not on every frame. The "real-time" ingredient list the user watches fill in while panning is driven by the client's on-device OCR, so frame upload is append-only and returns immediately. The single extract pins the model to openai/gpt-4.1 on the OpenAI provider (no fallbacks), keeping the system+images prompt prefix on one endpoint's cache. This kills the old per-frame re-extraction (a full VLM re-read on every frame โ O(nยฒ) cost over a session) while keeping the progressive feel.
POST/v1/scan-sessionsAuthorization
Open a streaming scan session for the current account. No body. The returned sessionId is then used to stream frames and finally to score. Sessions are account-scoped and expiresAt is 30 minutes out.
curl -s -X POST https://api.mybestie.io/v1/scan-sessions \ -H 'Authorization: Bearer <token>'
{
"sessionId": "b3f1c2a4-5e6d-7f80-9a1b-2c3d4e5f6071",
"expiresAt": "2026-06-24T00:30:00.000Z"
}
Errors: 401 (missing or invalid bearer token).
POST/v1/scan-sessions/:id/framesAuthorization
Append one camera frame to the session. This is append-only and instant โ no vision model runs. Call it repeatedly while the user pans and render the live ingredient list from your on-device OCR; the response's ingredients is the session's currently stored list (empty until you run /extract). Capped at 6 frames per session. Rate-limited per account (route scan-frames, 300/day, env RL_SCAN_FRAMES_PER_DAY). See Scan flow for the perceived-latency design.
Request body
| Field | Type | Description |
|---|---|---|
image required | string | One camera frame as a base64 data URI (same shape as read-label images). Hints (barcodeHint, language, ocrTokens) belong on /extract, not here. |
Response
| Field | Type | Description |
|---|---|---|
sessionId | string | Echoed session id. |
frameCount | number | Frames accumulated so far (1โ6). |
ingredients[] | string[] | The session's currently stored INCI list โ empty until /extract runs. |
curl -s -X POST https://api.mybestie.io/v1/scan-sessions/<id>/frames \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"image":"data:image/jpeg;base64,/9j/4AAQ..."}'
{
"sessionId": "b3f1c2a4-5e6d-7f80-9a1b-2c3d4e5f6071",
"frameCount": 2,
"ingredients": []
}
Errors: 400 (missing image / invalid body), 401 (missing or invalid bearer token), 403 (session owned by another account), 404 (session not found or expired), 413 (frame cap of 6 reached), 429 (rate limit + Retry-After).
POST/v1/scan-sessions/:id/extractAuthorization
The single vision-model pass over all frames accumulated by /frames. Reconciles the tiles of the curved label into one de-duplicated, ordered INCI list, stores it on the session, and returns it. Call this once, when the user stops panning. The scan path pins openai/gpt-4.1 on the OpenAI provider (no fallbacks). Rate-limited per account (route scan-extract, 60/day, env RL_SCAN_EXTRACT_PER_DAY). All body fields are optional. Tip: /v1/score auto-runs this for you if the session has frames but no ingredients yet.
Request body
| Field | Type | Description |
|---|---|---|
barcodeHint | string | If a barcode was read โ helps disambiguate and lets a confident read self-heal the catalog for that barcode. |
language | string | ISO language hint for productName. |
ocrTokens | string[] | On-device OCR ingredient tokens โ corroboration anchors for the reconcile step. |
Response
| Field | Type | Description |
|---|---|---|
sessionId | string | Echoed session id. |
frameCount | number | Frames the extraction read over (1โ6). |
ingredients[] | string[] | The INCI list extracted across all frames. |
confidence | number | Model confidence in the read (0โ1). |
completeness | number | Model's 0โ1 estimate it reached the END of the ingredient list โ low means capture more. |
reconciliation | object | corroborated / vlmOnly / ocrDropped / ocrMissedCandidates โ how the VLM read reconciled with on-device OCR. |
productName | string | null | Product name when legible; null otherwise. |
curl -s -X POST https://api.mybestie.io/v1/scan-sessions/<id>/extract \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"barcodeHint":"3606000537128","language":"en"}'
{
"sessionId": "b3f1c2a4-5e6d-7f80-9a1b-2c3d4e5f6071",
"frameCount": 2,
"ingredients": ["Aqua", "Glycerin", "Niacinamide", "Phenoxyethanol"],
"confidence": 0.9,
"completeness": 0.8,
"reconciliation": { "corroborated": ["Aqua", "Glycerin"], "vlmOnly": ["Niacinamide", "Phenoxyethanol"], "ocrDropped": [], "ocrMissedCandidates": [] },
"productName": "Hydrating Serum"
}
Errors: 400 (invalid body), 401 (missing or invalid bearer token), 403 (session owned by another account), 404 (session not found or expired), 422 (no frames to extract โ code: "no_frames"), 429 (rate limit + Retry-After), 502/503 (vision model failed / not configured).
GET/v1/scan-sessions/:idAuthorization
Current state of a scan session โ the latest extraction without sending a new frame. Only the owning account may read it.
curl -s https://api.mybestie.io/v1/scan-sessions/<id> \ -H 'Authorization: Bearer <token>'
{
"sessionId": "b3f1c2a4-5e6d-7f80-9a1b-2c3d4e5f6071",
"status": "open",
"frameCount": 2,
"ingredients": ["Aqua", "Glycerin", "Niacinamide", "Phenoxyethanol"],
"confidence": 0.9,
"reconciliation": { "corroborated": ["Aqua", "Glycerin"], "vlmOnly": ["Niacinamide", "Phenoxyethanol"], "ocrDropped": [], "ocrMissedCandidates": [] },
"productName": "Hydrating Serum",
"createdAt": "2026-06-24T00:00:00.000Z",
"updatedAt": "2026-06-24T00:01:30.000Z",
"expiresAt": "2026-06-24T00:30:00.000Z"
}
Errors: 401 (missing or invalid bearer token), 403 (session owned by another account), 404 (session not found or expired).
GET/v1/resolveAuthorization
Deterministic product lookup by barcode (GTIN/EAN). Returns a minimal product reference when the catalog has a match, null otherwise. The iOS app uses this to confirm a scanned barcode before scoring.
curl -s 'https://api.mybestie.io/v1/resolve?barcode=8410757001090' \ -H 'Authorization: Bearer <token>'
{
"product": { // null when not found
"id": "8410757001090",
"name": "Hydrating Serum",
"brand": "Acme", // optional
"category": "serum", // optional
"barcode": "8410757001090" // optional
}
}
Query: barcode (required, 8โ14 digits; spaces/hyphens stripped). Errors: 400 (invalid barcode), 401 (missing/invalid bearer token).
POST/v1/identifyAuthorization
Visual product identification โ an Apple Vision FeaturePrint vector โ ranked candidate products by image similarity (pgvector k-NN, lowest L2 distance = closest). The device computes the vector; the server never sees the photo. Rate-limited to 200/day per account (k-NN is DB-expensive).
curl -s -X POST https://api.mybestie.io/v1/identify \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"featureprint":[0.12, 0.46, โฆ 768 floats], "revision":1}'
{
"revision": 1, // echoed
"candidates": [ // ranked by distance asc
{ "productId": "5901234123457", "name": "Moisturizing Cream",
"brand": "BrandA", // optional
"imageId": "img-โฆ", "distance": 0.234 }
]
}
Body: featureprint (exactly 768 finite numbers, required), revision (non-negative integer, required). Errors: 400 (wrong dimension / non-finite / bad revision), 401 (auth), 429 (rate limit).
POST/v1/imagesAccount session
Upload a user's private scan photo (front / ingredients) to secure object storage (DO Spaces). The stored image surfaces in /v1/history as a short-lived presigned imageUrl. Requires a real account session (images FK to accounts.id) โ a device-only / demo scope is rejected 403. Max 6 MB; rate-limited to 120/day per account.
curl -s -X POST 'https://api.mybestie.io/v1/images?type=label' \ -H 'Authorization: Bearer <token>' \ -H 'content-type: image/jpeg' \ --data-binary '@label.jpg'
{ "id": "12345", "type": "label" }
Query: ?type=label (default) or ingredients. Body: raw binary image (Content-Type must be image/*). Errors: 401 (auth), 403 (account scope required), 413 (exceeds 6 MB), 429 (rate limit).
POST/v1/images/linkAccount session
Attach already-uploaded scan photos (from POST /v1/images) to the caller's most-recent scored entry. For the multi-step scan flow that precomputes the score and then snaps the product photo โ so the photo can't ride on the original /v1/score call. Each image id is ownership-checked; only the ids you send are written (the other is left as-is). Surfaces as the presigned imageUrl in history. Also accepts an optional barcode โ the step-3 auto-scanned product code โ stored on the same row and surfaced as barcode in history (lenient validation: [A-Za-z0-9._-]{4,64}; junk is ignored, not errored).
curl -s -X POST https://api.mybestie.io/v1/images/link \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"labelImageId":"42","ingredientsImageId":"43","barcode":"0036308510106"}'
{ "linked": true, "barcodeStored": true } // each false when nothing to write / not owned
Body: { labelImageId?, ingredientsImageId?, barcode? }. Errors: 401 (auth), 403 (account scope required).
POST/v1/images/nameAccount session
Read the product name off an uploaded front-label photo with one cheap VLM turn, and store it on the caller's most-recent scored entry (name_source = "vlm") โ the inference side of rebuilding product metadata instead of fetching the catalog. The stored name becomes the title in history. Account session only. Rate-limited 100/day.
curl -s -X POST https://api.mybestie.io/v1/images/name \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"imageId":"42"}'
{ "name": "CVS Health Ultra Sheer SPF 45" } // null when unreadable / not owned
Body: { imageId } (a /v1/images id you own). Errors: 401 (auth), 403 (account scope required), 429 (rate limit).
GET/v1/schema
JSON Schema (generated from zod) for the score request and response, plus the product shape. No Authorization.
curl -s https://api.mybestie.io/v1/schema
# โ { "request": {โฆ}, "response": {โฆ}, "product": {โฆ} }
POST/v1/pricesAuthorization
Competitor-price lookup via SerpApi. engine: "shopping" (prices/sellers) or "organic" (web results). Query is a product name or barcode/UPC. Rate-limited per account; 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://api.mybestie.io/v1/prices \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <token>' \
-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 (missing or invalid bearer token), 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 Authorization.
curl -s https://api.mybestie.io/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/searchAuthorization
Product-name search over the Open Beauty Facts catalog for the in-app search screen. Trigram (pg_trgm) name + brand match, ranked prefix-first then by similarity. Only scoreable products (an ingredient list on file) are returned, so each result taps straight into POST /v1/score by productId. Rate-limited per account.
Query
| Query | Type | Default |
|---|---|---|
q required | string (min 2) | โ (alias query) |
limit | integer 1โ50 | 20 |
cursor | integer โฅ 0 (offset) | 0 |
curl -s 'https://api.mybestie.io/v1/products/search?q=cerave&limit=20' \ -H "Authorization: Bearer $TOKEN"
{
"query": "cerave",
"items": [
{ "productId": "0036308510106", "name": "CeraVe Moisturizing Cream", "brand": "CeraVe", "category": "facial_moisturizer", "imageUrl": "https://images.openbeautyfacts.org/..." }
],
"nextCursor": "20"
}
nextCursor is the ?cursor= offset for the next page, or null at the end. category is the canonical product category derived from the catalog descriptor. Errors: 400 (missing / too-short q, or limit/cursor out of range), 401 (missing or invalid bearer token), 429 (rate limit).
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 Authorization.
| 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). When a catalog product has no ingredients but a saved contribution exists, suggestedIngredients returns the best saved list.
curl -s 'https://api.mybestie.io/v1/products/8410757001090/exists?withIngredients=true'
{
"productId": "8410757001090",
"exists": true,
"hasIngredients": true,
"suggestedIngredients": ["Aqua", "Glycerin"]
}
POST/v1/products/:id/ingredientsAuthorization
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://api.mybestie.io/v1/products/3606000537128/ingredients \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"ingredients":["Aqua","Glycerin","Niacinamide","Phenoxyethanol"],"lastSeenProductId":"3606000537128"}'
{
"id": "1",
"barcode": "3606000537128",
"ingredients": ["aqua", "glycerin", "niacinamide", "phenoxyethanol"],
"rawText": null,
"lastSeenProductId": "3606000537128",
"status": "pending",
"createdAt": "2026-06-21T20:00:00.000Z"
}
Errors: 400 (empty list), 401 (missing or invalid bearer token).
POST/v1/products/:id/imageAuthorization
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://api.mybestie.io/v1/products/3606000537128/image \ -H 'Authorization: Bearer <token>' \ -H 'content-type: image/jpeg' \ --data-binary @label.jpg
{
"id": "1",
"barcode": "3606000537128",
"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 (missing or invalid bearer token), 413 (> 6MB), 415 (non-image), 503 (storage not configured).
GET/v1/products/:id/contributions
Submitted ingredient lists for a barcode, newest first. No Authorization.
curl -s https://api.mybestie.io/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 Authorization.
curl -s https://api.mybestie.io/v1/products/3606000537128/images
# โ { "items": [ { "id", "barcode", "contentType", "byteSize", "createdAt" } ] }
Categories
Category-level context for the app's How It Compares section โ a few safer picks within a product category, plus the deterministic score distribution that the category histogram is drawn from. Both are keyed by a canonical category id (e.g. sunscreen).
GET/v1/categories/:id/recommendationsAuthorization
A few safer picks in a product category โ the lowest-hazard catalog products by the precomputed deterministic estimate, for the recommendation strip under the app's How It Compares section. Display fields only: the underlying score is a rough estimate used to rank and is never returned.
Path & query
| Param | Type | Default |
|---|---|---|
:id path | string | canonical category id (e.g. sunscreen) |
limit | integer 1โ6 | 3 |
exclude | string (barcode) | โ (omit one product, e.g. the just-scanned one) |
curl -s 'https://api.mybestie.io/v1/categories/sunscreen/recommendations?limit=3' \ -H "Authorization: Bearer $TOKEN"
{
"category": "sunscreen",
"products": [
{ "productId": "0301240587", "name": "Mineral Sunscreen SPF 30", "brand": "Badger", "imageUrl": "https://images.openbeautyfacts.org/..." }
]
}
A known category with no eligible products returns an empty products array. Errors: 400 (limit out of range), 401 (missing or invalid bearer token), 404 (unknown category id).
GET/v1/categories/:id/distributionAuthorization
The deterministic score distribution for a canonical product category โ rough estimates over community-sourced ingredient lists โ used by the app's How It Compares histogram. The optional ?score= locates a product within the distribution (its percentile and bucket).
Path & query
| Param | Type | Default |
|---|---|---|
:id path | string | canonical category id |
score | integer 0โ100 | โ (locate within the distribution) |
curl -s 'https://api.mybestie.io/v1/categories/sunscreen/distribution?score=38' \ -H "Authorization: Bearer $TOKEN"
{
"category": "sunscreen",
"displayName": "Sunscreen",
"n": 212,
"mean": 47.0,
"stddev": 18.0,
"minScore": 0,
"maxScore": 100,
"buckets": [ { "min": 0, "max": 10, "count": 6, "pct": 2.8 }, "โฆ 10 buckets spanning 0-10 โฆ 90-100" ],
"percentile": 63.5,
"bucketIndex": 4
}
percentile / bucketIndex are null when no ?score= is supplied. Errors: 400 (score out of range), 401 (missing or invalid bearer token), 404 (unknown category or too few samples).
GET/v1/historyAuthorization
This account's past /v1/score scans, newest first, as full HistoryEntry objects โ the stored request/response flattened into one easy-to-consume shape (everything top-level: score, band, product, ingredients, interactions, coverage, narrative, citations). Keyset-paginated โ pass the returned nextCursor as ?cursor= for the next (older) page.
Full entries are rich. To render just a list, prefer /v1/history/summary (slim rows); fetch one full entry from /v1/history/:id when a row is opened.
| Query | Type | Default |
|---|---|---|
limit | integer 1โ100 | 20 |
cursor | string (id) | โ |
band | great ยท good ยท risky ยท high ยท avoid | โ |
band filters by risk band โ the five fixed score buckets great 0โ19 ยท good 20โ39 ยท risky 40โ59 ยท high 60โ79 ยท avoid 80โ100 (higher = more hazard). Repeatable or comma-separated (?band=risky,high,avoid); rows in any listed band match (union). Omit for all scans; scans with no score are excluded while filtering.
curl -s 'https://api.mybestie.io/v1/history?limit=20&band=risky,high,avoid' \ -H 'Authorization: Bearer <token>'
{
"items": [ HistoryEntry, โฆ ], // see the HistoryEntry shape below
"nextCursor": "41"
}
HistoryEntry
{
"id": "42",
"createdAt": "2026-06-21T19:30:00.000Z",
"mode": "llm",
"score": 18, // raw 0โ100 hazard (band stays a frontend concern)
"title": "CeraVe Moisturizing Cream", // resolved display name
"summary": "Low overall risk.",
"language": "en",
"imageUrl": "https://โฆ", // presigned user scan photo โ catalog image โ null
"product": { // null when scored by ingredient list
"productId": "0036308510106",
"name": "CeraVe Moisturizing Cream",
"brand": "CeraVe",
"imageUrl": "https://โฆ",
"descriptor": "face cream"
},
"scannedIngredients": ["Aqua", "Glycerin", โฆ], // what was scored (from the request)
"ingredients": [{ "input": "Aqua", "hazardScore": 0, "hazardFlags": [],
"note": "โฆ", "category": "solvent",
"estimatedConcentrationPct": 50, "citationIds": [] }],
"interactions": [],
"coverage": { "matched": 2, "total": 2, "unmatched": [] },
"preferences": null,
"narrative": "A gentle cream.",
"citations": []
}
Errors: 400 (bad cursor or unknown band), 401 (missing or invalid bearer token).
GET/v1/history/summaryAuthorization
Slim, list-shaped scan history โ newest first, same keyset pagination as /v1/history but it ships only what a list row renders. The full request/response blobs stay server-side; the display fields are projected straight out of storage. Use this for the History screen and fetch one full entry from /v1/history/:id when a row is tapped.
| Query | Type | Default |
|---|---|---|
limit | integer 1โ100 | 20 |
cursor | string (id) | โ |
band | great ยท good ยท risky ยท high ยท avoid | โ |
Each item
| Field | Type | Notes |
|---|---|---|
id | string | Monotonic id; doubles as the cursor and the /v1/history/:id key. |
mode | "default" | "llm" | Scoring engine used. |
score | integer | null | Product hazard 0โ100. |
title | string | Display name โ product descriptor โ leading ingredients โ "Scanned product". |
summary | string | null | One-line verdict from the stored response. |
productId | string | null | Resolved barcode, when the scan had one. |
createdAt | string (ISO-8601) | When the scan was recorded. |
Supports the same band risk filter as /v1/history โ great 0โ19 ยท good 20โ39 ยท risky 40โ59 ยท high 60โ79 ยท avoid 80โ100, repeatable or comma-separated, union across bands.
curl -s 'https://api.mybestie.io/v1/history/summary?limit=20&band=great,good' \ -H 'Authorization: Bearer <token>'
{
"items": [{
"id": "42",
"mode": "llm",
"score": 18,
"title": "CeraVe Moisturizing Cream",
"summary": "Low overall risk; one fragrance allergen flagged.",
"productId": "0036308510106",
"createdAt": "2026-06-21T19:30:00.000Z"
}],
"nextCursor": "41"
}
Errors: 400 (bad cursor or unknown band), 401 (missing or invalid bearer token).
GET/v1/history/:idAuthorization
One stored scan as a full HistoryEntry (the same flat shape /v1/history returns), fetched by id from a /summary row. Scoped to the owning account โ an id belonging to another device returns 404, never another account's data. Render the report screen directly from this entry on tap.
curl -s https://api.mybestie.io/v1/history/42 \ -H 'Authorization: Bearer <token>'
HistoryEntry // the flat object documented under GET /v1/history
Errors: 401 (missing or invalid bearer token), 404 (no such entry for this account).
PATCH/v1/history/:idAuthorization
Rename one stored scan โ sets a user-authored product_name (recorded as name_source = 'user', overriding any VLM/catalog name), which becomes the entry's title on subsequent reads. Scoped to the owning account; another account's id returns 404. Body: { name } โ trimmed server-side, capped at 200 chars; a blank name is rejected 400. Rate-limited 300/day per account.
curl -s -X PATCH https://api.mybestie.io/v1/history/42 \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"name":"My Night Cream"}'
{ "id": "42", "name": "My Night Cream" }
Errors: 400 (blank name), 401 (missing or invalid bearer token), 404 (no such entry for this account), 429 (rate limited).
DELETE/v1/history/:idAuthorization
Delete one stored scan (soft-delete: the row is retained server-side with a deleted_at stamp but excluded from every history read). Scoped to the owning account; another account's id returns 404. Rate-limited 300/day per account.
curl -s -X DELETE https://api.mybestie.io/v1/history/42 \ -H 'Authorization: Bearer <token>'
{ "deleted": true }
Errors: 401 (missing or invalid bearer token), 404 (no such entry for this account, or already deleted), 429 (rate limited).
DELETE/v1/historyAuthorization
Clear all of this account's stored scans in one call โ the bulk companion to DELETE /v1/history/:id. Same soft-delete semantics (rows are retained server-side with a deleted_at stamp but excluded from every history read) and scoped to the calling account, so it never touches another account's data. Returns the number of scans cleared; idempotent โ a second call on an already-empty history returns { "deleted": 0 }. Rate-limited per account, sharing the per-entry history-mutate bucket.
curl -s -X DELETE https://api.mybestie.io/v1/history \ -H 'Authorization: Bearer <token>'
{ "deleted": 12 }
Errors: 401 (missing or invalid bearer token), 429 (rate limited).
GET/v1/profileAuthorization
This account's saved name, preferences/allergens, language, and skin tone. Returns an empty default if none stored. Preferences/allergies/language auto-fill /v1/score (llm) when the request omits them; skinTone is stored customization, surfaced for the client to use.
curl -s https://api.mybestie.io/v1/profile \ -H 'Authorization: Bearer <token>'
{
"accountId": "a1b2c3d4",
"name": "Trevor",
"preferencesText": "vegan, fragrance-free",
"allergies": ["fragrance", "parabens"],
"language": "en",
"skinTone": "medium-deep",
"createdAt": "2026-06-21T19:00:00.000Z",
"updatedAt": "2026-06-21T19:00:00.000Z"
}
Errors: 401 (missing or invalid bearer token).
PUT/v1/profileAuthorization
Upsert the profile. Partial โ only sent fields change; null clears one. Body: { name?, preferencesText?, allergies?, language?, skinTone? }. name is trimmed, โค100 chars; skinTone is a trimmed free-form string, โค50 chars.
curl -s -X PUT https://api.mybestie.io/v1/profile \
-H 'content-type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"name":"Trevor","preferencesText":"vegan, fragrance-free","allergies":["fragrance","parabens"],"language":"en","skinTone":"medium-deep"}'
Returns the saved profile (same shape as GET). Errors: 400 (bad body), 401 (missing or invalid bearer token).
DELETE/v1/profileAuthorization
Forget this device's stored preferences/allergens.
curl -s -X DELETE https://api.mybestie.io/v1/profile \
-H 'Authorization: Bearer <token>'
# โ { "deleted": true }
Errors: 401 (missing or invalid bearer token).
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; not exactly one of ingredientsยทproductIdยทsessionId; empty list; or mode:"llm" without an LLM key. |
| 401 | Missing or malformed Authorization on a route that requires it. |
| 403 | Accessing a scan session that belongs to another account. |
| 404 | Product not found (unknown barcode), scan session not found or expired, or unknown route. |
| 413 | Uploaded image exceeds the 6MB limit, or a scan session hit its 6-frame cap. |
| 415 | Image upload with a non-image/* Content-Type. |
| 422 | Product found but has no ingredients, or a scan session has no ingredients yet (code: "no_ingredients") โ prompt the user to contribute / keep scanning. |
| 429 | Per-account rate limit (/v1/prices, /v1/score llm, /v1/read-label, /v1/scan-sessions/:id/frames). 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