Back to all posts

An API Is a Boundary, Not a URL

Sep 26, 2026
9 min read
An API Is a Boundary, Not a URL

The most dangerous API response is not always a 500.

Sometimes it is a 200 containing almost the data your frontend expected.

The request worked. JSON arrived. The loading state disappeared. Then a component tried to format order.total.amount, except the backend now returns totalAmount. Nothing failed at the network boundary, so the failure moved deeper into the UI where it became harder to explain.

That is what makes API work deceptive. We tend to describe an API as a list of URLs, but the URL is the least interesting part. The real API is an agreement about inputs, outputs, errors, timing, repetition, and change.

An endpoint tells you where to send a request. A contract tells both sides what the request means.

Let us keep following the checkout request from the previous article and make that contract explicit.

Validate where trust changes

The browser sends this body:

{ "cartId": "cart_42", "deliveryMethod": "courier" }

TypeScript can prove that our frontend created the right object. It cannot prove that the server received it unchanged. Requests can come from an old frontend bundle, a mobile client, a script, a browser extension, or someone calling the endpoint by hand.

The network boundary erases TypeScript's guarantees. Runtime validation restores them.

import { z } from 'zod'; const CreateOrderRequest = z.object({ cartId: z.string().min(1), deliveryMethod: z.enum(['courier', 'pickup']), }); const input = CreateOrderRequest.parse(request.body);

This is not defensive programming for unusually malicious users. It is how the backend prevents an assumption from travelling further into the system.

Without validation, an invalid deliveryMethod might reach pricing, inventory, and persistence before something finally throws. With validation, the request stops at the boundary and returns a useful problem while the system is still in a known state.

The response needs the same treatment on the frontend. A generated type or shared interface improves development, but it is not runtime evidence.

const OrderResponse = z.object({ id: z.string(), status: z.enum(['pending', 'confirmed']), total: z.object({ amount: z.number(), currency: z.string().length(3), }), }); const order = OrderResponse.parse(await response.json());

I do not validate every harmless response in every application. That can become ceremony. I validate the boundaries where wrong data would be expensive, confusing, or difficult to recover from: authentication, checkout, payments, persisted user work, and integrations owned by another team.

Errors are part of the response model

A successful response usually gets careful types. Errors often get whatever the framework happens to throw.

That creates frontend code like this:

try { await createOrder(input); } catch (error) { showToast('Something went wrong'); }

The message may be acceptable for an unknown infrastructure failure. It is terrible for "one product is no longer available" or "the delivery address is outside the service area." Those are expected business outcomes. The user can act on them.

A small structured error is enough:

type ApiProblem = { code: 'CART_CHANGED' | 'OUT_OF_STOCK' | 'INVALID_ADDRESS'; message: string; field?: string; requestId: string; };

The stable part is code. The backend can improve message without breaking frontend behavior. The optional field lets a form place the error next to an input. The requestId connects the user's failure to server logs.

HTTP status still matters. A 400 means the request itself is invalid. 401 means authentication is missing or no longer valid. 403 means identity is known but the action is forbidden. 409 means the request conflicts with current state. 429 means slow down. 500 means the server failed to complete an operation it should have been able to complete.

The status gives infrastructure and generic clients a category. The problem code gives the product a decision.

Retrying changes the meaning of a request

Our user presses Place order. The backend creates the order, but the connection drops before the response reaches the browser.

What should the UI do?

From its point of view, the result is unknown. Retrying might recover cleanly. It might also create a second order.

This is where idempotency stops being backend vocabulary and becomes a user-experience requirement. The frontend generates a key for the action and sends it with the request:

const idempotencyKey = crypto.randomUUID(); await fetch('/orders', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey, }, body: JSON.stringify(input), });

The backend stores the key with the result. If the same operation arrives again, it returns the original result instead of performing the write twice.

The detail people miss is scope. The key belongs to one user intent, not one HTTP attempt. A network retry reuses it. A genuinely new click after the user changes the cart gets a new key.

Disabling the button is still good interface behavior. It is not a correctness guarantee. Two tabs can submit. A client can retry. A timeout can hide a successful response. Correctness must survive outside the DOM.

Versioning is mostly about restraint

Teams often imagine API versioning as /v1, /v2, then a heroic migration. Most healthy evolution is much less dramatic.

Adding an optional field is usually safe. Adding a new enum value may not be safe if the frontend uses an exhaustive switch and has no fallback. Renaming a field is breaking. Changing null to an omitted property can be breaking. Turning cents into decimal currency units while keeping the field name is definitely breaking, and especially unpleasant because the value still looks valid.

My default rule is additive evolution:

  • add before removing;
  • let old and new clients coexist;
  • measure whether the old field is still used;
  • remove only after the migration is complete.

For a rename, the backend can temporarily return both fields:

{ "totalAmountCents": 4299, "total": { "amount": 42.99, "currency": "CAD" } }

The frontend moves to total. Telemetry confirms old consumers have disappeared. Only then does totalAmountCents go away.

That overlap can feel untidy. It is also much cheaper than requiring every deployed client to change at the same instant.

Dates and money expose weak contracts quickly

Two values deserve suspicion at every API boundary: dates and money.

42.99 is not a complete monetary value. Which currency? Is it a floating-point amount or a display representation? Can tax introduce more precision? A safer contract sends an integer in minor units or a decimal string, plus an explicit currency.

Likewise, 2026-09-26 is a calendar date, while 2026-09-26T14:30:00Z is an instant. They are not interchangeable. A delivery day may intentionally have no timezone. An order creation timestamp absolutely does.

The frontend should not have to infer these semantics from a field name. Good contracts make units and meaning boringly explicit.

Generate types, but keep a runtime boundary

OpenAPI, GraphQL code generation, tRPC, and shared schema packages can remove a large amount of duplicated typing. I like that. If the server already knows the response shape, the frontend should not retype it from memory.

But generated types answer a build-time question: do these codebases agree on the declared contract?

Runtime validation answers a different question: did this particular response satisfy it?

You may need both. A generated client keeps everyday development fast. Runtime parsing protects the important places where deployment skew, stale clients, proxies, or external integrations can make reality differ from the declaration.

The useful architecture is not "validate everything twice." It is to choose where a contract deserves enforcement and make that choice visible.

The UI should consume decisions, not transport details

A component should not contain a switch over raw status codes, parse arbitrary error bodies, or know that one endpoint wraps data under result while another does not.

Put that translation in the API layer:

type CreateOrderResult = | { ok: true; order: Order } | { ok: false; reason: 'cart-changed' | 'out-of-stock' | 'invalid-address' };

Now the UI handles product outcomes. The API client handles HTTP, schemas, and server problem codes. The backend handles validation and business rules. The boundaries line up with the questions each layer can answer.

That is the part I care about most. Type safety is useful, but the real goal is not a beautiful generated interface. It is making failure unsurprising.

The next article goes one layer deeper. Once the API accepts "create order," the database has to preserve customers, orders, products, and their relationships without letting them contradict each other.

If your frontend has to guess what an API error means, send me the shape of that guess. Those awkward branches are usually the clearest map of where the contract needs work.

Telegram

More than a blog post

I share frontend news and the reasoning behind it throughout the day. Pick the language that feels natural to you.

Need to discuss your project? Get in touch.