Designing an API people can use
Before you write a line of NestJS, you write the contract for the partner ordering API: which resources exist, what each method does, which status codes the Partner app can branch on, one error shape, and pagination that stays correct while 1,800 shops keep ordering.
By the end you can
- Model shops, price lists, orders, order lines and invoices as resources, and choose between a sub-resource and a filter
- Pick methods by their meaning (safe, idempotent, PUT vs PATCH) and model "cancel" as a state change
- Return status codes in a fixed order of checks, with one RFC 9457 problem details body
- Build opaque cursor pagination with a tie-breaker, a limit cap and an index that serves it
- Ship changes without breaking old app versions: money in cents, UTC timestamps, additive changes
The everyday picture
Think of the order desk at a wholesale market. Every trader fills in the same paper form: shop number, then one row per item. The clerk stamps each form with a ticket number, and from then on that ticket number is the only name the order has. When a form is wrong, the clerk does not shout "no". She hands back a rejection slip with fixed boxes: what kind of problem, which row, and what to fix. A trader who has seen one slip can read them all.
The desk also has rules about time. You can cancel an order until the porters start picking it, and after that the answer is always the same slip: "already being picked". When a trader asks to see his orders, the clerk does not count "page 37". She says "here are the next 25 after ticket 90412", so new orders arriving at the desk never shift what he sees. An API is that desk, written down so that a program can follow it.
Model the resources first
APIs done right showed the basics: nouns in paths, methods for verbs. The harder part is deciding which nouns exist and how they relate. For the Partner app you need five: shops, each shop's price list, orders, the lines inside an order, and invoices. Start from what the app needs to show and do, not from your database tables. Users are shop owners like Ravi, shop staff, Mangrove's sales reps who each look after about 40 shops, and admins.
Each resource gets one canonical URL: /v1/orders/90412. Two tests decide whether something is nested under another resource or stands on its own.
- Can it exist without the parent? An order line has no life outside its order, so it is a sub-resource:
/v1/orders/90412/lines. An invoice is created from an order, but then it has its own life: it is sent, paid or overdue, and accounts staff look it up by number. It gets its own top-level URL,/v1/invoices/7731, and the order links to it with aninvoiceIdfield. - Do people ask for it across parents? A rep wants "all orders from my 40 shops placed today". If orders lived only under
/v1/shops/1042/orders, that question would need 40 calls, and each order would have two URLs. So orders are a top-level collection, and the shop is a filter:GET /v1/orders?shopId=1042&status=PLACED.
For a shop user, the API takes the shop from the login, so Ravi's app calls GET /v1/orders and sees only his shop's orders. If the app also sends shopId, the server checks it against the login; it never trusts it. How that check works is the topic of Who are you and what may you do?. Keep nesting to one level below a top-level resource. Paths like /shops/1042/orders/90412/lines/3 repeat facts the server already knows and give clients two ways to spell one thing.
Quick check
Accounts staff search unpaid invoices across all shops, and each invoice has its own due date and payment status. Where should invoices live?
An invoice has its own life and people search it across orders and shops, so it needs its own collection and URL. Nesting it only under the order would force staff to know the order first, and a nested object has no URL to link, page or filter.
Methods by meaning, and cancel as a state change
HTTP defines two properties that clients and proxies rely on. A safe method reads and changes nothing: GET, HEAD, OPTIONS and the rarely used TRACE. An idempotent method has the same effect whether it runs once or five times: all safe methods, plus PUT and DELETE. POST and PATCH promise neither. These are promises your code must keep. A GET that marks orders as "seen" breaks the first one, and a browser prefetch or a link checker will change data.
PUT replaces the whole resource with the body you send. PATCH changes only the fields you send. The difference bites when Lagoon Digital's app wants to change only the delivery note on an order. If the API offers PUT and the app sends {"deliveryNote": "Back door"}, a server that keeps PUT's meaning replaces the whole order with that body, and every field the body left out is gone. For partial updates, use PATCH with JSON Merge Patch: send the fields to change, and null removes a field. Use PUT when the client really owns the whole thing, for example PUT /v1/shops/1042/delivery-address.
Cancelling an order is not a field edit. It has rules: only an order in PLACED can be cancelled, a cancel releases reserved stock, and it notifies the distribution centre. A PATCH that sets status hides those rules behind a field anyone could set to anything. DELETE is wrong too, because a cancelled order must stay readable for history and audit. Model it as an action on the order: POST /v1/orders/90412/cancel. Then decide what a repeat means. A second cancel on an order that is already cancelled returns 200 with the order, so a retry on a weak connection is harmless. A cancel on an order the DC is picking returns 409 Conflict.
Quick check
The Partner app lets a shop change the delivery note while an order is still PLACED. Which request fits best?
A partial change to one field is what PATCH with a merge patch is for. PUT means "replace the whole order with this", so the missing fields would be wiped. A new POST action adds an endpoint for a plain field edit with no special rules, and a GET must never change data.
Status codes in a fixed order of checks
Lagoon Digital's developers write code like "if 409, refresh the order; if 422, show the message; if 503, retry later". That only works if the same situation always gives the same code. The easiest way to get there is to run the checks in a fixed order, so that when several things are wrong at once, every endpoint reports the same one first.
What each answer tells the app:
- 401: no valid login. The app refreshes its token or shows the login screen. 403: logged in, but this role may not call this endpoint, such as shop staff opening an admin report.
- 400: the request is malformed or breaks the documented schema: a missing
linesarray, a quantity of -3, an unknown field. The app has a bug or a form check to add. - 404: the order does not exist for this caller. Another shop's order also gets 404, so an attacker cannot learn which order numbers exist (Who are you and what may you do?).
- 409: the request is fine, but the resource's current state forbids it, such as cancelling an order being picked. The app reloads the order and shows its new state.
- 422: well formed and allowed in this state, but it breaks a business rule: an article that is not on this shop's price list, or an order over the shop's credit limit. The app shows the message to the user.
- Success:
201 Createdwith aLocationheader for a new order;200for reads, edits and actions;204 No Contentwhen there is nothing to return, such as removing a saved basket;202 Acceptedwhen work continues in the background, such as a rep's CSV export of her 40 shops (Background jobs and idempotency).
The line between 400 and 422 is a team convention, and teams draw it in different places. Ours is "400 for the shape, 422 for the rules". What matters is that you write it down and keep it everywhere. A 500 is never a choice. It means a bug, so it pages someone.
One error shape: problem details
RFC 9457 defines problem details, a standard error body sent with the media type application/problem+json. It has five members, all optional, plus any extensions you add:
type: a URI that names the kind of problem, such ashttps://api.mangrove.example/problems/order-not-cancellable. This is what clients branch on. Point it at a docs page that explains the problem. If you leave it out, it meansabout:blank: "nothing beyond the status code".title: a short summary of that type, the same every time it occurs.status: the HTTP status code, repeated for convenience.detail: an explanation of this occurrence, for a human: "Order 90412 is being picked at the North DC."instance: a URI for this occurrence, often the request path or an error id.
Clients must never parse detail or title, because you will reword them. For validation errors, add an errors array with one entry per bad field. Each entry has a pointer, a JSON Pointer such as #/lines/2/qty, so the app can put the message next to the right input. RFC 9457 uses this pattern in its own example. Add a requestId extension that support staff can search for in the logs. Clients must ignore extension members they do not know, which lets you add more later without breaking anyone.
Quick check
Lagoon Digital's app needs to show "Call your rep" whenever a cancel fails because picking has started. What should the app check?
type is the stable, machine-readable name of the problem. detail is free text that will change with the next rewording or translation. Status 409 alone is too coarse: other conflicts, such as an edit after dispatch, return 409 too.
Pagination, filters and sorting
APIs done right showed why offset pages drift when rows are added or removed, and why OFFSET 100000 is slow. Here we turn the cursor idea into a contract. A partner shop's order list is sorted newest first by placedAt. Many orders share a timestamp, especially on festival mornings, so the sort needs a tie-breaker: placedAt descending, then id descending. The cursor holds both values from the last row of the page, and the next page asks for rows that come strictly after that pair.
Make the cursor opaque: encode it as base64url text that clients store and send back, and document that they must not build or read it. If Lagoon Digital's code starts building cursors from id values, you can never add a field to the cursor or change the sort without breaking the app. Opaque does not mean secret. Anyone can decode base64, so the server must validate every cursor it receives and answer 400 if it is not one it made, or was made for another sort.
The rest of the contract:
- Limits:
limitdefaults to 25 and has a maximum of 100. A larger value is a 400, so a mistake shows up in testing instead of loading 50,000 rows on a phone. - Response:
{"data": [...], "nextCursor": "..."}, withnextCursor: nullon the last page. Leave out a total count by default. Counting every matching row on each page request means reading all of them, and an infinite list does not need it. - Filters: a fixed allowlist of query parameters.
status=PLACED,PICKINGmeans "either". Date ranges are half-open,placedFromincluded andplacedToexcluded, so consecutive days never overlap. - Sorting:
sort=-placedAt, where a minus means descending. Allow only sorts that an index serves, and keep the sort and filters the same for every page of one list.
Formats and changes that do not break anyone
A few format rules prevent whole classes of bugs. Money is an integer number of cents plus a currency: "totalCents": 184250, "currency": "USD". JSON numbers become floating point in JavaScript, where 0.1 + 0.2 is 0.30000000000000004, and a wholesale invoice that is off by a cent will not reconcile. Timestamps are ISO 8601 in UTC with a Z, as profiled by RFC 3339: "placedAt": "2026-10-20T08:15:00Z". The app converts to the shop's local time for display. A delivery day is a date, not a time, so it is "deliveryDate": "2026-10-22". IDs are strings in JSON, even when the database uses bigint, because JavaScript numbers lose precision above about 9 quadrillion and because you may change the id format later.
The Partner app on a shop's phone may stay on an old version for months, so /v1 must keep working for every version still in use. Changes are safe when old clients cannot notice them: a new optional response field, a new endpoint, a new optional filter. Changes that break old clients need a new version or a long migration: removing or renaming a field, changing a type, making an optional input required, or tightening validation on input that used to pass. Enum values are a trap. Adding ON_HOLD to status looks additive, but an app with a strict switch statement will crash on it. Write into the contract from day one that clients must treat unknown values as a generic state, such as "Processing", and test that they do. How to announce and retire old versions comes in Testing and documenting the API.
Build it
We will write the contract for partner orders: the endpoint table, example responses, the error bodies, the cursor helper and the query behind it. In Your first NestJS service you turn it into code.
-
Write the endpoint table
Every path, the method, what it does and the codes it can return. Review this with Lagoon Digital before anyone writes a controller.
OutputGET /v1/orders list (filters, sort, cursor) 200 400 POST /v1/orders place an order 201 400 422 GET /v1/orders/{orderId} read one order 200 404 PATCH /v1/orders/{orderId} edit note or date while PLACED 200 400 404 409 POST /v1/orders/{orderId}/cancel cancel while PLACED 200 404 409 GET /v1/orders/{orderId}/lines lines of one order 200 404 GET /v1/shops/{shopId}/price-list prices for one shop 200 404 GET /v1/invoices/{invoiceId} read one invoice 200 404 POST /v1/order-exports CSV of many shops' orders 202 400 GET /v1/order-exports/{exportId} export status and download 200 404 Every endpoint can also return 401, 403, 429 and 503. -
Show what success looks like
A created order answers 201 with a
Locationheader, so the client knows its URL without building it. The body is the order as a GET would return it.OutputHTTP/1.1 201 Created Location: /v1/orders/90412 Content-Type: application/json { "id": "90412", "shopId": "1042", "status": "PLACED", "placedAt": "2026-10-20T08:15:00Z", "deliveryDate": "2026-10-22", "totalCents": 184250, "currency": "USD", "lineCount": 14, "invoiceId": null }A list page carries the data and the cursor for the next page, nothing else.
JSON{ "data": [ { "id": "90412", "status": "PLACED", "placedAt": "2026-10-20T08:15:00Z", "totalCents": 184250 }, { "id": "90398", "status": "PICKING", "placedAt": "2026-10-20T07:58:00Z", "totalCents": 61200 } ], "nextCursor": "WyItcGxhY2VkQXQiLCIyMDI2LTEwLTIwVDA3OjU4OjAwWiIsIjkwMzk4Il0" } -
Write the two error bodies every client will meet
A validation failure with field pointers, then a state conflict. Both use
Content-Type: application/problem+json.JSON{ "type": "https://api.mangrove.example/problems/validation", "title": "The request is not valid.", "status": 400, "detail": "2 fields need attention.", "instance": "/v1/orders", "requestId": "req_8f2c1e", "errors": [ { "pointer": "#/lines/2/qty", "detail": "must be an integer from 1 to 999" }, { "pointer": "#/deliveryDate", "detail": "must be a date like 2026-10-22" } ] }JSON{ "type": "https://api.mangrove.example/problems/order-not-cancellable", "title": "This order can no longer be cancelled.", "status": 409, "detail": "Order 90412 has been picked at the North DC since 10:42 UTC.", "instance": "/v1/orders/90412/cancel", "requestId": "req_91aa07", "orderStatus": "PICKING" } -
Encode and decode the cursor
The cursor is a small JSON array, encoded as base64url so it is safe in a query string. Decoding checks everything, because the client could send anything.
base64urlneeds Node 16 or later.TypeScript// src/common/cursor.ts export interface OrderCursor { sort: string; // the sort it was made for, e.g. '-placedAt' placedAt: string; // sort key of the last row, ISO 8601 UTC id: string; // tie-breaker: the last row's id } export class BadCursorError extends Error {} // the API maps this to a 400 export function encodeCursor(c: OrderCursor): string { const json = JSON.stringify([c.sort, c.placedAt, c.id]); return Buffer.from(json, 'utf8').toString('base64url'); } export function decodeCursor(raw: string, sort: string): OrderCursor { let parts: unknown; try { parts = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')); } catch { throw new BadCursorError('The cursor is not one this API made.'); } if (!Array.isArray(parts) || parts.length !== 3 || !parts.every((p) => typeof p === 'string')) { throw new BadCursorError('The cursor is not one this API made.'); } const [s, placedAt, id] = parts as string[]; if (s !== sort) { throw new BadCursorError(`The cursor was made for sort=${s}, not sort=${sort}.`); } if (Number.isNaN(Date.parse(placedAt)) || !/^\d{1,19}$/.test(id)) { throw new BadCursorError('The cursor is not one this API made.'); } return { sort: s, placedAt, id }; }A quick try in Node shows the round trip and both kinds of rejection:
TypeScriptconst next = encodeCursor({ sort: '-placedAt', placedAt: '2026-10-20T08:15:00.000Z', id: '90412' }); console.log(next); console.log(decodeCursor(next, '-placedAt')); for (const [raw, sort] of [[next, 'deliveryDate'], ['hello', '-placedAt']]) { try { decodeCursor(raw, sort); } catch (e) { console.log(`${e.constructor.name}: ${(e as Error).message}`); } }OutputWyItcGxhY2VkQXQiLCIyMDI2LTEwLTIwVDA4OjE1OjAwLjAwMFoiLCI5MDQxMiJd { sort: '-placedAt', placedAt: '2026-10-20T08:15:00.000Z', id: '90412' } BadCursorError: The cursor was made for sort=-placedAt, not sort=deliveryDate. BadCursorError: The cursor is not one this API made. -
Write the query and the index that serve it
The row comparison
(placed_at, id) < (…, …)means "strictly after this pair" in a newest-first list. The index has the same columns in the same direction, so Postgres reads exactly the rows of one page. Ask forlimit + 1rows: if the extra row comes back, there is a next page.SQLCREATE INDEX partner_order_shop_placed_idx ON partner_order (shop_id, placed_at DESC, id DESC); -- page after the cursor, limit 25 (+1 to know if there is more) SELECT id, status, placed_at, total_cents FROM partner_order WHERE shop_id = $1 AND (placed_at, id) < ($2::timestamptz, $3::bigint) ORDER BY placed_at DESC, id DESC LIMIT 26;OutputLimit -> Index Scan using partner_order_shop_placed_idx on partner_order Index Cond: ((shop_id = 1042) AND (ROW(placed_at, id) < ROW(...)))The plan shows the whole condition used as an index condition, so page 500 costs the same as page 1. Prisma has no row comparison in
where; the same filter written asplacedAt < X OR (placedAt = X AND id < Y)gives the same rows, so check its plan withEXPLAINon your data (Reading query plans).
Bring it to your team
Ask these while you review the endpoint table with Lagoon Digital, before any code is written.
- "Which resources can exist without their parent? Is anything nested that people will search across parents?"
- "For each endpoint, which status codes can it return, and what should the app do for each one?"
- "Which
typeURIs will the app branch on, and is each one documented?" - "What makes this list's sort unique? Which index serves it, and what is the maximum page size?"
- "If we add a status value next month, what does the current app show?"
- "Are all money fields integer cents and all timestamps UTC with a Z?"
Red flags
200 OKwith"success": false, or a 500 for something the client did.- App code that compares error messages as text.
- A status field anyone can set with PATCH, instead of actions with rules.
- Cursors the client builds itself, or a sort with no unique tie-breaker.
- Prices as floats in dollars, or timestamps with no time zone.
Practice
Page through orders without losing ties
Write nextPage(orders, limit, after). orders is a shop's order list, already sorted newest first: by placedAt descending, then id descending. after is null for the first page, or the cursor { placedAt, id } of the last order the app showed. Return { data, nextCursor }: up to limit orders that come strictly after the cursor, and the cursor of the last returned order, or null when no orders are left after this page. If limit is not an integer from 1 to 100, throw an error whose message contains limit. All placedAt values are UTC strings in the same format, so you can compare them with < and ===.
function nextPage(orders, limit, after) {
// orders: newest first (placedAt desc, then id desc)
const rest = after ? orders.filter((o) => o.placedAt < after.placedAt) : orders;
const data = rest.slice(0, limit);
const last = data[data.length - 1];
return { data, nextCursor: { placedAt: last.placedAt, id: last.id } };
}
const orders = [
{ id: 90420, placedAt: '2026-10-20T08:15:00Z' },
{ id: 90417, placedAt: '2026-10-20T08:15:00Z' },
{ id: 90412, placedAt: '2026-10-20T08:15:00Z' },
{ id: 90398, placedAt: '2026-10-20T07:58:00Z' },
{ id: 90371, placedAt: '2026-10-19T16:40:00Z' },
];
const ids = (page) => page.data.map((o) => o.id);
test('the first page has the newest orders and a cursor for the last one', () => {
const p = nextPage(orders, 2, null);
expect(ids(p)).toEqual([90420, 90417]);
expect(p.nextCursor).toEqual({ placedAt: '2026-10-20T08:15:00Z', id: 90417 });
});
test('an order sharing the timestamp of the cursor is not lost at the page edge', () => {
const p = nextPage(orders, 2, { placedAt: '2026-10-20T08:15:00Z', id: 90417 });
expect(ids(p)).toEqual([90412, 90398]);
});
test('the last page has nextCursor null', () => {
const p = nextPage(orders, 2, { placedAt: '2026-10-20T07:58:00Z', id: 90398 });
expect(ids(p)).toEqual([90371]);
expect(p.nextCursor).toBe(null);
});
test('a limit over 100 is rejected', () => {
expect(() => nextPage(orders, 500, null)).toThrow('limit');
});
Show a solution
function nextPage(orders, limit, after) {
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
throw new Error('limit must be an integer from 1 to 100');
}
// strictly after the cursor in a newest-first list: older time,
// or the same time with a smaller id (the tie-breaker)
const isAfter = (o) =>
o.placedAt < after.placedAt ||
(o.placedAt === after.placedAt && o.id < after.id);
const rest = after ? orders.filter(isAfter) : orders;
const data = rest.slice(0, limit);
const hasMore = rest.length > limit;
const last = data[data.length - 1];
return { data, nextCursor: hasMore ? { placedAt: last.placedAt, id: last.id } : null };
}Answer five partner-order situations
For each situation, write the status code, a type name if it is an error (such as order-not-cancellable), and one sentence on what the Partner app should do. (1) A shop places an order with a line for an article that is not on its price list. (2) Ravi cancels order 90412 a second time after the first cancel succeeded but the reply was lost. (3) Shop staff open /v1/orders/90377, which belongs to another shop. (4) A rep asks for a CSV of last month's orders for her 40 shops. (5) The app sends limit=500.
- Situation 1 is a business rule on well-formed input (422), not a schema error
- Situation 2 returns 200 with the cancelled order, because cancel is safe to repeat
- Situation 3 returns 404, so the other shop's order numbers do not leak
- Situation 4 returns 202 with a Location for the export, and 5 returns 400 with a pointer to the limit
- Each error names a type the app can branch on, never a message to parse
Show a model answer
(1) 422, type article-not-on-price-list, with an errors entry pointing at #/lines/3/articleId; the app marks that line and suggests removing it. (2) 200 with the order in CANCELLED; the app shows it as cancelled, exactly as after the first reply. (3) 404, type not-found; the app shows "Order not found". (4) 202 Accepted with Location: /v1/order-exports/77; the app polls that URL until the status is READY and then offers the download. (5) 400, type validation, pointer limit with "must be at most 100"; this is an app bug, so it shows a generic error and logs the requestId.
Check yourself
A rep's app lists orders for one shop with sort=-placedAt. The rep switches the sort to deliveryDate but the app keeps sending the old cursor. What should the API do?
The cursor's values only mean something in the sort it was made for, so it is invalid input: a 400 that tells the app to restart from the first page. Quietly returning page 1 hides an app bug and the rep sees repeated orders. 409 is for a resource's state, and a list that changes is normal for cursors.
Lagoon Digital asks for an endpoint that marks an order as "seen" by the shop. A developer proposes doing it inside GET /v1/orders/{orderId}. What is the main problem?
Clients, browsers and proxies assume a GET changes nothing and may send it at any time, so a hidden write produces wrong data. A separate POST /v1/orders/{orderId}/seen makes the change explicit. GETs can carry auth and return any body, and a new endpoint is an additive change.
Which planned change to the v1 order response can ship without risk to Partner app versions already on shop phones?
Old clients ignore a field they do not know, so a new optional response field is additive. A type change and a rename both break code that reads the old field, and a newly required input makes old app versions fail with 400 on every order.
Flashcards
Key takeaways
- Give each resource one URL. Nest only what cannot exist without its parent; use filters for everything people search across.
- Methods are promises: GET is safe, PUT and DELETE are idempotent, PATCH is partial, and state changes with rules are explicit actions.
- Run checks in a fixed order so the same situation always gets the same code, and answer every error with one problem details shape whose
typethe app branches on. - Cursors are opaque, include a unique tie-breaker, are validated on the way in, and come with a limit cap and a matching index.
- Integer cents, UTC timestamps, string ids and tolerant enums keep old app versions working while v1 grows.
Sources
- RFC 9457: Problem Details for HTTP APIs, IETF, 2023
- RFC 9110: HTTP Semantics, IETF, 2022: safe and idempotent methods, status codes, Location
- RFC 7396: JSON Merge Patch, IETF, 2014
- RFC 3339: Date and Time on the Internet: Timestamps, IETF, 2002
- Row and array comparisons, PostgreSQL documentation