Methods, status codes, and idempotency
The HTTP authoring discipline behind a route handler: choosing the method by intent, the status code by outcome, and making a retried POST safe with an idempotency key.
You can now stand up a route handler that parses its input with Zod and returns a typed body.
The second handler you write raises questions the first never did.
Does a “soft-delete this invoice” endpoint use DELETE, or POST because it also sends a notification?
When two requests race to create the same invoice and one loses on a unique key, does the loser get a 400, a 409, or a 500?
These are the lines a reviewer leaves on your pull request, because each answer changes how the rest of the system behaves around your endpoint.
Earlier you read methods and status codes as a client, deciding what to expect from someone else’s API; now you choose them as the author: a method by intent, a status code by outcome, plus an idempotency key that stops a retried POST from charging a card twice.
The method and the status code are the contract; the body is only the explanation.
The method is the contract, not POST-for-everything
Section titled “The method is the contract, not POST-for-everything”Open almost any codebase that grew without review and you’ll find every endpoint is a POST.
List invoices? POST /invoices/list.
Delete one? POST /invoices/delete.
Read a single record? POST /invoices/get.
The spec permits it, but it throws away free information: the request line of every call now says nothing.
The method is the first signal of intent anything downstream reads, before the URL and long before the body.
A reviewer who reads DELETE /invoices/abc knows the shape of what’s about to happen without opening the handler, and so does every cache, retry policy, and monitoring rule, because none of them have to parse your JSON.
Two properties from the HTTP semantics chapter carry the rest of the discussion: whether a method is safe and whether it is idempotent .
Idempotence is about the end state: calling DELETE twice lands the same “row is gone” state even if the second response differs.
Here’s the five-method palette, each described by what it declares to anyone reading the request line:
GETis safe, idempotent, and cacheable: it reads, searches, and lists, with no side effects ever. AGETthat writes breaks the contract every cache and crawler relies on. Anything that “fetches” or “lists” is aGET.POSTis non-idempotent. Use it to create a resource the server names (POST /invoicesreturns theidthe server chose), or to invoke an operation that isn’t idempotent, such as sending an email, charging a card, or firing a webhook. A repeated call may duplicate the effect unless the handler prevents it, which is the last section of this lesson.PUTis an idempotent full replacement: the client sends the entire new shape of the resource and the handler replaces what’s there. Send the same body twice and you land the same state both times.PATCHis a partial update: the client sends only the fields that change, as a plain JSON object. Whether it’s idempotent depends on the change: setting a status to a fixed value ({ status: 'sent' }) is idempotent, incrementing a counter is not.DELETEis an idempotent removal. The first call deletes the row; what the second returns is a per-project choice between404(“there’s nothing here”) and204(“the end state you asked for is reached either way”). Pick one and document it.
The example a reviewer will use on you: you need to cancel an invoice, and cancelling sends the customer an email and fires a webhook to billing, both real side effects.
So POST /invoices/[id]/cancel is correct: cancel is a genuine action, and POST is the method for actions with side effects.
But a teammate who writes POST /invoices/[id]/status with the body { status: 'cancelled' } is wrong.
Setting a field to a value is a state-diff, which is what PATCH is for; a POST carrying { status } is a PATCH wearing a POST costume.
So the discriminator is: is the operation a state-diff (“make these fields have these values”) or a non-idempotent action (“do this thing that has consequences”)?
A state-diff goes to PATCH or PUT; an action goes to POST.
A route.ts file exports one async function per method, so picking the verb literally is picking which function runs.
The two tabs below put the wrong choice next to the right one for a status change.
export async function POST( request: NextRequest, { params }: RouteContext<'/api/invoices/[invoiceId]/status'>,) { // reads { status } from the body, then sets the field}A PATCH wearing a POST costume. The body sets a field to a value, a state-diff, but the method and the /status sub-path dress it up as an action. The reviewer rejects it.
export async function PATCH( request: NextRequest, { params }: RouteContext<'/api/invoices/[invoiceId]'>,) { // reads { status } from the body, then sets the field}The method already declares the intent. A partial update is a PATCH on the resource itself, with no /status sub-path. (A POST /invoices/[id]/cancel would still be right for the cancel action, which has real side effects.)
The status code is the outcome, read first
Section titled “The status code is the outcome, read first”The method is the first thing read on the way in; the status code is the first thing read on the way out, and not just by humans.
Before anyone opens the body, generic tools read the status class.
An alerting rule rings your phone on a spike of 5xx (your fault) but stays quiet on a 4xx (the caller’s).
A retry policy retries a 503 but never a 400.
None of these tools open your JSON; they read the number.
So never return 200 with { error: ... }.
A 200 means “this worked,” and a body that then explains a failure lies to every tool in the path: alerting stays quiet, retries move on, the dashboard counts a healthy request.
State the outcome in the number, explain it in the body.
Here is the working subset, the dozen or so codes a real handler returns.
Success and redirect are quick; the 4xx group is where the reviewable decisions live.
2xx: it worked
Section titled “2xx: it worked”| Code | Name | When you return it |
|---|---|---|
200 | OK | Succeeded, body returned. GET reads; mutations that hand back the updated resource. |
201 | Created | A POST created a resource. Pair it with a Location header pointing at the new resource’s URL. |
202 | Accepted | Accepted but not finished: the work was queued for a background job. The body carries a poll URL or a job id so the client can check on it later. |
204 | No Content | Succeeded, nothing to return. The body is empty. |
The default for any mutation the client cares about is 200 with the updated row: return 204 from “mark this invoice paid” and the client must fire a second GET for the new state; return the row and it already has it.
Save 204 for a DELETE or a fire-and-forget PATCH, and 202 for work that outlives the request, where the body is a handle the client polls.
3xx: go somewhere else
Section titled “3xx: go somewhere else”Most route handlers never redirect, so two codes are worth recognizing.
308 is a permanent redirect that preserves the method, so a POST stays a POST; use it when a URL has moved for good.
303 See Other is the POST-redirect-GET pattern: after a POST succeeds, 303 forces the browser’s follow-up request onto a GET of the new resource, whatever the original method was.
4xx: the client got it wrong
Section titled “4xx: the client got it wrong”| Code | Name | When you return it |
|---|---|---|
400 | Bad Request | The wire payload is malformed: truncated JSON, wrong content type, unparseable. You couldn’t even read it. |
401 | Unauthorized | No valid credentials, so you can’t tell who the caller is. |
403 | Forbidden | You know who the caller is, but they’re not allowed to do this. |
404 | Not Found | The resource doesn’t exist, or it belongs to another tenant (more on this below). |
409 | Conflict | The request collides with current state: a duplicate unique key, a version mismatch. |
422 | Unprocessable Content | The payload parsed but failed validation: a field is the wrong shape or value. |
429 | Too Many Requests | Rate limit exceeded. The Retry-After header carries the wait. |
400 vs 422. This is the line your Zod pipeline already draws.
400 means the bytes were malformed and request.json() threw, so you never reached the contents.
422 means the JSON parsed but the object failed your schema, the operational meaning of last lesson’s rule that a safeParse failure returns 422.
Can’t parse, 400; parsed but rejected, 422.
(Some teams collapse both into 400; that’s defensible, but you pick it once and apply it everywhere.)
401 vs 403 vs 404, a security decision. Identity unknown is 401: no session cookie, an expired token, nothing to authenticate.
Identity known but unauthorized is 403: the caller’s role is member and the route needs admin.
The third code is the most security-sensitive decision in the lesson.
When a caller requests an invoice that exists but belongs to a different organization, the instinct is 403 (“you’re not allowed to see this”); return 404 instead.
A 403 confirms the resource exists, so an attacker probing ids learns “invoice abc is real, I just can’t reach it,” and on tenant-scoped data that leak is a real finding in a security review.
A 404 proves nothing.
So the rule is 404, not 403, for resources scoped to a tenant you don’t belong to, refusing access by denying existence.
You won’t wire this by hand: a later chapter’s authedRoute wrapper returns the 401/403, and its tenantDb helper enforces the 404 structurally.
409 Conflict. The request is fine in isolation but collides with the current state of the world, in one of two shapes.
A duplicate unique key: two requests race to create an invoice with the same slug, the database’s unique constraint rejects the second, and that’s a 409, not a 500, because the server didn’t break, the request lost a legitimate race.
Or an optimistic concurrency mismatch: the client read version 3, someone else saved version 4, and the client’s update arrives still believing it’s editing version 3.
The Problem Details body carries why it conflicted so the client can decide what to do.
429 Too Many Requests. The one authoring detail to get right is the response shape: send Retry-After carrying the wait in seconds (Retry-After: 30), not an HTTP date, so the client parses 30 and waits with no timezone math.
A handful of 4xx codes are real but rare, worth recognizing so you’re not lost when you see one: 405 Method Not Allowed (let Next.js return this automatically for any method the file doesn’t export), 410 Gone (the resource was permanently removed, for sunset URLs), 413 Content Too Large (the body blew past the platform limit), and 415 Unsupported Media Type (the wrong Content-Type, such as text/plain to a JSON-only endpoint).
5xx: you got it wrong
Section titled “5xx: you got it wrong”| Code | Name | When you return it |
|---|---|---|
500 | Internal Server Error | An uncaught exception. Something in your handler threw and nothing caught it. |
502 / 503 / 504 | Bad Gateway / Unavailable / Gateway Timeout | An upstream you depend on failed, is down, or timed out. |
500 carries a production-stakes detail: the body must not carry the stack trace.
A trace leaks your file paths, dependency versions, and sometimes a secret in a variable name, a genuine finding in a security audit.
The trace goes to your observability sink; the body carries a correlationId your support team can look up, and nothing more.
The cousins (502/503/504) are for upstream failures: when a call to Stripe, Resend, or an AI provider fails, name which upstream broke in the body so the client doesn’t blame your service for someone else’s outage.
The codes have an order: a reviewer doesn’t scan the table, they walk a sequence of questions and the first “no” picks the code. The diagram makes that sequence visible, and it’s the same order the last lesson’s parse-then-authorize ladder runs in.
The reviewer’s order of questions, top to bottom, the same order your handler runs its checks. Each question peels off to its status code on the answer that fails it; you reach the green success only when every gate above it has passed. An uncaught throw anywhere short-circuits to a 500.
As code, that diagram is the early-return ladder of nearly every mutating handler you’ll write.
The POST below creates an invoice: each step asks one question from the tree and returns the matching code on a “no,” routing every error body through last lesson’s problem() helper so the shape never drifts across handlers.
export async function POST(request: NextRequest) { const session = await getSession(request); if (!session) return problem(401, 'no-session'); if (!session.roles.includes('member')) return problem(403, 'insufficient-role');
const input = parseOr422(createInvoiceSchema, await request.json());
const existing = await getInvoiceBySlug(session.orgId, input.slug); if (existing) return problem(409, 'invoice-slug-conflict');
const invoice = await createInvoice(session.orgId, input); return NextResponse.json(invoice, { status: 201, headers: { Location: `/api/invoices/${invoice.id}` }, });}Identity and permission first, before any data is touched: no session is 401, and a session that lacks the member role this route requires is identified-but-not-allowed, a 403. (In a real app the authedRoute wrapper does this; it’s inline here so the codes are visible.)
export async function POST(request: NextRequest) { const session = await getSession(request); if (!session) return problem(401, 'no-session'); if (!session.roles.includes('member')) return problem(403, 'insufficient-role');
const input = parseOr422(createInvoiceSchema, await request.json());
const existing = await getInvoiceBySlug(session.orgId, input.slug); if (existing) return problem(409, 'invoice-slug-conflict');
const invoice = await createInvoice(session.orgId, input); return NextResponse.json(invoice, { status: 201, headers: { Location: `/api/invoices/${invoice.id}` }, });}The schema gate. The bytes already parsed, or the framework boundary would have returned 400; now parseOr422 runs the schema and throws a 422 Problem with per-field errors on failure. That is the 400-vs-422 line, in code.
export async function POST(request: NextRequest) { const session = await getSession(request); if (!session) return problem(401, 'no-session'); if (!session.roles.includes('member')) return problem(403, 'insufficient-role');
const input = parseOr422(createInvoiceSchema, await request.json());
const existing = await getInvoiceBySlug(session.orgId, input.slug); if (existing) return problem(409, 'invoice-slug-conflict');
const invoice = await createInvoice(session.orgId, input); return NextResponse.json(invoice, { status: 201, headers: { Location: `/api/invoices/${invoice.id}` }, });}The conflict check. A duplicate slug means this request lost a race, so 409, not 500. Tenant scope is enforced inside getInvoiceBySlug: an id from another org surfaces as a 404 there, never a 403.
export async function POST(request: NextRequest) { const session = await getSession(request); if (!session) return problem(401, 'no-session'); if (!session.roles.includes('member')) return problem(403, 'insufficient-role');
const input = parseOr422(createInvoiceSchema, await request.json());
const existing = await getInvoiceBySlug(session.orgId, input.slug); if (existing) return problem(409, 'invoice-slug-conflict');
const invoice = await createInvoice(session.orgId, input); return NextResponse.json(invoice, { status: 201, headers: { Location: `/api/invoices/${invoice.id}` }, });}Every gate passed and the server named the new resource, so 201 with a Location header pointing at the new row.
The classification only sticks if you practice it. Each chip is a one-line scenario; drop it under the status code a reviewer would expect, the same discriminations you’ll defend on a pull request.
Sort each outcome into the status code a reviewer would expect your handler to return. Drag each item into the bucket it belongs to, then press Check.
request.json() threw.member, but the route requires admin.total came through as a negative number.POST /invoices succeeded and the server assigned the new id.Two confusions tend to survive even a clean run, so here is a question on each.
A client sends POST /api/invoices with the body { "email": "not-an-email", "total": 50 }. request.json() parses it without complaint, but createInvoiceSchema.safeParse() flags email as invalid. Which status does the handler return?
200, with the field errors listed in the response body400 Bad Request422 Unprocessable Content500 Internal Server Error400, which is for a payload request.json() can’t even read. The contents then failed the schema, which is exactly what 422 means. A 200 would lie to every monitor and retry policy in the path, and a 5xx would page your on-call for the client’s mistake.A signed-in user sends GET /api/invoices/abc. The row exists, but it belongs to a different organization than the caller’s. The handler can read the caller’s identity fine; the row just isn’t theirs. Which status does a reviewer require here?
401 Unauthorized403 Forbidden404 Not Found409 Conflict404. A 403 would admit the row is real, and on tenant-scoped data an attacker walking ids can use that admission to map which records exist behind the wall, the existence leak a security review flags. A 404 admits nothing: from where this caller stands, the row is indistinguishable from one that never existed. 401 is the wrong axis, since the caller is identified, and 409 is for a request that collides with current state, which nothing here does.Idempotency: making a retried POST safe
Section titled “Idempotency: making a retried POST safe”You have a POST that charges a customer’s card.
The request arrives, your handler calls Stripe, the charge goes through, and Stripe says OK.
Then, in the half-second before your 200 reaches the client, the network blips and the client never sees the response.
So it retries: the HTTP layer retries automatically, or the user staring at a stuck spinner hits “Pay” again.
Your handler receives a second, identical request, calls Stripe again, and the card gets charged twice.
POST is non-idempotent by default, so nothing in the protocol prevents this.
To make one safe under retry, the handler has to add idempotency itself.
The contract
Section titled “The contract”Any public POST with a non-idempotent side effect, whether that’s charging, sending an email, firing a webhook, or creating a resource, accepts an Idempotency-Key request header: a client-generated UUID that names the logical operation, “charge invoice abc, this one specific time,” not the HTTP request.
Your handler reads and validates Idempotency-Key with a HeadersSchema, like any other input.
What’s new is what it does with it.
The mechanism
Section titled “The mechanism”The mechanism is four steps.
- Read the
Idempotency-Keyheader off the incoming request. - Hash it together with the route and the authenticated tenant. Scoping the dedup to the key plus endpoint plus org means one tenant’s key can never collide with, or replay against, another tenant’s operations.
- Try to claim the operation by inserting that hash into a
processed_requeststable withINSERT ... ON CONFLICT DO NOTHING. This is the atomic claim, the heart of the whole thing. Either your insert wins (the hash wasn’t there, so this is the first time; proceed, run the side effect, and store the response) or it loses to the conflict (the hash was already there, so this is a retry). - On a loss, return the cached response you stored the first time, instead of running the side effect again.
The diagram traces both passes; watch where the retry forks off and skips the side effect.
- Client Route Handler
POST /chargeIdempotency-Key: k1 - Handler hashes the claim
hash(k1 + route + tenant) - Handler processed_requests
INSERT … ON CONFLICT DO NOTHINGclaim WINS · row inserted - Handler Stripe charge side effect runs card charged
- Handler processed_requests store the response in the row
- Handler Client
201 Createdreturned
k1 with the route and tenant, wins the atomic claim, runs the
charge, stores the response in the row, and returns 201.
- Client Route Handler same
POST /chargeIdempotency-Key: k1first response was lost - Handler hashes the claim
hash(k1 + route + tenant)— identical - Handler processed_requests
INSERT … ON CONFLICT DO NOTHINGclaim LOSES · row already exists - Handler Stripe charge side effect skipped never re-runs
- Handler processed_requests read the cached response from the row
- Handler Client
same
201 Createdreturned
201 both times The dedup row absorbs the retry. The customer's card is charged once, the client gets a consistent answer, and the system stays correct under retry.
k1 arrives, the side effect
runs exactly once.
The sketch below is recognition-level: it shows the claim primitive and the table shape, not production code.
The real implementation wraps the claim and the mutation in one transaction and handles a claim that wins but then fails the work; you’ll build that complete version in a later chapter on webhook ingestion, where the same processed_events pattern dedups Stripe’s webhook retries.
export async function POST(request: NextRequest) { const session = await getSession(request); if (!session) return problem(401, 'no-session');
const key = request.headers.get('Idempotency-Key'); if (!key) return problem(400, 'idempotency-key-required');
const fingerprint = hashKey(key, '/api/charges', session.orgId); const claim = await db .insert(processedRequests) .values({ fingerprint }) .onConflictDoNothing() .returning();
// → the webhook chapter wraps the claim + the charge in one transaction if (claim.length === 0) return readCachedResponse(fingerprint);
const charge = await chargeInvoice(session.orgId, key); return NextResponse.json(charge, { status: 201 });}Read the header. On a side-effecting POST, a missing key is a malformed request, a 400, because the client must supply one.
export async function POST(request: NextRequest) { const session = await getSession(request); if (!session) return problem(401, 'no-session');
const key = request.headers.get('Idempotency-Key'); if (!key) return problem(400, 'idempotency-key-required');
const fingerprint = hashKey(key, '/api/charges', session.orgId); const claim = await db .insert(processedRequests) .values({ fingerprint }) .onConflictDoNothing() .returning();
// → the webhook chapter wraps the claim + the charge in one transaction if (claim.length === 0) return readCachedResponse(fingerprint);
const charge = await chargeInvoice(session.orgId, key); return NextResponse.json(charge, { status: 201 });}Hash the key with the route and the tenant, never the bare key, so one tenant’s key can never collide with or replay against another tenant’s operation.
export async function POST(request: NextRequest) { const session = await getSession(request); if (!session) return problem(401, 'no-session');
const key = request.headers.get('Idempotency-Key'); if (!key) return problem(400, 'idempotency-key-required');
const fingerprint = hashKey(key, '/api/charges', session.orgId); const claim = await db .insert(processedRequests) .values({ fingerprint }) .onConflictDoNothing() .returning();
// → the webhook chapter wraps the claim + the charge in one transaction if (claim.length === 0) return readCachedResponse(fingerprint);
const charge = await chargeInvoice(session.orgId, key); return NextResponse.json(charge, { status: 201 });}The atomic claim. Win the insert and returning() hands back the row; lose it to the unique conflict and you get an empty array. The database decides the race with no read-then-write window for a second request to slip through.
export async function POST(request: NextRequest) { const session = await getSession(request); if (!session) return problem(401, 'no-session');
const key = request.headers.get('Idempotency-Key'); if (!key) return problem(400, 'idempotency-key-required');
const fingerprint = hashKey(key, '/api/charges', session.orgId); const claim = await db .insert(processedRequests) .values({ fingerprint }) .onConflictDoNothing() .returning();
// → the webhook chapter wraps the claim + the charge in one transaction if (claim.length === 0) return readCachedResponse(fingerprint);
const charge = await chargeInvoice(session.orgId, key); return NextResponse.json(charge, { status: 201 });}An empty array means a retry, so return the response stored the first time and the charge never reruns. The comment marks the scope line: the production handler wraps the claim and the charge in one transaction.
Two details round out the contract. The dedup row doesn’t live forever. It expires after a tunable window, 24 hours by default, the convention Stripe popularized. After that, a replay of the same key produces a fresh row and runs the operation again, on the assumption that a retry a day later is a genuinely new intent, not a stuck network packet. A background job sweeps expired rows, which a later chapter covers.
The same discipline applies to in-app forms, with a different carrier.
A form-driven Server Action can’t read an HTTP header, so its idempotency key rides as a hidden UUID field in the form, generated when the form mounts (the same key that reconciles the optimistic UI you met in the chapter on useOptimistic).
A header at the handler boundary, a hidden field at the action boundary: same idea, same safety.
So when is the key required? On non-idempotent methods with side effects, and only there. The round below walks the boundary cases.
Decide whether each endpoint needs to require an `Idempotency-Key` header. Mark each statement True or False.
A POST that charges a customer’s card should require an Idempotency-Key.
A GET that lists invoices should require an Idempotency-Key.
GET is already safe and idempotent by definition — repeating it changes nothing on the server, so there’s no side effect to dedup. The key would be dead weight.A POST that creates a new organization should require an Idempotency-Key.
A PUT that replaces a user’s full profile needs an Idempotency-Key to be safe under retry.
PUT is already idempotent by method — sending the same full-replacement body twice lands the same final state. The method gives you retry-safety for free, so the header adds nothing here.Reveal card-by-card review
The method states the request’s intent, the status code states the outcome, and an Idempotency-Key header makes a retried side-effecting POST safe.
The next lesson turns the query string into a real list endpoint: filtering, sorting, searching, and paginating, all as a Zod-validated contract.
External resources
Section titled “External resources”These are the canonical references for what this lesson taught. Keep them bookmarked for the day you’re staring at an unfamiliar status code or writing your first idempotent endpoint for real.
MDN's method reference, with the safe / idempotent / cacheable table that drives every choice in this lesson.
The complete, readable catalog of every code, for the day you meet one outside this lesson's subset.
The spec behind the application/problem+json error body this chapter standardizes on.
The de-facto reference for the Idempotency-Key pattern, including the 24-hour window this lesson named.