# API reference > Every route under `src/app/api/`, with method, path, auth requirements, > request shape, response shape, and known error cases. Routes the user > never sees (the chat tool layer, the MCP server) are deliberately > excluded — those are documented in [`modules.md`](./modules.md#7-ask-chat). Conventions: - All routes return JSON unless explicitly noted. - `?` marks optional fields; the routes treat them as nullable. - HTTP status codes are first-class — they tell the caller whether a failure is retryable. - The `runtime = "nodejs"` annotation matters for upload routes — Next.js edge runtime has a hard body-size cap that would reject large files. --- ## 1. Health ### `GET /api/health` **File:** `src/app/api/health/route.ts` **Auth:** none **Caching:** `dynamic = "force-dynamic"` (no caching) **Use:** Docker healthcheck in `Dockerfile:86-87` and `docker-compose.yml:18-22` **Response 200:** ```json { "status": "ok", "uptime": 1234, "timestamp": "2026-09-06T10:00:00.000Z" } ``` --- ## 2. Sites ### `POST /api/sites` **File:** `src/app/api/sites/route.ts` **Auth:** server-only (PocketBase superuser) **Caching:** none (no `dynamic` annotation but no caching either) Creates a new site. `project_code` is required, unique, and 1-64 chars. **Request body:** ```ts { project_code: string, // required, unique name?: string, duration?: string, // e.g. "8 months" contract_value?: number, work_hours?: number, hours_per_week?: number, wages_hours_per_day?: number, job_start_date?: string // YYYY-MM-DD } ``` **Responses:** - `201 { site: SiteRecord }` - `400 { error: "project_code is required" }` / `"Body must be JSON"` / `"project_code must be 64 characters or fewer"` - `409 { error: "project_code \"X\" is already in use" }` (matched via `idx_sites_project_code`) - `500 { error: }` ### `PATCH /api/sites/[id]` **File:** `src/app/api/sites/[id]/route.ts` Partial update. `project_code` is **locked** after creation. Numeric fields are coerced via `Number()`. `job_start_date: null` clears the date explicitly. **Request body:** same shape as POST minus `project_code` (rejected with 400). **Responses:** - `200 { site: SiteRecord }` - `400 { error: "project_code cannot be changed once a site exists" }` - `500 { error: }` ### `POST /api/sites/[id]/fixed-costs` **File:** `src/app/api/sites/[id]/fixed-costs/route.ts` Adds a fixed cost for one site. Required: `description`, `cost > 0`, `date_logged` as `YYYY-MM-DD`, `is_recurring` as boolean. `notes` capped at 500 chars. **Request body:** ```ts { description: string, // required, ≤ 200 chars cost: number, // required, > 0 date_logged: string, // required, YYYY-MM-DD is_recurring: boolean, // required notes?: string // ≤ 500 chars } ``` **Responses:** - `201 { fixed_cost: FixedCostRecord }` - `400 { error: "validation", field, message }` (any validation failure) - `404 { error: "site_not_found" }` - `500 { error: "write_failed" }` --- ## 3. Vehicles ### `POST /api/vehicles` **File:** `src/app/api/vehicles/route.ts` Creates a vehicle. Required: `project_code` (≤ 64), `description`, `site` (relation id). Optional: `qr_code_id`, `fuel_limits`, `unit_type` (`fuel` | `plant`), `standard_work_hours`, `cost_per_hour`. **Responses:** - `201 { vehicle: VehicleRecord }` - `400` (missing field) / `409` (unique-constraint violation) / `500` ### `PATCH /api/vehicles/[id]` **File:** `src/app/api/vehicles/[id]/route.ts` Updates `description`, `project_code`, `unit_type`. Empty payload → 400. **Responses:** `200 { vehicle }` / `400` / `500` ### `DELETE /api/vehicles/[id]` Hard-deletes a vehicle. `plant_hours_log.vehicle` has `cascadeDelete=False` (`scripts/pb-bootstrap-schema.py:234`), so a vehicle with logs cannot be deleted — the route surfaces that as a `409` with a friendly message. **Responses:** - `200 { ok: true }` - `409 { error: "Cannot delete this plant — it has plant hours logged against it. Clear or reassign those logs first." }` - `500` ### `PATCH /api/vehicles/[id]/attention-dismiss` **File:** `src/app/api/vehicles/[id]/attention-dismiss/route.ts` Stamps `attention_dismissed_at` to "now" so the dismissed vehicle disappears from the Overview's "Attention required" rail. Re-acknowledging is allowed (overwrites with a new timestamp). **Responses:** `200 { ok, attention_dismissed_at }` / `500` --- ## 4. Plant hours ### `POST /api/plant-hours/import` **File:** `src/app/api/plant-hours/import/route.ts` **Runtime:** nodejs **Max file size:** 10 MB Accepts xlsx or csv via `multipart/form-data` with one `file` field. See [`data-flows.md §3`](./data-flows.md#3-plant-hours-import-synchronous) for the full pipeline. **Response 200:** ```ts { batchId: string, totalRows: number, imported: number, skipped: number, mismatched: number, warnings: Array<{ row: number; reason: string }>, errors: Array<{ row: number; reason: string }>, } ``` `errors[].reason` includes `"duplicate: vehicle_qr=X start_date=Y"` for the `(vehicle_qr, start_date)` unique-index rejections. **Errors:** - `400 { error: "No file provided" }` / `"Empty file"` / `"File too large"` / `"multipart/form-data required"` / `"File could not be parsed as Excel or CSV"` / `"Workbook has no sheets"` / `"File contains no data rows"` - `400 { error: "Failed to create import batch", details: }` ### `GET /api/plant-hours/exception-report/[batchId]` **File:** `src/app/api/plant-hours/exception-report/[batchId]/route.ts` **Auth:** server-only Reconstructs the per-row exception CSV for one batch by re-parsing the batch's `original_file` and matching against the imported rows. **Response:** `text/csv` (Content-Disposition: `attachment; filename="_plant_hours_exceptions.csv"`) **Errors:** `400 { error: "invalid_batch_id" }` / `"no_sheet"` / `500 { error: "source_file_unavailable" }` ### `GET /api/plant-hours/template` **File:** `src/app/api/plant-hours/template/route.ts` **Auth:** server-only Returns the canonical `Plant_Hours_Template_2.xlsx` workbook, with the sample row's QR / description / cost-per-hour pulled from the first vehicle in PocketBase (so the template matches a real asset). **Response:** `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` (attachment, `Cache-Control: no-store`) ### `DELETE /api/plant-hours/[id]` **File:** `src/app/api/plant-hours/[id]/route.ts` Removes one `plant_hours_log` row. Used by the "Delete row" actions on the Attention panel and the per-vehicle log table. **Responses:** `200 { ok: true }` / `500 { error: }` --- ## 5. Timesheets ### `POST /api/timesheets/import` **File:** `src/app/api/timesheets/import/route.ts` **Runtime:** nodejs (multipart) Accepts xlsx/xls/csv via `multipart/form-data` with optional form fields `siteId` and `createMissingEmployees` (`"true"` to auto-create missing casual employees tied to `siteId`). See [`data-flows.md §1`](./data-flows.md#1-timesheet-import-synchronous) for the format auto-detection. **Response 200:** ```ts { batchId: string, totalRows: number, imported: number, skipped: number, errors: Array<{ row: number; reason: string }>, detectedFormat: "casual-matrix" | "permanent-wide" | "long-format-csv" | "long-format-xlsx", createdEmployees: Array<{ employeeNo: string; name: string }>, } ``` **Errors:** `400` for missing file, parse failure, or zero rows extracted. --- ## 6. Fuel logs ### `POST /api/fuel-logs/import` **File:** `src/app/api/fuel-logs/import/route.ts` **Runtime:** nodejs (multipart) Accepts xlsx or csv via `multipart/form-data` with one `file` field. See [`data-flows.md §2`](./data-flows.md#2-fuel-log-import-synchronous) for the column allowlist and site-resolution strategy. **Response 200:** ```ts { batchId: string, totalRows: number, imported: number, skipped: number, errors: Array<{ row: number; reason: string }>, } ``` `errors[].reason` includes PB field-level errors (flattened via `formatPbError`) so the operator can see which field rejected the row. **Errors:** `400` for missing file, parse failure, missing required columns, or zero rows. --- ## 7. Purchase orders ### `POST /api/purchase-orders/upload` **File:** `src/app/api/purchase-orders/upload/route.ts` **Runtime:** nodejs **Auth:** server-only Accepts a PDF or image via `multipart/form-data` with one `file` field. SHA-256 hashes the bytes; if a `data_import_batches` row with the same hash and `status=processed` exists within the last 30 days, returns `200 { duplicate: true, existing_batch_id, existing_po_id }` and creates nothing. **Mime allowlist** (`ALLOWED_MIME` in `src/lib/purchase-order-schema.ts`): PDF + JPEG/PNG/WebP. **Response 200:** `{ duplicate: true, existing_batch_id, existing_po_id, filename, status: "duplicate" }` **Response 202:** `{ batch_id, status: "queued", filename }` **Errors:** `400` for invalid form, missing file, empty file, too-large file, unsupported mime. `500` for hash or batch-create failure. ### `POST /api/purchase-orders/process-batch` **File:** `src/app/api/purchase-orders/process-batch/route.ts` **Runtime:** nodejs **Auth:** `x-ingest-secret: ${N8N_INGEST_SECRET}` (shared secret with n8n) **Body:** `{ batch_id: string (15-char PB id), extracted: PurchaseOrderIngestPayload }` **Side effects:** creates the PO row, all PO items, moves the file from the batch to the PO, finalises the batch (`status=processed`). See [`data-flows.md §4`](./data-flows.md#4-purchase-order-upload-ingest-async-via-n8n) for the full pipeline and three dedup layers. **Response 200:** ```ts { ok: true, id: string, // PO id po_number: string, // normalised (uppercase, "PO" prefix stripped) po_number_extracted: string, // what the AI actually read duplicate?: true, // present only on the dedup branch warnings?: string[], // e.g. "fuzzy match: ..." when matchSite fell back } ``` **Errors:** All error responses include `retryable: true` (5xx-class) or `retryable: false` (4xx-class) so n8n can decide. The cases: - `400 unauthorized` / `invalid_json` / `bad_batch_id` / `missing_extracted` / `validation_failed` - `404 batch_not_found` - `409 batch_not_processing` (race / duplicate callback) - `500` with `step` for `lookup_duplicate` / `match_site` / `create_po` / `create_items` / `file_move` (502) / `batch_finalize` ### `GET /api/purchase-orders/by-number/[poNumber]/items` **File:** `src/app/api/purchase-orders/by-number/[poNumber]/items/route.ts` **Auth:** server-only Used by the purchasing detail page when the user is looking up items by PO number. Returns the PO + items. **Response 200:** `{ po, items }` (PO record + line items array, or `null` if no match) ### `PATCH /api/purchase-orders/[id]/supplier` **File:** `src/app/api/purchase-orders/[id]/supplier/route.ts` **Auth:** server-only **Body:** `{ supplier: string }` **Responses:** `200 { purchase_order }` / `400` / `404` / `500` --- ## 8. Delivery notes ### `POST /api/delivery-notes/upload` **File:** `src/app/api/delivery-notes/upload/route.ts` **Runtime:** nodejs **Auth:** server-only Manual upload of a delivery note. Required form fields: `file` (image or PDF), `site` (site id). Optional: `delivery_note_number`. The OCR pipeline picks the upload up via `processing_status="pending"`. Also creates a `data_import_batches` row (`batch_type=delivery_notes`) with `processed_record_id=` so the OCR callback can find it without a relation field on `delivery_notes`. A failure to create the batch row is logged but does not fail the upload (the DN is already persisted and the OCR pipeline can still pick it up). **Response 201:** ```ts { id: string, deliveryNoteNumber: string, siteId: string, projectCode: string, processingStatus: "pending", created: string, } ``` **Errors:** `400 { error: "No file provided" }` / `"Missing site"` / `"Site not found"` / `500` ### `GET /api/delivery-notes/[id]/slip` **File:** `src/app/api/delivery-notes/[id]/slip/route.ts` **Runtime:** nodejs **Auth:** `x-ingest-secret: ${N8N_INGEST_SECRET}` Proxies the slip file for n8n. Resolves `dn.slip_image` and streams the bytes from PocketBase back to the caller, with `Content-Disposition: attachment; filename=""` so the workflow's binary metadata carries a sensible name. **Responses:** - `200` with `application/octet-stream` (or the original mime) body - `400 { error: "invalid_dn_id" }` - `401 { error: "unauthorized" }` - `404 { error: "dn_not_found" }` / `"no_slip_image"` - `500 { error: "pb_url_not_configured" }` / `502 { error: "slip_fetch_failed" }` ### `POST /api/delivery-notes/[id]/ocr-result` **File:** `src/app/api/delivery-notes/[id]/ocr-result/route.ts` **Runtime:** nodejs **Auth:** `x-ingest-secret: ${N8N_INGEST_SECRET}` **Body:** see header doc in the file — `po_number`, `delivery_note_number`, `date_received`, `received_by_name`, `confidence` (0..1), `items[]`, `raw_ocr` The three-tier decision (auto / needs-review / manual) is the load-bearing business logic. See [`data-flows.md §5`](./data-flows.md#5-delivery-note-upload-ocr-result-async-via-n8n) and `src/lib/ocr-result-tier.ts`. **Response 200:** ```ts { ok: true, tier: "auto" | "needs-review" | "manual", receivedItemsCreated: number, receivedItems: Array<{ id: string; po_item_id: string; qty: number }>, durationMs: number, } ``` **Errors:** `400 invalid_dn_id` / `invalid_json` / `401 unauthorized` / `404 dn_not_found` / `500` ### `POST /api/delivery-notes/[id]/assign-po` **File:** `src/app/api/delivery-notes/[id]/assign-po/route.ts` **Auth:** server-only **Body:** `{ po: string }` (PO id) Manual fallback for OCR failures. Sets `processing_status="manual-verified"`, `is_verified=true`, links the PO, and stamps `ocr_completed_at`. **Responses:** `200 { deliveryNote }` / `400` / `404` / `500` --- ## 9. Data import batches ### `GET /api/data-import-batches/[id]/status` **File:** `src/app/api/data-import-batches/[id]/status/route.ts` **Runtime:** nodejs **Auth:** server-only Polled by the browser after a PO upload. For processed PO batches, also resolves `processed_record_id` to the `purchase_order_number` so the UI can show "MEGCPO1234" instead of a raw id. **Response 200:** ```ts { id: string, status: "pending" | "processing" | "processed" | "failed" | "dead", attempts: number, last_error: string | null, processed_record_id: string | null, processed_record_label: string | null, // PO number when batch_type=purchase_orders processed_at: string | null, } ``` **Errors:** `400 { error: "bad_id" }` / `404 { error: "not_found" }` / `500 { error: "lookup_failed" }` ### `POST /api/data-import-batches/[id]/retry` **File:** `src/app/api/data-import-batches/[id]/retry/route.ts` **Runtime:** nodejs **Auth:** server-only Resets a `failed` or `dead` batch back to `pending` with `attempts=0`. Already-pending batches return 409. **Responses:** - `200 { id, status: "pending" }` - `400 { error: "bad_id" }` - `404 { error: "not_found" }` - `409 { error: "not_retryable", current_status }` - `500` --- ## 10. Records ### `POST /api/records/[collection]/[id]/verify` **File:** `src/app/api/records/[collection]/[id]/verify/route.ts` **Auth:** server-only **Body:** `{ verified?: boolean }` (default `true`) **Allowed collections:** `purchase_orders`, `delivery_notes` Generic verify toggle. The collection allowlist is hardcoded — everything else returns 400. **Responses:** `200 { id, is_verified }` / `400 { error: "Collection \"X\" is not verifiable" }` / `500` --- ## 11. MEGA Clock The standalone QR timekeeping PWA at `/clock`. The PIN gate handles auth — these routes trust the caller the same way every other clock/PWA flow does. ### `POST /api/clock/scan` **File:** `src/app/api/clock/scan/route.ts` **Auth:** server-only Clock a worker in or out by scanning their `employee_no` badge. The direction is implied by the worker's most recent `clock_events` row: no row or `out` → in, `in` → out (which also writes/updates a `timesheet_entries` row). **Body:** ```ts { employee_no: string } ``` **Responses:** - `200 { direction: "in", employeeName, at }` - `200 { direction: "out", employeeName, sessionHours, dailyTotalHours }` - `400 { error: "Body must be JSON" }` / `"employee_no is required"` - `404 { error: "Unknown badge" }` ### `POST /api/clock/vehicle-lookup` **File:** `src/app/api/clock/vehicle-lookup/route.ts` **Auth:** server-only Called by the Fleet tab the instant a vehicle QR is decoded. Looks up the vehicle and the most-recently-inserted `odo_reading` for it (the "previous reading" the popup shows). **Body:** ```ts { qr_code_id: string } ``` **Responses:** - `200 { vehicle: { id, description, qrCodeId, siteId, unitType, fuelLimits, costPerHour }, hasPrevious: boolean, previousReading: number | null }` - `400 { error: "Body must be JSON" }` / `"qr_code_id is required"` - `404 { error: "Unknown vehicle QR" }` ### `POST /api/clock/vehicle-scan` **File:** `src/app/api/clock/vehicle-scan/route.ts` **Auth:** server-only Writes one `fuel_logs` row capturing the current odometer/hour-meter reading for a vehicle whose QR was just scanned. Pairs with `/api/clock/vehicle-lookup`, which the UI calls first to resolve the QR and surface the previous reading. The scan endpoint deliberately captures *one* number per scan (the dash reading) and stamps a zero-litre row. Capturing fuel alongside is left to the spreadsheet import — the Fleet tab is for the end-of-shift reading, not for the pump sheet. **Body:** ```ts { qr_code_id: string, odo_reading: number } ``` **Responses:** - `200 { vehicleId, odoReading, dateLogged, logId }` - `400 { error: "Body must be JSON" }` / `"qr_code_id is required"` / `"odo_reading is required"` / `"odo_reading must be greater than zero"` / `{ error: "" }` - `404 { error: "Unknown vehicle QR" }` --- ## 12. Chat (gated) ### `POST /api/chat` **File:** `src/app/api/chat/route.ts` **Caching:** `dynamic = "force-dynamic"` **Auth:** none — gated by `ENABLE_CHAT=true`. **No user authentication.** **Protocol:** Server-Sent Events `Content-Type: text/event-stream; charset=utf-8`. Events emitted: - `meta` — `{ provider, model }` (first) - `delta` — `{ text }` (repeated, as the model streams) - `tool` — `{ name }` (when a tool is called) - `done` — `{ text, iterations, stoppedOnLimit }` (final) - `error` — `{ message }` (on failure) The chat MUST stay disabled in production until real authentication exists — see the `.env.local.example` warning and [multi-site-assessment §7](./multi-site-assessment.md#7-the-chatagent-layer). **Other chat routes** (also gated): `/dashboard/chat` page. The chat consumes no other server route; every figure comes through the agent tool layer (`src/lib/agent/`). --- ## 13. What is **not** under `/api/` For completeness — these are out of scope for this reference but may be searched for: - The **MCP server** (`src/mcp/server.ts`) is `npm run mcp`, stdio-only, never a route. Exposes the same `tools` array the chat uses. - The **chat tool layer** (`src/lib/agent/`) is server-internal; the chat route calls it via in-process imports, not over HTTP. - The **Pure metrics helpers** (`src/lib/metrics/`) are server-internal functions, not routes. --- ## 14. Auth matrix | Route | Auth | Notes | | --- | --- | --- | | `GET /api/health` | none | Liveness | | `POST /api/sites` | server-only | Superuser via `pbAdmin()` | | `PATCH /api/sites/[id]` | server-only | | | `POST /api/sites/[id]/fixed-costs` | server-only | | | `POST /api/vehicles` | server-only | | | `PATCH /api/vehicles/[id]` | server-only | | | `DELETE /api/vehicles/[id]` | server-only | | | `PATCH /api/vehicles/[id]/attention-dismiss` | server-only | | | `POST /api/plant-hours/import` | server-only | Multipart, nodejs | | `GET /api/plant-hours/exception-report/[batchId]` | server-only | | | `GET /api/plant-hours/template` | server-only | | | `DELETE /api/plant-hours/[id]` | server-only | | | `POST /api/timesheets/import` | server-only | Multipart, nodejs | | `POST /api/fuel-logs/import` | server-only | Multipart, nodejs | | `POST /api/purchase-orders/upload` | server-only | Multipart, nodejs | | `POST /api/purchase-orders/process-batch` | `x-ingest-secret` | n8n callback | | `GET /api/purchase-orders/by-number/[poNumber]/items` | server-only | | | `PATCH /api/purchase-orders/[id]/supplier` | server-only | | | `POST /api/delivery-notes/upload` | server-only | Multipart | | `GET /api/delivery-notes/[id]/slip` | `x-ingest-secret` | n8n file proxy | | `POST /api/delivery-notes/[id]/ocr-result` | `x-ingest-secret` | n8n callback | | `POST /api/delivery-notes/[id]/assign-po` | server-only | Manual fallback | | `GET /api/data-import-batches/[id]/status` | server-only | UI poll | | `POST /api/data-import-batches/[id]/retry` | server-only | | | `POST /api/records/[collection]/[id]/verify` | server-only | Allowlist: POs + DNs | | `POST /api/chat` | gated by `ENABLE_CHAT=true` | **No user auth** — the gate is the env flag, not identity | | `POST /api/clock/scan` | server-only | Behind the /clock PIN gate | | `POST /api/clock/vehicle-lookup` | server-only | Behind the /clock PIN gate | | `POST /api/clock/vehicle-scan` | server-only | Behind the /clock PIN gate | **Routes with no protection at all:** the chat route (when enabled) and any route that runs purely server-side. The browser-facing routes delegate their auth to the PIN gate, not to the route — see [`architecture.md §4`](./architecture.md#4-auth-model).