Bondery Docs

API routes

Every Fastify route in apps/api/src/routes/ is part of the published API contract. Docs are compiled from Zod schemas — never hand-written per endpoint. OpenAPI (packages/openapi-spec/openapi.yaml) is committed; the pre-commit hook regenerates it on API/schema changes. CI enforces freshness. Don't rely on deploy to regenerate.

See schemas.md for which @bondery/schemas subpaths each app may import.

Canonical route shape

Relative imports within apps/api are extensionless (same as packages/*). @bondery/* workspace imports are unchanged.

import type { FastifyZodOpenApiSchema } from "fastify-zod-openapi";
import { withOkResponse, withCreatedResponse } from "../../lib/platform/openapi/responses.js";
import { contactResponseSchema } from "@bondery/schemas";
import { uuidParamSchema } from "@bondery/schemas/http";

export const myRoutes: AppRoutePlugin = async (fastify) => {
  fastify.addHook("onRoute", (routeOptions) => {
    if (routeOptions.schema) {
      routeOptions.schema.tags = ["Contacts"];
    }
  });

  fastify.get(
    "/:id",
    {
      schema: {
        description: "Get a single contact by UUID.",
        params: uuidParamSchema,
        response: withOkResponse(contactResponseSchema, "Contact details"),
      } satisfies FastifyZodOpenApiSchema,
    },
    async (request, reply) => { /* ... */ },
  );
};

Register the plugin in register-all.ts with the correct area (integration, session, admin, internal, webhook, or composite). Area shells in lib/platform/route-areas.ts attach auth hooks and openApiArea — route modules must not call registerApiKeyProtectedHooks, registerSessionAuthHooks, or applyOpenApiRouteMeta directly.

