Section B · Shape the surface

Resource Modeling

An API is a system turned into nouns and a few verbs. This chapter is how to find the right resources, lay out their URLs, use the HTTP methods for what each one promises, and return status codes that actually mean something.

Nouns, not verbs

Model the system as things, and let HTTP supply the verbs. Snip's control plane has one central thing, the link. So the path holds the noun and the method holds the action:

POST   /v1/links           create a link
GET    /v1/links/a3Xk9bQ   read it
PATCH  /v1/links/a3Xk9bQ   change it
DELETE /v1/links/a3Xk9bQ   remove it

The alternative is POST /v1/createLink, POST /v1/getLink, POST /v1/deleteLink. Each name is fine alone. The cost shows up at scale: with verbs in paths, every new operation is a new name a caller must learn, and no two engineers pick the same one (removeLink, deleteLink, destroyLink). With nouns and four methods, a stranger who knows that Snip has links and tags can guess the whole surface, and proxies, caches, SDK generators and log tools can reason about a request from its method alone. A GET is safe to retry and cache because the protocol says so, not because your docs do.

Note the scope. This is the control plane: the API where you manage links. The redirect, GET snip.ly/a3Xk9bQ answering 302, is the data plane. It has no JSON, no auth and a latency budget in single-digit milliseconds. Do not model it as a resource of the API; it is a different system with different rules.

Finding the resources is the real work. Start from the nouns your users say aloud, not from your tables. Snip's database has a link_redirect_rules join table and a click_events partition per day. Neither is a resource. "Link" and "click" are. A resource is something a caller can name, fetch and reason about; a table is something you happen to store.

Collections and instances

Every resource has two URLs. The collection is a plural noun and the instance is the collection plus an identifier:

/v1/links              the collection: list, create
/v1/links/{code}       one instance: read, change, delete
/v1/links/{code}/clicks   a sub-resource: the clicks on that link

Use the plural even for the instance path. /v1/link/{code} beside /v1/links doubles the names to remember for no gain.

A sub-resource exists when it has no meaning without its parent. A click belongs to one link and you always reach it through that link, so GET /v1/links/{code}/clicks reads well. It also lets the server check one thing, that the caller may see this link, before it touches any click.

Keep nesting to one level

One level is plenty. Suppose Snip adds workspaces. /v1/workspaces/{ws}/links/{code}/clicks works, but the URL now carries the whole hierarchy. If a link moves between workspaces, its URL changes and every stored reference breaks. Callers must also hold the parent id just to read the child. The fix is that any resource with its own stable identifier gets its own top-level path. The code a3Xk9bQ is globally unique, so /v1/links/a3Xk9bQ is enough. Ask for the workspace with a filter: GET /v1/links?workspace=ws_91. Chapter 3 covers filters and pagination.

The exception: when the child's id is only unique within the parent (order line 3 of order 1042), nest it, because the pair is the identity.

Identifiers

Expose an identifier made for the outside, not your primary key. Snip's links use code: seven base62 characters, such as a3Xk9bQ. The database row has a bigint key that no response ever shows.

Three reasons. First, a raw sequence leaks facts: /v1/links/48213 tells a competitor how many links you have made, and lets anyone walk the range. Second, the key is an implementation detail you will want to change. If you shard the table (see Scaling the Database), the key moves or changes shape; the public id must not. Third, the id appears in URLs, logs, webhooks and customer support tickets for years. It outlives the schema.

A good public id is:

  • Opaque. It carries no meaning a caller could parse, so you never owe them a promise about it.
  • URL-safe. Base62 needs no percent-encoding. Avoid +, / and = from plain base64.
  • Stable. It never changes and is never reused after a delete. Reuse sends a stale webhook to the wrong link.
  • Hard to guess. Seven base62 characters give about 3.5 trillion values (627). That stops casual enumeration; it does not replace authorization. Every read still checks that the caller owns the link, and a miss on someone else's code returns the same 404 as a code that does not exist.

Do not call an unguessable id a secret. Anyone with a3Xk9bQ can see the short URL anyway, but a link's analytics, its target and its edit rights must come from the token, never from knowing the code.

What each method promises

Each method is a contract with every cache, proxy and client library between you and the caller. Break it and they will do the wrong thing on your behalf, such as retrying a request you meant to run once. RFC 9110 defines two terms. A method is safe if it asks for no change in server state. It is idempotent if sending it ten times leaves the same state as sending it once.

MethodSafeIdempotentRequest bodyTypical success
GETyesyesnone200
HEADyesyesnone200, no body
OPTIONSyesyesrare204 or 200
POSTnonoyes201, 202, 200
PUTnoyesfull representation200, 201, 204
PATCHnonot by defaultthe changed fields200
DELETEnoyesnone204

GET, HEAD, OPTIONS

GET has no side effects. That is what lets a CDN cache it and a browser prefetch it. Never put a state change behind a GET; a link-preview bot that follows GET /v1/links/a3Xk9bQ/archive would archive the link. HEAD is GET without the body, useful to test existence or read ETag cheaply. OPTIONS reports what a path allows and carries CORS preflight in browsers. You rarely write handlers for these two; your framework does.

