---
title: "Contract-First API Development with OpenAPI"
description: "PgBeam's entire API surface is generated from a single OpenAPI spec: Go server types, route registration, TypeScript SDK, and interactive documentation. Here's how the pipeline works."
canonical: "https://pgbeam.com/blog/contract-first-api-development"
last-updated: "2026-09-14T19:37:21.000Z"
---

# Contract-First API Development with OpenAPI

> PgBeam's entire API surface is generated from a single OpenAPI spec: Go server types, route registration, TypeScript SDK, and interactive documentation. Here's how the pipeline works.

URL: https://pgbeam.com/blog/contract-first-api-development
Published: 2026-03-12
Author: Alexis Rico
Tags: engineering, 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:

<File name="info.yaml" />

<File name="projects.yaml" />

<File name="databases.yaml" />

<File name="cache-rules.yaml" />

<File name="replicas.yaml" />

<File name="domains.yaml" />

<File name="billing.yaml" />

<File name="project.yaml" />

<File name="database.yaml" />

<File name="common.yaml" />

<File name="parameters.yaml" />

<File name="responses.yaml" />

<File name="security-schemes.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 `ServerInterface` with one method per endpoint

A `RegisterHandlers` function 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):

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:

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:

The same script generates TypeScript interfaces that tie operation names to their request/response types:

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:

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:

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.

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 `string` to `integer`, 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.
