Skip to content
Chapter 46Lesson 3

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:

  • GET is safe, idempotent, and cacheable: it reads, searches, and lists, with no side effects ever. A GET that writes breaks the contract every cache and crawler relies on. Anything that “fetches” or “lists” is a GET.
  • POST is non-idempotent. Use it to create a resource the server names (POST /invoices returns the id the 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.
  • PUT is 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.
  • PATCH is 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.
  • DELETE is an idempotent removal. The first call deletes the row; what the second returns is a per-project choice between 404 (“there’s nothing here”) and 204 (“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.

app/api/invoices/[invoiceId]/status/route.ts
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.

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.

CodeNameWhen you return it
200OKSucceeded, body returned. GET reads; mutations that hand back the updated resource.
201CreatedA POST created a resource. Pair it with a Location header pointing at the new resource’s URL.
202AcceptedAccepted 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.
204No ContentSucceeded, 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.

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.

CodeNameWhen you return it
400Bad RequestThe wire payload is malformed: truncated JSON, wrong content type, unparseable. You couldn’t even read it.
401UnauthorizedNo valid credentials, so you can’t tell who the caller is.
403ForbiddenYou know who the caller is, but they’re not allowed to do this.
404Not FoundThe resource doesn’t exist, or it belongs to another tenant (more on this below).
409ConflictThe request collides with current state: a duplicate unique key, a version mismatch.
422Unprocessable ContentThe payload parsed but failed validation: a field is the wrong shape or value.
429Too Many RequestsRate 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).

CodeNameWhen you return it
500Internal Server ErrorAn uncaught exception. Something in your handler threw and nothing caught it.
502 / 503 / 504Bad Gateway / Unavailable / Gateway TimeoutAn 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.

Could you parse the body?
No 400 malformed
parsed
Could you identify the caller?
No 401 no identity
known
Is the caller allowed?
No 403 no permission
allowed
Does it exist in this tenant?
No 404 missing / cross-tenant
found
Conflicts with current state?
Yes 409 conflict
no clash
Passed the schema?
No 422 validation
valid
Did the work succeed?
uncaught throw 500 server fault
Yes
200 / 201 / 202 / 204 it worked

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.

1 / 1

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.

400 Malformed payload
401 No identity
403 Identity, no permission
404 Missing or cross-tenant
409 Conflict with state
422 Validation failed
500 Server fault
201 Resource created
The JSON body was truncated mid-stream and request.json() threw.
No session cookie is present, so the handler can’t tell who the caller is.
The caller’s role is member, but the route requires admin.
The invoice id is valid, but the row belongs to another organization.
Two requests raced to create the same unique slug; this one lost.
The body parsed cleanly, but total came through as a negative number.
An upstream Stripe call threw and nothing in the handler caught it.
A 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 body
400 Bad Request
422 Unprocessable Content
500 Internal Server Error

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 Unauthorized
403 Forbidden
404 Not Found
409 Conflict

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.

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 is four steps.

  1. Read the Idempotency-Key header off the incoming request.
  2. 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.
  3. Try to claim the operation by inserting that hash into a processed_requests table with INSERT ... 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).
  4. 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.

  1. Client Route Handler POST /charge  Idempotency-Key: k1
  2. Handler hashes the claim hash(k1 + route + tenant)
  3. Handler processed_requests INSERT … ON CONFLICT DO NOTHING claim WINS · row inserted
  4. Handler Stripe charge side effect runs card charged
  5. Handler processed_requests store the response in the row
  6. Handler Client 201 Created returned
First request — the key is new. The handler hashes k1 with the route and tenant, wins the atomic claim, runs the charge, stores the response in the row, and returns 201.
  1. Client Route Handler same POST /charge  Idempotency-Key: k1 first response was lost
  2. Handler hashes the claim hash(k1 + route + tenant) — identical
  3. Handler processed_requests INSERT … ON CONFLICT DO NOTHING claim LOSES · row already exists
  4. Handler Stripe charge side effect skipped never re-runs
  5. Handler processed_requests read the cached response from the row
  6. Handler Client same 201 Created returned
Retry — same key, claim loses. The identical request hashes to the same value, so the insert hits the conflict. The handler short-circuits: the charge is skipped and the stored response is replayed. That fork is the entire idea.
2 requests arrived original + retry, same key
1 charge happened the side effect ran exactly once
2 identical responses client sees the same 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.

Net result: one charge, two identical responses. No matter how many times the request with key 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.

1 / 1

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.

The textbook case. Charging is a non-idempotent side effect that costs money, so a lost response plus a retry must not double-charge — exactly what the key prevents.

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.

Creating a server-named resource is non-idempotent — without the key, a retried request would create a second org. The key lets the second call return the first one’s response instead.

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.

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.

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.