API Architecture
Single-source, type-safe REST APIs: Fastify routes (TypeBox) generate OpenAPI, which generates typed clients and React Query hooks.
We treat Fastify routes + TypeBox schemas as the single source of truth for both:
- Runtime behavior: request/response validation in Fastify
- Tooling: OpenAPI spec generation, then typed clients/hooks generation
Data flow: TypeBox (backend) → OpenAPI spec → generated types & Zod schemas → generated clients & React Query hooks.
Principles
- Single source of truth: schemas live with the route (no parallel hand-written spec)
- Runtime validation by default: every route defines request/response schemas
- Generated consumers: clients/hooks are generated from the route-derived OpenAPI spec
Stack
- Runtime: Node.js LTS — stability, ecosystem, tooling
- Framework: Fastify — performance, TypeBox, plugin-based, ESM-native
- Database: PostgreSQL (any host via
DATABASE_URL; PGLite for tests withPGLITE=true) - ORM: Drizzle — type-safe queries, SQL-like syntax, no code generation
- Spec: OpenAPI 3 — standard interoperability for docs/tooling/SDKs
- Client generation:
@hey-api/openapi-ts— typed TS client + Zod schemas - React integration:
@tanstack/react-queryhooks generated in@repo/react
How it works
- Author routes with TypeBox schemas (in
apps/api/src/routes/; Fastify uses them for validation) - Generate OpenAPI from route definitions
- Generate SDKs from OpenAPI (TypeScript client + Zod schemas + React Query hooks)
At runtime, the request path is: request → Fastify → schema validation → handler → Drizzle queries → typed JSON response.
Example
Route schema (Fastify)
import { Type } from '@sinclair/typebox'
const HealthResponseSchema = Type.Object({
ok: Type.Literal(true),
})
fastify.get('/health', {
schema: {
response: {
200: HealthResponseSchema,
},
},
}, async (request, reply) => {
return reply.send({
ok: true,
})
})Generate OpenAPI
The OpenAPI spec is generated from route definitions and written to apps/api/openapi/openapi.json.
pnpm --filter @repo/api generate:openapiGenerate TypeScript client
Hey API generates type-safe clients from the OpenAPI spec (generated output lives in packages/core; do not hand-edit):
// Generated by @hey-api/openapi-ts
import { createClient } from './gen/client'
const client = createClient({
baseUrl: 'https://api.example.com',
})Usage (Next.js)
createClient supports three auth modes. See Authentication for full details.
import { createClient } from '@repo/core'
import { useHealthCheck } from '@repo/react'
// No-auth: health check without Bearer
const client = createClient({ baseUrl: process.env.NEXT_PUBLIC_API_URL! })
// JWT mode: token from cookie or session
const client = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_URL!,
getAuthToken: async () => session?.token,
getRefreshToken: async () => session?.refreshToken,
onTokensRefreshed: async ({ token, refreshToken }) => { /* persist */ },
})
// API key mode: static Bearer for servers/CLIs
const client = createClient({
baseUrl: process.env.NEXT_PUBLIC_API_URL!,
apiKey: 'venc_xxx_secret',
})
const health = await client.healthCheck()
const { data } = useHealthCheck()OpenAPI Integration
- Generation:
apps/api/openapi/openapi.json(generated from routes viagenerate-openapi.ts) - Serving:
/reference/openapi.json(programmatic access) - Docs UI:
/reference(Scalar) - AI tooling: MCP integration can consume the spec for agent workflows
API Reference auth
The Scalar UI at /reference supports two sign-in flows. Dynamic Labs (primary): embedded iframe to the web app login; on success the Dynamic JWT is passed via postMessage and applied as Bearer. API key fallback: collapsible "Use API key instead" section for pasting venc_* or Bearer tokens. Requires WEB_APP_URL and DYNAMIC_ENVIRONMENT_ID for the Dynamic flow; when unset, only the paste flow is shown.
SDK Generation
TypeScript is generated by @hey-api/openapi-ts. Other languages can use standard OpenAPI generators (for example openapi-generator, oapi-codegen, progenitor). All SDKs are generated from the same OpenAPI spec produced from routes.
Database
The API layer uses Drizzle for queries and drizzle-kit for migrations. Migration strategy depends on the database runtime; see ADR 008: Database Platform & Strategy.
For local Supabase: pnpm reset from the monorepo root runs Supabase DB reset, Drizzle migrations, and scripts/seed.ts (details in ADR 008).
Custodial Wallets
The /wallets routes implement the Vencura Wallet custodial wallet API: create, list, balance, sign, and send. All operations require Bearer auth (Dynamic JWT or API key). See Custodial Wallet API for endpoint details and Security Model for encrypted keys and rate limiting.
Security
- Headers: X-Content-Type-Options, X-Frame-Options, CSP, HSTS
- CORS: configurable origin restrictions
- Rate limiting: per-IP
- Validation & observability: schema validation on requests/responses, security events logged,
trust proxyfor correct client IP behind a reverse proxy