Contract-First API Development with OpenAPI
PgBeam's control plane API has around 40 endpoints across projects, databases, cache rules, replicas, custom domains, billing, and marketplace integrations. Every one of them is defined in OpenAPI YAML before any Go or TypeScript code is written.
The spec is the source of truth. The Go server, TypeScript SDK, and API documentation are all generated from it.
Why contract-first
The alternative is writing the API server first and generating the spec from code annotations. That works until the annotations drift from reality, the SDK doesn't match the server, or the docs show a field that was renamed two weeks ago.
With contract-first, drift is caught at build time. If the spec says the response has a proxy_host field, the generated Go struct has a ProxyHost field, the TypeScript SDK types include proxy_host, and the documentation page shows it. Change the spec, regenerate, and everything updates. If something is out of sync, the build fails.
The spec structure
We split the OpenAPI spec across multiple files instead of maintaining one giant YAML:
A build script auto-discovers all files in paths/ and schemas/, assembles them into a single openapi.yaml, and then a bundler produces three variants:
- full.yaml: all endpoints, used for Go server generation
- public.yaml: strips endpoints marked
x-internal: true, used for the SDK and docs - internal.yaml: only the internal endpoints, used for marketplace handlers
One annotation in the YAML controls visibility everywhere. The Vercel Marketplace webhook handler is x-internal: true, so it never appears in the public SDK or documentation.
Go server generation
We use an OpenAPI code generator to produce typed Go code from the full bundle. It generates:
- Request/response structs from schemas
- A
ServerInterfacewith one method per endpoint - A
RegisterHandlersfunction that wires routes to the HTTP router - An embedded spec served at
/openapi.json
The ServerInterface looks like this (simplified for clarity, the actual generated code includes typed path and query parameters):
type ServerInterface interface {
CreateProject(ctx context.Context) error
ListProjects(ctx context.Context) error
GetProject(ctx context.Context) error
UpdateProject(ctx context.Context) error
DeleteProject(ctx context.Context) error
// ... every endpoint
}Our handler struct implements this interface. The compiler enforces completeness: if we add an endpoint to the spec and regenerate, the code won't compile until we implement the new method.
Route registration is also generated:
func RegisterHandlers(router Router, si ServerInterface) {
router.POST("/v1/projects", si.CreateProject)
router.GET("/v1/projects", si.ListProjects)
// ... every route
}No hand-written routing. No risk of a path typo or a missing handler.
TypeScript SDK generation
The SDK generation has two steps.
First, a type generator reads public.yaml and produces TypeScript types for every operation: request params, query params, path params, and response types, all derived from the OpenAPI schemas.
Second, a custom script reads the spec and generates an operations map that groups endpoints by tag:
export const operationsByTag = {
projects: {
createProject: { method: "POST", path: "/v1/projects" },
listProjects: { method: "GET", path: "/v1/projects" },
getProject: { method: "GET", path: "/v1/projects/{project_id}" },
// ...
},
databases: {
createDatabase: { method: "POST", path: "/v1/projects/{project_id}/databases" },
// ...
},
} as const;The same script generates TypeScript interfaces that tie operation names to their request/response types:
export interface ApiOperations {
projects: {
createProject(params: {
body: CreateProjectMutationRequest
}): Promise<CreateProjectMutationResponse>;
listProjects(params: {
queryParams: ListProjectsQueryParams
}): Promise<ListProjectsQueryResponse>;
// ...
};
}The SDK client uses these types at runtime through a two-level Proxy. The outer proxy resolves tag names (client.api.projects), the inner proxy resolves operation names (createProject). When you call client.api.projects.createProject(...), the inner proxy looks up { method: "POST", path: "/v1/projects" } from the operations map, substitutes any path parameters, and dispatches the HTTP request. No methods are implemented manually. The entire API surface is a thin runtime layer over the generated maps:
const client = new PgBeamClient({ token: "pbk_..." });
// Tag-based: typed params and response
const project = await client.api.projects.createProject({
body: { name: "my-app", org_id: "org_123", database: { ... } }
});
// Route-based: same types, different syntax
const project = await client.api.request("GET /v1/projects/{project_id}", {
pathParams: { project_id: "proj_123" }
});Both approaches are fully typed. Adding a new endpoint to the YAML and regenerating makes it available in the SDK with zero manual work.
SDK error handling
All API errors are wrapped in a typed ApiError class that extracts the structured error from the response body:
import { ApiError } from "pgbeam";
try {
await client.api.projects.createProject({ body: { ... } });
} catch (err) {
if (err instanceof ApiError) {
console.log(err.status); // 409
console.log(err.message); // "project already exists"
console.log(err.body); // { error: { code: "conflict", message: "..." } }
}
}The SDK retries automatically on transient failures (408, 429, 502, 503, 504) and network errors. Retries use exponential backoff with jitter, starting at 500ms and capping at 30 seconds. A Retry-After header from the server overrides the calculated delay.
Idempotency and retries
Mutating API calls can fail in ambiguous ways. The server creates the resource, but the response is lost due to a network timeout. The client retries, and now you have a duplicate.
PgBeam's API supports idempotency keys. The client sends an Idempotency-Key header with a unique value (typically a UUID). The server stores the response keyed by that value in PostgreSQL. If the same key is sent again, the server returns the stored response instead of executing the operation a second time.
This is implemented as HTTP middleware. It runs before the handler, checks for an existing response, and short-circuits if one is found. The handler never knows whether it's a first attempt or a retry.
The key is scoped by hashing the org ID, HTTP method, URL path, and the raw key value together. This means the same idempotency key on different endpoints or different organizations won't collide. If two concurrent requests arrive with the same key, a PostgreSQL advisory lock serializes them: the first request executes the handler and stores the result, the second polls until the result is available and replays it.
Transient errors (408, 429, 502, 503, 504) are not cached. The idempotency record is deleted so the client can retry with the same key and get a fresh attempt. Typed HTTP errors (400, 404, 500) returned by the handler are cached like successful responses, so a validation error on a duplicate request returns the same response instead of re-executing.
On the SDK side, the client generates a UUID per request and reuses it across retries. For mutating operations, this happens automatically when retries are enabled. For read operations, retries are safe without a key since GET requests are inherently idempotent.
const client = new PgBeamClient({
token: "pbk_...",
retry: { maxRetries: 3 },
});
// POST with automatic idempotency key
const project = await client.api.projects.createProject({
body: { name: "my-app", org_id: "org_123", database: { ... } }
});The idempotency window is time-limited. Keys expire after 24 hours, and a background cleaner runs hourly to remove expired records.
API documentation generation
A documentation generator reads public.yaml and produces MDX pages for each endpoint. A post-processing script adds metadata to each page:
- An "at a glance" table with the HTTP method, path, and auth requirement
- A copy-pasteable curl snippet with placeholder variables
- A feature flag callout if the endpoint is gated via
x-feature-flag - An interactive component for request/response schemas
We also support x-hidden: true for endpoints that should be accessible via direct URL but not listed in the sidebar. Useful for deprecated endpoints in transition.
The generated docs cover the "what" well: endpoints, schemas, types, auth. They don't cover the "why". We handle that with hand-written guide pages that link to the generated reference. The guides explain concepts. The generated pages are the reference. Both live in the same docs site, and the separation means generated pages never contain prose that could go stale.
What this prevents
We've caught several classes of bugs at generation time that would otherwise reach production:
- Missing fields: schema adds a required field, Go struct compilation fails
- Type mismatches: schema changes a field from
stringtointeger, SDK types break at build - Route conflicts: two endpoints claim the same path/method, the bundler fails
- Stale docs: caught by design, docs regenerate from the same spec
The full pipeline
One command. Everything stays in sync.
The trade-off is that the generation pipeline itself is infrastructure you maintain. But for an API that serves a dashboard, a public SDK, and marketplace integrations all consuming the same surface, the consistency is worth it.
This pipeline is one of the guardrails that makes AI-driven development practical: agents edit the OpenAPI YAML, run pnpm generate, and the compiler catches mistakes immediately.
Try PgBeam or check the live benchmarks.