Skip to main content

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:

DependencyReturnsUsed when
get_current_userthe User ORM row (auto-provisioned)the route needs the full user or team
get_actora lightweight Actor(user_id, user_name, team_id)the route writes an activity-log entry
require_adminthe User ORM row, only if on the admin allowlistthe 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.

Inconsistent scoping is intentional in places

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.

Two endpoints are genuinely public

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 & pathPurposeAuth
GET /api/auth/meReturn 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/meUpdate 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/logoutRecord a logout activity event.get_actor
GET /api/auth/configPublic: returns the AdjustSquare login URL so the frontend can redirect.none
GET /api/auth/debugDebug 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 & pathPurposeAuth
GET /api/sso/contents-estimation/authorizeMint 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 & pathPurposeAuth & scope
POST /api/claimsCreate a claim for the caller's team. Writes activity.get_actor
GET /api/claimsList 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}/archiveArchive 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}/unarchiveRestore 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-profilesThe team's shopping + depreciation profiles for the submit modal.get_current_user, 403 on other team
POST /api/claims/{id}/submit-to-adjust-squareSubmit 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}/submissionsThe 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 & pathPurposeAuth & scope
POST /api/claims/{claim_id}/roomsAdd a room to a claim. Writes activity.get_actor, 403 on other team
GET /api/claims/{claim_id}/roomsList 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}/archiveArchive 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}/unarchiveRestore 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}/jobsList 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 & pathPurposeBehavior
POST /api/uploadUpload 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-listImport 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-photosUpload 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}/appendAdd 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-audioUpload 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_CONCURRENCY in lib/api.ts). To keep parallel appends from racing on the orig_#### numbering, the client assigns each batch a start_index (its non-overlapping slice of the ordered selection) and the server writes orig_{start_index+i}.jpg. A skipped (unreadable) file just leaves a gap in the index range, which grouping tolerates. The job's total_frames/file_size_bytes are bumped with an atomic in-DB increment so concurrent appends can't clobber each other's counts. Clients that omit start_index fall 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_image in services/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 final orig_####.jpg name). 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 the 413 goes 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 & pathPurpose
POST /api/uploads/initOpen 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}/completeJoin 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 & pathPurpose
GET /api/jobsList the 50 most recent jobs.
GET /api/jobs/{id}Get one job.
GET /api/jobs/{id}/videoStream the original video (HTTP range support for seeking).
GET /api/jobs/{id}/audioStream the original audio (range support).
GET /api/jobs/{id}/framesList extracted frame URLs.
GET /api/jobs/{id}/frames/{name}Serve one frame image.
GET /api/jobs/{id}/photosList uploaded source photo URLs.
GET /api/jobs/{id}/staged-photosList 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/regroupReplace all group assignments (enforces MAX_PHOTOS_PER_ITEM; leaves manual segments untouched).
PUT /api/jobs/{id}/staged-photos/deleteSoft-delete staged photos.
PUT /api/jobs/{id}/staged-photos/restoreRestore soft-deleted staged photos.
PUT /api/jobs/{id}/staged-photos/segmentCrop 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-segmentsRemove a photo's segment crops and restore the original whole photo.
PUT /api/jobs/{id}/staged-photos/detailsSet 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}/quoteExact credit cost to process the job plus the team's balance, for the confirmation modal (charges nothing).
POST /api/jobs/{id}/processConfirm 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/quoteCost 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-restRun 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}/cancelDiscard a not-yet-started job (e.g. a cancelled confirmation), deleting its file.
GET /api/jobs/{id}/logsThe job's log lines: the in-memory buffer while it runs, the job_log_archives copy once finished.
GET /api/jobs/{id}/metricsTiming, token, and cost summary.
GET /api/jobs/{id}/streamServer-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 & pathPurpose
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}/archiveArchive an item (sets is_archived + archived_at; item_number untouched). Rejected with 400 for submitted items. Writes activity.
POST /api/inventory/item/{item_id}/unarchiveRestore an archived item (clears archived_at). Writes activity.
POST /api/inventory/{job_id}/approveApprove or unapprove one item. Writes activity.
POST /api/inventory/{job_id}/auto-approveApply saved auto-approval rules to unapproved items.
POST /api/inventory/{job_id}/bulkApply one field patch to many items (skips submitted items).
POST /api/inventory/{job_id}/itemManually 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}/photoReplace an item's primary photo.
POST /api/inventory/item/{item_id}/photosAttach 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}/moveMove a photo to another item.
POST /api/inventory/items/mergeMerge several items into one (reassigns photos, deletes losers).
GET /api/inventory/{job_id}/export/excelExcel 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/csvCSV export; ?approved_only=true. Archived items are always excluded.
GET /api/rooms/{room_id}/inventoryAll items across every job in a room. Active items by default; ?archived=true lists archived ones.
GET /api/rooms/{room_id}/export/excelRoom-level Excel export, with the same photo links as the job export.
GET /api/rooms/{room_id}/export/csvRoom-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 & pathPurpose
GET /api/settings/automationGet the auto-approval rules.
PUT /api/settings/automationSave auto-approval rules. Writes activity with a before snapshot.
GET /api/settings/promptsGet the AI prompt settings bundle (presets, built-in defaults, char cap).
PUT /api/settings/promptsValidate 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 & pathPurpose
GET /api/activityThe activity log feed.
POST /api/activity/{log_id}/restoreUndo a restorable action from its snapshot.

billing​

Method & pathPurposeAuth
GET /api/billing/summaryThe 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 & pathPurposeAuth
GET /api/dashboard/metricsTeam-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).

EndpointPurpose
GET /api/training/overviewMissions with the caller's progress, points, badges, training claim id, certificate state. Quiz answers are never included.
POST /api/training/startCreate (or return) the caller's sandbox training claim. Idempotent.
POST /api/training/missions/{key}/quizGrade a quiz submission; stores the best score; returns per-question results and explanations.
GET /api/training/missions/{key}/checksSide-effect-free grading of the mission's doing-checks, safe to poll. Powers the on-screen task tracker.
POST /api/training/missions/{key}/checkGrade the mission's doing-checks against the training claim's real state; completes the mission and banks points when everything passes.
GET /api/training/leaderboardTeam-scoped points standings.
POST /api/training/certificateIssue 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 & pathPurpose
GET /api/claims/{claim_id}/insured-linksEvery invitation on this claim, newest first, with how far each recipient got.
POST /api/claims/{claim_id}/insured-linksInvite 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}/resendSend 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}/revokeShut 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 & pathPurpose
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}/identifyRecord the homeowner's name. Never a gate.
POST /api/public/insured/{token}/roomsAdd a room to the claim. An existing name returns that room rather than duplicating it.
POST /api/public/insured/{token}/rooms/{room_id}/filesUpload 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}/itemsAdd 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}/photosA 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}/contentServe 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}/thumbA 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}/submitTurn 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-eventsMailgun 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 & pathPurposeAuth
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-jobsJust 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/logsEvery 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/activityThe 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/mediaBrowse 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/componentsEvery 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/componentsSet 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 & pathPurpose
GET /healthLiveness check (no /api prefix). Returns {status, model}; used by the Docker healthcheck.

Next​

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