Trace Developers

API reference

Base URL https://trace.foundrcode.com/api/v1. JSON in, JSON out. Lists are paginated with ?page= and return a meta block. Timestamps are ISO 8601 in UTC.

Session

POST /api/v1/auth/login

Exchange credentials and a device identifier for a bearer token.

POST /api/v1/auth/logout

Revoke the token making the request.

GET /api/v1/auth/me

The signed-in user, their role, and the team the token is scoped to.

POST /api/v1/auth/switch-team

Move the session to another team the user belongs to.

Sync

GET /api/v1/bootstrap projects:read

Everything a device needs on first run: projects with coordinates, tags, checklists and team settings, in one request.

POST /api/v1/devices/heartbeat

Report queue depth and app version. This is what fills the "devices with a backlog" panel in the office.

Projects

GET /api/v1/projects projects:read

Active projects, newest activity first. Filter with ?q= and ?status=.

GET /api/v1/projects/nearby projects:read

Projects within range of a coordinate. Takes lat, lng and accuracy_m, and returns the same match reasoning the app uses.

POST /api/v1/projects projects:write

Create a project. Coordinates are optional; without them the project can never be auto-matched.

GET /api/v1/projects/{project} projects:read

One project, with counts.

Photos

GET /api/v1/photos photos:read

Photos, filterable by project, tag, date range and assignment source.

POST /api/v1/photos photos:write

Upload. Multipart, and idempotent on local_capture_id — a repeat returns 200 with duplicate:true rather than a second row.

GET /api/v1/photos/{photo} photos:read

One photo with its URLs, tags, GPS and assignment reasoning.

PATCH /api/v1/photos/{photo} photos:write

Edit the caption, retag, or move it to another project. Capture time and GPS are read-only: they are evidence.

Before & after

GET /api/v1/photo-groups photos:read

Paired captures, with both halves resolved.

POST /api/v1/photo-groups photos:write

Pair two photos as a before and an after.

Checklists

GET /api/v1/checklists checklists:read

Checklists on the jobs this user can see. ?mine=1 narrows to their own assignments.

GET /api/v1/checklists/{checklist} checklists:read

One checklist with its items and their evidence.

PATCH /api/v1/checklist-items/{item} checklists:write

Tick an item off. An item marked requires_photo is refused with a 422 until one is attached.

Tasks

GET /api/v1/tasks tasks:read

Tasks, filterable by project, status (open, done, overdue, all) and ?mine=1.

GET /api/v1/tasks/{task} tasks:read

One task, with the photo it points at.

POST /api/v1/tasks tasks:write

Raise a task. Naming a photo_id is enough — it implies the job it was taken on.

PATCH /api/v1/tasks/{task} tasks:write

Edit, reassign, or set completed to close it. Closing records who and when.

DELETE /api/v1/tasks/{task} tasks:write

Withdraw a task.

Pages

GET /api/v1/pages pages:read

Pages, filterable by project and searchable with ?q=. Bodies are omitted from the list.

GET /api/v1/pages/{page} pages:read

One page, returned both as Markdown source and as already-escaped HTML.

POST /api/v1/pages pages:write

Write a page.

PATCH /api/v1/pages/{page} pages:write

Edit one.

Customers

GET /api/v1/customers customers:read

The customer list, searchable with ?q= across name, company, email, phone and city.

GET /api/v1/customers/{customer} customers:read

One customer, with their additional contacts.

POST /api/v1/customers customers:write

Create a customer — usually the direction that matters, since the CRM already knows who they are.

Comments

GET /api/v1/comments comments:read

A thread on a photo or a project.

POST /api/v1/comments comments:write

Post a comment. @mentions of teammates are resolved at write time.

Reports

GET /api/v1/reports reports:read

Reports, with their status and share state.

GET /api/v1/reports/{report} reports:read

One report, its photos in order, and the download URL when it has rendered.

A photo, in full

{
  "id": 8412,
  "local_capture_id": "5f2c9d18-3a6e-4b71-9e2f-70d1c2a4b8e3",
  "project": { "id": 91, "name": "Harlan Street" },
  "captured_at": "2026-08-19T14:02:11Z",
  "uploaded_at": "2026-08-19T18:40:02Z",
  "media_type": "photo",
  "description": "Flashing pulled away from the stack.",
  "tags": ["Before", "Roof"],
  "location": {
    "lat": 39.7392,
    "lng": -104.9903,
    "accuracy_m": 8
  },
  "assignment": {
    "source": "gps",
    "distance_m": 22,
    "reason": null
  },
  "urls": {
    "full": "https://…/photos/8412/file",
    "thumb": "https://…/photos/8412/file?size=thumb",
    "annotated": null
  },
  "size_bytes": 2841100
}

A page, in full

Pages come back twice: body as the author typed it, for anything that wants to edit or re-render it, and body_html already run through the same escaping path the web uses. An integrator embedding a scope in their own system should not have to reimplement — or forget — the sanitising.

{
  "id": 44,
  "title": "Scope of work",
  "excerpt": "What was agreed Strip the front elevation back to the deck…",
  "project": { "id": 91, "name": "Harlan Street" },
  "updated_by": "Nadia Fischer",
  "updated_at": "2026-08-19T09:14:02Z",
  "body": "## What was agreed\n\nStrip the front elevation…",
  "body_html": "<h2>What was agreed</h2>\n<p>Strip the front elevation…</p>"
}

assignment.source is one of gps, manual, corrected or unassigned. When it is unassigned, reason says why the matcher declined — ambiguous, low_accuracy, outside_radius or no_fix. It never guesses, and it never returns a match it is not sure of.