Nested route modules (e.g. contacts/enrichment/*) inherit metadata from the parent shell — only add description and response there.

Route ordering in API docs

The docs site renders endpoints in Fastify registration order — there is no separate sort step. Full rationale and examples: .agents/skills/bondery-api/references/api-route-ordering.md.

Path tiers

TierNameExamples
1CollectionGET/POST/DELETE /api/contacts
2Static siblings/map-pins, /by-social, /important-dates/upcoming
3Single resourceGET/PATCH/DELETE /api/contacts/{id}
4Sub-resources/api/contacts/{id}/groups, /api/groups/{id}/contacts
5Auxiliarymerge, enrich-queue (AUXILIARY_FIRST_SEGMENTS in @bondery/schemas/openapi/route-order)

Same tier → alphabetical by path segment.

HTTP methods (same path)

GET → POST → PUT → PATCH → DELETE

Integrator dependency order in the tags array in apps/api/src/openapi/swagger-config.ts. Route plugin registration order lives in apps/api/src/routes/register-all.ts.

Health → Contacts → Groups → Tags → Interactions → Import → Share → Geocode → Me → Sync → Extension → Chat → Subscriptions → Stats → Webhooks → Internal

Checklist when adding routes

  • Route registered in the correct tier (not appended at file bottom by default)
  • Same-path methods follow GET → POST → PUT → PATCH → DELETE
  • New auxiliary paths added to AUXILIARY_FIRST_SEGMENTS when tier 5
  • pnpm run check:openapi passes (includes route-order CI check)

Checklist for new or changed routes

  • description on every route schema
  • response with withOkResponse, withCreatedResponse, or explicit status map + standardErrorResponses
  • satisfies FastifyZodOpenApiSchema on the schema object
  • Request shapes from @bondery/schemas or @bondery/schemas/http (use contactIdSchema for UUID params, not plain z.string())
  • New route plugin added to ROUTE_MOUNTS in register-all.ts with the correct area
  • Handler return shape matches the declared response schema
  • OpenAPI example on every success response schema (see below)
  • OpenAPI example on every JSON request body schema (see below)
  • If the route returns 409, spread conflictResponse or syncConflictResponse from @bondery/schemas/http/responses
  • If an error status returns a non-ApiError JSON body, override that status in response (see GET /health/ready 503)
  • pnpm run build:api or pnpm exec turbo build --filter=api from repo root after route/schema changes
  • OpenAPI spec updates automatically via the pre-commit hook when apps/api or packages/schemas change; run pnpm run check:openapi manually before release if needed

OpenAPI response examples

The docs site shows route-level example payloads (not auto-generated model placeholders). Examples are attached via Zod .meta({ example }) and wired automatically by withOkResponse / withCreatedResponseno per-route example argument.

When adding a new *ResponseSchema:

  1. Add EXAMPLE_* to packages/schemas/src/openapi/fixtures/responses.ts (compose from fixtures/entities.ts and fixtures/primitives.ts).
  2. Chain .meta({ example: EXAMPLE_* }) on the response schema export.
  3. Register the schema in packages/schemas/scripts/check-contracts-openapi-examples.ts if it is a new export.

For inline route-only schemas (e.g. admin stats), attach .meta({ example }) on the schema in the route file and import the fixture from @bondery/schemas.

pnpm run check:contracts validates every registered example with schema.parse(example). pnpm run check:openapi-spec -w api fails if any 2xx or 4xx/5xx application/json response lacks an example or has an empty schema, and if any POST/PUT/PATCH JSON request body lacks an example.

OpenAPI request examples

The docs site shows copy-pasteable request payloads on mutation routes. Examples attach via Zod .meta({ example }) on the same schema used in schema.body — no per-route example argument.

When adding or changing a request body schema:

  1. Add EXAMPLE_*_REQUEST to packages/schemas/src/openapi/fixtures/requests.ts (compose from fixtures/entities.ts and fixtures/primitives.ts).
  2. Chain .meta({ example: EXAMPLE_*_REQUEST }) on the body schema export in @bondery/schemas.
  3. Register the schema in packages/schemas/scripts/check-contracts-openapi-examples.ts under REQUEST_SCHEMA_EXAMPLES.

Guidelines:

  • Create (POST): show required fields only.
  • Update (PATCH/PUT): show a small partial payload (one to three fields).
  • Wire shape: examples are what the client sends (pre-transform input).
  • Shared bodies (idsRequestBodySchema, tagMembershipRequestSchema, …): one example reused across routes.
  • No JSON example: multipart uploads (POST …/photo) and body-less creates (POST /api/chat/sessions) — document in description instead.

OpenAPI error response examples

Standard errors come from standardErrorResponses in @bondery/schemas/http/responses (400, 401, 403, 404, 429, 500, 503). They are included automatically by withOkResponse / withCreatedResponse — no per-route work for most endpoints.

Error examples live in packages/schemas/src/openapi/fixtures/errors.ts. The wire shape is { error: string } with optional retryAfter on 429 (see apiErrorResponseSchema).

HelperWhen to use
conflictResponseRoute returns 409 with { error } only (API keys, checkout, relationships, important dates)
syncConflictResponseRoute returns 409 with { error, contact } (contact PATCH sync conflict)

generate:openapi patches duplicate empty error schemas (schema: {}) to $ref: ApiError — a fastify-zod-openapi limitation when reusing the same Zod component across routes.

Server bootstrap

The API separates app assembly from runtime boot:

FunctionModuleUse
buildApp()apps/api/src/build-app.tsPlugins, swagger, routes — no Redis, JWKS verify, or listen
buildServer()apps/api/src/build-server.tsbuildApp() plus onReady (auth JWKS verify, sync wake) and onClose shutdown
registerAllRoutes()apps/api/src/routes/register-all.tsOrdered route mount table — add new route modules here

apps/api/scripts/generate-openapi.ts imports build-app.ts directly so OpenAPI generation never loads index.ts side effects (auto-listen). New runtime startup hooks belong in build-server.ts, not build-app.ts. OpenAPI generation and API integration tests call applyApiBootEnv() from @bondery/helpers/env so no local .env is required — values come from the manifest boot profile (boot.value when exampleValue is empty or a placeholder).

Redis

Long-lived ioredis connections are owned by apps/api/src/lib/data/redis.ts:

ClientUsed for
getRedisCommands()Rate limit, WS tickets, sync wake publish
getRedisSubscriber()Sync wake subscribe only

build-server.ts onClose calls shutdownSyncWakeRuntime() then shutdownRedis(). Do not call new Redis() elsewhere — CI enforces via check-redis-singleton. Health probes use ephemeral clients intentionally.

API key route policy

Auth and openApiArea are applied by area shells when routes mount in register-all.ts. Shells live in lib/platform/route-areas.ts:

area in mount tableShellAPI keys
integrationintegrationRoutesAllowed (read / full)
sessionsessionRoutesDenied (session bearer only)
adminadminRoutesDenied (admin bearer only)
internalinternalRoutesDenied (service secret)
webhookopenApiAreaRoutes("internal", …)HMAC in handler
compositepassthroughPlugin composes sub-shells (sync HTTP vs WS)

Runtime enforcement: assertApiKeyAccess allows API keys only when openApiArea === "integration".

CI: pnpm run check:route-security -w api (wired into check:types). Boot-time audit: route-security-audit.test.ts in test:api.

Layering

The API uses four code layers under apps/api/src/. Each layer has a single responsibility — place new code in the correct folder before opening a PR.

LayerFolderResponsibility
Routesroutes/HTTP adapter only: Zod/OpenAPI schemas, auth context (getAuth / withDomainRoute), response shaping. No business logic.
Domainsdomains/Sync-aware CRM mutations (contacts, groups, tags, import, merge). Emit sync changes via emitSyncBatch / persistSyncChanges.
Servicesservices/App features and read queries: me, billing, chat, interactions, notifications, admin stats, */queries.ts list/detail reads.
Liblib/Infrastructure subsystems — see lib/README.md. No .ts files at lib/ root.

lib/ subsystems

FolderResponsibility
lib/platform/Fastify glue, auth, errors, OpenAPI, route shells
lib/data/Prisma, Redis, pagination, search, select-fragments.ts
lib/contacts/Shared CRM primitives (channels, enrichment, avatars)
lib/integrations/Third-party adapters (e.g. Mapy geocoding)
lib/extension/, lib/import/, lib/notifications/Extension, import helpers, email transporter
lib/sync/, lib/health/Sync engine and health probes (existing layout)

data/select-fragments.ts holds reusable Prisma select shapes; services/<area>/queries.ts holds full read handlers for GET routes.

Where to put new code

TaskLayer
POST /api/contacts createdomains/contacts/ + thin route
GET /api/contacts list/searchservices/contacts/queries.ts + thin route
POST /api/me/feedback emailservices/notifications/ + thin route
AI chat agent, tools, quotaservices/chat/
Offline sync push dispatchlib/sync/apply-mutation.tsdomains/
Shared contact channel parsinglib/contacts/ (used by domains and services)

Dependency direction: routesservices / domainslib. Domains may call lib/sync and lib/contacts; services may call lib and domains for orchestration. lib must not import from routes/ or services/ (CI: check-lib-imports).

Route file structure

Keep route plugins small. Split large resources into focused modules registered from a thin index.ts:

index.ts
list-routes.ts
detail-routes.ts
mutation-routes.ts
schemas.ts

Utility modules under routes/ (parsers, schemas, route registrars) are listed in scripts/route-non-plugin-files.json so CI does not treat them as mountable route plugins.

Command layer (mutations)

Mutating handlers (POST / PUT / PATCH / DELETE) must not call Prisma write methods directly from route files. Use:

LayerFolderWhen
CRM commandsapps/api/src/domains/Contacts, groups, tags, import, merge — emit sync when touching SYNC_TABLES
Platform servicesapps/api/src/services/Me, chat, billing, interactions, notifications — no sync emit

Route adapter pattern:

import { withDomainRoute } from "../../lib/platform/with-domain-route.js";
import { createGroup } from "../../domains/groups/index.js";

fastify.post("/", { schema: { ... } }, withDomainRoute(async (ctx, request, reply) => {
  const { data } = await createGroup(ctx, request.body);
  return reply.status(201).send({ group: data.group });
}));

Helpers: domainContextFromRequest, persistSyncChanges (CRM sync emit).

CI enforces no route writes via pnpm run check-no-route-writes.

Read paths (queries)

GET handlers must not embed Prisma query logic inline. Extract reads into services/<area>/queries.ts and call from the route:

import { listContacts } from "../../services/contacts/queries.js";

fastify.get("/", { schema: { ... } }, async (request) => {
  const { client, user } = getAuth(request);
  return listContacts(client, user.id, request.query, request.log);
});

Existing query modules: services/contacts/queries.ts, services/tags/queries.ts, services/groups/queries.ts, services/interactions/queries.ts.

Container deployment (GHCR + Dokploy)

The API runs as a long-lived Node process in Docker, not serverless. See API container (GHCR).

Build pipeline:

  1. Image buildapps/api/Dockerfile uses turbo prune api --docker, pnpm install --frozen-lockfile --ignore-scripts, and turbo build --filter=api.
  2. CI — GitHub Actions pushes to ghcr.io/usebondery/api (:beta on main, semver tags on release).
  3. Runtimenode apps/api/dist/index.js listens on PORT (default 26631).

Workspace packages follow the Turborepo compiled-package model: types./src/*.ts (editor IntelliSense), import / node / default./dist/*.js (runtime). Production build depends on ^build; local dev cold-starts via compile (tsc to dist/) with package tsc --watch companions. When adding package subpaths (especially directory barrels like src/foo/index.ts), run pnpm run sync-exports from the repo root.

packages/openapi-spec/openapi.yaml is committed; generation is not part of the deploy build. Website builds run generate:api-docs to emit MDX under docs/api/api-reference/_generated/ from that spec.

Shared primitives

ImportPurpose
contactIdSchema, EXAMPLE_CONTACT_IDUUID path/body params with OpenAPI examples
EXAMPLE_* in @bondery/schemasComposed OpenAPI response fixtures (openapi/fixtures/)
okResponse, createdResponse, standardErrorResponses, conflictResponse, syncConflictResponseLow-level response map builders in @bondery/schemas/http/responses
withOkResponse, withCreatedResponseApp-level helpers that add standard errors (apps/api/src/lib/platform/openapi/responses.ts)
*ResponseSchema in entity modulesList/detail/delete shapes (contactsListResponseSchema, etc.)

Enforcement

CommandWhat it checks
pnpm run check-api-schema-patterns -w apiRoute files have descriptions, responses, FastifyZodOpenApiSchema
pnpm run check:route-security -w apiNo auth hooks in routes; mount table covers all plugins; non-plugin files listed in route-non-plugin-files.json
pnpm run check:openapiRegenerates spec, fails on drift, requires JSON examples on 2xx and 4xx/5xx, bans empty error schemas and "Default Response" on public paths
pnpm run check-route-errors -w apiBans legacy error patterns; requires code on DomainError
pnpm run check:contractsSchema boundary assertions + OpenAPI example validation (check-contracts-openapi-examples.ts)

CI runs all of the above on every pull request.

Error responses

Every API error JSON body includes a human-readable error string and a stable machine code. Clients should branch on code; display error to users for 4xx.

StatusWire bodyNotes
4xx{ error, code }Use throw helpers — message is client-safe
5xx{ error: "Internal Server Error", code, request_id }Generic message only; detail in server logs
409 sync{ error, code: "SYNC_CONFLICT", contact }Optimistic concurrency
429{ error, code: "RATE_LIMIT_EXCEEDED", retryAfter }Rate limit

Throwing errors

  • Routes and domains throw — never reply.status(5xx). The global setErrorHandler in build-app.ts maps all thrown errors via map-error-to-response.ts.
  • Helpers (apps/api/src/lib/platform/errors/http-errors.ts): unauthorized, forbidden, badRequest, notFound, conflict, internal, serviceUnavailable.
  • Domain logic: throw new DomainError(message, statusCode, code, cause?) for 4xx; throw internal(code, cause?) for 5xx (never leak DB/driver text).
  • Codes: SCREAMING_SNAKE in apps/api/src/lib/platform/errors/codes.ts (auth) or entity-specific at call sites (CONTACT_NOT_FOUND, TAGS_LIST_FAILED). Convention: {ENTITY}_{ACTION}_{REASON}.
  • Auth: migrated to helpers with AUTH_* / API_KEY_* codes — no Error + statusCode.

pnpm run check-route-errors -w api bans handleDomainError, reply.status(5xx) in routes, Error + statusCode in auth, and DomainError without code.

References

On this page