⚠️ 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 Workers fetch() may strip Set-Cookie on a
server-to-server hop and the BFF has to put the token in its own session cookie.
The browser never sends that header.
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.
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. As with login, the JWT is not
returned to a browser and 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)