Open appvitae.ai

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 id as a string and type as a stable resource type.
  • Public fields live under attributes.
  • Relationships live under relationships when 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 error envelope.

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