POST

POST is the catch-all: create a member of a collection, or run an action that fits no other method. It is neither safe nor idempotent, so two identical requests can make two links. That is why Snip accepts an Idempotency-Key header on every POST, covered in Caching, Concurrency & Idempotency.

PUT versus PATCH

PUT replaces the whole resource with the body. Fields you leave out are cleared or reset. That makes it idempotent and simple to reason about, and it is a trap for fields the client does not know about: an old SDK that predates tags sends a PUT without them and wipes every caller's tags.

PATCH sends only the change. Snip's most common edit is a new destination, so callers send one field:

PATCH /v1/links/a3Xk9bQ HTTP/1.1
Host: api.snip.ly
Authorization: Bearer sk_live_…
Content-Type: application/json

{ "url": "https://example.com/spring-sale" }

My rule: offer PATCH and skip PUT unless clients truly hold the full document, as with a config file. Snip uses the JSON merge-patch rules of RFC 7396: a field present is set, a field absent is untouched, and null clears it. The exception is a small resource of three fields, where PUT is just as short and removes a class of partial-update bugs. Whichever you choose, say so in the reference, and do not ship both with different rules.

DELETE

DELETE is idempotent in state, not in response. The first call returns 204; the second may return 404 because the link is already gone, and that is fine. The end state, no link, is the same. Do not return an error to "protect" the caller from a repeat.

Status codes that mean something

The status line is the first thing a client reads and the only thing a proxy, a retry library or a metrics dashboard reads. Make it true. The worst habit is 200 OK with {"error": "not found"} in the body. Monitoring shows a healthy API while callers fail, retry logic never sees a failure, and every client has to parse the body to learn what the status already could have said.

Success

  • 200 OK: the request worked and the body holds the result. Use it for GET and for PATCH that returns the updated link.
  • 201 Created: a new resource exists. Send a Location header with its URL, and the new representation as the body.
  • 202 Accepted: the server took the request but has not finished it. Return a status resource the caller can poll. See Async, Webhooks & Streaming.
  • 204 No Content: it worked and there is nothing to say. The right answer to DELETE.

A 3xx in a JSON API is usually an accident. 301 or 308 on a moved path is acceptable, but many clients drop the Authorization header on a cross-origin redirect, so avoid redirecting authenticated calls at all. Snip's 302 lives on the data plane only.

Client errors

  • 400 Bad Request: the request is malformed, such as invalid JSON or a wrong type.
  • 401 Unauthorized: no valid credentials. The name is wrong; it means unauthenticated.
  • 403 Forbidden: valid credentials, but the token lacks the scope or the right.
  • 404 Not Found: no such resource, or one the caller may not know exists.
  • 409 Conflict: the request clashes with current state, such as a custom alias already taken.
  • 422 Unprocessable Content: well-formed, but it fails a rule, such as a url that is not a URL. Teams split 400 from 422 or use only 400; both work if you pick one and document it.
  • 429 Too Many Requests: the caller is over its limit. Send Retry-After. See Rate Limits & Quotas.

Server errors

  • 500 Internal Server Error: our bug. The caller did nothing wrong.
  • 503 Service Unavailable: we are overloaded or down for now. Send Retry-After.

The split matters because clients decide on it. A well-behaved client retries 429, 503 and network errors with backoff, and never retries a 4xx that says its request was wrong. If you return 500 for a validation failure, clients retry a request that can never succeed. The body of every error is a separate contract; Errors & Validation sets Snip's as RFC 9457 application/problem+json.

A 404 for "exists but you cannot see it" costs you a little debugging help and gives an attacker nothing. Use 403 only when the caller already knows the resource exists, as when their token lacks a scope for a path they were told about.

Naming conventions

Pick one rule for each choice, write it down, and apply it with no exceptions. Callers learn your style from the first three endpoints and then predict the rest. One odd name costs more than a bad convention, because it breaks the prediction.

  • JSON fields: one case. Snip uses snake_case: short_url, created_at, total_clicks. Either snake or camel works. Mixing them is the error.
  • Collections: plural nouns, lowercase. /v1/links, /v1/api-keys. If a name has two words, use kebab-case in the path, since URL paths are case-sensitive and hyphens survive being read aloud. Never /v1/apiKeys in one place and /v1/api_keys in another.
  • No verbs in paths. /v1/links/a3Xk9bQ, not /v1/getLink?code=a3Xk9bQ.
  • Filters are query parameters, not path segments. GET /v1/links?tag=launch, not /v1/links/tag/launch. A path names one resource; a query narrows a collection. That also lets callers combine filters without you defining a path for each combination.
  • Same name, same meaning, everywhere. If created_at is an RFC 3339 UTC timestamp on a link, it is the same on a click. If one endpoint says url and another target for the same idea, a caller will assume they differ.
  • Booleans and counts read plainly. archived or is_archived, total_clicks. Choose one form for booleans.

