API
tRPC (internal) · REST (public) · Webhooks · Rate limit · SDK
A SaaS exposes two APIs that look nothing like each other. Internally, tRPC erases the boundary between server and client so a TypeScript refactor catches API-shape drift before review. Externally, REST is the lingua franca of integrators — versioned URLs, signed webhooks, rate limit headers, and an auto-generated SDK that you publish to npm. Treat them as separate products with separate evolution rules.
Primary stack
The default picks this pillar assumes
- tRPC
- Upstash Ratelimit
- Stainless
Alternates worth knowing
Swap in when scale, cost or constraints change
- Server Actions
- Speakeasy
- Fern
tRPC (internal)
End-to-end type-safe RPC between your Next.js client and server. No code generation, no schemas, no manual typing. tRPC routers are just functions with Zod input validation; the client gets them with full IntelliSense.
How it flows
step by step- 01step
Define a router: createTRPCRouter({ orgs: orgsRouter }).
- 02step
Each procedure: publicProcedure.input(z.object({...})).query/mutation(...).
- 03step
Client side: trpc.orgs.list.useQuery() — full type inference + cache management.
- 04step
Auth: middleware on protectedProcedure checks auth() and injects ctx.user/orgId.
Cases & edge cases
watch for thesetRPC is NOT for public APIs — it's not a stable wire protocol, it's a client-server convention.
Don't expose Zod errors directly to the client — format them with a normalized error shape.
Long-running queries should use the streaming variant or split into mutation + polling.
Vendors & tools
what implements thisEnd-to-end TypeScript RPC.
Server Actions
alternateBuilt into Next.js; great for forms; less ergonomic for complex reads.
Where it lives
code references- server/api/routers/
- server/api/trpc.ts // base helpers, middleware
- lib/trpc/client.ts
Notes
marginThink of tRPC as 'server actions with a query cache'. Server Actions are fine for forms; tRPC wins when you have lots of reads that need caching, invalidation and optimistic updates.
Public REST
The API integrators call: REST under /v1/, OpenAPI-described, consistent error format, paginated lists, sparse fieldsets. This is the API you'll regret in 2028 if you don't design it carefully in 2026.
How it flows
step by step- 01step
Routes under app/api/v1/<resource>/route.ts.
- 02step
Request validation via Zod (same schemas the tRPC layer uses).
- 03step
Response envelope: { data, meta: { pagination } } — or just data when single.
- 04step
Errors: { error: { code, message, details? } } — code is a stable string (snake_case).
Cases & edge cases
watch for theseURL versioning ('/v1/') for major breaking changes only. Additive fields don't bump version.
Pagination: cursor-based (returns next_cursor), never offset — offset doesn't survive inserts.
Always include `total_count` only when it's cheap. Don't COUNT(*) on a 100M-row table per request.
Checklist
ship readiness- must
URL versioning (/v1/)
- must
OpenAPI doc generated from code
- must
Cursor pagination on list endpoints
- must
Stable error codes documented
Where it lives
code references- app/api/v1/
- lib/api/openapi.ts // OpenAPI generator from Zod schemas
Rate Limiting
Sliding-window rate limits at the edge (Upstash Ratelimit or Cloudflare Rate Limiting Rules). Standard headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After. Budget per API key for paying customers; per IP for anonymous.
How it flows
step by step- 01step
Edge middleware reads API key (or IP).
- 02step
Looks up the org's tier → tier defines the budget (e.g. 1000 req/min on Pro).
- 03step
Sliding-window check via Upstash Redis.
- 04step
Allowed → set rate-limit headers on response, forward.
- 05step
Denied → 429 with Retry-After header + clear message in body.
Cases & edge cases
watch for theseDifferent endpoints have different costs. Don't treat /webhooks/test the same as /search.
Spike protection: short-window burst limits PLUS long-window sustained limits.
Always document limits in the API docs — surprise 429s are a churn trigger.
Vendors & tools
what implements thisUpstash Ratelimit
primarySliding-window + token-bucket, Redis-backed, edge-friendly.
Where it lives
code references- middleware.ts // ratelimit at the edge
- lib/ratelimit/budgets.ts
Idempotency
Mutating endpoints accept Idempotency-Key header. Server stores (key, response) for 24h; same key returns the cached response instead of re-executing. Critical for payment + webhook handlers.
How it flows
step by step- 01step
Client generates a UUID for each mutating call.
- 02step
Server checks `idempotency_keys` table for (org_id, key).
- 03step
Hit → return cached response unchanged.
- 04step
Miss → execute, store (key, request_hash, response, expires_at).
- 05step
After 24h → entry auto-vacuumed.
Cases & edge cases
watch for theseKey reuse with different body MUST 409 — that's a client bug, not a retry.
Network retry on a 5xx is the canonical use case — design the retry SDK to set the same key.
Don't accept idempotency keys on GET — they're already idempotent.
Where it lives
code references- drizzle/schema/idempotency-keys.ts
- lib/api/with-idempotency.ts
Notes
marginStripe's idempotency pattern is the reference — every SaaS API should crib from their docs.
Webhooks (outbound)
Your SaaS notifies customer systems of events (subscription.updated, deployment.finished, etc.). Signed with HMAC, delivered with retries, surfaced in an admin UI so customers can debug failures.
How it flows
step by step- 01step
Domain event happens → publish to internal queue (Inngest event).
- 02step
Webhook worker reads subscribers for that event type per org.
- 03step
Worker POSTs to subscriber URL with X-Signature: HMAC-SHA256(secret, body).
- 04step
Subscriber returns 2xx → mark delivered.
- 05step
Non-2xx → retry with exponential backoff (e.g. 5x over 24h) → mark failed → expose in UI.
Cases & edge cases
watch for theseCustomers MUST verify signatures — provide a code sample in their language.
Document the exact JSON structure for each event type and never break it without versioning.
Provide a webhook 'replay' button in the admin UI — saves so many support tickets.
Checklist
ship readiness- must
HMAC signing with a per-subscription secret
- must
Exponential backoff retries
- should
Replay button per delivery attempt
- must
Per-event-type documentation with JSON examples
Where it lives
code references- lib/webhooks/dispatch.ts
- lib/webhooks/sign.ts
- app/(admin)/webhooks/page.tsx // customer-facing webhook log
Versioning
Additive changes (new optional field, new endpoint) don't bump the version. Breaking changes (remove/rename field, change semantics) require a new /v2. Old versions stick around for 12 months minimum.
How it flows
step by step- 01step
Field rename: add the new field, dual-write both for a release, deprecate old in docs, ship /v2 without old.
- 02step
Behavior change: feature flag the new behavior per org, default off, opt-in via header (?api_version=2026-03-15).
- 03step
Endpoint removal: 410 Gone with a Sunset header pointing to docs.
- 04step
Stripe-style date-based versioning is the standard 2026 alternative — each customer pinned to a date.
Cases & edge cases
watch for theseDate-versioning is more granular than URL versioning — better for fast-moving APIs (Stripe's playbook).
Sunset: announce 12 months ahead, email all affected API keys, surface a banner in their dashboard 6 months ahead.
Don't 'silently change' a response shape. Even a new field can break a strict parser.
Decisions
pick once, live with itDecision 01
URL versioning or date-based?
pickURL (/v1/) for the first 2 years. Date-based after you have enough integrators to justify the support burden.
whyURL is dead-simple to communicate; date-based is more powerful but adds a 'which version are you on?' dance with customers. Defer until needed.
SDKs
Auto-generated SDKs in TS, Python, Go from your OpenAPI spec. Published to npm/PyPI/Go modules. Include retries, idempotency keys by default, and a typed error class.
How it flows
step by step- 01step
OpenAPI spec generated from Zod → committed to repo on every release.
- 02step
Stainless / Speakeasy / Fern run in CI → generate per-language SDKs into separate repos.
- 03step
On API release → bump SDK version, publish, tag GitHub releases.
- 04step
SDK README ships with curl-style examples + 'Try it' link to docs.
Cases & edge cases
watch for theseHand-written SDKs drift. Always generate.
Per-language conventions matter — `client.subscriptions.create(...)` (Python) vs `client.subscriptions.create({...})` (TS). The generator handles this.
Provide raw HTTP fallback — if the SDK doesn't expose a new field yet, customers can still hit the URL.
Vendors & tools
what implements thisPer-language SDK generation with great DX; used by Cloudflare, Anthropic, etc.
Speakeasy
alternateSimilar, with strong OSS roots.
Fern
alternateOSS, very flexible.
API Authentication
Three identities hit your public API: end-users (via OAuth or session), backend integrations (API keys), service tokens (short-lived, scoped). Each has its own model.
How it flows
step by step- 01step
User: Bearer JWT from your auth provider, validated at the edge.
- 02step
API key: long-lived secret prefixed with `sk_live_…`. Stored hashed; only the prefix shown after creation.
- 03step
Service token: short-lived, scoped to specific resources; issued by an admin SDK.
- 04step
OAuth: only if third-party apps act ON BEHALF of users — standard authorization code flow.
Cases & edge cases
watch for theseShow the API key value EXACTLY ONCE on creation. Store only the hash + prefix.
Per-key scopes (read:orders, write:customers) — cap blast radius of leaked keys.
Per-key rotation (cmd or UI) without changing the org's other keys.
Audit log every API key usage — required for SOC 2.
Where it lives
code references- lib/api/auth/api-key.ts
- drizzle/schema/api-keys.ts
Apple Pencil draws with pressure. Fingers still scroll. Saved per pillar.