{"openapi":"3.0.3","info":{"title":"Capture Image Sync API","version":"0.1.0","description":"Backend for the capture-image app. The Android app **pushes** extracted captures (text + metadata + image); a client (e.g. a browser/VS Code extension) **pulls** them using a per-device API key.\n\n### Auth model\n- `POST /auth/*` exchange email+password for a Supabase session (JWT).\n- `/devices/*` are account-level and require that **user JWT** (`Authorization: Bearer <accessToken>`).\n- `/captures/*` are device-level and require a **device API key** — the `cik_…` key minted by `POST /devices`. Send it as `Authorization: Bearer <apiKey>` or `x-api-key: <apiKey>`.\n\nDevice keys are stored hashed (SHA-256); the raw key is shown only once."},"servers":[{"url":"http://localhost:8787","description":"Local"}],"x-extract-allowed-models":["gpt-4o-mini","gpt-4o","gpt-4.1-mini","gpt-4.1"],"x-extract-default-model":"gpt-4o-mini","tags":[{"name":"Health","description":"Liveness & readiness probes"},{"name":"Auth","description":"Account registration and login"},{"name":"Devices","description":"Per-device API keys (account-scoped)"},{"name":"Captures","description":"Push and pull captures (device-scoped)"},{"name":"Extract","description":"Server-side OCR via an LLM vision model (device-scoped)"}],"components":{"securitySchemes":{"userAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"Supabase access token from POST /auth/login."},"deviceAuth":{"type":"http","scheme":"bearer","description":"Device API key (cik_…) as a bearer token."},"deviceApiKey":{"type":"apiKey","in":"header","name":"x-api-key","description":"Device API key (cik_…). Alternative to the Authorization header."}},"schemas":{"Error":{"type":"object","properties":{"error":{"type":"string","example":"Unauthorized"},"code":{"type":"string","example":"unauthorized"}},"required":["error"]},"Credentials":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string","format":"email","example":"user@example.com"},"password":{"type":"string","minLength":8,"example":"hunter2hunter2"}}},"Session":{"type":"object","properties":{"userId":{"type":"string","format":"uuid"},"accessToken":{"type":"string"},"refreshToken":{"type":"string"},"expiresAt":{"type":"integer","description":"Unix seconds","example":1735689600}}},"Device":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"Android — Pixel 8"},"platform":{"type":"string","enum":["android","extension","ios","windows","unknown"]},"key_prefix":{"type":"string","example":"cik_a1b2c3d4"},"revoked":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"},"last_seen_at":{"type":"string","format":"date-time","nullable":true}}},"Capture":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"clientId":{"type":"string","nullable":true,"description":"The app's own capture id; upserts are idempotent on it."},"category":{"type":"string","example":"Receipts"},"text":{"type":"string","description":"Raw OCR text"},"imageWidth":{"type":"integer","nullable":true},"imageHeight":{"type":"integer","nullable":true},"structured":{"type":"string","nullable":true,"description":"AI-structured output (json/csv/vcard) as text, if provided."},"structuredFormat":{"type":"string","nullable":true,"enum":["json","csv","vcard",null]},"structuredAt":{"type":"string","format":"date-time","nullable":true},"capturedAt":{"type":"string","format":"date-time","nullable":true},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"imageUrl":{"type":"string","nullable":true,"description":"Short-lived signed URL to the image (expires per SIGNED_URL_TTL_SECONDS)."}}}}},"paths":{"/health":{"get":{"tags":["Health"],"summary":"Liveness probe","security":[],"responses":{"200":{"description":"Process is up","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"service":{"type":"string"}}}}}}}}},"/ready":{"get":{"tags":["Health"],"summary":"Readiness probe (verifies Supabase connectivity)","security":[],"responses":{"200":{"description":"Ready"},"503":{"description":"Supabase unreachable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/auth/register":{"post":{"tags":["Auth"],"summary":"Create an account","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Credentials"}}}},"responses":{"201":{"description":"Account created","content":{"application/json":{"schema":{"type":"object","properties":{"userId":{"type":"string","format":"uuid"},"email":{"type":"string","format":"email"}}}}}},"400":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/auth/login":{"post":{"tags":["Auth"],"summary":"Log in and receive a session","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Credentials"}}}},"responses":{"200":{"description":"Session","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Session"}}}},"401":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/auth/refresh":{"post":{"tags":["Auth"],"summary":"Exchange a refresh token for a fresh access token","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["refreshToken"],"properties":{"refreshToken":{"type":"string"}}}}}},"responses":{"200":{"description":"Refreshed session","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Session"}}}},"401":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/devices":{"post":{"tags":["Devices"],"summary":"Register a device and mint its API key","description":"The raw `apiKey` is returned ONCE here and never again.","security":[{"userAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","example":"Chrome extension"},"platform":{"type":"string","enum":["android","extension","ios","windows","unknown"],"default":"unknown"}}}}}},"responses":{"201":{"description":"Device created","content":{"application/json":{"schema":{"type":"object","properties":{"device":{"$ref":"#/components/schemas/Device"},"apiKey":{"type":"string","example":"cik_aB3...zZ","description":"Shown once."}}}}}},"400":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"tags":["Devices"],"summary":"List the account's devices","security":[{"userAuth":[]}],"responses":{"200":{"description":"Devices","content":{"application/json":{"schema":{"type":"object","properties":{"devices":{"type":"array","items":{"$ref":"#/components/schemas/Device"}}}}}}},"401":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/devices/{id}":{"delete":{"tags":["Devices"],"summary":"Revoke a device key","security":[{"userAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Revoked","content":{"application/json":{"schema":{"type":"object","properties":{"revoked":{"type":"string"}}}}}},"401":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/captures":{"post":{"tags":["Captures"],"summary":"Push a capture","description":"Send as `multipart/form-data` (image + fields) or `application/json` (no image). Idempotent on `clientId`: re-sending the same id updates the row (e.g. a later save that adds structured output).","security":[{"deviceAuth":[]},{"deviceApiKey":[]}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"image":{"type":"string","format":"binary","description":"JPEG (optional)"},"clientId":{"type":"string"},"category":{"type":"string","default":"Text"},"text":{"type":"string"},"imageWidth":{"type":"integer"},"imageHeight":{"type":"integer"},"capturedAt":{"type":"integer","description":"Epoch millis"},"structured":{"type":"string"},"structuredFormat":{"type":"string","enum":["json","csv","vcard"]}}}},"application/json":{"schema":{"type":"object","properties":{"clientId":{"type":"string"},"category":{"type":"string","default":"Text"},"text":{"type":"string"},"imageWidth":{"type":"integer"},"imageHeight":{"type":"integer"},"capturedAt":{"type":"integer","description":"Epoch millis"},"structured":{"type":"string"},"structuredFormat":{"type":"string","enum":["json","csv","vcard"]}}}}}},"responses":{"201":{"description":"Stored","content":{"application/json":{"schema":{"type":"object","properties":{"capture":{"$ref":"#/components/schemas/Capture"}}}}}},"400":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Image exceeds MAX_IMAGE_MB","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"tags":["Captures"],"summary":"List captures (cursor-paginated, newest first)","security":[{"deviceAuth":[]},{"deviceApiKey":[]}],"parameters":[{"name":"since","in":"query","schema":{"type":"string","format":"date-time"},"description":"Only captures created after this ISO timestamp (incremental sync)."},{"name":"category","in":"query","schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Opaque cursor from a previous page’s `nextCursor`."}],"responses":{"200":{"description":"Page of captures","content":{"application/json":{"schema":{"type":"object","properties":{"captures":{"type":"array","items":{"$ref":"#/components/schemas/Capture"}},"nextCursor":{"type":"string","nullable":true,"description":"Pass as `cursor` for the next page; null on the last page."}}}}}},"400":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/captures/{id}":{"get":{"tags":["Captures"],"summary":"Fetch one capture","security":[{"deviceAuth":[]},{"deviceApiKey":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Capture","content":{"application/json":{"schema":{"type":"object","properties":{"capture":{"$ref":"#/components/schemas/Capture"}}}}}},"401":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"tags":["Captures"],"summary":"Delete a capture and its image","security":[{"deviceAuth":[]},{"deviceApiKey":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"string"}}}}}},"401":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/extract":{"post":{"tags":["Extract"],"summary":"Extract text from an uploaded image (server-side LLM OCR)","description":"Runs OCR on the uploaded image using a configured OpenAI-compatible vision model. The caller can customize the system prompt, output format, language hint, model, and vision detail level. The result is returned in the response body — it is NOT persisted as a capture. Typical flow: client uploads image to /extract, shows the returned text editable, then pushes the final text + image to /captures.","security":[{"deviceAuth":[]},{"deviceApiKey":[]}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["image"],"properties":{"image":{"type":"string","format":"binary","description":"JPEG, PNG, WebP, or GIF."},"prompt":{"type":"string","description":"Override the system prompt. Default is EXTRACT_DEFAULT_PROMPT from env.","example":"Extract the receipt total, currency, vendor, and date. Return them as a single short string."},"format":{"type":"string","enum":["plain","markdown","json","fields"],"default":"plain","description":"plain → unstyled text; markdown → GFM; json → single JSON object; fields → typed form-field list (see the schema field below)."},"schema":{"type":"string","description":"Only used with format=fields. JSON-encoded array of target field specs — one entry per form field. When present, the model is constrained to filling exactly those fields (fill mode). When omitted, the model proposes fields itself (discover mode). Each spec: {name, label?, type?, required?, options?, description?}.","example":"[{\"name\":\"vendor\",\"label\":\"Vendor\",\"type\":\"string\",\"required\":true},{\"name\":\"total\",\"label\":\"Total\",\"type\":\"currency\",\"required\":true},{\"name\":\"date\",\"label\":\"Date\",\"type\":\"date\"}]"},"language":{"type":"string","description":"Hint the model about the language of the text (e.g. \"English\", \"Vietnamese\"). \"auto\" disables the hint.","example":"English"},"model":{"type":"string","description":"OpenAI vision model id. Must be in OPENAI_ALLOWED_MODELS. Defaults to OPENAI_DEFAULT_MODEL.","example":"gpt-4o-mini"},"detail":{"type":"string","enum":["auto","low","high"],"default":"auto","description":"Vision detail level. \"low\" is cheap and fast; \"high\" pays more tokens for higher fidelity."}}},"encoding":{"image":{"contentType":"image/jpeg, image/png, image/webp, image/gif"}}}}},"responses":{"200":{"description":"Extracted text plus token usage and effective model. `fields` is present only when format=fields.","content":{"application/json":{"schema":{"type":"object","properties":{"text":{"type":"string","description":"Raw model output. For text formats, this is the answer. For fields format, this is the JSON string that was parsed into `fields`."},"fields":{"type":"array","description":"Present only for format=fields. One entry per suggested (or requested) field.","items":{"type":"object","properties":{"name":{"type":"string","description":"snake_case identifier."},"label":{"type":"string"},"type":{"type":"string","enum":["string","number","date","currency","enum","boolean"]},"value":{"nullable":true,"description":"Coerced value: number for number/currency, ISO YYYY-MM-DD for date, boolean for boolean, string otherwise. null when missing or illegible."},"unit":{"type":"string","description":"Currency symbol or ISO code (only for type=currency)."},"confidence":{"type":"number","minimum":0,"maximum":1},"evidence":{"type":"string","description":"Shortest substring from the image that supports the value."},"required":{"type":"boolean"},"coerced":{"type":"boolean","description":"True when the server successfully normalized the raw LLM value into the declared type."}},"required":["name","label","type","confidence","evidence","required","coerced"]}},"target":{"nullable":true,"description":"For format=fields fill-mode: the target schema echoed back. Null in discover mode.","type":"array","items":{"type":"object"}},"usedFallback":{"type":"boolean","description":"For format=fields: true when the LLM output failed to parse and the server returned an empty fields array."},"model":{"type":"string","description":"Effective model used (echoed by the LLM)."},"format":{"type":"string","enum":["plain","markdown","json","fields"]},"finishReason":{"type":"string","nullable":true},"usage":{"type":"object","nullable":true,"properties":{"promptTokens":{"type":"integer"},"completionTokens":{"type":"integer"},"totalTokens":{"type":"integer"}}}},"required":["text","model","format"]}}}},"400":{"description":"Bad request (missing image, bad mime type, disallowed model, malformed schema, etc.)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Image exceeds MAX_IMAGE_MB","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit hit","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"OPENAI_API_KEY is not configured on the server","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}}}