{"playbook":"API design","url":"https://agentwranglers.se/playbook/api-design","updated":"2026-10-21","license":"https://creativecommons.org/licenses/by/4.0/","credit":"Required by the licence: credit Agent Wranglers with a link to the rule or playbook you used.","how":"Each rule's How text is on its own page: add .md to its url.","rules":[{"chapter":"Resources and methods","name":"Name resources as plural nouns","level":"Recommended","rule":"Address each kind of thing as a plural noun, and each single thing by its ID under it. Keep verbs out of paths.","check":"A linter such as Spectral passes on the OpenAPI file with three custom rules. Paths are lowercase with hyphens, no segment is a verb such as `get` or `create`, and no path nests deeper than one parent.","url":"https://agentwranglers.se/playbook/api-design/resources-and-methods/resource-names","related":["/playbook/api-design/resources-and-methods/stable-addresses"],"sources":[]},{"chapter":"Resources and methods","name":"Use each method for what it means","level":"Recommended","rule":"Read with GET, create with POST, replace with PUT, change part with PATCH and remove with DELETE. GET and HEAD never change stored data.","check":"A test snapshots the stored data, calls every GET route twice and snapshots again. Nothing changed. Another test proves a repeated PUT or DELETE leaves the same state as one call.","url":"https://agentwranglers.se/playbook/api-design/resources-and-methods/http-methods","related":["/playbook/api-design/reliability/idempotency-keys","/playbook/adversarial-testing/access-and-accounts/cross-site-requests"],"sources":[{"label":"RFC 9110 section 9.2 Common Method Properties","url":"https://www.rfc-editor.org/rfc/rfc9110#section-9.2"},{"label":"RFC 5789 PATCH Method for HTTP","url":"https://www.rfc-editor.org/rfc/rfc5789"}]},{"chapter":"Resources and methods","name":"Answer with the right status code","level":"Recommended","rule":"Pick the status code that matches what happened. Never answer 200 with an error inside the body.","check":"A contract test sends a malformed body, a missing token, a token without access, an unknown ID and a valid create. It gets 400, 401, 403, 404 and 201 with `Location`, in that order.","url":"https://agentwranglers.se/playbook/api-design/resources-and-methods/status-codes","related":["/playbook/website/redirects-and-canonicals/redirect-codes","/playbook/operations/metrics-and-traces/red-metrics","/playbook/api-design/resources-and-methods/method-not-allowed","/playbook/api-design/requests-and-responses/strict-input","/playbook/api-design/errors/validation-errors"],"sources":[{"label":"RFC 9110 section 15 Status Codes","url":"https://www.rfc-editor.org/rfc/rfc9110#section-15"},{"label":"RFC 6585 Additional HTTP Status Codes","url":"https://www.rfc-editor.org/rfc/rfc6585"}]},{"chapter":"Resources and methods","name":"Return 405 with the allowed methods","level":"Recommended","rule":"When a path exists but the method doesn't apply, answer 405 and list what works in the `Allow` header.","check":"`curl -i -X DELETE https://api.example.com/orders` returns 405 with `Allow: GET, POST`.","url":"https://agentwranglers.se/playbook/api-design/resources-and-methods/method-not-allowed","related":["/playbook/api-design/resources-and-methods/status-codes"],"sources":[{"label":"RFC 9110 section 15.5.6 405 Method Not Allowed","url":"https://www.rfc-editor.org/rfc/rfc9110#section-15.5.6"},{"label":"RFC 9110 section 10.2.1 Allow","url":"https://www.rfc-editor.org/rfc/rfc9110#section-10.2.1"}]},{"chapter":"Resources and methods","name":"Keep addresses stable","level":"Recommended","applies":["public-api"],"rule":"Never change what an address means once clients use it. Add new addresses instead.","check":"Rename a test customer in a test. Every address that pointed to that customer before the rename still answers 200 or 308.","url":"https://agentwranglers.se/playbook/api-design/resources-and-methods/stable-addresses","related":["/playbook/api-design/resources-and-methods/resource-names","/playbook/website/when-things-break/deprecation-and-sunset"],"sources":[{"label":"W3C Cool URIs don't change","url":"https://www.w3.org/Provider/Style/URI"},{"label":"RFC 9110 section 15.4.9 308 Permanent Redirect","url":"https://www.rfc-editor.org/rfc/rfc9110#section-15.4.9"}]},{"chapter":"Requests and responses","name":"Use JSON with one naming style","level":"Recommended","rule":"Send and accept JSON with `Content-Type: application/json`, and name every field in one style across the whole API.","check":"A linter such as Spectral, with a casing rule, passes on the OpenAPI file. A request with `Content-Type: text/plain` gets 415.","url":"https://agentwranglers.se/playbook/api-design/requests-and-responses/json-conventions","related":["/playbook/architecture/data-shapes/convert-at-the-edge"],"sources":[{"label":"RFC 8259 section 8.1 Character Encoding","url":"https://www.rfc-editor.org/rfc/rfc8259#section-8.1"},{"label":"RFC 9110 section 15.5.16 415 Unsupported Media Type","url":"https://www.rfc-editor.org/rfc/rfc9110#section-15.5.16"}]},{"chapter":"Requests and responses","name":"Send dates as RFC 3339 with a time zone","level":"Recommended","rule":"Write every moment in time as an RFC 3339 string with a zone or offset. Write a calendar date with no time as `YYYY-MM-DD`.","check":"Turn on format checks in the validator. A JSON Schema with `format: date-time` validates every response in the contract tests, and no timestamp lacks `Z` or an offset. A request with the time zone `EST` gets a 400 that names the place form, such as `America/New_York`.","url":"https://agentwranglers.se/playbook/api-design/requests-and-responses/dates-and-times","related":["/playbook/website/seo/publication-dates","/playbook/website/languages-and-regions/locale-content","/playbook/app-ui/dates-and-numbers/user-time-zone","/playbook/app-ui/dates-and-numbers/calendar-dates"],"sources":[{"label":"RFC 3339 Date and Time on the Internet","url":"https://www.rfc-editor.org/rfc/rfc3339"}]},{"chapter":"Requests and responses","name":"Send money as an integer with its currency","level":"Required","risk":"money","applies":["payments"],"rule":"Send amounts as an integer count of the currency's smallest unit, together with the ISO 4217 currency code. Never use floating-point numbers for money.","check":"Search the OpenAPI file for money fields typed `number`. None remain, and each amount has a `currency` beside it.","url":"https://agentwranglers.se/playbook/api-design/requests-and-responses/money","related":["/playbook/architecture/data-shapes/value-objects","/playbook/data/schema-design/exact-types","/playbook/payments/amounts-and-orders/money-in-minor-units","/playbook/payments/amounts-and-orders/allocate-remainders","/playbook/app-ui/dates-and-numbers/locale-number-input"],"sources":[]},{"chapter":"Requests and responses","name":"Send IDs as strings","level":"Recommended","rule":"Type every ID as a string, even when it looks like a number today.","check":"The OpenAPI file declares every `id` and `...Id` field as `type: string`.","url":"https://agentwranglers.se/playbook/api-design/requests-and-responses/string-ids","related":["/playbook/data/schema-design/stable-keys"],"sources":[{"label":"RFC 8259 section 6 Numbers","url":"https://www.rfc-editor.org/rfc/rfc8259#section-6"}]},{"chapter":"Requests and responses","name":"Give every response one shape","level":"Recommended","rule":"Return the same fields of the same types every time, and describe them in the contract. Leave out a field only when the contract says it is optional.","check":"Contract tests validate every response against its schema with `additionalProperties: false`, and they pass. Schemas built with `allOf` use `unevaluatedProperties: false` instead, because `additionalProperties` can't see fields from the other parts.","url":"https://agentwranglers.se/playbook/api-design/requests-and-responses/response-shape","related":["/playbook/architecture/data-shapes/one-return-type","/playbook/adversarial-testing/data-and-secrets/only-needed-fields"],"sources":[{"label":"JSON Schema 2020-12","url":"https://json-schema.org/draft/2020-12"}]},{"chapter":"Requests and responses","name":"Use enums for fixed values","level":"Recommended","rule":"Give every field with a fixed set of values an enum in the contract, and document what each value means.","check":"Every status or type field in the OpenAPI file has `enum`, and a client test with an unknown value does not crash.","url":"https://agentwranglers.se/playbook/api-design/requests-and-responses/api-enums","related":["/playbook/architecture/data-shapes/enums-not-strings"],"sources":[{"label":"JSON Schema 2020-12 Validation","url":"https://json-schema.org/draft/2020-12/json-schema-validation"}]},{"chapter":"Requests and responses","name":"Accept only fields you know","level":"Required","risk":"takeover","rule":"Validate every request body, query parameter and path parameter against its schema at the edge. Reject unknown fields and wrong types with a clear error.","check":"Send a body with an extra field `\"role\": \"admin\"` and one with a string where a number belongs. Both get 422 naming the field, as the [status codes](https://agentwranglers.se/playbook/api-design/resources-and-methods/status-codes) table sets.","url":"https://agentwranglers.se/playbook/api-design/requests-and-responses/strict-input","related":["/playbook/architecture/data-shapes/parse-at-the-boundary","/playbook/adversarial-testing/access-and-accounts/mass-assignment","/playbook/adversarial-testing/abuse-and-limits/resource-limits","/playbook/api-design/resources-and-methods/status-codes","/playbook/adversarial-testing/injection/prototype-pollution","/playbook/stack/web-frameworks/zod-pydantic"],"sources":[{"label":"OWASP Input Validation Cheat Sheet","url":"https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html"},{"label":"JSON Schema 2020-12","url":"https://json-schema.org/draft/2020-12"}]},{"chapter":"Requests and responses","name":"Say which format PATCH accepts","level":"Recommended","rule":"Accept PATCH bodies in one documented patch format, and name it in the `Content-Type` header.","check":"A PATCH that changes one field leaves every other field unchanged. A PATCH sent as `text/plain` gets 415 with `Accept-Patch`.","url":"https://agentwranglers.se/playbook/api-design/requests-and-responses/patch-format","related":["/playbook/adversarial-testing/access-and-accounts/mass-assignment"],"sources":[{"label":"RFC 7396 JSON Merge Patch","url":"https://www.rfc-editor.org/rfc/rfc7396"},{"label":"RFC 6902 JSON Patch","url":"https://www.rfc-editor.org/rfc/rfc6902"},{"label":"RFC 5789 section 3.1 The Accept-Patch Header","url":"https://www.rfc-editor.org/rfc/rfc5789#section-3.1"}]},{"chapter":"Requests and responses","name":"Set caching headers on every response","level":"Required","risk":"data","rule":"Send an explicit `Cache-Control` header on every API response. Send `no-store` on any API response with personal or account data.","check":"A contract test calls every route and finds `Cache-Control` on each answer. Every route that needs a token answers with `no-store`.","url":"https://agentwranglers.se/playbook/api-design/requests-and-responses/api-cache-headers","related":["/playbook/website/performance/cache-control","/playbook/adversarial-testing/requests-and-files/private-responses-uncached"],"sources":[{"label":"RFC 9111 section 5.2 Cache-Control","url":"https://www.rfc-editor.org/rfc/rfc9111#section-5.2"}]},{"chapter":"Requests and responses","name":"Stream in a standard format and describe each item","level":"Recommended","rule":"When an answer arrives in parts, stream it as server-sent events or as JSON Lines. Describe each item with `itemSchema` in the OpenAPI contract.","check":"Every `text/event-stream` and `application/jsonl` response in the contract has an `itemSchema`. A contract test reads a whole stream and validates each item against it. A test that reconnects with `Last-Event-ID` gets only the events after that ID.","url":"https://agentwranglers.se/playbook/api-design/requests-and-responses/streamed-responses","related":["/playbook/api-design/errors/problem-details","/playbook/ai-features/latency-cost-and-fallbacks/stream-answers"],"sources":[{"label":"OpenAPI 3.2.1 Streaming Sequential Media Types","url":"https://spec.openapis.org/oas/v3.2.1.html#streaming-sequential-media-types"},{"label":"HTML Standard The Last-Event-ID header","url":"https://html.spec.whatwg.org/multipage/server-sent-events.html#the-last-event-id-header"},{"label":"HTML Standard Server-sent events authoring notes","url":"https://html.spec.whatwg.org/multipage/server-sent-events.html#authoring-notes"}]},{"chapter":"Errors","name":"Send errors as problem details","level":"Recommended","rule":"Answer every 4xx and 5xx with an RFC 9457 problem details object, served as `application/problem+json`.","check":"Trigger a 400, a 404 and a 409 in contract tests. Each has `Content-Type: application/problem+json` and the four fields, and `status` equals the response code.","url":"https://agentwranglers.se/playbook/api-design/errors/problem-details","related":["/playbook/architecture/errors-and-nulls/exceptions-not-error-codes","/playbook/architecture/errors-and-nulls/domain-exceptions","/playbook/api-design/requests-and-responses/streamed-responses","/playbook/ai-features/structured-output-and-tools/tool-errors-for-models","/playbook/app-ui/data-views/error-in-place"],"sources":[{"label":"RFC 9457 Problem Details for HTTP APIs","url":"https://www.rfc-editor.org/rfc/rfc9457"}]},{"chapter":"Errors","name":"Keep error types stable","level":"Recommended","applies":["public-api"],"rule":"Treat each error `type` as part of the contract. Clients branch on it, so never rename or reuse one.","check":"A test lists the problem types the code can return. Each one appears in the contract and its URI answers 200.","url":"https://agentwranglers.se/playbook/api-design/errors/stable-error-types","related":["/playbook/architecture/errors-and-nulls/domain-exceptions"],"sources":[{"label":"RFC 9457 section 3 Members of a Problem Details Object","url":"https://www.rfc-editor.org/rfc/rfc9457#section-3"}]},{"chapter":"Errors","name":"Name every field that failed","level":"Recommended","rule":"When input fails validation, return every failing field at once, with where it is and why it failed.","check":"Send a body with two invalid fields. The answer lists both, with pointers such as `#/items/0/quantity`.","url":"https://agentwranglers.se/playbook/api-design/errors/validation-errors","related":["/playbook/architecture/data-shapes/parse-at-the-boundary","/playbook/api-design/resources-and-methods/status-codes","/playbook/app-ui/forms/server-errors-on-fields","/playbook/app-ui/forms/server-side-validation"],"sources":[{"label":"RFC 9457 Problem Details for HTTP APIs","url":"https://www.rfc-editor.org/rfc/rfc9457"}]},{"chapter":"Errors","name":"Hide internals in errors","level":"Recommended","rule":"Never put stack traces, queries, file paths or server versions in an error. Log them, and return a reference the client can quote.","check":"Force a server error in staging. The body holds only the problem fields and a reference, and the log holds the trace under that reference.","url":"https://agentwranglers.se/playbook/api-design/errors/no-internal-details","related":["/playbook/operations/logs/request-ids","/playbook/adversarial-testing/data-and-secrets/short-errors"],"sources":[{"label":"OWASP Error Handling Cheat Sheet","url":"https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html"}]},{"chapter":"Errors","name":"Say when to retry","level":"Recommended","rule":"For 429 and 503, send `Retry-After` so clients know how long to wait.","check":"Exceed the rate limit in a test. The 429 answer has `Retry-After` with a whole number of seconds. A forced 503, such as during maintenance mode, carries it too.","url":"https://agentwranglers.se/playbook/api-design/errors/retry-after","related":["/playbook/website/security/rate-limits"],"sources":[{"label":"RFC 9110 section 10.2.3 Retry-After","url":"https://www.rfc-editor.org/rfc/rfc9110#section-10.2.3"},{"label":"RFC 6585 section 4 429 Too Many Requests","url":"https://www.rfc-editor.org/rfc/rfc6585#section-4"}]},{"chapter":"Lists","name":"Page lists with cursors","level":"Recommended","rule":"Return every list that can grow past one page in pages, and point to the next page with an opaque cursor, not a page number.","check":"Create items while a test pages through a list. Every item appears exactly once, and the last page has no next cursor. A tampered cursor gets 400.","url":"https://agentwranglers.se/playbook/api-design/lists/cursor-pagination","related":["/playbook/data/queries-and-indexes/keyset-pagination","/playbook/app-ui/lists-and-tables/bounded-lists","/playbook/app-ui/lists-and-tables/load-more-button","/playbook/notifications/in-app-notifications/notification-inbox"],"sources":[{"label":"RFC 8288 Web Linking","url":"https://www.rfc-editor.org/rfc/rfc8288"}]},{"chapter":"Lists","name":"Cap the page size","level":"Recommended","rule":"Set a default page size and a maximum. Clamp or reject anything above the maximum.","check":"Request `?limit=100000`. The answer holds at most the maximum, or a 400 problem names the limit.","url":"https://agentwranglers.se/playbook/api-design/lists/page-size-limit","related":["/playbook/adversarial-testing/abuse-and-limits/resource-limits","/playbook/adversarial-testing/hostile-agents-and-bots/limit-bulk-reads"],"sources":[{"label":"OWASP API4:2023 Unrestricted Resource Consumption","url":"https://api-security.owasp.org/editions/2023/en/0xa4-unrestricted-resource-consumption/"}]},{"chapter":"Lists","name":"Filter with plain parameters","level":"Recommended","rule":"Let clients filter lists by named query parameters, and document each one with its type.","check":"A request with `?stauts=shipped` returns 400 naming the unknown parameter.","url":"https://agentwranglers.se/playbook/api-design/lists/filtering","related":["/playbook/app-ui/lists-and-tables/server-sort-and-filter"],"sources":[]},{"chapter":"Lists","name":"Sort in a stable order","level":"Recommended","rule":"Sort every list in a documented, stable order, and let clients choose from a fixed set of sort keys.","check":"Two identical list requests return items in the same order, and an unlisted sort field gets 400.","url":"https://agentwranglers.se/playbook/api-design/lists/sorting","related":["/playbook/app-ui/lists-and-tables/server-sort-and-filter"],"sources":[]},{"chapter":"Lists","name":"Return empty lists as empty","level":"Recommended","rule":"Answer an empty result with 200 and an empty array. Never answer 404 or `null` for a list with nothing in it.","check":"List the orders of a test customer with no orders. The answer is 200 with `\"items\": []`.","url":"https://agentwranglers.se/playbook/api-design/lists/empty-lists","related":["/playbook/architecture/errors-and-nulls/never-return-null","/playbook/app-ui/data-views/empty-states"],"sources":[]},{"chapter":"Lists","name":"Search with QUERY when the filter outgrows the address","level":"Optional","rule":"Send a search that is too long for query parameters, or that holds personal data, in the body of a QUERY request. Never send it as a GET with a body.","check":"Send the same `QUERY /orders` with a JSON body twice. Both answers are 200 with the same items, and no stored data changed. The request without `Content-Type` gets 400, and one sent as `text/plain` gets 415 with `Accept-Query`.","url":"https://agentwranglers.se/playbook/api-design/lists/query-method","related":["/playbook/api-design/api-security/no-secrets-in-urls"],"sources":[{"label":"RFC 10008 The HTTP QUERY Method","url":"https://www.rfc-editor.org/rfc/rfc10008"},{"label":"RFC 10008 section 2.1 Media Types and Content Negotiation","url":"https://www.rfc-editor.org/rfc/rfc10008#section-2.1"}]},{"chapter":"Changes over time","name":"Make only additive changes","level":"Recommended","applies":["public-api"],"rule":"Change a live API only by adding optional inputs, response fields, endpoints or enum values. Never remove, rename or retype what clients use.","check":"A tool such as oasdiff compares the new OpenAPI file with the released one, and the build fails on any breaking change.","url":"https://agentwranglers.se/playbook/api-design/changes-over-time/additive-changes","related":["/playbook/api-design/changes-over-time/tolerant-clients","/playbook/testing/integration-and-contract-tests/contract-tests","/playbook/api-design/webhooks-and-events/event-versions"],"sources":[]},{"chapter":"Changes over time","name":"Version only for breaking changes","level":"Recommended","applies":["public-api"],"rule":"Pick one versioning scheme, and release a new major version only when a break can't be avoided.","check":"The contract states the scheme, and every route answers under exactly one version.","url":"https://agentwranglers.se/playbook/api-design/changes-over-time/versioning","related":["/playbook/website/when-things-break/deprecation-and-sunset"],"sources":[]},{"chapter":"Changes over time","name":"Announce deprecation and sunset in headers","level":"Recommended","applies":["public-api"],"rule":"Mark a retiring endpoint with the `Deprecation` header and its shutdown date with `Sunset`, and link to the migration guide.","check":"`curl -sI https://api.example.com/v1/orders` shows `Deprecation`, `Sunset` and the `Link` to the guide.","url":"https://agentwranglers.se/playbook/api-design/changes-over-time/deprecation-headers","related":["/playbook/website/when-things-break/deprecation-and-sunset","/playbook/api-design/api-security/inventory"],"sources":[{"label":"RFC 9745 The Deprecation HTTP Response Header Field","url":"https://www.rfc-editor.org/rfc/rfc9745"},{"label":"RFC 8594 The Sunset HTTP Header Field","url":"https://www.rfc-editor.org/rfc/rfc8594"}]},{"chapter":"Changes over time","name":"Tell clients to ignore what they don't know","level":"Recommended","applies":["public-api"],"rule":"Document that clients must ignore unknown fields and handle unknown enum values, so additions never break them.","check":"An SDK test feeds a response with an extra field and a new enum value. The client keeps working.","url":"https://agentwranglers.se/playbook/api-design/changes-over-time/tolerant-clients","related":["/playbook/api-design/changes-over-time/additive-changes","/playbook/architecture/data-shapes/parse-at-the-boundary"],"sources":[]},{"chapter":"Changes over time","name":"Keep a changelog","level":"Recommended","applies":["public-api"],"rule":"Publish a dated changelog for the API, with every addition, deprecation and removal.","check":"The last three contract changes each have a dated changelog entry.","url":"https://agentwranglers.se/playbook/api-design/changes-over-time/api-changelog","related":["/playbook/engineering/documentation/docs-with-the-change","/playbook/engineering/documentation/user-changelog"],"sources":[]},{"chapter":"Reliability","name":"Accept idempotency keys on POST","level":"Required","risk":"money","rule":"Let clients send an `Idempotency-Key` header on POST and PATCH, and return the first result for a repeated key instead of acting twice.","check":"Send the same POST with the same key twice. One order exists, and both answers are identical. The same key with a changed body gets 422. A payment POST without the header gets 400.","url":"https://agentwranglers.se/playbook/api-design/reliability/idempotency-keys","related":["/playbook/api-design/resources-and-methods/http-methods","/playbook/app-ui/forms/single-submit","/playbook/data/transactions-and-concurrency/idempotent-writes","/playbook/payments/amounts-and-orders/idempotent-charge-creation","/playbook/payments/refunds-disputes-and-fraud/provider-refunds","/playbook/ai-features/structured-output-and-tools/idempotent-tools","/playbook/app-ui/permissions-flags-and-failures/request-timeouts"],"sources":[{"label":"Expired IETF draft on the Idempotency-Key HTTP Header Field","url":"https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/"}]},{"chapter":"Reliability","name":"Stop lost updates with ETags","level":"Recommended","rule":"Send an `ETag` with every resource, and require `If-Match` on updates, so one client can't silently overwrite another's change.","check":"Two clients read the same order, and both send a PATCH with the first ETag. The second gets 412. A PATCH without `If-Match` gets 428.","url":"https://agentwranglers.se/playbook/api-design/reliability/optimistic-concurrency","related":["/playbook/website/performance/conditional-requests","/playbook/data/transactions-and-concurrency/conditional-writes","/playbook/app-ui/forms/autosave"],"sources":[{"label":"RFC 9110 section 13.1.1 If-Match","url":"https://www.rfc-editor.org/rfc/rfc9110#section-13.1.1"},{"label":"RFC 6585 section 3 428 Precondition Required","url":"https://www.rfc-editor.org/rfc/rfc6585#section-3"}]},{"chapter":"Reliability","name":"Retry with backoff and jitter","level":"Recommended","rule":"Retry only safe, idempotent or key-protected requests, and wait longer after each failure, with random jitter.","check":"A test server answers 503 three times. The client retries with growing gaps, honours `Retry-After` and stops at the cap. A 400 is never retried.","url":"https://agentwranglers.se/playbook/api-design/reliability/retry-backoff","related":["/playbook/testing/integration-and-contract-tests/outside-service-failures","/playbook/app-ui/permissions-flags-and-failures/request-timeouts","/playbook/ai-features/latency-cost-and-fallbacks/model-timeouts","/playbook/data/transactions-and-concurrency/dead-letter-queue","/playbook/email/unsubscribe-and-bounces/retry-and-expire","/playbook/operations/capacity-and-cost/load-shedding","/playbook/notifications/push-notifications/prune-push-subscriptions"],"sources":[{"label":"RFC 9110 section 9.2.2 Idempotent Methods","url":"https://www.rfc-editor.org/rfc/rfc9110#section-9.2.2"},{"label":"RFC 9110 section 10.2.3 Retry-After","url":"https://www.rfc-editor.org/rfc/rfc9110#section-10.2.3"}]},{"chapter":"Reliability","name":"Set timeouts on every call","level":"Required","risk":"outage","rule":"Give every outgoing and incoming request a time limit. Never wait forever on another service.","check":"Point a staging dependency at a server that accepts connections and never answers. The request fails within the set limit with a 504 or 503 problem.","url":"https://agentwranglers.se/playbook/api-design/reliability/timeouts","related":["/playbook/testing/integration-and-contract-tests/outside-service-failures","/playbook/data/operations/query-timeouts","/playbook/app-ui/permissions-flags-and-failures/request-timeouts","/playbook/ai-features/latency-cost-and-fallbacks/model-timeouts","/playbook/adversarial-testing/abuse-and-limits/resource-limits"],"sources":[]},{"chapter":"Reliability","name":"Tell clients their rate limit","level":"Recommended","applies":["public-api"],"rule":"Limit requests per client, and tell clients the limit, what is left and when it resets.","check":"Send requests past the limit. Answers carry the limit headers, and the first refused one is 429 with `Retry-After`.","url":"https://agentwranglers.se/playbook/api-design/reliability/rate-limit-headers","related":["/playbook/website/security/rate-limits"],"sources":[{"label":"IETF draft on RateLimit header fields for HTTP","url":"https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/"},{"label":"RFC 6585 section 4 429 Too Many Requests","url":"https://www.rfc-editor.org/rfc/rfc6585#section-4"}]},{"chapter":"Reliability","name":"Run long jobs asynchronously","level":"Recommended","rule":"When work can take longer than 10 seconds, accept it with 202 and a status resource the client can poll.","check":"Start an export in a test. The answer is 202 with `Location`, and polling ends in a completed state with a result link.","url":"https://agentwranglers.se/playbook/api-design/reliability/long-running-jobs","related":["/playbook/ai-features/latency-cost-and-fallbacks/batch-offline-work","/playbook/app-ui/feedback-and-dialogs/long-action-progress"],"sources":[{"label":"RFC 9110 section 15.3.3 202 Accepted","url":"https://www.rfc-editor.org/rfc/rfc9110#section-15.3.3"}]},{"chapter":"API security","name":"Authenticate with tokens in headers","level":"Required","risk":"takeover","rule":"Authenticate API calls with short-lived tokens in the `Authorization` header, issued through OAuth 2 for anything acting for a user.","check":"A call without a token gets 401 with a `WWW-Authenticate` header. An expired token gets 401 too. A freshly issued access token expires at most one hour after it was issued.","url":"https://agentwranglers.se/playbook/api-design/api-security/token-auth","related":["/playbook/adversarial-testing/access-and-accounts/token-checks","/playbook/adversarial-testing/requests-and-files/strict-cors","/playbook/infrastructure/networking-and-dns/service-to-service-auth"],"sources":[{"label":"RFC 9700 Best Current Practice for OAuth 2.0 Security","url":"https://www.rfc-editor.org/rfc/rfc9700"},{"label":"RFC 6750 OAuth 2.0 Bearer Token Usage","url":"https://www.rfc-editor.org/rfc/rfc6750"},{"label":"RFC 9728 OAuth 2.0 Protected Resource Metadata","url":"https://www.rfc-editor.org/rfc/rfc9728"}]},{"chapter":"API security","name":"Grant narrow scopes","level":"Required","risk":"takeover","rule":"Give each token only the scopes its job needs, and check the scope on every endpoint.","check":"Call `POST /orders` with an `orders:read` token. The answer is 403 and names `orders:write`.","url":"https://agentwranglers.se/playbook/api-design/api-security/token-scopes","related":["/playbook/website/standard-addresses/oauth-authorization-server","/playbook/accounts/roles-and-permissions/delegated-permissions"],"sources":[{"label":"RFC 6749 section 3.3 Access Token Scope","url":"https://www.rfc-editor.org/rfc/rfc6749#section-3.3"},{"label":"RFC 6750 section 3.1 Error Codes","url":"https://www.rfc-editor.org/rfc/rfc6750#section-3.1"}]},{"chapter":"API security","name":"Authorise every object","level":"Required","risk":"data","rule":"Check on every request that the caller may access the exact object, not just the endpoint.","check":"Request another customer's order with your own token. The answer is 404.","url":"https://agentwranglers.se/playbook/api-design/api-security/object-authorisation","related":["/playbook/adversarial-testing/access-and-accounts/object-access","/playbook/api-design/files-and-uploads/signed-downloads"],"sources":[{"label":"OWASP API1:2023 Broken Object Level Authorization","url":"https://api-security.owasp.org/editions/2023/en/0xa1-broken-object-level-authorization/"}]},{"chapter":"API security","name":"Keep secrets out of addresses","level":"Required","risk":"data","rule":"Never put tokens, keys or personal data in paths or query strings. Send them in headers or the body.","check":"Call an endpoint with `?api_key=...`. The answer is 400 or 401, and no log line holds the key.","url":"https://agentwranglers.se/playbook/api-design/api-security/no-secrets-in-urls","related":["/playbook/api-design/lists/query-method","/playbook/website/privacy/personal-data-out-of-urls"],"sources":[{"label":"RFC 9110 section 17.9 Sensitive Information in URIs","url":"https://www.rfc-editor.org/rfc/rfc9110#section-17.9"},{"label":"RFC 6750 section 2.3 URI Query Parameter","url":"https://www.rfc-editor.org/rfc/rfc6750#section-2.3"}]},{"chapter":"API security","name":"Store API keys as hashes","level":"Required","risk":"takeover","applies":["public-api"],"rule":"Show each API key once, store only its hash, and let clients hold two keys at a time so they can rotate.","check":"Search the database for any stored key value. Only hashes appear. Issue a second key for one client and revoke the first. The revoked key gets 401 on the next call, and the second key still works.","url":"https://agentwranglers.se/playbook/api-design/api-security/api-keys","related":["/playbook/adversarial-testing/access-and-accounts/secure-random-tokens","/playbook/accounts/sessions-and-security-activity/connected-apps"],"sources":[]},{"chapter":"API security","name":"Refuse plain HTTP on the API","level":"Required","risk":"takeover","rule":"Serve the API over HTTPS only. Refuse plain HTTP calls with an error instead of redirecting them.","check":"`curl -si http://api.example.com/orders` fails to connect or answers 403. It never answers 301, 302, 307 or 308.","url":"https://agentwranglers.se/playbook/api-design/api-security/api-https-only","related":["/playbook/website/hosting-and-network/https-tls"],"sources":[{"label":"OWASP REST Security Cheat Sheet","url":"https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html"},{"label":"IETF draft on Protecting Credentials with HTTP APIs","url":"https://datatracker.ietf.org/doc/draft-ietf-httpapi-privacy/"}]},{"chapter":"API security","name":"List every API you run","level":"Recommended","rule":"Keep a list of every API, version and host you expose, and switch off what is no longer used.","check":"Compare the list with the routes the servers answer. Every live route is on the list.","url":"https://agentwranglers.se/playbook/api-design/api-security/inventory","related":["/playbook/website/standard-addresses/api-catalog","/playbook/api-design/changes-over-time/deprecation-headers"],"sources":[{"label":"OWASP API9:2023 Improper Inventory Management","url":"https://api-security.owasp.org/editions/2023/en/0xa9-improper-inventory-management/"}]},{"chapter":"Contracts and docs","name":"Write the contract first","level":"Recommended","rule":"Describe every endpoint, parameter, body, response and error in one OpenAPI document, kept in version control next to the code.","check":"The linter passes on the OpenAPI file in the build, and every route the server answers appears in it.","url":"https://agentwranglers.se/playbook/api-design/contracts-and-docs/openapi-contract","related":["/playbook/testing/integration-and-contract-tests/contract-tests"],"sources":[{"label":"OpenAPI Specification 3.2.1","url":"https://spec.openapis.org/oas/v3.2.1.html"},{"label":"JSON Schema 2020-12","url":"https://json-schema.org/draft/2020-12"}]},{"chapter":"Contracts and docs","name":"Test the API against its contract","level":"Recommended","rule":"Run tests that send requests and validate every response against the contract.","check":"Change a field's type in the code on a branch. The contract tests fail.","url":"https://agentwranglers.se/playbook/api-design/contracts-and-docs/contract-tests","related":["/playbook/testing/integration-and-contract-tests/contract-tests","/playbook/testing/integration-and-contract-tests/verify-the-fakes"],"sources":[{"label":"OpenAPI Specification 3.2.1","url":"https://spec.openapis.org/oas/v3.2.1.html"}]},{"chapter":"Contracts and docs","name":"Give every endpoint an example","level":"Recommended","applies":["public-api"],"rule":"Add a realistic request and response example to every operation in the contract.","check":"A linter rule finds no operation without an example, and every example validates against its schema.","url":"https://agentwranglers.se/playbook/api-design/contracts-and-docs/examples","related":["/playbook/engineering/documentation/tested-docs"],"sources":[{"label":"OpenAPI 3.2.1 Example Object","url":"https://spec.openapis.org/oas/v3.2.1.html#example-object"}]},{"chapter":"Contracts and docs","name":"Generate docs and clients from the contract","level":"Recommended","applies":["public-api"],"rule":"Build the reference docs from the OpenAPI file, not by hand. Generating SDKs from it too is optional.","check":"The published docs show the same version number as the OpenAPI file in the latest release. Where you ship an SDK, the build regenerates it and fails when the committed copy differs.","url":"https://agentwranglers.se/playbook/api-design/contracts-and-docs/generated-docs","related":["/playbook/engineering/documentation/generated-reference"],"sources":[{"label":"OpenAPI Specification 3.2.1","url":"https://spec.openapis.org/oas/v3.2.1.html"}]},{"chapter":"Contracts and docs","name":"Make the API easy for agents to find","level":"Recommended","applies":["public-api"],"rule":"Link the contract from a well-known place, so AI agents and tools can find and use the API without a human.","check":"Fetch `/.well-known/api-catalog`. It links to the OpenAPI file, which loads and validates. A linter rule finds no operation without an `operationId` and a `description`.","url":"https://agentwranglers.se/playbook/api-design/contracts-and-docs/agent-discovery","related":["/playbook/website/ready-for-ai-agents/agent-readiness-overview","/playbook/website/ready-for-ai-agents/agentic-resource-discovery","/playbook/website/standard-addresses/api-catalog","/playbook/website/ready-for-ai-agents/link-headers"],"sources":[{"label":"RFC 9727 api-catalog well-known URI","url":"https://www.rfc-editor.org/rfc/rfc9727"},{"label":"OpenAPI 3.2.1 Operation Object","url":"https://spec.openapis.org/oas/v3.2.1.html#operation-object"}]},{"chapter":"Contracts and docs","name":"Mark server-set fields read-only and secrets write-only","level":"Recommended","rule":"Mark every field the server sets, such as `id`, `createdAt` and `status`, with `readOnly: true` in the contract. Mark every field a client sends but never gets back, such as a password, with `writeOnly: true`.","check":"A linter rule finds `readOnly: true` on every `id`, `createdAt` and `updatedAt`, and `writeOnly: true` on every field named `password` or `secret`. A contract test sends an update with a changed `id` and `createdAt`, and the stored values stay the same. No response in the contract tests holds a write-only field.","url":"https://agentwranglers.se/playbook/api-design/contracts-and-docs/read-only-fields","related":["/playbook/adversarial-testing/access-and-accounts/mass-assignment"],"sources":[{"label":"OpenAPI 3.2.1 Validating readOnly and writeOnly","url":"https://spec.openapis.org/oas/v3.2.1.html#validating-readonly-and-writeonly"},{"label":"JSON Schema 2020-12 Validation","url":"https://json-schema.org/draft/2020-12/json-schema-validation"}]},{"chapter":"Webhooks and events","name":"Sign every webhook","level":"Required","risk":"money","applies":["public-api"],"rule":"Sign each webhook body with a secret shared with the receiver, and include a timestamp, so receivers can reject fakes and replays.","check":"Send a test webhook with one byte of the body changed. The receiver rejects it with 401, and a timestamp 10 minutes old is rejected too.","url":"https://agentwranglers.se/playbook/api-design/webhooks-and-events/signed-webhooks","related":["/playbook/adversarial-testing/access-and-accounts/constant-time-compare","/playbook/payments/payment-events-and-reconciliation/events-drive-fulfilment","/playbook/notifications/text-messages/sms-delivery-status"],"sources":[{"label":"RFC 2104 HMAC Keyed-Hashing for Message Authentication","url":"https://www.rfc-editor.org/rfc/rfc2104"}]},{"chapter":"Webhooks and events","name":"Retry until the receiver accepts","level":"Recommended","applies":["public-api"],"rule":"Treat any answer other than 2xx as a failure, and retry with growing gaps for a documented time.","check":"Point a test webhook at an endpoint that answers 500. The sender retries with growing gaps, and the event can be fetched afterwards.","url":"https://agentwranglers.se/playbook/api-design/webhooks-and-events/webhook-retries","related":["/playbook/payments/payment-events-and-reconciliation/missed-event-sweep"],"sources":[]},{"chapter":"Webhooks and events","name":"Make receivers idempotent","level":"Required","risk":"money","rule":"Give every event a unique ID, and make receivers ignore an ID they have already processed.","check":"Deliver the same event twice. The receiver acts once and answers 2xx both times.","url":"https://agentwranglers.se/playbook/api-design/webhooks-and-events/idempotent-receivers","related":["/playbook/payments/payment-events-and-reconciliation/events-drive-fulfilment"],"sources":[]},{"chapter":"Webhooks and events","name":"Don't rely on event order","level":"Recommended","rule":"Include a timestamp or sequence number in every event, and let receivers fetch the current state instead of trusting arrival order.","check":"Deliver `order.shipped` before `order.paid` in a test. The receiver ends with the correct final state.","url":"https://agentwranglers.se/playbook/api-design/webhooks-and-events/event-order","related":["/playbook/payments/payment-events-and-reconciliation/events-drive-fulfilment","/playbook/payments/subscriptions/provider-billing-engine"],"sources":[]},{"chapter":"Webhooks and events","name":"Version event payloads","level":"Recommended","applies":["public-api"],"rule":"Name each event type and version its payload, and change it only by adding fields, as with the API itself.","check":"The contract lists every event type the system sends, and each validates against its schema in tests.","url":"https://agentwranglers.se/playbook/api-design/webhooks-and-events/event-versions","related":["/playbook/api-design/changes-over-time/additive-changes","/playbook/testing/integration-and-contract-tests/contract-tests"],"sources":[{"label":"OpenAPI 3.2.1 webhooks field","url":"https://spec.openapis.org/oas/v3.2.1.html#oas-webhooks"}]},{"chapter":"Webhooks and events","name":"Check webhook addresses before sending","level":"Required","risk":"takeover","applies":["public-api"],"rule":"Accept only public HTTPS addresses as webhook targets, and never let a webhook reach your internal network.","check":"Register `https://169.254.169.254/` and `https://localhost/` as webhook targets. Both are refused. Register a test host that resolves to a public address, then point its DNS at `127.0.0.1`. The next send is refused and no request reaches the loopback address.","url":"https://agentwranglers.se/playbook/api-design/webhooks-and-events/webhook-targets","related":["/playbook/adversarial-testing/requests-and-files/block-server-side-request-forgery"],"sources":[{"label":"OWASP Server-Side Request Forgery Prevention Cheat Sheet","url":"https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html"}]},{"chapter":"Files and uploads","name":"Upload straight to storage with a signed address","level":"Recommended","rule":"Let clients send file bytes straight to object storage through a pre-signed address that your API issues, limited in size, type and time.","check":"Ask for an upload address for a 10 MB limit. Upload a file 1 byte over the limit and storage refuses it. Wait past the expiry and upload again, and storage answers 403. The key in the address never contains the client's file name.","url":"https://agentwranglers.se/playbook/api-design/files-and-uploads/presigned-uploads","related":["/playbook/engineering/running-in-production/stateless-processes","/playbook/api-design/files-and-uploads/confirm-uploads","/playbook/adversarial-testing/requests-and-files/safe-file-uploads"],"sources":[]},{"chapter":"Files and uploads","name":"Make large uploads resumable","level":"Recommended","rule":"Let any upload over 100 MB, or any upload from a phone, continue from the last received byte after a dropped connection.","check":"Start a 500 MB upload, cut the network at 50 percent and reconnect. The upload finishes without sending the first half again, and the stored file's SHA-256 matches the original.","url":"https://agentwranglers.se/playbook/api-design/files-and-uploads/resumable-uploads","related":["/playbook/api-design/files-and-uploads/confirm-uploads"],"sources":[{"label":"IETF draft on Resumable Uploads for HTTP","url":"https://datatracker.ietf.org/doc/draft-ietf-httpbis-resumable-upload/"}]},{"chapter":"Files and uploads","name":"Confirm every upload on the server","level":"Recommended","rule":"Keep a new file `pending` until the server has checked its size, type and checksum from the stored bytes. Never trust what the client reports.","check":"Upload a text file named `photo.jpg` with a content type of `image/jpeg`. The file never leaves `pending`, and the API answers with a problem response naming the type.","url":"https://agentwranglers.se/playbook/api-design/files-and-uploads/confirm-uploads","related":["/playbook/api-design/files-and-uploads/presigned-uploads","/playbook/api-design/files-and-uploads/resumable-uploads","/playbook/adversarial-testing/requests-and-files/safe-file-uploads","/playbook/app-ui/forms/file-upload-field"],"sources":[{"label":"OWASP File Upload Cheat Sheet","url":"https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html"}]},{"chapter":"Files and uploads","name":"Scan files for malware before anyone opens them","level":"Required","risk":"takeover","rule":"Scan every file that other people or systems will open, and serve it only after the scan says clean.","check":"Upload the EICAR test file. It stays in quarantine, its status reads `infected`, and a download request answers 403 or 404.","url":"https://agentwranglers.se/playbook/api-design/files-and-uploads/scan-uploads","related":["/playbook/adversarial-testing/requests-and-files/safe-file-uploads","/playbook/adversarial-testing/requests-and-files/safe-archive-extraction"],"sources":[{"label":"OWASP File Upload Cheat Sheet","url":"https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html"}]},{"chapter":"Files and uploads","name":"Rebuild every uploaded image","level":"Recommended","rule":"Decode each uploaded image, turn it upright, shrink it to a size limit and save it again as a new file without metadata. Never serve the original.","check":"Upload a photo with GPS tags and a 90-degree orientation tag. A metadata reader such as ExifTool finds no GPS or EXIF tags in the served file, and it shows upright. A 20,000 by 20,000 pixel PNG is refused.","url":"https://agentwranglers.se/playbook/api-design/files-and-uploads/process-images","related":["/playbook/website/performance/image-optimization","/playbook/stack/databases-and-storage/image-processing"],"sources":[{"label":"OWASP File Upload Cheat Sheet","url":"https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html"}]},{"chapter":"Files and uploads","name":"Serve private files through short-lived links","level":"Required","risk":"data","rule":"Keep the bucket private. Check access on every download request, then redirect to a signed address that expires within 5 minutes and sets safe download headers.","check":"Fetch the bucket address of a private file without a signature, and storage answers 403. A signed address works, then answers 403 after 5 minutes. A file named `rapport \"Q3\"\\r\\nX: y.pdf` downloads with one `Content-Disposition` header and no extra header.","url":"https://agentwranglers.se/playbook/api-design/files-and-uploads/signed-downloads","related":["/playbook/api-design/api-security/object-authorisation","/playbook/adversarial-testing/data-and-secrets/private-storage-buckets","/playbook/email/email-security/safe-attachments"],"sources":[{"label":"RFC 6266 Content-Disposition in HTTP","url":"https://www.rfc-editor.org/rfc/rfc6266"},{"label":"RFC 8187 Encoding of HTTP header parameters","url":"https://www.rfc-editor.org/rfc/rfc8187"}]},{"chapter":"Files and uploads","name":"Lay out storage by owner and let rules clean it","level":"Recommended","rule":"Name every stored object by stage, owner and a random ID, with the stage as the first prefix. Let storage lifecycle rules delete what nobody needs.","check":"Read the bucket's lifecycle settings with the provider's tool. It has a rule for quarantine, one for unfinished multipart uploads and, with versioning on, one for old versions. A listing finds no unfinished multipart upload older than 2 days.","url":"https://agentwranglers.se/playbook/api-design/files-and-uploads/storage-lifecycle","related":["/playbook/compliance/records-and-data-map/retention-schedule"],"sources":[]},{"chapter":"Files and uploads","name":"Store the same file once per owner","level":"Optional","rule":"When users upload the same file often, store it once per owner under its SHA-256 hash and count the references.","check":"One owner uploads the same file twice, and storage holds one object with a count of 2. Deleting one record keeps the object, and deleting the second removes it. A second owner uploading the same file gets their own object.","url":"https://agentwranglers.se/playbook/api-design/files-and-uploads/dedupe-by-hash","related":["/playbook/api-design/files-and-uploads/delete-files-with-owner"],"sources":[{"label":"FIPS 180-4 Secure Hash Standard","url":"https://csrc.nist.gov/pubs/fips/180-4/upd1/final"}]},{"chapter":"Files and uploads","name":"Delete files when their owner is deleted","level":"Required","risk":"law","applies":["eu-users"],"rule":"Record every stored object in a files table with its owner, and delete the objects in the same job that deletes the owner.","check":"Create a test owner with an upload, a thumbnail and two versions of a file. Delete the owner. A listing of the owner's prefix, including old versions, is empty. The weekly sweep reports zero objects without a row.","url":"https://agentwranglers.se/playbook/api-design/files-and-uploads/delete-files-with-owner","related":["/playbook/api-design/files-and-uploads/dedupe-by-hash","/playbook/website/privacy/data-requests"],"sources":[{"label":"GDPR Article 17 on right to erasure","url":"https://eur-lex.europa.eu/eli/reg/2016/679/oj"}]},{"chapter":"Files and uploads","name":"Neutralise formulas in spreadsheet exports","level":"Recommended","rule":"In every CSV or spreadsheet export, prefix a single quote to cell text starting with `=`, `+`, `-`, `@`, a tab or a carriage return.","check":"Export a record whose name is `=1+1` and one whose name is `@SUM(A1)`. The file holds `'=1+1` and `'@SUM(A1)`, and a unit test covers all six starting signs.","url":"https://agentwranglers.se/playbook/api-design/files-and-uploads/spreadsheet-exports","related":["/playbook/accounts/profile-and-account-lifecycle/self-service-export"],"sources":[{"label":"OWASP CSV Injection","url":"https://community.owasp.org/attacks/CSV_Injection"},{"label":"RFC 4180 Common Format for CSV Files","url":"https://www.rfc-editor.org/rfc/rfc4180"}]}]}