Backend API reference
The backend is a FastAPI app. All routes are mounted under the /api prefix in backend/app/main.py and grouped into ten routers. Interactive OpenAPI docs are always available at /docs (and /redoc) on a running backend.
The auth model
Almost every endpoint requires an AdjustSquare JWT. The token arrives either as an Authorization: Bearer <jwt> header or as the bt_shared_data cookie. Two FastAPI dependencies wrap this:
| Dependency | Returns | Used when |
|---|---|---|
get_current_user | the User ORM row (auto-provisioned) | the route needs the full user or team |
get_actor | a lightweight Actor(user_id, user_name, team_id) | the route writes an activity-log entry |
require_admin | the User ORM row, only if on the admin allowlist | the route is part of the internal admin dashboard (/api/admin/...) |
get_actor depends on get_current_user, so both resolve the same token once per request. Team scoping is enforced in the route: reads and writes check that the target claim's team_id matches the caller's, returning 403 on mismatch and 404 when the record does not exist. See authentication for the validation flow.
Some job, room, and inventory read endpoints resolve by id without a team check (they are reached only after a team-scoped claim load in the UI). Treat the claim-level checks as the security boundary. This is noted per group below.
GET /api/training/certificates/{code} (read-only certificate verification) and the whole of /api/public/insured/... (the insured's own inventory link) take no JWT at all. The second is the only public write surface in the app, and its rules are different: an unguessable token whose hash alone is stored, one claim per token, per-token rate limits, per-file and per-link size ceilings, and a deliberate rule that it never answers 401 (the frontend session guard logs a user out on any 401 from /api/*, and a homeowner has no account to log back into). Read Insured inventory link and the module docstring in routes/insured_public.py before adding anything under /api/public/.
auth
| Method & path | Purpose | Auth |
|---|---|---|
GET /api/auth/me | Return the current user and team, including is_admin, the user's timezone preference, and features (the team's resolved component visibility, e.g. {"training": true}); logs a login event when called with a bearer token (the callback exchange). | get_current_user |
PUT /api/auth/me | Update the caller's display preferences. Currently {timezone}: an IANA zone name validated against the zoneinfo database, or "" for browser-local time. Returns the refreshed /auth/me payload. | get_current_user |
POST /api/auth/logout | Record a logout activity event. | get_actor |
GET /api/auth/config | Public: returns the AdjustSquare login URL so the frontend can redirect. | none |
GET /api/auth/debug | Debug view of the signed-in identity: resolved user and team, raw JWT claims when a token is present, auth mode (local bypass vs JWT), and Adjust Square readiness (base URL set + a team/fallback token). The team token is masked, never returned in full. | get_current_user |
sso
| Method & path | Purpose | Auth |
|---|---|---|
GET /api/sso/contents-estimation/authorize | Mint a short-lived login token for the signed-in user and return {redirect_url}: the Contents Estimation callback with the token attached, so the Integrations card opens CE already signed in. 503 when the hand-off is unconfigured, 409 for a user with no Contents Estimation id. See the hand-off. | get_current_user |
claims
| Method & path | Purpose | Auth & scope |
|---|---|---|
POST /api/claims | Create a claim for the caller's team. Writes activity. | get_actor |
GET /api/claims | List the team's claims with per-claim spend. Active claims by default; ?archived=true lists the archived ones. | get_current_user, team-scoped |
GET /api/claims/{id} | Get one claim with spend. | get_current_user, 403 on other team |
PATCH /api/claims/{id} | Inline-edit claim fields. Writes activity. | get_actor, 403 on other team |
POST /api/claims/{id}/archive | Archive a claim (soft-hide; sets is_archived + archived_at). Reversible and non-destructive. 400 if the claim has been submitted (submitted_at set). Writes activity. | get_actor, 403 on other team |
POST /api/claims/{id}/unarchive | Restore an archived claim (clears archived_at). Writes activity. | get_actor, 403 on other team |
DELETE /api/claims/{id} | Internal/admin hard delete of a claim and everything beneath it (rooms, jobs, items, item/staged photos, files on disk). No longer exposed in the UI — archiving is the only user-facing removal, and the 30-day purge is the only deletion. The endpoint remains for the purge/admin use. | require_admin (allowlist {7, 10}), not team-scoped |
GET /api/claims/{id}/adjust-square-profiles | The team's shopping + depreciation profiles for the submit modal. | get_current_user, 403 on other team |
POST /api/claims/{id}/submit-to-adjust-square | Submit selected rooms' items to Adjust Square. Records a Submission row (date, item/photo counts, claim key, end-to-end processing time; status flips from processing to complete when the background item/photo work finishes). | get_actor + get_current_user, 403/409 |
GET /api/claims/{id}/submissions | The claim's submission history, newest first, plus total_items / unsubmitted_items (the UI disables Send for Estimation when everything is submitted). | get_actor, team-scoped with the admin exception |
The submit endpoint is the most complex in the system: it authenticates to Adjust Square as the user's team, creates or updates the remote claim, and spawns background tasks for photo upload and AI triggering. It returns 409 if the team has no Adjust Square token. Archived rooms and archived items are excluded from the submission payload. Full detail in the Adjust Square integration.
rooms
| Method & path | Purpose | Auth & scope |
|---|---|---|
POST /api/claims/{claim_id}/rooms | Add a room to a claim. Writes activity. | get_actor, 403 on other team |
GET /api/claims/{claim_id}/rooms | List rooms with media and item counts (video/photo/audio/items/submitted). Active rooms by default; ?archived=true lists archived ones. Item counts exclude archived items. | get_current_user, 403 on other team |
GET /api/rooms/{room_id} | Get a room with submitted/unsubmitted counts. | none (by id) |
POST /api/rooms/{room_id}/archive | Archive a room (sets is_archived + archived_at). 400 if the room contains any submitted item. Reversible. Writes activity. | get_actor, 403 on other team (via parent claim) |
POST /api/rooms/{room_id}/unarchive | Restore an archived room (clears archived_at). Writes activity. | get_actor, 403 on other team |
PUT /api/rooms/{room_id} | Rename a room. Writes a non-restorable rename_room activity entry. | get_actor, 403 on other team (checked via the owning claim) |
GET /api/rooms/{room_id}/jobs | List a room's jobs with live item counts (current_item_count excludes archived items, so it tracks deletes and merges; items_found is the snapshot from processing). | none (by id) |
upload
All upload endpoints create a job and write a (non-restorable) activity entry.
| Method & path | Purpose | Behavior |
|---|---|---|
POST /api/upload | Upload a video to a room. | Streams to disk (size-capped), creates a job, starts the video pipeline in the background. |
POST /api/claims/{id}/import-contents-list | Import one or more vendor contents-list PDFs onto a CLAIM, split across rooms. | Parks the files and returns an ImportBatch immediately; the read runs in the background, creating one room and one awaiting_grouping photo job per room the document names. Poll GET /api/integrations/imports/{batch_id} for progress. PDFs only. See PDF import. |
POST /api/upload-photos | Upload the first batch of photos to a room, plus any PDFs. | Normalizes each photo to JPEG, seeds one StagedPhoto per file, leaves the job in awaiting_grouping. Does not auto-start AI. Also accepts .pdf: the job is created at importing_pdf instead and the worker (run_pdf_import, a background task without REDIS_URL) reads the item photos out of the document (see PDF import). Optional photo_count_hint form field is the total number of loose photos in the whole selection, so the PDF's group indices can be taken past the range the appends will claim. |
POST /api/upload-photos/{id}/append | Add more photos to an existing photo job. | Continues the orig_####.jpg numbering and group indices, adds more StagedPhoto rows, and updates total_frames / file_size_bytes. Only while the job is awaiting_grouping or importing_pdf. Refuses PDFs (400): a PDF yields an unknown number of photos, so it cannot fit the client-assigned index range this endpoint relies on, and the frontend sends every PDF with the first request. |
POST /api/upload-audio | Upload an audio file to a room. | Streams to disk, creates a job, starts the audio pipeline in the background. |
Each takes room_id as form data and the file(s) as multipart, validates the extension, and enforces its size cap (returns 413 if exceeded): MAX_UPLOAD_SIZE_MB for video/audio, MAX_PHOTO_UPLOAD_MB (running total across the whole job) for photos. Auth: get_actor.
Batched photo upload
Production traffic runs through a Cloudflare Tunnel that caps a single request body at roughly 100MB, so the frontend never sends a whole photo selection in one request. It greedy-fills the files into batches up to an 80MB budget (headroom for multipart boundaries and the encoded body), sends the first batch to POST /api/upload-photos, then sends each remaining batch to POST /api/upload-photos/{id}/append. The result is one room equals one job with one grouping pile, just delivered over several requests. The append endpoint rejects anything but an awaiting_grouping or importing_pdf photo job (409/400), and MAX_PHOTO_UPLOAD_MB is enforced as a running total across the whole job, not per request. A single photo over the 100MB hard limit is caught in the browser up front with a clear message, since it can never succeed batched or not.
Parallelism (speed). Two things make a large (hundreds of photos) upload fast rather than crawl:
- Parallel batches. Batch 0 creates the job and must land first; every remaining batch is an append and the client uploads up to 4 at a time (
PHOTO_UPLOAD_CONCURRENCYinlib/api.ts). To keep parallel appends from racing on theorig_####numbering, the client assigns each batch astart_index(its non-overlapping slice of the ordered selection) and the server writesorig_{start_index+i}.jpg. A skipped (unreadable) file just leaves a gap in the index range, which grouping tolerates. The job'stotal_frames/file_size_bytesare bumped with an atomic in-DB increment so concurrent appends can't clobber each other's counts. Clients that omitstart_indexfall back to appending after the current row count (serial-only). - Reply first, re-encode after. The request only moves bytes: each photo is streamed to disk exactly as it arrived (1 MB reads, never the whole batch in memory), its header is checked with Pillow to confirm it is an image (milliseconds, no decode;
looks_like_imageinservices/image_encode.py), and the response goes out. Normalising each file to an upright RGB JPEG runs afterwards as a FastAPI background task (reencode_photos_in_background, a few files at a time on the thread pool, replacing each file atomically under its finalorig_####.jpgname). Until that runs the file is the upload as sent, which the browser, imgproxy, the pipeline (_prepare_image_for_model) and the Segment endpoint all open upright themselves, so nothing waits on it. The size ceiling is enforced on the running byte total as the files land, and a batch that trips it is removed from disk before the413goes out.
Both endpoints return skipped_photos (the unreadable files they dropped); the frontend aggregates it across every batch and shows it in the uploader's "files skipped" notice, and also renders live per-photo tiles (queued → uploading → done/skipped) plus elapsed time and a byte-rate ETA.
Chunked, resumable uploads
The single-shot POST /api/upload (and /api/upload-audio) sends the whole file in one request, which fails when a CDN or tunnel caps the request body (Cloudflare's tunnel rejects bodies over ~100 MB on Free/Pro, well below the app's 2 GB). The chunked endpoints split a large file into small parts so each request stays under that cap, while the app still accepts the full size. The frontend uses these by default for video and audio (uploadVideo/uploadAudio in lib/api.ts); the one-shot endpoints remain for compatibility.
| Method & path | Purpose |
|---|---|
POST /api/uploads/init | Open a session. JSON body { kind: "video"|"audio", filename, room_id, total_size }. Validates the room and extension up front and returns { upload_id, chunk_size, received }. |
PUT /api/uploads/{id}/chunks/{index} | Store one chunk (raw body). Idempotent: re-sending an index overwrites it, which makes retries safe. |
GET /api/uploads/{id} | Report received chunk indices so a client can resume by sending only the missing ones. |
POST /api/uploads/{id}/complete | Join the chunks in order into the final file and hand it to the pipeline, exactly like a one-shot upload. Returns the Job. |
DELETE /api/uploads/{id} | Abort and discard the partial upload. |
Auth: get_actor on every endpoint. Chunks are written to a per-upload scratch dir (uploads/.chunks/{id}/) with an atomic rename per part, so a crash mid-write never leaves a half-written part. complete verifies the parts form a gap-free 0..n-1 run and (if total_size was given) that the assembled size matches, returning 400 otherwise. Per-chunk size is hard-capped (MAX_CHUNK_BYTES, 32 MB) and the total still honours MAX_UPLOAD_SIZE_MB. The upload_id is validated as a UUID, so it cannot traverse the filesystem.
Speed. init recommends 16 MB chunks (RECOMMENDED_CHUNK_BYTES), and the browser keeps four in flight at once (UPLOAD_CHUNK_CONCURRENCY in lib/uploadChunks.mjs, shared with the insured link's uploader). One chunk at a time moved at the speed of a single connection and paid a full round trip (browser, Cloudflare, tunnel, server and back) per chunk, which on a high-latency link left most of the upstream idle. Each chunk goes up over XMLHttpRequest, whose upload progress events let the bar move as bytes leave the browser rather than in whole-chunk jumps. The chunk planning and retry policy (planChunks, uploadPercent, runWithConcurrency, sendChunkWithRetry: three attempts on a network drop, timeout, rate limit, or 5xx, never on another 4xx) are pure and unit-tested with node --test, and shared with the insured link's uploader so the two paths cannot drift. Because chunks arrive in any order, the per-chunk running-total check in put_chunk is approximate under parallelism; the real guards are init (rejects total_size over the cap) and complete (rejects an assembled size that differs from total_size).
complete joins the parts through services/chunk_assembly.py on the thread pool: a single part is renamed into place (no copy) and several are streamed together with an 8 MB buffer. The old code copied 1 MB at a time through aiofiles (two thousand thread hops for a 1 GB video).
jobs
The jobs router carries job reads, media serving, photo staging, and the progress stream.
| Method & path | Purpose |
|---|---|
GET /api/jobs | List the 50 most recent jobs. |
GET /api/jobs/{id} | Get one job. |
GET /api/jobs/{id}/video | Stream the original video (HTTP range support for seeking). |
GET /api/jobs/{id}/audio | Stream the original audio (range support). |
GET /api/jobs/{id}/frames | List extracted frame URLs. |
GET /api/jobs/{id}/frames/{name} | Serve one frame image. |
GET /api/jobs/{id}/photos | List uploaded source photo URLs. |
GET /api/jobs/{id}/staged-photos | List staged photos with their groupings. Each photo includes its original_filename (the real upload name behind the synthetic orig_####.jpg) and captured_at (EXIF date taken), which the grouping UI's "Sort by name" / "Sort by date" use. POST /upload-photos and .../append accept an optional captured_ats form field (JSON array of dates aligned to files). For a PDF import each photo also carries source_description (the note printed beside it in the document, passed to the AI as a hint), source_item_key (its printed label) and source_room (the room heading it was printed under). |
PUT /api/jobs/{id}/staged-photos/regroup | Replace all group assignments (enforces MAX_PHOTOS_PER_ITEM; leaves manual segments untouched). |
PUT /api/jobs/{id}/staged-photos/delete | Soft-delete staged photos. |
PUT /api/jobs/{id}/staged-photos/restore | Restore soft-deleted staged photos. |
PUT /api/jobs/{id}/staged-photos/segment | Crop user-drawn boxes from one photo into known items (AS-705); hides the original. Each item has a name (blank = AI names it), a quantity, and one or more boxes. |
PUT /api/jobs/{id}/staged-photos/clear-segments | Remove a photo's segment crops and restore the original whole photo. |
PUT /api/jobs/{id}/staged-photos/details | Set a user-written name + quantity for one item (its staged-photo ids). A non-empty name marks it user-described, so the AI is skipped and it costs no credit; an empty name hands it back to the AI. |
GET /api/jobs/{id}/quote | Exact credit cost to process the job plus the team's balance, for the confirmation modal (charges nothing). |
POST /api/jobs/{id}/process | Confirm and start processing an uploaded job (photo after grouping, or video/audio after the credit confirmation). Spends the credits up front (402 if short), then starts the matching pipeline. Accepts awaiting_grouping / awaiting_confirmation / error. |
GET /api/jobs/{id}/read-the-rest/quote | Cost of another AI pass over the photos in a finished job whose answer ran out of room: one credit per photo re-read, plus groups (how many that is) and the team's balance. Charges nothing. Priced per pass, never per item: we know only THAT items remain. |
POST /api/jobs/{id}/read-the-rest | Run that extra pass. Photo jobs in complete only, and only while a group has a pass left (photo_max_passes, 3, counting the pipeline's own call). Spends the credits up front (402 if short) and refunds them if the pass fails. |
POST /api/jobs/{id}/cancel | Discard a not-yet-started job (e.g. a cancelled confirmation), deleting its file. |
GET /api/jobs/{id}/logs | The job's log lines: the in-memory buffer while it runs, the job_log_archives copy once finished. |
GET /api/jobs/{id}/metrics | Timing, token, and cost summary. |
GET /api/jobs/{id}/stream | Server-Sent Events stream of progress, logs, and final metrics. |
These endpoints are id-addressed and not team-checked; they back the processing and review UI.
The SSE progress stream
GET /api/jobs/{id}/stream polls the job every ~1.5 seconds (up to ~10 minutes) and emits three event types: progress (status, percent, current stage), log (new log lines), and a final metrics event when the job reaches complete or error. Caching and buffering are disabled so events arrive immediately through nginx and the Next dev proxy.
inventory
The largest router: item CRUD, approval, bulk edits, multi-photo management, merging, and exports.
| Method & path | Purpose |
|---|---|
GET /api/inventory/{job_id} | List a job's items (with photos), ordered by item number. Active items by default; ?archived=true lists archived ones. |
PUT /api/inventory/item/{item_id} | Edit an item; marks it reviewed. Writes activity. |
POST /api/inventory/item/{item_id}/archive | Archive an item (sets is_archived + archived_at; item_number untouched). Rejected with 400 for submitted items. Writes activity. |
POST /api/inventory/item/{item_id}/unarchive | Restore an archived item (clears archived_at). Writes activity. |
POST /api/inventory/{job_id}/approve | Approve or unapprove one item. Writes activity. |
POST /api/inventory/{job_id}/auto-approve | Apply saved auto-approval rules to unapproved items. |
POST /api/inventory/{job_id}/bulk | Apply one field patch to many items (skips submitted items). |
POST /api/inventory/{job_id}/item | Manually add an item (supports insert-between numbering). |
DELETE /api/inventory/item/{item_id} | Internal hard delete of an item (snapshot stored for restore). No longer exposed in the UI — archiving replaced it. Retained for the undo-of-add path and the 30-day purge. |
PUT /api/inventory/item/{item_id}/photo | Replace an item's primary photo. |
POST /api/inventory/item/{item_id}/photos | Attach a photo to an item. |
DELETE /api/inventory/item/{item_id}/photos/{photo_id} | Remove a photo (promotes the next if primary). |
PUT /api/inventory/item/{item_id}/photos/{photo_id} | Set primary / reorder a photo. is_primary: true demotes every other photo on the item and copies the photo onto the item's photo_path, which is what the inventory table thumbnail and the export read. |
PUT /api/inventory/item/{item_id}/photos/{photo_id}/move | Move a photo to another item. |
POST /api/inventory/items/merge | Merge several items into one (reassigns photos, deletes losers). |
GET /api/inventory/{job_id}/export/excel | Excel export (summary + per-room sheets + a Photos sheet); ?approved_only=true. Archived items are always excluded. Item names are hyperlinked to their primary photo. |
GET /api/inventory/{job_id}/export/csv | CSV export; ?approved_only=true. Archived items are always excluded. |
GET /api/rooms/{room_id}/inventory | All items across every job in a room. Active items by default; ?archived=true lists archived ones. |
GET /api/rooms/{room_id}/export/excel | Room-level Excel export, with the same photo links as the job export. |
GET /api/rooms/{room_id}/export/csv | Room-level CSV export. |
GET /api/photos/{job_id}/{filename} | Serve a stored photo file. |
Mutating endpoints use get_actor and write activity entries (most with a before snapshot enabling restore).
The archive lifecycle and the 30-day purge
Claims, rooms, and items are never deleted by hand. The only removal action in the UI is archive (is_archived + archived_at), which hides the thing from the default lists, counts, exports, and Adjust Square submission while keeping every row and file. Archiving is blocked once something is submitted (a claim with submitted_at, a room holding any submitted item, or a submitted item). Restoring clears archived_at.
A background job (app.services.archive_cleanup.purge_expired_archived, started from app.main.lifespan) runs on startup and then once a day. It permanently deletes anything whose archived_at is more than 30 days old, cascading to everything beneath it and removing its files on disk. This is the only automatic deletion in the system.
settings
| Method & path | Purpose |
|---|---|
GET /api/settings/automation | Get the auto-approval rules. |
PUT /api/settings/automation | Save auto-approval rules. Writes activity with a before snapshot. |
GET /api/settings/prompts | Get the AI prompt settings bundle (presets, built-in defaults, char cap). |
PUT /api/settings/prompts | Validate and save prompt settings; logs exactly what changed. |
Settings are stored as JSON files on disk (not in the database). See automation and prompts.
activity
| Method & path | Purpose |
|---|---|
GET /api/activity | The activity log feed. |
POST /api/activity/{log_id}/restore | Undo a restorable action from its snapshot. |
billing
| Method & path | Purpose | Auth |
|---|---|---|
GET /api/billing/summary | The team's spend total (total_spend_usd) and the flat credit rates (video/photo/audio). When ENABLE_BILLING is on it also returns billing_enabled, the team's prepaid credits_balance, and a buy_credits_url, which drive the persistent credits widget (the team's available balance + a Buy credits button). When billing is off those are false/null and the widget is hidden. | get_current_user, team-scoped |
When prepaid billing is enabled, an upload does not process immediately: video/audio jobs park in awaiting_confirmation (photos in awaiting_grouping) and the client first calls GET /api/jobs/{id}/quote for the exact cost + balance to show a confirmation modal. Credits are spent at confirm — POST /api/jobs/{id}/process spends up front (the gate) and returns 402 with { error: "insufficient_credits", credits_balance, required_amount, buy_url } when the team cannot cover it, otherwise it starts the pipeline. The spend fails open if the billing service is unreachable, a cancelled job is discarded via POST /api/jobs/{id}/cancel, and a job that later fails processing is refunded. See billing.
dashboard
| Method & path | Purpose | Auth |
|---|---|---|
GET /api/dashboard/metrics | Team-scoped headline numbers for the Dashboard stat cards. Returns the processing-speed metric: items_cataloged (items the pipeline produced across the team's completed jobs), processing_seconds (their total wall-clock completed_at - created_at), and seconds_per_item (the average, or null when there are none yet). Jobs still running, or completed but with no items, are excluded so they cannot distort the per-item average. | get_current_user, team-scoped |
training
The training program (see Training and certification).
| Endpoint | Purpose |
|---|---|
GET /api/training/overview | Missions with the caller's progress, points, badges, training claim id, certificate state. Quiz answers are never included. |
POST /api/training/start | Create (or return) the caller's sandbox training claim. Idempotent. |
POST /api/training/missions/{key}/quiz | Grade a quiz submission; stores the best score; returns per-question results and explanations. |
GET /api/training/missions/{key}/checks | Side-effect-free grading of the mission's doing-checks, safe to poll. Powers the on-screen task tracker. |
POST /api/training/missions/{key}/check | Grade the mission's doing-checks against the training claim's real state; completes the mission and banks points when everything passes. |
GET /api/training/leaderboard | Team-scoped points standings. |
POST /api/training/certificate | Issue the caller's certificate once all missions are complete (409 before that). |
GET /api/training/certificates/{code} | Public, no auth: verify a shared certificate code. Returns {valid: false} for unknown or revoked codes. |
Training claims also change the behavior of three existing endpoints: uploads into them complete instantly with fixture items (no pipeline, no credits), GET /api/jobs/{id}/quote returns 0 credits, and POST /api/claims/{id}/submit-to-adjust-square performs a dry run that never contacts Adjust Square.
insured
The insured's own inventory link (see Insured inventory link). Two routers, deliberately in separate files so nothing authenticated is added to the public one by accident.
Adjuster-facing, behind get_actor and team-scoped like everything else. Never returns a token: only the token's hash is stored.
| Method & path | Purpose |
|---|---|
GET /api/claims/{claim_id}/insured-links | Every invitation on this claim, newest first, with how far each recipient got. |
POST /api/claims/{claim_id}/insured-links | Invite the insured by email address. Re-inviting an address that already has a live link resends that one, so their existing work is kept. |
POST /api/insured-links/{link_id}/resend | Send again. Rotates the token: their drafts survive, but the URL in the earlier email stops working, because the raw token was never stored and cannot be recovered. |
POST /api/insured-links/{link_id}/revoke | Shut a link immediately. Work already submitted stays on the claim. |
Public, no auth, token in the path. Failures are 404, 409, 413, 415, 422, or 429, never 401.
| Method & path | Purpose |
|---|---|
GET /api/public/insured/{token} | Everything the wizard renders, including on resume. Records that the link was opened. Also carries the address the link was sent to (insured_email), which is the homeowner's own and is what the submission history shows as who added each row. |
POST /api/public/insured/{token}/identify | Record the homeowner's name. Never a gate. |
POST /api/public/insured/{token}/rooms | Add a room to the claim. An existing name returns that room rather than duplicating it. |
POST /api/public/insured/{token}/rooms/{room_id}/files | Upload ONE photo with its note. One file per request, so a dropped connection costs one file rather than a batch. |
POST /api/public/insured/{token}/uploads/init, GET .../uploads/{id}, PUT .../uploads/{id}/chunks/{n}, POST .../uploads/{id}/complete, DELETE .../uploads/{id} | The chunked path for a video or voice recording, mirroring /api/uploads above with the scope changed to the link. GET reports the parts received so a Retry resumes with only the missing ones; init sweeps uploads abandoned for over a day. Details in Insured inventory link. |
POST /api/public/insured/{token}/rooms/{room_id}/items | Add a typed row. |
PATCH /api/public/insured/{token}/items/{item_id} | Save one field as they leave it. Only the fields sent are written. |
DELETE /api/public/insured/{token}/items/{item_id} | Remove a row and its photos, on disk too. |
POST /api/public/insured/{token}/items/{item_id}/photos | A photo for the table's Photo column. Never goes to the AI. |
PATCH /api/public/insured/{token}/files/{file_id} | Edit a file's note. |
DELETE /api/public/insured/{token}/files/{file_id} | Remove a file. Does not credit back the link's byte ceiling. |
GET /api/public/insured/{token}/files/{file_id}/content | Serve one of their own uploads back at full size, so a resumed session shows what they sent and the lightbox has a real photo to show. Scoped to the link. |
GET /api/public/insured/{token}/files/{file_id}/thumb | A small copy of one photo (longest edge 640px) for the media grid, made on first request and cached beside the original. Uploads are kept at their original size, and a camera photo is routinely 16 megapixels, so pointing the grid at /content meant decoding hundreds of megabytes of bitmap to fill tiles a couple of hundred pixels wide. 404 for video and audio. |
POST /api/public/insured/{token}/submit | Turn the drafts into work on the claim. Photo jobs park at awaiting_grouping, videos at awaiting_confirmation, typed rows become inventory items directly. Spends no credits and starts no AI. |
POST /api/public/insured/mailgun-events | Mailgun delivery webhook. Public because Mailgun calls it, but HMAC-verified against MAILGUN_WEBHOOK_SIGNING_KEY; with no key set every call is rejected. |
Admin dashboard (/api/admin)
The internal operator dashboard. Unlike every other router, these endpoints aggregate across all teams (no team scoping) and are gated by require_admin, so only the Adjust Square user IDs in ADMIN_EXTERNAL_USER_IDS (default {7, 10}) can reach them. A non-admin gets 403 "This page is restricted."; an unauthenticated request gets 401.
| Method & path | Purpose | Auth |
|---|---|---|
GET /api/admin/overview?range=<range> | One aggregate payload for the whole dashboard: headline credit spend, system counts (claims, items, videos, audios, photo requests), credit spend by media type, a per-team usage leaderboard (claims / videos / audios / photo requests / items / credits), the latest claims, the last 10 jobs (any status), jobs processing now, recently failed jobs (with error messages), the recent activity feed, recent Adjust Square submissions, and aggregate quality metrics (avg items/claim, avg credits/claim, avg processing time per request). The stats include ai_cost_usd (our OpenRouter spend in dollars within the range, from the persisted per-job ai_cost_usd) and two live figures that ignore the range: active_users (users seen in the last 5 minutes via users.last_seen_at) and active_jobs. All customer spend is in credits, always whole numbers. | require_admin |
GET /api/admin/users?search=<q> | List users (id, external id, name, email, team, is_admin) for the impersonation picker, optionally filtered by name or email. Capped at 200. | require_admin |
POST /api/admin/impersonate/{user_id} | Mint a short-lived token that authenticates as that user so an admin can see the app exactly as they do. Returns {token, user}; the frontend swaps tokens and can switch back. Carries an impersonated_by audit claim and writes a non-restorable impersonate_user activity entry. Returns 400 if the user has no Adjust Square id. | require_admin |
GET /api/admin/active-jobs | Just the live count of jobs processing right now: { "active_jobs": <int> }. The same number as the overview's active_jobs stat (it shares one query), but cheap enough to poll on a short interval. The dashboard polls it to drive a favicon job-count badge, so an operator with /admin pinned can see at a glance when jobs suddenly start running. | require_admin |
GET /api/admin/logs | Every processing job system-wide, newest first, each tagged with its claim, team, and creating user, and carrying its log entries (live in-memory buffer while running, the job_log_archives copy once finished). Filters: team_id, user_id, status (a job status or active for anything still processing), q (substring on filename or claim number). Paginates with limit/offset and returns total plus the full teams and users lists for the filter dropdowns. Backs the Logs page (/logs). | require_admin |
GET /api/admin/activity | The activity log across all teams, newest first, each row tagged with its team (the customer GET /api/activity scopes to the caller's team). Filters: team_id, q (substring on user, action, or target); paginates with limit/offset. Restore stays team-scoped through the existing POST /api/activity/{id}/restore, so the dashboard only offers Restore on rows from the admin's own team. | require_admin |
GET /api/admin/media | Browse every uploaded media item system-wide (one row per processing job), newest first, tagged with team, user, and claim. Each item carries directly renderable URLs: the video/audio capability stream, a thumbnail (first extracted frame or first photo), and photo jobs list their uploaded photos (capped at 12, photo_count has the total). file_exists flips to false once the original file was cleaned off disk. Filters: type (video/photo/audio), team_id, q (filename or claim number); paginates with limit/offset. Backs the admin Media tab. | require_admin |
GET /api/admin/components | Every toggleable UI component (the registry in services/component_visibility.py) with its per-component default, global state, and per-team overrides. Resolution: team override > global override > registry default. Training's default is HIDDEN: it is enabled from the admin Teams tab. | require_admin |
PUT /api/admin/components | Set or clear one visibility override: {component, team_id, visible}. team_id: "" targets the global row; visible: null clears the override so the level above applies. Returns the updated table. What a user's team resolves to is delivered as features on GET /api/auth/me. | require_admin |
Impersonation lets an operator debug a customer's exact view. The endpoint signs a valid AdjustSquare-format token for the target user with the shared ADJUSTSQUARE_JWT_SECRET, so it authenticates as them on every subsequent request; the token is short-lived and carries an impersonated_by claim for audit. The admin's own token is kept client-side so they can switch back, and an app-wide banner makes the impersonation obvious. Every impersonation is recorded in the activity log.
The optional range query param scopes every time-bounded figure to one window: today, 24h, week, 30d, year, or all (the default; an unrecognized value falls back to all). Two figures intentionally ignore the range because they describe current state: active_jobs and the "jobs processing now" list are always live. Both also exclude photo jobs in awaiting_grouping (those wait on a user action, not on our processing). "Photo requests" counts billed photo requests (the sum of photo_request_count), not raw photos, and the per-request job time normalizes each photo batch's wall-clock by its request count so batches of different sizes are comparable.
See authentication for the allowlist and gating, and billing for what "credits" means.
health
| Method & path | Purpose |
|---|---|
GET /health | Liveness check (no /api prefix). Returns {status, model}; used by the Docker healthcheck. |
Next
- Authentication: how the JWT becomes a user and team.
- Adjust Square: the submit endpoint in depth.
- Frontend: how the client calls all of this.
| GET /api/admin/api-requests | Outbound AI API calls (OpenRouter) grouped by job, newest activity first. Each group carries per-job totals (request count, cost, time, tokens), the owning claim/team/user, and its full request list (request JSON with base64 images redacted, full response JSON, attempts, duration, real cost from usage accounting). Filters: q (job id / claim number / filename), team_id, user_id; limit/offset paginate the groups. Backs the "API Requests" tab on the Logs page. | require_admin |
| POST /api/admin/jobs/{id}/dismiss | Mark a hung/abandoned job as failed so it stops counting as active: sets a retryable error message, refunds any up-front charge (idempotent), archives its in-memory logs. 409 for jobs already finished. | require_admin |
| DELETE /api/admin/jobs/{id} | Hard-delete a job and everything beneath it: items (and their photos), staged photos, archived logs, and files on disk. The AI API request log rows are kept as the cost accounting record. Cannot be undone. | require_admin |