Design-First: A Worked API
Every earlier chapter decided one thing in isolation — the style, the resources, the errors, the limits. This one spends all of them at once on Snip's public API, and does it the way a team should: write the contract first, review it on paper, then build. The payoff of design-first is that the expensive mistakes are the cheap ones to fix here, before a line of server code exists.
Design the spec first
The cheapest time to change an API is before it has an implementation, because at that point changing it costs an edit to a text file instead of a migration, a deprecation cycle, and a conversation with every team that already integrated. Design-first means you write the full contract — every endpoint, field, status code, and error — as a document, circulate it, argue about it, and only then write the server that satisfies it.
This inverts the common order, where the API is whatever the handlers happened to return and the "spec" is generated from the code after the fact. That order bakes today's database schema and this sprint's shortcuts straight into the public surface, and by the time anyone reads the generated spec, callers already depend on its accidents. Writing the contract first forces the questions early, while they are still cheap: is this a link or a shortcut? Does creating one return the whole object or just its code? What comes back when the url is missing? None of those need a running service to answer, and all of them are expensive to change once one does.
The document is not throwaway. In practice it is an OpenAPI file that becomes the source of truth: it generates client libraries, drives request validation at the gateway, renders the reference docs, and seeds a mock server a partner can code against before the real one ships. One text file, written first, pays for itself many times.
The OpenAPI document
OpenAPI is a standard, machine-readable way to describe an HTTP API: paths, methods, parameters, request and response schemas, and the security scheme, all in one YAML or JSON file. Here is the core of Snip's, enough to show the shape without the full surface:
openapi: 3.1.0
info:
title: Snip API
version: "1.4.0" # the spec's own version; the API major version is in the path
servers:
- url: https://api.snip.ly/v1
security:
- apiKey: []
paths:
/links:
post:
summary: Create a short link
parameters:
- in: header
name: Idempotency-Key
schema: { type: string, format: uuid }
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/LinkCreate" }
responses:
"201":
description: Created
headers:
Location: { schema: { type: string } }
content:
application/json:
schema: { $ref: "#/components/schemas/Link" }
"422": { $ref: "#/components/responses/Problem" }
"429": { $ref: "#/components/responses/Problem" }
get:
summary: List links
parameters:
- { in: query, name: cursor, schema: { type: string } }
- { in: query, name: limit, schema: { type: integer, default: 20, maximum: 100 } }
- { in: query, name: tag, schema: { type: string } }
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/LinkList" }
/links/{code}:
get:
summary: Fetch one link
parameters:
- { in: path, name: code, required: true, schema: { type: string } }
responses:
"200":
headers:
ETag: { schema: { type: string } }
content:
application/json:
schema: { $ref: "#/components/schemas/Link" }
"404": { $ref: "#/components/responses/Problem" }
components:
securitySchemes:
apiKey:
type: http
scheme: bearer
schemas:
Link:
type: object
required: [code, url, short_url, created_at]
properties:
code: { type: string, example: "a3Xk9bQ" }
url: { type: string, format: uri }
short_url: { type: string, format: uri }
tags: { type: array, items: { type: string } }
total_clicks: { type: integer, description: "Eventually consistent." }
created_at: { type: string, format: date-time }
Read it as the contract, not as code: a Link always has code, url, short_url, and created_at; total_clicks is documented as eventually consistent so no caller treats it as exact; the POST takes an optional Idempotency-Key; the list endpoint pages by cursor with a hard maximum: 100. Every one of those lines is a decision from an earlier chapter, now written down where a reviewer and a client generator can both read it.
The worked surface
Here is the whole public surface on one page — the thing the earlier chapters were building toward, assembled.
Endpoints
| Method & path | Does | Success | Notable |
|---|---|---|---|
POST /v1/links | Create a short link | 201 + Location | Idempotency-Key; scope links:write |
GET /v1/links | List links | 200 | Cursor paging; ?tag=, ?sort=-created_at; links:read |
GET /v1/links/{code} | Fetch one | 200 + ETag | Conditional GET; links:read |
PATCH /v1/links/{code} | Change destination | 200 | If-Match → 412; links:write |
DELETE /v1/links/{code} | Delete | 204 | Idempotent; links:write |
GET /v1/links/{code}/clicks | Read analytics | 200 | ?since=&until=, cursor; eventually consistent; analytics:read |
POST /v1/links/import | Bulk import | 202 + operation | Long-running; poll the operation |
GET /v1/operations/{id} | Check async job | 200 | status pending / succeeded / failed |
The cross-cutting rules
- Auth (ch 06):
Authorization: Bearer sk_live_…for first-party; OAuth 2.0 + PKCE for third-party apps; every token carries scopes, and a call outside its scope gets403. - Errors (ch 04): every non-2xx is
application/problem+jsonwith a stabletypeURI and a field-levelerrorsarray; clients branch ontype, never on prose. - Limits (ch 07):
RateLimit-Limit/Remaining/Reseton every response;429+Retry-Afterover the limit; free 100/min, pro 1,000/min. - Collections (ch 03): cursor paging with
{ data, next_cursor, has_more }, default 20 and max 100, no total count. - Safe retries (ch 08):
Idempotency-Keyon creates,If-Matchon updates, backoff with jitter on429/503. - Events (ch 09): a
link.clickedwebhook, batched and sampled, HMAC-signed inSnip-Signature, at-least-once — consumers de-duplicate on event id. - Evolution (ch 05): the major version is in the path; new fields and endpoints are additive within
/v1; a/v2only when a real break is unavoidable, announced withDeprecationandSunsetheaders.
A full request, start to finish
Trace one flow through every rule at once. The marketing team's tool creates a tracked link, reads it back cheaply, then pages its clicks.
1 — Create, idempotently.
POST /v1/links HTTP/1.1
Host: api.snip.ly
Authorization: Bearer sk_live_8f2a…
Idempotency-Key: 5c1f3e9a-1b2c-4d5e-8f90-a1b2c3d4e5f6
Content-Type: application/json
{ "url": "https://example.com/spring?utm=email", "tags": ["spring-campaign"] }
HTTP/1.1 201 Created
Location: https://api.snip.ly/v1/links/a3Xk9bQ
RateLimit-Remaining: 993
ETag: "d41d8cd9"
{ "code": "a3Xk9bQ", "url": "https://example.com/spring?utm=email",
"short_url": "https://snip.ly/a3Xk9bQ", "tags": ["spring-campaign"],
"total_clicks": 0, "created_at": "2026-03-14T09:21:00Z" }
If the response never arrived and the client retried with the same Idempotency-Key, it would get this same 201 and the same a3Xk9bQ back — one link, not two.
2 — Read it back, conditionally. The client keeps the ETag and, next time, asks only for changes:
GET /v1/links/a3Xk9bQ HTTP/1.1
Authorization: Bearer sk_live_8f2a…
If-None-Match: "d41d8cd9"
HTTP/1.1 304 Not Modified
3 — Page the clicks. Analytics is eventually consistent and large, so it is time-ranged and cursor-paged:
GET /v1/links/a3Xk9bQ/clicks?since=2026-03-14&limit=100 HTTP/1.1
Authorization: Bearer sk_live_8f2a…
HTTP/1.1 200 OK
{ "data": [ { "ts": "2026-03-14T09:22:11Z", "country": "US", "referrer": "email" } ],
"next_cursor": "c2Vx_8820", "has_more": true }
One flow touched auth, idempotency, the ETag cache, eventual consistency, and cursor paging — each decided in its own chapter, now working together without friction. That is what the design-first document was protecting.
The design review
Before the spec is frozen, it goes past other engineers. A good API review is not a style debate; it works a checklist of the decisions that are expensive to reverse, because those are the only ones worth slowing down for. The questions a reviewer should ask:
- Are the resources nouns, and is the hierarchy shallow?
POST /v1/links, not/createLink; clicks hang off a link one level deep, not three (ch 02). - Does every list endpoint paginate, and by cursor? A collection with no limit is a future outage and a breaking change waiting to happen (ch 03).
- Is the error shape the same everywhere, and stable? One
problem+jsonmodel, machine-readabletype, no prose-parsing (ch 04). - Can this grow without a new version? Required request fields, tight enums, and renamed keys are the traps; look for anything that forces a
/v2(ch 05). - Is every endpoint scoped, and is the least-privilege token obvious? A read-only dashboard must never need
links:write(ch 06). - Is a retried write safe? Creates take an idempotency key; updates take
If-Match; the client knows which errors are retriable (ch 08). - What is eventually consistent, and does the contract say so?
total_clicksand the clicks feed lag; a caller must not treat them as exact.
Run the review against the spec, not a demo. A working demo hides every one of these questions behind a happy path; the OpenAPI file shows the required fields, the error responses, and the limits in black and white, which is exactly what you want an argument to be about.
What you would defend
A reviewer will push on the choices that look arbitrary. Three worth defending out loud, because each had a live alternative:
REST, not GraphQL, for the public surface. Snip's shapes are small and CRUD-like, the clients are unknown and numerous, and HTTP caching on GET /v1/links/{code} is worth keeping. GraphQL would buy flexible field selection the surface does not need and cost the caching and the simple mental model. The internal service mesh is a different story and uses gRPC (ch 01).
The opaque code as the id, not a database key. It is the same token the redirect already uses, it is URL-safe, and it leaks nothing about row counts or internal storage. Exposing an auto-increment id would invite enumeration and weld the public contract to a storage detail (ch 02).
Path versioning, blunt as it is. A partner can read /v1 in a URL, test it in a browser, and pin to it without ceremony. Header or media-type versioning is more granular, but for a public API consumed by every language and every skill level, the visible, un-missable version wins — and the cost, duplicating the surface for a rare /v2, is a cost you pay rarely (ch 05).
None of these is the only defensible answer. The point of the document and the review is that the answer is written down, the alternative is named, and the next engineer can see why — which is the whole difference between an API that was designed and one that merely accreted.
✓ Check yourself
You have the whole surface now. Test whether the design-first case holds together.
A teammate says, "Let's skip the spec and generate it from the handlers once they work — same document in the end." What does that order cost you that writing the spec first would not?
Show answer
Generating the spec from code bakes the implementation's accidents — the current schema's field names, this sprint's shortcuts, whatever the handler happened to return — straight into the public contract, and you only see them after callers already depend on them. Writing the spec first forces the expensive questions (resource names, required fields, error shapes) while they still cost a text edit rather than a migration and a deprecation. The documents are not interchangeable: one is a design, the other is a description of an accident. See designing the spec first.
In the worked flow, the client sends
If-None-Matchon the second read and gets a304. What did that save, and what did it not save?Show answer
The
304saves sending the response body over the wire — bandwidth and parsing on the client. It does not by itself save the server's work of determining the currentETag, which may still mean a lookup, unless the server also caches the validator. So a conditional GET is a bandwidth optimization first; it only becomes a load optimization if the ETag can be answered cheaply. See the full request and ch 08.A reviewer wants to add a required
campaign_idfield toPOST /v1/linksfor a new internal need. Why should that set off an alarm, and what is the non-breaking way to get what they want?Show answer
Adding a required request field breaks every existing caller the instant it ships — their current, valid requests start failing validation. The non-breaking move is to make it optional (and, if the internal need is strict, enforce it only for the internal client via scope, or carry it in
tags/metadata), reserving a required field for a real/v2. See the review checklist and ch 05.Why does the contract go out of its way to document
total_clicksas "eventually consistent" instead of just returning the number?Show answer
Because the number is fed by the asynchronous click pipeline and lags reality by seconds to minutes, a caller that treats it as exact — reconciling billing against it, or asserting it in a test — will see it "wrong" and file a bug that is really a misunderstanding. Writing the consistency guarantee into the contract makes the lag part of the promise, not a surprise. See the worked surface.
The bulk import returns
202with an operation to poll rather than blocking until it finishes. What problem does the synchronous version hit at 50,000 links, and what does the client now owe?Show answer
A synchronous import holds an HTTP connection open for the whole job, which blows past request timeouts at the client, the gateway, and every proxy between, and ties up a server worker the whole time. The
202+ operation pattern returns immediately; the client now owes a poll loop againstGET /v1/operations/{id}(or a webhook) to learn the outcome, and must handle thependingstate. See the surface and ch 09.
Takeaway
- Design-first means writing the full contract — an OpenAPI document — before the server, because the expensive mistakes are cheap to fix while the API is still a text file.
- That document becomes the source of truth: it generates clients, validates requests, renders docs, and mocks the API for partners before it ships.
- The worked surface spends every earlier decision at once — REST style, noun resources, cursor paging, one error model, scoped auth, rate-limit headers, idempotency and conditional requests, signed at-least-once webhooks, path versioning — and they fit together because each was chosen to.
- A design review works the short list of expensive-to-reverse decisions against the spec, not a demo; a demo hides all of them behind the happy path.
- Defend the choices that have live alternatives — REST over GraphQL, the opaque code as id, path versioning — by naming the alternative and the reason, so the next engineer sees why.
That closes the guide. For the systems behind the surface — how Snip serves those 100 million redirects a day — read the companion: Designing for Scale →
Further reading
- OpenAPI Specification — the current standard for describing an HTTP API as a machine-readable contract.
- JSON Schema — the schema language OpenAPI 3.1 uses for request and response bodies.
- Google API Design Guide — a thorough, opinionated house style for resource-oriented APIs; useful as a second opinion on the decisions here.
- Zalando RESTful API Guidelines — a large public style guide with concrete, numbered rules on naming, errors, and pagination.
- RFC 9110 — HTTP Semantics — the one document that defines what the methods, status codes, and conditional headers actually mean.