arch-atlas
SaaS Atlas·Pillar 05 / 12

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.

8Nodes6Vendors1Decisions8Checklist2Flows

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
01
ContractType-safe app boundary

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
  1. 01

    Define a router: createTRPCRouter({ orgs: orgsRouter }).

    step
  2. 02

    Each procedure: publicProcedure.input(z.object({...})).query/mutation(...).

    step
  3. 03

    Client side: trpc.orgs.list.useQuery() — full type inference + cache management.

    step
  4. 04

    Auth: middleware on protectedProcedure checks auth() and injects ctx.user/orgId.

    step

Cases & edge cases

watch for these

tRPC 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 this

tRPC

primarysite

End-to-end TypeScript RPC.

Server Actions

alternate

Built into Next.js; great for forms; less ergonomic for complex reads.

Where it lives

code references
References
  • server/api/routers/
  • server/api/trpc.ts // base helpers, middleware
  • lib/trpc/client.ts

Notes

margin

Think 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.

02
ContractVersioned + documented

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
  1. 01

    Routes under app/api/v1/<resource>/route.ts.

    step
  2. 02

    Request validation via Zod (same schemas the tRPC layer uses).

    step
  3. 03

    Response envelope: { data, meta: { pagination } } — or just data when single.

    step
  4. 04

    Errors: { error: { code, message, details? } } — code is a stable string (snake_case).

    step

Cases & edge cases

watch for these

URL 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
  • URL versioning (/v1/)

    must
  • OpenAPI doc generated from code

    must
  • Cursor pagination on list endpoints

    must
  • Stable error codes documented

    must

Where it lives

code references
References
  • app/api/v1/
  • lib/api/openapi.ts // OpenAPI generator from Zod schemas
03
SecurityPer-org + per-key budgets

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
  1. 01

    Edge middleware reads API key (or IP).

    step
  2. 02

    Looks up the org's tier → tier defines the budget (e.g. 1000 req/min on Pro).

    step
  3. 03

    Sliding-window check via Upstash Redis.

    step
  4. 04

    Allowed → set rate-limit headers on response, forward.

    step
  5. 05

    Denied → 429 with Retry-After header + clear message in body.

    step

Cases & edge cases

watch for these

Different 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 this

Upstash Ratelimit

primary

Sliding-window + token-bucket, Redis-backed, edge-friendly.

Where it lives

code references
References
  • middleware.ts // ratelimit at the edge
  • lib/ratelimit/budgets.ts
04
SecuritySafe retries

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
  1. 01

    Client generates a UUID for each mutating call.

    step
  2. 02

    Server checks `idempotency_keys` table for (org_id, key).

    step
  3. 03

    Hit → return cached response unchanged.

    step
  4. 04

    Miss → execute, store (key, request_hash, response, expires_at).

    step
  5. 05

    After 24h → entry auto-vacuumed.

    step

Cases & edge cases

watch for these

Key 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
References
  • drizzle/schema/idempotency-keys.ts
  • lib/api/with-idempotency.ts

Notes

margin

Stripe's idempotency pattern is the reference — every SaaS API should crib from their docs.

05
DistributionSigned event delivery

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
  1. 01

    Domain event happens → publish to internal queue (Inngest event).

    step
  2. 02

    Webhook worker reads subscribers for that event type per org.

    step
  3. 03

    Worker POSTs to subscriber URL with X-Signature: HMAC-SHA256(secret, body).

    step
  4. 04

    Subscriber returns 2xx → mark delivered.

    step
  5. 05

    Non-2xx → retry with exponential backoff (e.g. 5x over 24h) → mark failed → expose in UI.

    step

Cases & edge cases

watch for these

Customers 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
  • HMAC signing with a per-subscription secret

    must
  • Exponential backoff retries

    must
  • Replay button per delivery attempt

    should
  • Per-event-type documentation with JSON examples

    must

Where it lives

code references
References
  • lib/webhooks/dispatch.ts
  • lib/webhooks/sign.ts
  • app/(admin)/webhooks/page.tsx // customer-facing webhook log
06
ContractBreaking changes that don't break

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
  1. 01

    Field rename: add the new field, dual-write both for a release, deprecate old in docs, ship /v2 without old.

    step
  2. 02

    Behavior change: feature flag the new behavior per org, default off, opt-in via header (?api_version=2026-03-15).

    step
  3. 03

    Endpoint removal: 410 Gone with a Sunset header pointing to docs.

    step
  4. 04

    Stripe-style date-based versioning is the standard 2026 alternative — each customer pinned to a date.

    step

Cases & edge cases

watch for these

Date-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 it
  • Decision 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.

07
DistributionAuto-generated client libs

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
  1. 01

    OpenAPI spec generated from Zod → committed to repo on every release.

    step
  2. 02

    Stainless / Speakeasy / Fern run in CI → generate per-language SDKs into separate repos.

    step
  3. 03

    On API release → bump SDK version, publish, tag GitHub releases.

    step
  4. 04

    SDK README ships with curl-style examples + 'Try it' link to docs.

    step

Cases & edge cases

watch for these

Hand-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 this

Stainless

primarysite

Per-language SDK generation with great DX; used by Cloudflare, Anthropic, etc.

Speakeasy

alternate

Similar, with strong OSS roots.

Fern

alternate

OSS, very flexible.

08
SecurityKeys · OAuth · Service tokens

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
  1. 01

    User: Bearer JWT from your auth provider, validated at the edge.

    step
  2. 02

    API key: long-lived secret prefixed with `sk_live_…`. Stored hashed; only the prefix shown after creation.

    step
  3. 03

    Service token: short-lived, scoped to specific resources; issued by an admin SDK.

    step
  4. 04

    OAuth: only if third-party apps act ON BEHALF of users — standard authorization code flow.

    step

Cases & edge cases

watch for these

Show 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
References
  • lib/api/auth/api-key.ts
  • drizzle/schema/api-keys.ts
Annotate0 strokes

Apple Pencil draws with pressure. Fingers still scroll. Saved per pillar.