API Review and Governance: A Review Checklist, Documenting Design Decisions and Designing the Bookstore Public API End to End
API review should happen on the contract pull request before code is written, with a written checklist and as advice rather than approval; everything tools can check is left to tools, and people look at meaning, consumers and security. At Bookstore we review a partner's draft with lint and people, turn a review finding into a new lint rule, collect the ADRs and the API inventory, design the purchase approval API end to end with real responses, and close the series.
Version note: The code in this post was built and run with C# 14 and .NET 10 (ASP.NET Core 10.0.15); the HTTP examples are real responses from this application, and the OpenAPI 3.2 document was linted with Redocly CLI 2.78.0 and Spectral 6.17.1.
Guess First
In Bookstore, the online bookstore used as the shared example across this site's series, some institutional orders must not ship until the institution's purchasing approver has approved them. Lakeside School's integration team writes and sends an API draft for this; it has passed their own tests. Part of the draft:
/approvePurchase:
post:
operationId: approvePurchase
requestBody:
content:
application/json:
schema:
type: object
properties:
orderId:
type: string
approved:
type: boolean
approverName:
type: string
responses:
'200':
description: success is false when the approval failed.
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
message:
type: string
After nine posts in this series, how many problems do you see in this part? How many of them would a lint tool catch, and how many can only a person see? The full draft and the review record are in the repository under docs/reviews/2026-11-purchase-approvals/.
API Review: Why and When?
In the first post we said that API design is a decision that is expensive to undo: changing the contract means changing everyone who uses it. Review should happen at the moment when this expensive decision is still cheap, before any code is written. In a review held after the endpoint has been written and tested, every problem found means either rework or a mistake that settles into the contract as "we will fix it in the next version". In Bookstore the review happens on the pull request that changes the contract, before implementation starts (BS-90).
Who reviews is a decision too. In large companies this belongs to a central API team or an "API guild". In a small team like Bookstore's, two people are enough: someone from the team that owns the API and, if possible, a consumer. In Lakeside School's draft the consumer was the author; Ayşe and Emre did the review. What matters is that the review is advice, not an approval board: the reviewer does not make the decision but shows whether it follows the style guide and earlier decisions. If it does not, and it should not, that goes on record as an ADR. How decisions are kept alive in a team, architecture reviews and the advice process are covered in the Architecture Decisions series post "Keeping Decisions Alive: Decision Logs, Architecture Reviews and the Advice Process".
The Review Checklist
The checklist is something like a summary of the nine posts of this series. Each row is a question tied to rules in the style guide; for rows that tools check, the reviewer reads the tool output and checks the rest personally:
| Area | Question | Rule |
|---|---|---|
| Consumers | Who will call it: bookstore-web, a partner, mobile? Is it public, partner or internal? | ADR-0001 |
| Resources | Names from the domain, one canonical URL, at most two path parameters, custom method or resource? | BS-01…BS-07 (lint) |
| Representation | camelCase, an absent field instead of null, Money objects, no internal fields, open response enums? | BS-10…BS-13, BS-52 |
| Methods and status codes | Are safe and idempotent methods used as such; 201/202/204/400/409/412/422/428 as in the guide? | BS-40…BS-46 |
| Errors | Problem Details with catalog types; new types in the catalog; validation keys as JSON paths? | BS-20…BS-25 (lint) |
| Collections | Paginated from the first version, sort and filters documented, unknown parameter 400? | BS-30…BS-37 (lint) |
| Evolution | Additive? If oasdiff reports a breaking change, is there a new api-version or an ADR? | BS-50…BS-55, BS-82 (CI) |
| Concurrency | Do changes to existing resources require If-Match; do creates with side effects require Idempotency-Key? | BS-60…BS-62 |
| Long work and bulk | 202 and a status monitor? Size limit, line report, quota? | BS-63…BS-76 (lint) |
| Security | Does identity come from the token, not the body; is there authorization for every item? | BS-91, API Security |
| Documentation | Descriptions, realistic examples per status code, webhooks written down? | BS-83 |
| Decisions | Deviations from the guide recorded as ADRs, open questions listed? | ADR-0001 |
The checklist's length is a risk: reading a twelve-row list from top to bottom on every small pull request soon turns into a formality. The way to prevent that is to leave everything that can be checked to tools. The CI chain from post 09 checks almost half of this table before it reaches the reviewer; the person looks where the tools cannot: meaning, consumers, security and consistency.
Reviewing the Draft: Tools and People
We ran the draft through lint first. The first time it ran, lint reported four errors; the fifth error below comes from a rule we added after the review:
$ npx spectral lint docs/reviews/2026-11-purchase-approvals/draft.yaml -D
/home/user/bookstore/docs/reviews/2026-11-purchase-approvals/draft.yaml
7:17 error bookstore-no-verbs-in-path Verb in path: /getApprovals paths./getApprovals
7:17 error bookstore-path-kebab-case Path is not kebab-case: /getApprovals paths./getApprovals
22:23 error bookstore-no-bare-arrays #/paths/~1getApprovals/get/responses/200/content/application~1json/schema/type returns a bare array paths./getApprovals.get.responses[200].content.application/json.schema.type
25:20 error bookstore-path-kebab-case Path is not kebab-case: /approvePurchase paths./approvePurchase
52:74 error bookstore-max-two-path-params Too deep: /institutions/{institutionId}/units/{unitId}/orders/{orderId}/approval paths./institutions/{institutionId}/units/{unitId}/orders/{orderId}/approval
✖ 5 problems (5 errors, 0 warnings, 0 infos, 0 hints)
Lint caught the verbs in the URLs, the camelCase and the path with three path parameters. The review found these:
- The approver arrives in the body, as
approverName. Any client can fill that in as it likes; the approver is the user in the token (BS-91). approved: booleanhides two different operations in one field, and rejecting has no reason.:approveand a:rejectthat takes a reason should be separate custom methods (the second post).- Every error returns 200 with
success: false; the same mistake as the old RPC endpoint from the first post. 403, 404, 409, 412 and 428 should be returned with Problem Details (03 and 04). - Two approvers can decide at the same time; the decision should be a conditional write (07).
getApprovalsreturns every approval in one array, without pagination (05). Lint missed it, because theBS-30rule only looked atoperationIds starting withlist.- The status values are
WAITING,OK,NOT_OKand a closed enum (06). - An approval has its own id and lifecycle; its canonical address should be
/purchase-approvals/{approvalId}, and the list under the institution a filtered view (02). - The partner has to learn about the decision by polling; a webhook should be described (08).
The fifth item became a rule: every operation whose 2xx response is a bare array now fails, because neither a page token nor a new field can be added to a bare array later. This is the by-product of a good review: every mistake a person finds that can happen again is left to the tool next time. The first time the rule ran, it found something in our own contract too:
$ npx spectral lint contract/bookstore.openapi.yaml
/home/user/bookstore/contract/bookstore.openapi.yaml
1075:23 error bookstore-no-bare-arrays #/paths/~1problems/get/responses/200/content/application~1json/schema/type returns a bare array paths./problems.get.responses[200].content.application/json.schema.type
✖ 1 problem (1 error, 0 warnings, 0 infos, 0 hints)
The problem type catalog (GET /problems) has returned an unpaginated bare array since post 05, and there is an ADR for that (ADR-0003). Turning it into an object now would break every client, so we turned the rule off for that operation and added a line to ADR-0003. A new rule can clash with old decisions; the fix is not to loosen the rule but to record the exception with its reason.
Documenting Decisions: ADRs and Exceptions
Throughout the series we wrote every important decision into an ADR. The decision log of the Bookstore API today:
- ADR-0001: The style guide is based on the Azure guidelines, with two exceptions: RFC 9457 Problem Details for errors, and Google's AIP-158 for pagination (01).
- ADR-0002: One-shot state changes are custom methods; things with a lifecycle are resources (02).
- ADR-0003: The problem type catalog is not paginated (05); one more exception was added in this post.
- ADR-0004: Additive changes without a version, breaking changes with a dated
api-version; the parameter is optional and defaults to the oldest version (06). - ADR-0005: Required If-Match, Idempotency-Key and long-running operations with a status monitor; the deviation from Azure of starting with POST (07).
- ADR-0006: Bulk orders, partial success, a report instead of 207, and the RateLimit draft (08).
- ADR-0007: Design-first and the four checks in CI; the OpenAPI 3.2 gaps of the tools (09).
- ADR-0008: Purchase approvals and the API review process (this post).
This list is the answer to a new team member's "why is it like this?". The style guide states the rule; the ADR says why the rule was made and which options were rejected. Deviating from a rule is allowed; deviating silently is not. Writing ADRs, their templates and how detailed an ADR should be are covered in the Architecture Decisions series post "Writing ADRs".
The API Inventory
The most neglected part of governance is knowing which APIs exist. In Bookstore, docs/api-catalog.md keeps every API surface on one line: its audience (public, bookstore-web, partners), the team that owns it, its contract and its lifecycle status. The old RPC endpoint is there too: deprecated, sunset date 31 March 2027, usage logged per client. The drift check from post 09 keeps this list current: if an endpoint that is not in the contract is added to the code, CI breaks. The security side of an inventory, why forgotten and undocumented endpoints are valuable to attackers, is covered in the API Security series post on misconfiguration and inventory.
End to End: Bookstore's Partner API
After the review the design became this (ADR-0008). A purchase approval is created for every pending institutional order. Its canonical address is /purchase-approvals/{approvalId}; /institutions/{institutionId}/purchase-approvals is a paginated view filtered by status. The decision is two custom methods: :approve and :reject, which asks for a reason. Only the institution's approver can decide, the decision requires If-Match, and an approval that has been decided cannot be decided again. The contract was written first, passed lint and review, and the Prism mock was given to Lakeside's team; the code was written after that. The flow on the real API:
$ curl -s -H "Authorization: Bearer linus" "http://127.0.0.1:52318/institutions/lakeside-school/purchase-approvals?status=Pending" | jq -c '.items[] | {id, orderId, unitId, amount: .amount.amount, status}'
{"id":"01a1c112-d7c0-7a06-81ce-e75908702949","orderId":"01a1c112-d7c0-706c-8cb9-c18a8fadc1a6","unitId":"main-campus","amount":7884.00,"status":"Pending"}
$ APPROVAL=$(curl -s -H "Authorization: Bearer linus" "http://127.0.0.1:52318/institutions/lakeside-school/purchase-approvals?status=Pending" | jq -r '.items[0].id')
$ curl -s -i -H "Authorization: Bearer linus" http://127.0.0.1:52318/purchase-approvals/$APPROVAL | grep -E '^(HTTP|ETag)'
HTTP/1.1 200 OK
ETag: "1"
$ curl -s -X POST -H "Authorization: Bearer nevin" -H 'If-Match: "1"' "http://127.0.0.1:52318/purchase-approvals/${APPROVAL}:approve" | jq -c '{status, type, detail}'
{"status":403,"type":"https://api.example.com/problems/approver-required","detail":"Only an approver of the institution can decide on a purchase approval."}
$ curl -s -X POST -H "Authorization: Bearer linus" "http://127.0.0.1:52318/purchase-approvals/${APPROVAL}:approve" | jq -c '{status, type}'
{"status":428,"type":"https://api.example.com/problems/precondition-required"}
$ curl -s -i -X POST -H "Authorization: Bearer linus" -H 'If-Match: "1"' "http://127.0.0.1:52318/purchase-approvals/${APPROVAL}:approve" | grep -E '^(HTTP|ETag)|^\{'
HTTP/1.1 200 OK
ETag: "2"
{"id":"01a1c112-d7c0-7a06-81ce-e75908702949","orderId":"01a1c112-d7c0-706c-8cb9-c18a8fadc1a6","institutionId":"lakeside-school","unitId":"main-campus","amount":{"amount":7884.00,"currency":"TRY"},"status":"Approved","requestedAt":"2026-11-09T16:10:00Z","decidedBy":"linus","decidedAt":"2026-11-14T23:45:20.599698Z"}
$ curl -s -H "Authorization: Bearer linus" http://127.0.0.1:52318/orders/01a1c112-d7c0-706c-8cb9-c18a8fadc1a6 | jq -c '{id, status}'
{"id":"01a1c112-d7c0-706c-8cb9-c18a8fadc1a6","status":"Confirmed"}
$ curl -s -X POST -H "Authorization: Bearer linus" -H 'If-Match: "2"' "http://127.0.0.1:52318/purchase-approvals/${APPROVAL}:approve" | jq -c '{status, type, detail}'
{"status":409,"type":"https://api.example.com/problems/approval-already-decided","detail":"Purchase approval 01a1c112-d7c0-7a06-81ce-e75908702949 is already Approved."}
Nevin Hanım can see the approval because it is her own unit's order; that is why she gets a 403 rather than a 404 when she tries to decide (BS-45). Linus's request without the header gets a 428, his request with the right ETag gets a 200, and the order moves to Confirmed. A second decision on a decided approval is a 409. Rejecting works the same way, with a reason:
$ curl -s -X POST -H "Authorization: Bearer nevin" -H "Content-Type: application/json" -H 'Idempotency-Key: "lakeside-2026-11-extra"' -d '{"unitId":"main-campus","lines":[{"isbn":"9780553293357","quantity":6}]}' http://127.0.0.1:52318/institutions/lakeside-school/order-batches | jq -c '{orderId, acceptedLines}'
{"orderId":"01a1dc73-aec0-71e7-a2f9-67755d94f47d","acceptedLines":1}
$ APPROVAL2=$(curl -s -H "Authorization: Bearer linus" "http://127.0.0.1:52318/institutions/lakeside-school/purchase-approvals?status=Pending" | jq -r '.items[0].id')
$ curl -s -X POST -H "Authorization: Bearer linus" -H 'If-Match: "1"' -H "Content-Type: application/json" -d '{"reason":"Foundation is already in the east campus library."}' "http://127.0.0.1:52318/purchase-approvals/${APPROVAL2}:reject" | jq -c '{status, decidedBy, reason}'
{"status":"Rejected","decidedBy":"linus","reason":"Foundation is already in the east campus library."}
$ curl -s -H "Authorization: Bearer nevin" "http://127.0.0.1:52318/orders/$(curl -s -H "Authorization: Bearer nevin" http://127.0.0.1:52318/purchase-approvals/$APPROVAL2 | jq -r .orderId)" | jq -c '{status, total: .total.amount}'
{"status":"Cancelled","total":1614.60}
This small flow uses almost every decision of the series together: a resource and custom methods from the domain, problem types from the catalog, a paginated and filtered list, an open status list, a conditional write, a bulk order with an Idempotency-Key, and a planned purchaseApprovalDecided webhook described in the contract. The open decisions written into the review record are part of the design too: whether two approvers are needed above a certain amount (Release 2), whether an approval expires, and whether a buyer can withdraw a pending request. We do not have to decide these now, but having them in writing makes us think today about whether a future change will be additive or breaking. Withdrawal, for example, can be added later as a custom method, and because the approval status list is open, a new Withdrawn value is an additive change too.
In ASP.NET Core
The approval endpoints are built from the same pieces as the rest of the series: route groups, TypedResults, ProblemCatalog.Create from the catalog, the Paging class from post 05, and .NET 10's AddValidation for the bound body. The decision itself makes three checks under a single lock (is there an If-Match, does it match, is the approval still pending) and changes the order's status at the same time; in an application with a database this is done with a single transaction and a rowversion. The role check is deliberately inside the endpoint: returning a 403 rather than a 404 to a user who "can see the approval but cannot decide" is a decision that can only be made by looking at the resource, not with a policy alone. This kind of resource-based authorization is covered in the ASP.NET Core Web API series post "Authorization in ASP.NET Core Web API: Roles, Policies and Resource-Based Authorization".
Common Pitfalls
Turning governance into approval bureaucracy. If every pull request waits for a committee, teams avoid review: either they do not write the API into the contract or they split the change into small pieces that slip through. Review should be fast, based on written rules and advisory.
Reviewing after implementation is done. A design mistake found when the code is finished goes into the contract as "we will fix it later" and stays there until the next version.
Filling the checklist with things a tool can check. The reviewer's attention belongs to the questions of meaning and security that the tool cannot see. A mistake found again and again in review should become a lint rule.
Accepting exceptions silently. Every exception without a written reason becomes an example the next team copies because "it was done like this here too".
Writing the inventory once and leaving it. An inventory without a drift check loses touch with reality at the first urgent fix.
What We Did in This Series and Where to Go from Here
Over ten posts we designed Bookstore's API through the decisions made before writing code: why a contract is an architectural decision and our own style guide, resources and URLs, methods and status codes, the RFC 9457 error contract, collections, versioning and deprecation, concurrency, idempotency and long-running operations, bulk operations and quotas, the design-first flow, and finally review and governance. Behind every decision there is a style guide rule, behind most of them an ADR, and behind all of them a real HTTP response; the snapshots contain the complete code at the end of each post.
This series covered design and kept implementation details short on purpose. Two series go deeper into the code. The ASP.NET Core Web API series covers the framework side of the same decisions; its latest posts in particular are worth reading next to this series: "Working with HttpContext, HttpRequest and HttpResponse: Headers, Cookies, Query, Body, Items and Features", the two posts on Minimal APIs in depth (parameter binding, and route groups, endpoint filters and TypedResults), "JSON, Content Negotiation and Formatters", "A Catalog of Built-in Middleware" and "Localization in Web APIs". The Solution Patterns series covers the database decisions behind the contract: "Exposing Ids", "Money and Rounding", "Time and Time Zones", "Idempotency Keys", "Optimistic Concurrency End to End", "Pagination Strategies in Depth" and "Bulk Import with a Validation Report". When you wonder how a contract decision from this series is reflected in the database and the code, the relevant posts of the two series can be read side by side.
A Small Experiment
★☆☆ Use the checklist to review the invoice endpoint from post 07. Which rows were checked by tools, and which did you look at yourself? Did you find a problem?
★★☆ Design a "withdraw" feature for approvals: a :withdraw custom method, who may call it, when it returns 409, and the new status value. Write the change into the contract and run it through lint and oasdiff. Does oasdiff count it as breaking?
★★★ Orders above a certain amount should need two approvers. How would you model that without breaking the current contract: a steps list on the approval object, or a separate resource? Write your decision and the options you rejected as ADR-0009.
This post completes the REST API Design series. When designing an API you can use the order of this series as a checklist: think about the consumer and the contract first, then resources, methods, errors and collections; write evolution, concurrency and long-running work into the contract from the start; and protect it all with a tool chain and a short, written review. For the details of the code, the ASP.NET Core Web API and Solution Patterns series are waiting for you.