Skip to main content

API reference

The API is a Hono app (apps/api/src/app.ts) mounted at /api. Every route except GET /api/health requires an identity (see Authentication and authorisation). Request bodies are JSON, validated with zod schemas from packages/core/src/schemas.ts, and size-capped while streaming (256 KB unless noted). Route IDs must be UUIDs. Response types are exported from @workbench/core and used by the web client (apps/web/src/lib/api.ts).

Errors are JSON: { "error": "<code>", "message": "…" }, plus issues for validation_error.

StatusCode examples
400bad_request (including a value the database refuses: a check violation or numeric overflow), validation_error, attribute_identifies_people, invalid_json, invalid_reference_data, invalid_prompt, unknown_occupation
401unauthenticated
403forbidden, not_provisioned, deactivated
404not_found
409conflict, pii_unavailable
413payload_too_large
429ai_budget_exceeded
503ai_unavailable, pii_unconfigured

Platform​

Method and pathWhoNotes
GET /api/healthAnyone{ ok, version, environment }. Unauthenticated.
GET /api/meSigned inThe user, environment, aiEnabled and sign-in mode.
GET /api/usersSigned inActive users (admins also see deactivated), for team pickers.
POST /api/usersAdminAdd a user. Audits user_created.
PATCH /api/users/:idAdminRole, active, name. Refuses to remove the last active admin. Audits user_updated.
GET /api/clientsSigned inAll clients.
POST /api/clientsSigned inAudits client_created.
PATCH /api/clients/:idCreator or adminName, sector, notes, archived. Audits client_updated. (No UI yet.)
GET /api/clients/:id/brandSigned inThe client's deliverable brand (or null) and whether the caller may change it.
PUT /api/clients/:id/brandAdmin, the client's creator or a lead of one of its engagementsLogo, colours, font; refused if the primary can't carry white text or an SVG logo has active content. Audits client_brand_updated.
DELETE /api/clients/:id/brandAs aboveAudits client_brand_removed.
GET /api/benchmarks?datasetId=Signed inCross-engagement medians; the optional dataset's engagement is excluded.

Engagements​