Consistency beats cleverness. A clever name that fits one endpoint perfectly and breaks the pattern is a net loss over the life of the API.

Actions that are not nouns

Some operations are not a create, read, update or delete of an obvious thing. Archive a link. Rotate an API key. Pause a webhook. Pure noun-thinking stalls here, so use a rule with two branches.

  1. If the action is a change of state, model the state as a field and use PATCH. Archiving a link makes it stop redirecting but keeps its history. That is a value of the link, so the field archived carries it:
    PATCH /v1/links/a3Xk9bQ HTTP/1.1
    Content-Type: application/json
    
    { "archived": true }
    Undoing it is the same call with false. It is idempotent by construction, the link stays readable, and no new endpoint exists to document.
  2. If the action is an event with its own record, an outcome or side effects beyond one field, make it a sub-resource and POST to it. Rotating a key creates a new key, retires the old one on a schedule and returns a secret that is shown once. That is not a field flip:
    POST /v1/api-keys/key_7Hq2/rotations HTTP/1.1
    Idempotency-Key: 9f1c2e4a-6d77-4b0e-8a31-5c2d0b7e1f90
    
    HTTP/1.1 201 Created
    Location: /v1/api-keys/key_7Hq2/rotations/rot_44
    The noun is rotation, a thing that happens and can be listed, read and audited.

For simple one-shot verbs where neither fits, POST /v1/links/{code}/archive is a reasonable and common choice, and readable. I prefer the state field for archive because the caller also needs to read whether a link is archived, and that wants a field anyway. Two ways to set the same fact (a field and an endpoint) gives you two ways to disagree. The test for any non-CRUD design is whether you can name the noun. If you can, make it a resource. If you cannot, and the effect is only a changed value, make it a field.

What to avoid: an /actions/ or /commands/ bucket where every odd operation lands. It brings back the verb-path style you left, one namespace at a time.

✓ Check yourself

Answer before you open the fold. Each question asks for a reason or a design choice.

  1. Snip's team proposes POST /v1/links/{code}/delete because the gateway they use blocks DELETE. What does that choice cost callers and intermediaries, and what would you do about the gateway?

    Show answer

    It hides a destructive action behind POST, which no cache or retry layer treats as idempotent, so a client cannot safely retry a timed-out delete. It also adds a verb path that every caller must learn. Fix the gateway rule, or if you cannot, accept a method-override header at the edge and keep the public contract DELETE. See What each method promises.

  2. A client sends the same DELETE /v1/links/a3Xk9bQ twice. The first returns 204, the second 404. Is the method still idempotent? Why does it matter?

    Show answer

    Yes. Idempotence is about server state after the call, not the response code. In both cases the link is gone. It matters because the client may retry after a timeout without knowing whether the first call landed, and the retry must be safe. See What each method promises.

  3. Why does Snip expose code and not the table's integer key, and what must still be true even though code is hard to guess?

    Show answer

    A sequential key leaks volume and invites range walks, and it ties the public contract to a storage detail that sharding may change. code is opaque, URL-safe and stable. Because it is only hard to guess, not secret, each request must still check that the caller may access that link. See Identifiers.

  4. You add workspaces. A teammate suggests /v1/workspaces/{ws}/links/{code}/clicks. What breaks over time, and what do you offer instead?

    Show answer

    The URL carries the hierarchy, so moving a link between workspaces changes its URL and breaks stored references, and callers must always know the parent. Keep the link at /v1/links/{code}, since its code is globally unique, and filter by workspace with a query parameter. See Collections and instances.

  5. Which status do you return when a caller creates a link with an alias that another account already uses, and which when the url field is not a URL? Why do these differ?

    Show answer

    409 for the alias: the request is valid but clashes with current state, and the caller can pick another alias. 422 (or 400, if you use one code for all bad input) for the bad url: it fails a rule no matter what state the server is in. The split tells clients what to change. See Status codes that mean something.

  6. You must add "pause a webhook endpoint". Walk through how you decide between a field and a sub-resource.

    Show answer

    Ask whether pausing is only a value the endpoint holds (a paused boolean that callers also read) or an event with its own record, outcome and history. If only a value, use PATCH on the endpoint. If you need a log of who paused it, when and why, make pauses a sub-resource and POST to it. See Actions that are not nouns.

Takeaway

  • Paths hold nouns, methods hold verbs. A surface built this way is guessable, and every cache and proxy can reason about it.
  • Give each resource a plural collection and an instance path, nest at most one level, and give any child with a globally unique id its own top-level path.
  • Expose an opaque, stable, URL-safe public id like code. Never show a database key, and still authorize every read.
  • Use each method for what it promises: GET safe, PUT and DELETE idempotent, PATCH for partial changes, POST for creation and for actions with no better home.
  • Make the status code true: 201 with Location, 204 for delete, 4xx for the caller, 5xx for you, and never 200 with an error inside.
  • Model a state change as a field with PATCH; model an event with its own record as a sub-resource. Pick one case and one rule, and keep it.

Next, how a caller reads a collection that has millions of members. Pagination, Filtering & Fields →

Further reading