Response Format
New Vitae.ai endpoints should use explicit JSON:API serializers at the controller boundary. The goal is that every API response is predictable, regardless of whether the frontend, CLI, MCP server, or a customer integration consumes it.
Serializers must live in packages/serializers, not inline in controllers or API modules. Controllers should call serializeSingle(request, Serializer, doc) or serializeCollection(request, Serializer, paginatedResult).
Single resource
{
"data": {
"id": "client_01h...",
"type": "client",
"attributes": {
"name": "Acme Corp",
"createdAt": "2026-05-15T13:00:00.000Z"
}
},
"links": {
"self": "/v1/clients/client_01h..."
}
}
Resource list
{
"data": [
{
"id": "job_01h...",
"type": "job",
"attributes": {
"title": "Senior Backend Engineer"
}
}
],
"links": {
"self": "/v1/jobs?page=1&limit=25",
"pagination": {
"page": 1,
"limit": 25,
"total": 125,
"totalPages": 5,
"hasNextPage": true,
"hasPreviousPage": false
}
}
}
Action result
Workflow commands still return a resource envelope. The changed resource is returned in data; secondary entities can be included in meta.
{
"data": {
"id": "inbox_item_01h...",
"type": "inbox_item",
"attributes": {
"status": "accepted"
}
}
}
Errors
{
"error": {
"code": "VALIDATION_FAILED",
"title": "Validation failed",
"message": "property source should not exist",
"details": [
{
"code": "VALIDATION_FAILED",
"title": "Validation failed",
"detail": "property source should not exist",
"field": "source",
"source": { "pointer": "/v1/candidates" }
},
{
"code": "VALIDATION_FAILED",
"title": "Validation failed",
"detail": "skills must be an array",
"field": "skills",
"source": { "pointer": "/v1/candidates" }
}
],
"requestId": "req_01h...",
"source": { "pointer": "/v1/candidates" }
}
}
code is a stable machine-readable string. Match on it rather than on the
human-readable message, which is free to change.
details lists every problem with the request, not just the first one. A
body with ten rejected fields comes back naming all ten, so an integration is
never reduced to fixing one field per retry. Each entry carries the constraint
that failed in detail and the property it applies to in field, using a
dotted path for nested objects such as profile.gender. Very large failures are
capped at 50 entries.
The top-level title, message, and code repeat the first entry, so a client
that only reads error.message keeps working unchanged.
Serializer rules
- Every resource exposes
idas a string andtypeas a stable resource type. - Public fields live under
attributes. - Relationships live under
relationshipswhen serialized. - Internal database fields like
_id,__v, raw ObjectIds, and deleted flags are not returned unless explicitly part of the public contract. - Dates are ISO 8601 strings.
- Field names are
camelCase. - Lists are always arrays under
data. - Pagination lives under
links.pagination. - Errors always use the
errorenvelope.
Migration note
The API has a global compatibility serializer that normalizes normal controller returns into { data, meta } while endpoints are migrated. JSON:API documents returned from explicit serializers bypass that compatibility wrapper.
Endpoints that manually write to the Express response object, stream files, or intentionally opt out with @SkipResponseSerialization() are compatibility surfaces and must document their custom response format.
Current manual-response surfaces:
- Stripe webhooks
- CSV, XLSX, attachment, and QR-code downloads
- OAuth redirects and revocation callbacks
- short URL redirects
- streaming agent chat
- candidate search streaming/export responses