REST API and webhooks
/api/v1 with bearer keys and an OpenAPI spec, company scoping, PSA lookups, deep links, signed webhooks.
Base: /api/v1. JSON. Auth: Authorization: Bearer <api_key>; the key is also accepted bare, or as X-API-Key. The spec is generated from the same Zod schemas the endpoints validate with and served at /api/v1/openapi.json. Cursor pagination: ?limit=50&cursor=..., response carries next_cursor.
Keys are created under Admin → API keys with scopes read, write, admin, reactions, and with either every company or a named set. The value is shown once.
Resources
GET /companies ?q=&external_system=&external_id=
POST /companies
GET /companies/:id
PATCH /companies/:id
GET /companies/:id/locations
GET /companies/:id/documents ?doc_type=&location_id=
GET /companies/:id/export JSON or Markdown
POST /locations
GET /locations/:id
PATCH /locations/:id
GET /doc-types (includes template fields)
GET /doc-types/:id
GET /documents/:id (resolved values + raw IDs)
POST /documents { company_id, doc_type_id, location_id?, title, field_values }
PATCH /documents/:id (partial field_values merge)
GET /documents/:id/revisions
GET /option-lists/:id/items
POST /option-lists/:id/items
GET /search ?q=&company_id=&doc_type=
GET /users
POST /users { email, name, role?, all_companies? }
field_values is keyed by field UUID, exactly as stored. Responses return resolved values (option labels, linked document titles, rendered markdown) alongside the raw ids.
Company scope
A key is created with either every company or a named set. Everything is filtered by it: lists omit what the key may not see, and a single record it may not see answers 404 not_found, the same answer a missing id gets, so a key cannot be used to find out which companies exist. POST /companies with a restricted key answers 403 forbidden, since the key could not see what it created.
PSA integration
A ticket in any PSA needs to show that client’s docs:
PUT /external-refs { entity, entity_id, system, external_id } (upsert)
GET /lookup ?system=halopsa&entity=company&external_id=123
-> the company plus its locations and documents summary
Deep links a PSA can embed without the API: /go/{system}/company/{external_id} redirects to the matching company page.
Vault
Requires the secrets:reveal scope, off by default.
GET /vault/items ?company_id=&q= (metadata only, scoped to the mapped collection)
POST /vault/items/:item_id/reveal { document_id, field_id } -> { password } (audited, no-store)
POST /vault/items/:item_id/totp { document_id, field_id } -> { code, period_remaining }
Secrets never appear in document, revision, search, export, or webhook payloads.
Knowledge base
See Knowledge base for the /kb/* routes: collections, search, articles with runbook steps, upsert and archive under an external id, grants, and favorites and votes for a named reader.
Webhooks
Created under Admin → Notifications. Events:
company.created,company.updatedlocation.created,location.updateddocument.created,document.updated,document.archivedfield.promoteddocument.duedomain.changed,domain.expiring(see Domain checks)kb.article.upserted,kb.article.archived({ collection_id, article_id, external_id, kind })
Payload:
{ "event": "document.updated", "occurred_at": "...", "data": { "...": "resolved document" } }
Headers: X-Trove-Event, X-Trove-Delivery, X-Trove-Signature: sha256=<hmac> over the raw body with the signing secret shown once when the webhook was made. Retries with exponential backoff, up to 8 attempts, from a worker in the app container. On a Workers deployment a Cron Trigger calls /api/internal/webhooks with CRON_SECRET for the same pass.
Security baseline
- Argon2id for local passwords
- API keys: random 32 bytes, shown once, stored as SHA-256, looked up by prefix
- Server-side HTML sanitization for rich text
- CSRF protection on session routes, rate limiting on auth and the API