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.

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

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.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/upload-photosUpload the first batch of photos to a room.Normalizes each to JPEG, seeds one StagedPhoto per file, leaves the job in awaiting_grouping. Does not auto-start AI.
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.
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 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).
  • Threaded re-encode. Each photo is normalized to JPEG in a thread pool (_encode_photos_concurrently in routes/upload.py), not one-at-a-time on the event loop, so a batch's per-photo CPU overlaps across cores and never blocks other requests. Pillow releases the GIL inside its C codecs, so this is real parallelism.

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}/completeConcatenate 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.

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).
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.
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.
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); ?approved_only=true. Archived items are always excluded.
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.
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 confirmPOST /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.

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 |