v1 account auth

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

MethodEndpointWhat it does
GET/healthLiveness probe.
POST/v1/auth/registerCreate an email/password account and return a bearer session.
POST/v1/auth/loginSign in with email/password and return a bearer session.
POST/v1/auth/appleSign in with Apple identity token and return a bearer session.
GET/v1/meCurrent account. ๐Ÿ”‘
POST/v1/scoreScore a product by ingredients, productId, or sessionId. ๐Ÿ”‘
POST/v1/scan-sessionsOpen a streaming scan session. ๐Ÿ”‘
POST/v1/scan-sessions/:id/framesAppend a camera frame โ€” fast, no VLM. ๐Ÿ”‘
POST/v1/scan-sessions/:id/extractThe single VLM pass over all frames โ†’ ingredient list. ๐Ÿ”‘
GET/v1/scan-sessions/:idCurrent scan-session state. ๐Ÿ”‘
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/searchSearch the catalog by product name/brand (scoreable results). ๐Ÿ”‘
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/categories/:id/recommendationsSafer-pick products in a category (no scores). ๐Ÿ”‘
GET/v1/categories/:id/distributionScore distribution for a category (How It Compares). ๐Ÿ”‘
GET/v1/historyThis account's past scans (paginated). ๐Ÿ”‘
GET/v1/profileSaved preferences/allergens. ๐Ÿ”‘
PUT/v1/profileUpsert preferences (partial). ๐Ÿ”‘
DEL/v1/profileForget 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

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.
sessionId one ofstringA scan session id โ€” scores the ingredients it captured from streamed frames (auto-extracts first if needed). Provide this or ingredients or productId.
lastSeenProductIdstringOptional context โ€” the last product the user had scanned. Stored verbatim with the request in history; no scoring logic.
labelImageIdstringOptional โ€” id of a private scan photo from POST /v1/images to link to this scan. Surfaces as the presigned imageUrl in history.
ingredientsImageIdstringOptional โ€” id of the ingredients-panel photo from POST /v1/images. Same linking as labelImageId.
productImagestringOptional โ€” 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".
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 language code for user-facing display/prose fields. Defaults to profile language, then en; canonical IDs remain stable.
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, 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).
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://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.

  1. POST /v1/scan-sessions โ†’ open a session, get sessionId.
  2. POST /v1/scan-sessions/:id/frames โ†’ repeat as the user pans (append-only, no VLM; up to 6 frames).
  3. POST /v1/scan-sessions/:id/extract โ†’ the single VLM pass over all frames โ†’ ingredient list.
  4. POST /v1/score with { 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

FieldTypeDescription
image requiredstringOne camera frame as a base64 data URI (same shape as read-label images). Hints (barcodeHint, language, ocrTokens) belong on /extract, not here.

Response

FieldTypeDescription
sessionIdstringEchoed session id.
frameCountnumberFrames 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

FieldTypeDescription
barcodeHintstringIf a barcode was read โ€” helps disambiguate and lets a confident read self-heal the catalog for that barcode.
languagestringISO language hint for productName.
ocrTokensstring[]On-device OCR ingredient tokens โ€” corroboration anchors for the reconcile step.

Response

FieldTypeDescription
sessionIdstringEchoed session id.
frameCountnumberFrames the extraction read over (1โ€“6).
ingredients[]string[]The INCI list extracted across all frames.
confidencenumberModel confidence in the read (0โ€“1).
completenessnumberModel's 0โ€“1 estimate it reached the END of the ingredient list โ€” low means capture more.
reconciliationobjectcorroborated / vlmOnly / ocrDropped / ocrMissedCandidates โ€” how the VLM read reconciled with on-device OCR.
productNamestring | nullProduct 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).

FieldTypeDefault
query requiredstringโ€”
engine"shopping" | "organic""shopping"
limitinteger 1โ€“5010
countryISO-3166 a2"us"
languageISO-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/: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.

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

ParamTypeDefault
:id pathstringcanonical category id (e.g. sunscreen)
limitinteger 1โ€“63
excludestring (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

ParamTypeDefault
:id pathstringcanonical category id
scoreinteger 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.

QueryTypeDefault
limitinteger 1โ€“10020
cursorstring (id)โ€”
bandgreat ยท 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.

QueryTypeDefault
limitinteger 1โ€“10020
cursorstring (id)โ€”
bandgreat ยท good ยท risky ยท high ยท avoidโ€”

Each item

FieldTypeNotes
idstringMonotonic id; doubles as the cursor and the /v1/history/:id key.
mode"default" | "llm"Scoring engine used.
scoreinteger | nullProduct hazard 0โ€“100.
titlestringDisplay name โ†’ product descriptor โ†’ leading ingredients โ†’ "Scanned product".
summarystring | nullOne-line verdict from the stored response.
productIdstring | nullResolved barcode, when the scan had one.
createdAtstring (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.

StatusWhen
400Malformed body; not exactly one of ingredientsยทproductIdยทsessionId; empty list; or mode:"llm" without an LLM key.
401Missing or malformed Authorization on a route that requires it.
403Accessing a scan session that belongs to another account.
404Product not found (unknown barcode), scan session not found or expired, or unknown route.
413Uploaded image exceeds the 6MB limit, or a scan session hit its 6-frame cap.
415Image upload with a non-image/* Content-Type.
422Product found but has no ingredients, or a scan session has no ingredients yet (code: "no_ingredients") โ€” prompt the user to contribute / keep scanning.
429Per-account rate limit (/v1/prices, /v1/score llm, /v1/read-label, /v1/scan-sessions/:id/frames). 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