Method and pathWhoNotes
GET /api/engagementsSigned inEngagements the user can read, with their role and PII flag.
POST /api/engagementsSigned inCreates the engagement (with a wrapped identity key when a KEK is configured) and adds the creator as lead with PII access. Audits engagement_created, member_added.
GET /api/engagements/:idMember
PATCH /api/engagements/:idLeadName, pack, region, sharing flags, retention, and reportNotes ({ sections, valueConcentration?, terms }, reportNotesSchema; null or empty notes clear them; a swap's from needs a letter and may not contain digits, £, $, € or %). reportNotes is refused with 409 conflict unless the engagement is active; a purge clears it. Changes only the fields sent. A pack change rescores the engagement's datasets. Audits engagement_updated, recording reportNotes as updated or cleared, never the text.
POST /api/engagements/:id/closeLeadAudits engagement_closed.
POST /api/engagements/:id/reopenLeadClosed only. Audits engagement_reopened.
POST /api/engagements/:id/purgeLeadBody { confirm } must equal the name. Closed only. Audits engagement_purged.
DELETE /api/engagements/:idAdminAudits engagement_deleted.
GET /api/engagements/:id/membersMember
PUT /api/engagements/:id/membersLeadAdd or update { email, role, piiAccess }. Keeps at least one lead. Nobody may switch on their own PII access (403, admins included); keeping or dropping it is fine. Audits member_added / member_updated.
DELETE /api/engagements/:id/members/:userIdLeadKeeps at least one lead. Audits member_removed.
GET /api/engagements/:id/overridesMember
PUT /api/engagements/:id/overridesLead, analystChecked against the current reference version; applied and re-scored in every dataset holding the title. Audits override_set.
DELETE /api/engagements/:id/overrides?title=Lead, analystRe-classifies without AI and re-scores. Audits override_removed.
GET /api/engagements/:id/datasetsMember
POST /api/engagements/:id/datasetsLead, analystCreates a dataset pinned to the current reference version. withIdentities needs PII access. useAi (default true) is stored as the dataset's AI choice. Audits dataset_created.
GET /api/engagements/:id/audit?offset=&limit=LeadThe engagement's trail, 100 a page by default.

Datasets​

Method and pathWhoNotes
GET /api/datasets/:idMemberDetail, summary, settings, mapping, progress and latestReferenceVersion.
PATCH /api/datasets/:idLead, analystRename, or save settings (re-scores). Audits dataset_renamed / dataset_rescored.
POST /api/datasets/:id/referenceLead, analyst{ version? }: rescore onto a published reference version (the newest by default). Audits dataset_reference_changed.
DELETE /api/datasets/:idLead, analystAudits dataset_deleted.
POST /api/datasets/:id/rowsLead, analyst{ chunk, rows, identities? }: up to 2,000 rows; 6 MB. employeeId and managerEmployeeId may not contain an @ (after NFKC): an email is never stored as an ID, and the whole chunk is refused with validation_error. Each row (employeeRowSchema) may carry fte (0–1, stored to two decimal places; absent, or a value that rounds to 0, means unknown and counts as 1) and attributes (up to five label → value groupings, headings distinct after trimming; the whole chunk is refused with validation_error if any heading or value looks like personal data, as attributesProblem judges). Idempotent per chunk number. Identities need PII access; audits identities_uploaded.
POST /api/datasets/:id/completeLead, analystChecks each extra grouping's variety (attributeVarietyProblem): one that would identify people is refused with 400 attribute_identifies_people, and the dataset stays uploading (delete it and upload again without that column). Otherwise extracts distinct titles; status becomes classifying. Audits dataset_uploaded.
POST /api/datasets/:id/classifyLead, analyst{ useAi? }: resolves up to 600 pending titles; finalises when none remain. Returns progress. Omitted useAi uses the dataset's stored choice; useAi can switch AI off for the call but never on for a dataset created with useAi: false.
GET /api/datasets/:id/rows?offset=&limit=MemberRows in upload order, up to 10,000 a page.
GET /api/datasets/:id/titlesMemberEach title's classification and reviewRequired.
POST /api/datasets/:id/identities/revealPII access{ purpose, employeeIds? }; 4 MB. Audits identities_revealed.
POST /api/datasets/:id/identities/matchPII access{ emails, purpose }; 8 MB. Returns matches by index, never the emails. Audits identities_matched.
POST /api/datasets/:id/exportsMember{ kind, artefact, named }: the browser records an export it generated. Audits export_generated.
GET /api/datasets/:id/ai/:kindMemberexec-summary or lifecycle: the cached AI version, or a rule-based one computed on the fly. Never spends tokens.
POST /api/datasets/:id/ai/:kindLead, analystGenerates with AI (or explains why not) and caches. Audits ai_generated.
POST /api/datasets/:id/ai/askMember{ question } (3–1,000 characters). 429/503 when AI is unavailable. Audits ai_generated.
GET /api/datasets/:id/copilot-plansMember
POST /api/datasets/:id/copilot-plansLead, analyst{ name, config }; 4 MB. Audits copilot_plan_saved.
GET /api/copilot-plans/:idMember
PUT /api/copilot-plans/:idLead, analystAudits copilot_plan_saved.
DELETE /api/copilot-plans/:idLead, analystAudits copilot_plan_deleted.
Export auditing is reported by the browser

Deliverables are generated in the browser, so the server learns of an export when the client calls POST /datasets/:id/exports afterwards. The call is fire-and-forget; the audit entry records what the client reports. Identity reveals, by contrast, are audited by the server as they happen.

Reference data​

Method and pathWhoNotes
GET /api/referenceSigned in{ version, publishedAt, notes } of the live version.
GET /api/reference/versions/:versionSigned inA published version's full data (drafts only for admins, under RLS).
GET /api/admin/referenceAdmin{ current, draft, versions } (metadata, no data).
POST /api/admin/reference/draftAdmin{ basedOn? }: start the draft. 409 if one exists. Audits reference_draft_created.
GET /api/admin/reference/draftAdmin{ draft } with data and live validation issues, or null.
PUT /api/admin/reference/draftAdmin{ data } or { section, value } or { notes }; 4 MB. Shape errors refuse the save.
POST /api/admin/reference/draft/publishAdmin{ notes } (3–2,000 characters). Refused while there are errors. Audits reference_published.
DELETE /api/admin/reference/draftAdminAudits reference_draft_discarded.

Prompts​

Method and pathWhoNotes
GET /api/admin/promptsAdminEach prompt's label, purpose, live version and draft.
GET /api/admin/prompts/datasetsAdminUp to 200 ready datasets across all engagements, for tests.
GET /api/admin/prompts/:keyAdminThe definition (variables, output contract) and every version.
POST /api/admin/prompts/:key/draftAdmin{ from? }. Audits prompt_draft_created.
PUT /api/admin/prompts/:key/draftAdminPartial template and notes; 128 KB. Returns the draft and its issues.
POST /api/admin/prompts/:key/draft/publishAdmin{ notes }. Refused while validatePrompt reports issues. Audits prompt_published.
DELETE /api/admin/prompts/:key/draftAdminAudits prompt_draft_discarded.
POST /api/admin/prompts/:key/testAdmin{ version, titles?, industry?, region?, datasetId?, question?, dryRun }. Metered unless a dry run. Audits prompt_tested.

Administration​

Method and pathWhoNotes
GET /api/admin/statsAdminEngagements, datasets, people analysed, cache entries and verified.
GET /api/admin/cache?q=&offset=&limit=AdminMost-used first.
PATCH /api/admin/cacheAdmin{ normalisedTitle, context, verified }. Audits cache_entry_verified.
DELETE /api/admin/cacheAdmin{ normalisedTitle, context }. Audits cache_entry_deleted.
GET /api/admin/audit?offset=&limit=&action=AdminThe platform trail, with engagement names. action filters by action code (not exposed in the UI).
GET /api/admin/ai-usageAdminTwelve months by month and feature, month-to-date spend, budget and model.