⚠️ This page is a hand-written sample, not the API. It describes a few dozen endpoints; the deployment serves several hundred. The authoritative surface is the live OpenAPI document at/doc, with Swagger UI at/ui, both generated from the route definitions themselves — plusserver/lib/mcp/openapi-snapshot.json, the committed snapshot of the same thing. Nothing generates or checks the page you are reading, and a 2026-09-09 audit found six endpoints on it that do not exist and three written under the wrong prefix. When this page and/docdisagree,/docis right.What is worth reading here is the part
/docdoes not carry: the auth model, the response envelope, and the status-code vocabulary.
Almost all API routes are under /api/. Inbound provider webhooks are the
deliberate exception and mount at the top level (/webhooks/{vendor}), because
the producer owns the body and the signature and none of the /api/* middleware
applies to them. /status, /photos/*, /.well-known/*, /sign/*, /sso,
/m2m/* and the ICS feed are also top-level.
Authenticated endpoints require a valid JWT in the __Host-inspector_token
HttpOnly cookie. The token is ES256-signed with a kid naming its keyring
entry. A Authorization: Bearer <token> header is also accepted.
Responses are JSON unless noted.
Health check. Its shape is load-bearing — scripts/check-deploy-lag.mjs reads
commit and branch and refuses to report "no lag" for a status it cannot
parse.
Response:
{
"status": "ok",
"app": "openinspection-core",
"version": "1.0.0",
"commit": "…",
"branch": "…",
"buildTime": "…",
"timestamp": "…"
}List all inspectors for the current tenant (used by the booking page).
Response:
{
"inspectors": [
{ "id": "user-id", "email": "john@example.com", "role": "inspector" }
]
}Get an inspector's raw availability for a date range: base weekly windows, date overrides, and already-booked dates. Rate-limited per IP.
Query params:
tenant(required) — the tenant slug from the booking page URL; resolved server-side (404 if unknown)start(optional) — ISO date string; defaults to todayend(optional) — ISO date string; defaults to 14 days from now
Response:
{
"success": true,
"data": {
"baseAvailability": [{ "dayOfWeek": 1, "startTime": "09:00", "endTime": "17:00" }],
"overrides": [{ "date": "2025-06-15", "isAvailable": false }],
"bookedSlots": ["2025-06-16"]
}
}Submit a booking request. Creates an inspection record with status: 'draft'.
Request body:
{
"propertyAddress": "123 Main St, Springfield",
"clientName": "Jane Smith",
"clientEmail": "jane@example.com",
"inspectorId": "user-id",
"date": "2025-06-15T09:00:00.000Z",
"templateId": "optional-template-id"
}Response:
{ "success": true, "inspectionId": "abc12345" }Returns the rendered HTML report page. Publicly accessible — no auth required. Supports id=demo for a demo report.
Returns the agreement to sign on-site. Find-or-creates the signing envelope
(agreement_requests + agreement_signers) so the on-site surface reads the
SAME pinned snapshot + signer set as the emailed flow. Returns
{ "agreement": null } when the tenant has no agreement template configured.
Response:
{
"agreement": {
"id": "agr-123",
"name": "Standard Terms",
"content": "# Standard Inspection Agreement\n\n..."
},
"requestId": "env-456",
"completionPolicy": "all",
"signers": [
{ "id": "sgr-1", "name": "Jane", "email": "jane@x.com", "role": "client", "status": "sent" }
]
}agreement.{id,name,content} is a backward-compatible subset; new fields are additive.
Record an on-site e-signature. The signature rides the agreement envelope (snapshot + Spec 5H audit chain + per-signer receipt email), and on completion runs the same pipeline as the emailed flow. Requires a configured agreement template — signing against no template is rejected (closes the prior legal hole where on-site signatures had zero legal evidence).
Request body:
{
"signatureBase64": "data:image/png;base64,...",
"signerId": "sgr-1",
"onBehalfOf": "Jane Doe",
"onBehalfDisclaimer": "..."
}signerId/onBehalfOf/onBehalfDisclaimer are optional. Without signerId,
the first non-terminal signer is targeted.
Response:
{ "success": true, "data": { "signed": true, "signerId": "sgr-1", "envelopeStatus": "signed" } }A repeat sign returns { "signed": true, "alreadySigned": true, ... }. With no
template configured, responds 409 with { "code": "no_agreement_template" }.
Removed from this page 2026-09-09: no such route has been found in the
codebase. The report pay-gate is driven from the public token track instead —
GET /api/public/checkout/:token and
POST /api/public/inspections/:id/pay-intent. See
../integrations/stripe.md for the payment flow
and /doc for the current shapes.
Verify credentials and set the __Host-inspector_token httpOnly cookie.
Request body:
{ "email": "user@example.com", "password": "s3cr3t" }Response:
{ "success": true, "data": { "redirect": "/inspections" } }The JWT is delivered only as the __Host-inspector_token HttpOnly cookie.
It is not in the body, and browser JavaScript must never store a token —
CLAUDE.md § JWT & Auth Security Rules makes both of those rules. The one
exception is the server-side BFF: a caller sending x-token-relay: 1 also gets
token in data, because the in-process BFF client does not forward the API's
Set-Cookie to the browser, and the login page has to put the token in its own
session cookie. The browser never sends that header. Only login (this step and
POST /api/auth/login/2fa) honours it; join and setup never return the token in
the body (see below).
If the user has 2FA enabled, no session is granted here. The response is
{ "success": true, "data": { "requires2fa": true, "challengeToken": "…" } }
with no Set-Cookie — challenge tokens travel JSON-only, so a stolen session
cookie alone can never bypass the second factor. The challenge is valid for five
minutes; post it back with the TOTP code to POST /api/auth/login/2fa to get a
session. That response follows the same delivery rule as this one: the session
arrives as the cookie, and only a caller sending x-token-relay: 1 also gets
token in data.
Returns 401 if credentials are invalid.
POST /api/auth/2fa/setup, /2fa/verify, /2fa/disable,
/2fa/recovery-codes/regenerate, and POST /api/auth/login/2fa. Shapes are in
/doc; the server side lives in server/api/auth/totp.ts.
Accept a team invite token, create the user account, and set the __Host-inspector_token cookie.
Request body:
{ "token": "invite-token", "password": "newpassword" }Response:
{ "success": true, "data": { "redirect": "/inspections" } }Sets the __Host-inspector_token HttpOnly cookie. The JWT is never in the
body — unlike login, this route ignores x-token-relay, and so does
POST /api/auth/setup. Their pages open a session by reading the JWT out of the
Set-Cookie header on the in-process API response. It must not be stored in
localStorage.
Returns 400 if the token is expired or already used.
Change the calling user's password. Requires a valid JWT or __Host-inspector_token cookie.
Request body:
{ "currentPassword": "oldpass", "newPassword": "newpass123" }Response:
{ "success": true }Returns 401 if currentPassword is wrong, 400 if newPassword is under 8 characters.
Send a password reset link to the given email address. Always returns 200 — no enumeration.
Request body:
{ "email": "user@example.com" }Response:
{ "success": true }A signed one-time token is stored in TENANT_CACHE KV with a 1-hour TTL. If RESEND_API_KEY is configured, an email is sent with the reset link. The link format is {APP_BASE_URL}/reset-password?token=<token>.
Exchange a valid reset token for a new password.
Request body:
{ "token": "reset-token", "newPassword": "newpass123" }Response:
{ "success": true }Returns 400 if the token is invalid, expired, or already used. Returns 400 if newPassword is under 8 characters. The token is invalidated on use.
All endpoints below require a valid JWT. The tenantId is extracted from the JWT custom:tenantId claim and scopes all queries.
List all inspections for the tenant.
Roles: any authenticated user
Response:
{
"inspections": [
{
"id": "abc12345",
"tenantId": "tenant-id",
"inspectorId": "user-id",
"propertyAddress": "123 Main St",
"clientName": "Jane Smith",
"clientEmail": "jane@example.com",
"templateId": "template-id",
"date": "2025-06-15T09:00:00.000Z",
"status": "draft",
"paymentStatus": "unpaid",
"price": 45000,
"referredByAgentId": null,
"createdAt": "..."
}
]
}Get a single inspection with its template schema.
Response:
{
"inspection": { ... },
"template": {
"id": "template-id",
"name": "Standard Home Inspection",
"version": 1,
"schema": { "sections": [...] }
}
}Create a new inspection.
Roles: owner, manager, inspector
Request body:
{
"propertyAddress": "456 Oak Ave, Portland",
"clientName": "Bob Jones",
"clientEmail": "bob@example.com",
"templateId": "template-id",
"inspectorId": "user-id",
"referredByAgentId": "optional-agent-id"
}Response: 201 Created
{ "message": "Inspection created successfully", "inspection": { ... } }Delete an inspection and its associated results. The inspection must belong to the caller's tenant.
Roles: owner, manager
Response:
{ "success": true }Returns 404 if the inspection is not found or belongs to a different tenant.
Update editable metadata on an inspection. Only the fields included in the request body are changed.
Roles: owner, manager, inspector
Allowed fields: propertyAddress, clientName, clientEmail, date, inspectorId, price, status
Valid status values: draft, completed, delivered
Request body (all fields optional):
{
"propertyAddress": "99 Updated Ave, Portland",
"clientName": "Jane Smith",
"clientEmail": "jane@example.com",
"date": "2025-07-01T10:00:00.000Z",
"inspectorId": "user-id",
"price": 45000,
"status": "completed"
}Response:
{ "inspection": { ... } }Returns 400 if no valid fields are provided or status is not a recognised value. Returns 404 if the inspection is not found or belongs to a different tenant.
Get the field data collected for an inspection.
Response:
{
"data": {
"roof_1": { "status": "Monitor", "notes": "Wear on north side", "photos": ["key1", "key2"] },
"foundation_1": { "status": "OK", "notes": "" }
}
}There has never been a PATCH on /{id}/results; the only method there is
GET. The bulk write path is POST /api/inspections/:id/results/batch, and
in normal editing nothing calls it per keystroke either: the editor's writes go
into a Yjs document held in a Durable Object, and the DO is the only writer of
inspection_results.data. See
../concepts/collab-editing.md.
This phantom route has cost real time before — develop/testing.md records an
E2E spec that patched it, got a silent 404 on its seed step, and stayed green
while testing nothing.
Mark an inspection as completed and email the report link to the client.
Roles: owner, manager, inspector
Response:
{ "success": true, "emailSent": true }Upload a photo to R2 storage for a specific checklist item.
Roles: owner, manager, inspector
Request: multipart/form-data
file— image fileitemId— checklist item ID the photo belongs to
Response:
{ "key": "tenant-id/inspection-id/item-id_abc123_photo.jpg", "success": true }Streams a photo from R2, scoped to the caller's tenant and inspection by the key
prefix. ?download=1 forces an attachment named after the stored original;
?w=<px> serves an on-the-fly thumbnail. The key contains /, which is why it
travels as a query parameter rather than a path segment.
The public report viewer has its own token-scoped twin in public-report.ts.
(This section used to describe GET /api/inspections/files/:key, a route that
does not exist.)
List all inspection templates for the tenant.
Response:
{
"templates": [
{ "id": "template-id", "name": "Standard Home Inspection", "version": 1 }
]
}List all users (inspectors) in the tenant.
Roles: owner, manager
Response:
{
"inspectors": [
{ "id": "user-id", "email": "john@example.com", "role": "inspector" }
]
}Rewrite a rough inspector note into a professional, objective comment using Gemini 1.5 Flash.
Roles: any authenticated user
Request body:
{
"text": "Some rust visible",
"context": "Electrical Panel"
}text(required) — the raw inspector note to rewritecontext(optional) — checklist item label used as context for the AI
Response:
{
"text": "Rust observed on the electrical panel enclosure. Recommend evaluation by a licensed electrician to assess corrosion extent and potential impact on panel safety."
}Requires AI to be configured: a resolvable credential (the tenant's own key, or a deployment-provided one in
saas) andAI_MODEL. Returns503when either is missing, and500when the model call itself fails.
Generate a high-level defect summary from an inspection's collected results using Gemini 1.5 Flash.
Roles: any authenticated user
Request body:
{ "inspectionId": "abc12345" }Response:
{
"summary": "The inspection identified significant concerns with the roof shingles and electrical panel that warrant immediate attention from licensed contractors."
}If no defect-status items are recorded the response is:
{ "summary": "No significant defects observed during this inspection." }Requires AI to be configured (a resolvable credential and
AI_MODEL;503otherwise). Returns403if the inspection does not belong to the caller's tenant,404if no results exist.
Get the calling inspector's weekly availability schedule.
Roles: any authenticated user
Response:
{ "availability": [{ "id": "...", "dayOfWeek": 1, "startTime": "09:00", "endTime": "17:00" }] }Replace the calling inspector's entire weekly schedule (full replace, not merge).
Roles: any authenticated user
Request body:
{
"slots": [
{ "dayOfWeek": 1, "startTime": "09:00", "endTime": "17:00" },
{ "dayOfWeek": 3, "startTime": "09:00", "endTime": "17:00" }
]
}Response: { "success": true, "count": 2 }
List date-specific availability overrides for the calling inspector.
Add a block-out date or custom-hours override.
Request body:
{ "date": "2025-07-04", "isAvailable": false }{ "date": "2025-12-24", "isAvailable": true, "startTime": "09:00", "endTime": "13:00" }Response: 201 Created — { "success": true, "override": { ... } }
Delete a date override by ID.
List all inspection templates for the tenant.
Create a new template. Roles: owner, manager
Request body:
{ "name": "Commercial Building", "schema": { "sections": [...] } }Update a template name or schema. Bumps version. Roles: owner, manager
Delete a template. Returns 409 if any inspection references it. Roles: owner, manager
Redirect the inspector's browser to Google OAuth consent. Requires GOOGLE_CLIENT_ID to be configured and a valid __Host-inspector_token cookie. Returns 501 if not configured.
Public OAuth redirect from Google. Exchanges the authorization code for tokens, fetches the primary calendar ID, and stores googleRefreshToken + googleCalendarId on the users row. Redirects to /inspections?calendar=connected.
Clears stored Google tokens from the users row. Requires __Host-inspector_token cookie.
Fetches the inspector's Google Calendar events for the next 30 days and inserts availabilityOverrides rows for any busy blocks. Existing overrides for the same date are skipped. Requires __Host-inspector_token cookie.
Response:
{ "success": true, "blockedDatesCreated": 3, "totalEvents": 12 }Export all tenant data as JSON for backup or migration.
Roles: owner, manager
Response:
{
"exportedAt": "2025-06-15T12:00:00.000Z",
"tenantId": "tenant-id",
"inspections": [...],
"inspectionResults": [...],
"templates": [...],
"agreements": [...],
"inspectionAgreements": [...]
}Create a 7-day team invite link. Sends a Resend email if RESEND_API_KEY is configured, otherwise logs the link to console.
Roles: owner, manager
Request body: { "email": "new@example.com", "role": "inspector" }
Response: 201 Created — { "success": true, "inviteLink": "https://…/join?token=…", "expiresAt": "…" }
List workspace members and pending invites.
Roles: owner, manager
Response:
{
"members": [
{ "id": "user-id", "email": "john@example.com", "role": "inspector", "createdAt": "..." }
],
"invites": [
{ "id": "token", "email": "pending@example.com", "role": "inspector", "expiresAt": "...", "status": "pending" }
]
}List all agreement templates for the tenant.
Roles: owner, manager
Response:
{
"agreements": [
{ "id": "agreement-id", "name": "Standard Terms", "version": 1, "createdAt": "..." }
]
}Create a new agreement template.
Roles: owner, manager
Request body:
{ "name": "Standard Terms", "content": "# Inspection Agreement\n\nThis agreement..." }Response: 201 Created
{ "success": true, "agreement": { "id": "agreement-id", "name": "Standard Terms", "version": 1, "createdAt": "..." } }Update an existing agreement template. Bumps the version field.
Roles: owner, manager
Request body (all fields optional):
{ "name": "Updated Terms", "content": "# Revised Inspection Agreement\n\n..." }Response:
{ "success": true, "agreement": { "id": "agreement-id", "name": "Updated Terms", "version": 2, "createdAt": "..." } }Returns 404 if the agreement does not exist or does not belong to the caller's tenant.
Delete an agreement template.
Roles: owner, manager
Response:
{ "success": true }Returns 404 if the agreement does not exist or does not belong to the caller's tenant.
Portal ↔ core machine-to-machine endpoints (e.g.
POST /api/admin/tenant-status,POST /api/admin/silo) are SaaS-only and not used by a standalone self-hosted deploy.
List inspections referred by the calling agent. Admins and owners can pass ?agentId=<id> to view any agent's reports.
Roles: agent, owner, manager
Query params (admin/owner only): agentId
Response:
{ "agentId": "agent-id", "reports": [...] }Referral leaderboard — inspection counts grouped by referredByAgentId, descending.
Roles: owner, manager
Response:
{
"leaderboard": [
{ "agentId": "agent-id", "total": 12 },
{ "agentId": "agent-id-2", "total": 7 }
]
}Both halves come from server/lib/response.ts (sendSuccess / sendError) and
are the same on every endpoint that uses them:
{ "success": true, "data": { }, "meta": { } }{ "success": false, "error": { "message": "…", "code": "…", "details": { } } }code is the machine-readable half and is what a client should branch on;
message is for a person. details carries whatever the specific failure can
say — the AI refusals, for instance, put their reason vocabulary there.
(The flat { "error": "…" } shape this section described until 2026-09-09 was
never what the code sends.)
Common status codes:
400— Missing or invalid request fields401— Missing or invalid JWT402— Subscription required (tenant status ispast_dueorpending; non-GET mutations blocked)403— Forbidden (insufficient role or tenant mismatch)404— Resource not found409— Conflict (e.g., deleting a template that is in use)500— Internal server error503— Dependency unavailable (e.g., missing API key, setup not complete)