Engineering
Designing APIs developers can read without the docs
Practical API design advice, with examples of field errors, validation, authentication, safe retries, and the tests that keep integrations working.
Pandabase team · · 18 min read
You copy an example request, swap in your API key, and send it. It fails.
The response says Invalid request. No field name. No explanation. You open the docs again, compare your JSON with the example, and start guessing.
Most developers have lost an afternoon this way. Often the underlying API does everything they need. The difficult part is figuring out what it expects.
When we think about API design at Pandabase, that's the experience we want to avoid. Someone integrating a payment flow already has plenty to work through. They shouldn't also have to figure out whether amount means dollars or cents.
The title is a little ambitious. You'll always need documentation for authentication, limits, and the details of how a product works. But once you've made a few requests, the next one should feel familiar.
Let's use a small, fictional invoicing API to work through what that looks like. These examples illustrate design choices; they aren't Pandabase endpoints.
Start with names people can guess
Suppose you've just created a customer:
POST /v1/customersNow you want to create an invoice. You'd probably try:
POST /v1/invoicesThat's a small thing, but it matters. Every time a reasonable guess works, there's one less trip back to the reference page.
The same goes for fields. If a customer has created_at, an invoice should too. If one endpoint uses customer, another shouldn't call the same relationship customer_ref without a good reason.
Spelling names out helps. amount_due takes a few more characters than amt, but you only type it once. You'll read it in responses, logs, and code reviews for years.
Prefixed IDs are useful for the same reason. Seeing inv_8f2c41a9 in a support ticket gives you a starting point. Seeing an unexplained string makes you ask another question. The prefix is a hint for people; clients should still treat the whole ID as opaque.
Make a response easy to understand
Here's a shortened invoice response:
{
"id": "inv_8f2c41a9",
"object": "invoice",
"customer": "cus_4b7a90e1",
"status": "open",
"currency": "usd",
"amount_due": 4900,
"due_date": "2026-10-27",
"created_at": "2026-09-27T14:03:11Z",
"paid_at": null
}You can get a fair idea of what's happening without looking anything up. There's an open invoice for a customer. It has a due date, and it hasn't been paid.
A few details still need an explicit contract. In this example, amounts are integers in the currency's smallest unit, so 4900 means $49.00. That's a rule to explain once and apply everywhere. Calling the field amount_cents would become awkward when you support a currency that doesn't use cents.
Dates deserve the same care. created_at is a UTC timestamp. due_date is a calendar date. paid_at: null tells us there isn't a payment time yet. Keeping that field present gives clients a predictable shape.
Creating an invoice should return that same object shape. Having to make a second request just to retrieve the thing you created adds work without helping anyone.
Decide what each field accepts
Required and nullable answer different questions. Required means the client must send the field. Nullable means its value can be null. An optional field doesn't automatically accept null, and an empty string isn't the same as either one.
For our example, the create request could define:
| Field | Required on create? | Accepts null? | Rule |
|---|---|---|---|
customer | Yes | No | An accessible customer ID |
due_date | No | Yes | A real calendar date in YYYY-MM-DD; omitted or null means due on receipt |
line_items | Yes | No | Between 1 and 250 items |
line_items[].quantity | No | No | An integer from 1 to 10,000; defaults to 1 |
line_items[].unit_amount | Yes | No | A nonnegative integer within the documented amount limit |
Put limits in the schema: string lengths, array sizes, numeric ranges, accepted enum values, and supported currencies. Validate on the server even if the SDK or checkout has already checked the input. Clients can bypass both.
Be deliberate about coercion. This API rejects "quantity": "2" because it expects a number. Quietly accepting strings on some endpoints and rejecting them on others leaves developers guessing. Unknown request fields should also produce an error so a typo doesn't disappear unnoticed.
Validate relationships as well as individual values. A correctly formatted customer ID still has to belong to the caller's account. A valid date may still violate a business rule. Check permissions before returning details about another account's records.
Be precise about money
Integer minor units avoid one source of rounding trouble, but you still need an upper bound. JavaScript cannot represent every integer above 9,007,199,254,740,991 exactly. If your API needs larger amounts, decimal strings are one option, with explicit parsing rules and SDK support. Choose the representation before clients depend on it.
Calculations need rules too. For fractional prices, tax, or exchange rates, use exact decimal arithmetic or scaled integers and specify the precision and rounding mode. Say whether you round each line or the invoice total; those can produce different results. Check quantities, calculated totals, and intermediate values for overflow.
Clients should be able to reproduce a total without reverse-engineering your rounding behavior.
Be clear about what an update does
A request like this should be straightforward:
PATCH /v1/invoices/inv_8f2c41a9
Content-Type: application/json
{ "due_date": "2026-11-03" }The due date changes. Everything else stays as it was.
But what happens if the client sends null? What if it sends total, which the server calculates? Those details need decisions too. For this example, null clears a field that allows it, and attempting to change a read-only field returns an error.
Silently ignoring input is frustrating because the request looks successful. The developer moves on, and the bug turns up somewhere else.
Concurrent edits are worth considering as well. If two people open the same invoice, one person's save shouldn't quietly erase the other's changes. An ETag returned with the invoice, sent back through If-Match on an update, gives the server a way to detect that conflict and ask the client to reload.
Give important actions a name
Finalizing an invoice might assign its number, calculate tax, and lock its line items. That's quite a lot to hide behind a writable status field.
An explicit action makes the intention easier to see:
POST /v1/invoices/inv_8f2c41a9/finalizeIt also gives you a natural place to explain why an action isn't allowed. If someone tries to void a paid invoice, the response can tell them which state prevented it.
Deletion needs that same thought. Removing a draft may be fine. Once an invoice has been finalized and sent, preserving the record matters. The API should offer the appropriate correction or cancellation flow and explain when to use it.
Make credentials and permissions straightforward
For this example, server requests use a bearer key over HTTPS:
Authorization: Bearer sk_test_exampleKeep secret keys out of URLs, browser bundles, and logs. Separate test and live credentials and data, give keys only the permissions they need, and provide a way to rotate and revoke them. A support tool that reads invoices shouldn't automatically have permission to issue refunds.
Authentication tells you who is calling. Authorization decides whether that caller can perform this action on this particular record. Check both on every request, including nested resources and expanded responses. Knowing an invoice ID is never sufficient permission to read it. OWASP's guidance on object-level authorization explains this failure mode.
Use an explicit allowlist for writable properties too. Passing an entire request body straight into a database update can accidentally let someone change ownership or internal flags.
Write errors for the person debugging them
An error response is often where someone spends the most time with your API. Give them something useful:
{
"error": {
"code": "invoice_not_open",
"message": "This invoice is already paid. Only open invoices can be voided.",
"param": null,
"request_id": "req_3Qm9Lp4c"
}
}The code gives the application something stable to handle. The message gives the developer an explanation. The request ID gives support a way to find the failed request.
For a bad field, name the field and describe the expected value. If several fields are invalid, report them together where possible. Nobody enjoys fixing a form one failed request at a time.
Use HTTP status codes consistently too. Missing credentials, insufficient permissions, and an invalid state are different problems. Returning 200 OK for all of them and putting the failure inside the body makes every integration do extra checking.
For this example, we'd use the following contract. The important part is that every endpoint follows it:
| Status | Meaning in this API |
|---|---|
400 Bad Request | The request cannot be parsed, such as malformed JSON |
401 Unauthorized | Credentials are missing or invalid; include the appropriate WWW-Authenticate challenge |
403 Forbidden | The caller is authenticated but lacks permission |
404 Not Found | The resource is missing, or its existence must not be disclosed to this caller |
409 Conflict | The action conflicts with the current state, or an idempotency key is already in progress |
412 Precondition Failed | The supplied If-Match value is stale |
413 Content Too Large | The request exceeds the body-size limit |
415 Unsupported Media Type | The endpoint does not accept the supplied content type |
422 Unprocessable Content | The parsed request fails field or business validation |
429 Too Many Requests | A rate limit was reached |
500, 502, 503, 504 | A server or upstream failure; the operation's outcome may need checking before retrying |
Some APIs use 400 for field validation too. Either approach needs a documented, consistent distinction. Keep errors about authorization and state separate from errors a person can fix in a form.
Show exactly which fields need fixing
Suppose someone submits a fractional minor-unit amount and a date in the wrong format. Return both errors in one 422 response:
{
"error": {
"code": "validation_failed",
"message": "Check the highlighted fields.",
"param": null,
"request_id": "req_3Qm9Lp4c",
"errors": [
{
"code": "invalid_integer",
"location": "body",
"param": "/line_items/0/unit_amount",
"message": "Enter an amount in whole minor units."
},
{
"code": "invalid_date",
"location": "body",
"param": "/due_date",
"message": "Use a valid date in YYYY-MM-DD format."
}
]
}
}Here, body-field paths use JSON Pointer. /line_items/0/unit_amount identifies unit_amount on the first item. For a missing required field, use the path where that field belongs. Property names containing ~ or / need the JSON Pointer escapes ~0 and ~1 respectively.
The location field removes ambiguity: an error for the query parameter limit would use "location": "query" and "param": "limit". Define the same convention for path parameters and headers. For a problem involving the request as a whole, use param: null and display it above the form.
The UI maps paths to its inputs and displays each message next to the relevant field. It branches on stable codes when special handling is needed, rather than parsing message text. Keep unmatched errors visible in a form-level summary, and associate inline messages with their inputs for assistive technology. If users can reorder line items while a request is running, map response indices back to the submitted items before displaying errors.
Collect independent failures in one pass, but don't run checks that depend on invalid values. There's no point calculating an invoice total when an amount failed parsing. Once validation succeeds, commit the operation atomically so a failed request doesn't leave half an invoice behind.
Avoid including raw submitted values in errors when they could contain card data, credentials, or personal information. Unexpected failures should return a safe message and a request ID; stack traces and database details belong in restricted internal logs.
This is a custom error envelope. If you're starting fresh, RFC 9457 Problem Details offers a standard alternative, including an example of a validation-error extension with JSON Pointers. Adopting it means using its defined fields and application/problem+json media type; the envelope above isn't an RFC 9457 response.
Assume someone will retry
A timeout leaves a developer with an uncomfortable question: did the request fail, or did only the response get lost?
For an operation that creates an invoice or moves money, guessing can be expensive. An idempotency key lets the client identify one intended operation across retries:
POST /v1/invoices
Idempotency-Key: order-6735-invoiceRepeating the same request with that key should return the recorded result within the documented retention window. Reusing the key with different input should produce a clear error.
The details matter here. Explain how long keys are retained, what happens while a request is still running, and which failures are saved. An SDK can help with retries, but it needs to reuse the key for the same operation. Generating a fresh key on each attempt defeats the purpose.
Rate limits belong in this conversation too. A 429 response with Retry-After tells a client when to try again. Backoff and jitter help keep retries from arriving all at once.
Set a total deadline and a maximum number of attempts. Retrying indefinitely can turn a brief outage into a backlog of duplicate work. A timeout means the caller stopped waiting; it doesn't prove that the server rolled back. After an uncertain result, reuse the same idempotency key or retrieve the operation's status.
Keep keys scoped to the caller and operation, and coordinate concurrent uses atomically. Two requests arriving together must not both execute before either saves its result. Downstream side effects need the same care: a local idempotency record alone doesn't prevent a payment provider from receiving duplicate charges.
Don't automatically retry ordinary validation or permission errors. Also, PATCH isn't inherently safe to repeat: setting a due date and incrementing a balance have different effects. Document retry behavior for the operation, not just its HTTP method.
Make every list work the same way
A developer who has paginated customers shouldn't need to learn another approach for invoices.
{
"data": [{ "id": "inv_8f2c41a9", "object": "invoice" }],
"has_more": true,
"next_cursor": "c2VxOjQ4MjE1"
}The next request passes that cursor back unchanged. Use the same envelope, parameter names, and limits across list endpoints.
Cursors work well for growing datasets, but they still need a stable ordering. Document that ordering and what clients should expect if records change while they're paging through them.
Filters should use familiar field names, and typos should fail visibly. If someone sends stauts=open, returning every invoice is a particularly unhelpful kind of success.
Keep related objects small by default. An explicit option to expand a customer can save an extra request when someone needs its details, without making every invoice response carry them.
Explain what happens after the request
Some operations take time. An export can return 202 Accepted with a job ID and a status endpoint. That gives the client something to track if the connection closes.
For webhooks, use Standard Webhooks. It gives providers a shared specification and reference libraries, so developers can reuse familiar verification tools across integrations. That's the same consistency we want from the rest of the API.
Delivery still needs clear expectations. Can an event arrive twice? Can events arrive out of order? How long will delivery be retried?
For an example API with at-least-once delivery, the consumer needs to deduplicate event IDs, verify the signature, and durably store the event before acknowledging it. Slow work can happen afterward. When an event might be stale, fetching the current object helps the consumer decide what to do next.
These details will always need documentation. The API can still help by giving events consistent names, stable IDs, and recognizable object shapes.
Use a Standard Webhooks reference library to verify deliveries against the raw request body before processing the payload. Follow the specification's signing and verification rules, document timestamp tolerance and secret rotation, and reject invalid signatures. Deduplication and processing also need to survive a crash between receiving an event and completing its effects.
Put limits around expensive work
A page-size limit is useful, but it won't stop a tiny request from launching hundreds of exports. Bound body sizes, batch counts, expansion depth, concurrent jobs, and expensive downstream calls. Tell clients which limit they hit and how to recover. OWASP's resource-consumption guidance covers why request counts alone are insufficient.
Treat user-supplied webhook and callback URLs as untrusted destinations. Restrict protocols, block access to private and internal network addresses, and recheck redirects and resolved addresses when connecting. Otherwise, a delivery feature can become a way to make requests into your own infrastructure.
Keep observability useful without collecting everything. Log request IDs, operation names, status codes, and timing; redact credentials and sensitive payloads. Tenant-specific responses also need an explicit cache policy so shared caches can't serve one customer's data to another.
Think about the person upgrading next year
Once a developer has working code, changing a field name becomes their problem too. Even changing a default can alter behavior they depend on.
A versioning policy should explain which changes require an upgrade and how to test them. For this example, a pinned API version lets an integration keep its existing behavior while the developer tries a newer version deliberately.
Be careful with changes that look harmless. A new enum value can break a client with an exhaustive switch. A new response field can fail strict validation. Explain where clients need to tolerate additions before relying on that flexibility.
An OpenAPI description can help keep schemas, generated types, and reference documentation aligned. It still needs review and checks against the actual server. A generated page is only as useful as the definition behind it.
Test the contract people depend on
A response matching its schema is a good start. It doesn't tell you whether a retry charged someone twice or whether another account can read the invoice.
Run contract checks against the server as well as the schema, and include the cases developers will hit outside the happy path:
- Missing, null, empty, malformed, unknown, and out-of-range fields, including nested array errors and multiple failures in one response.
- Missing credentials, restricted keys, and attempts to read or change another account's records through direct, nested, and expanded endpoints.
- Simultaneous requests with the same idempotency key, a reused key with different input, and a lost response after a successful write.
- Stale
If-Matchvalues, invalid state transitions, and failures that must leave no partial writes. - Pagination while records change, rate-limit responses, job failures, and timeouts with uncertain outcomes.
- Invalid webhook signatures, duplicate and out-of-order events, and a consumer restarting midway through processing.
Keep representative requests from supported API versions as regression tests. Run documentation examples too. A quickstart that stopped working three releases ago is a broken part of the product.
Give your AI assistant the same rules
If you're using an AI coding assistant on your next project, write down these decisions before asking it to build endpoints. Otherwise, separate prompts can produce separate conventions: one pagination shape for customers, another for invoices, and a third error format for authentication.
Here's a starting point you can copy into your project's AGENTS.md or CLAUDE.md, depending on which instruction file your tool reads. Adapt it to your stack and existing API. If you already have an instruction file, merge the relevant rules into it.
# API development instructions
## Before making changes
- Read the existing endpoints, schemas, tests, and API conventions first.
- Follow established public contracts. Flag conflicting requirements before changing them.
- Make the smallest correct change. Avoid unrelated refactors and new dependencies unless needed.
- For a new endpoint, define its request, response, permissions, errors, and retry behavior before implementation.
- Ask about missing business rules that affect money, permissions, or irreversible actions. Do not invent them.
## Requests and responses
- Use consistent resource names, field names, timestamps, and response shapes.
- For new APIs, prefer plural resource paths, snake_case fields, UTC timestamps, and opaque prefixed IDs.
- Define required, optional, nullable, and read-only fields separately.
- Document defaults, accepted formats, size limits, and numeric bounds.
- Validate on the server. Reject unknown request fields and unsupported filters.
- Use integer minor units or documented decimal strings for money. Define currency, precision, rounding, and overflow rules.
- Make state transitions explicit and enforce them atomically.
- Keep pagination, filtering, sorting, and expansion conventions consistent and bounded.
## Errors
- Reuse one error contract across endpoints and document its HTTP status mapping.
- Give errors stable machine-readable codes, useful human messages, and request IDs.
- Return independent field validation failures together when possible.
- For the custom envelope in this guide, use an errors array with code, location, param, and message on each item.
- Use JSON Pointer paths for body fields, including array indices; use param: null for request-wide errors.
- Distinguish malformed requests, field validation, authentication, authorization, and state conflicts.
- Never return success status codes for failed operations or expose secrets, stack traces, or database details.
## Security and reliability
- Check caller permissions and resource ownership on every operation, including nested and expanded resources.
- Allowlist writable properties. Keep secrets out of URLs, client bundles, and logs.
- Bound request sizes, batch sizes, expensive work, and concurrent jobs.
- Support idempotency for operations that must not execute twice; handle concurrent retries and downstream effects.
- Document timeout behavior, retry limits, key retention, and how to recover an uncertain result.
- Use Standard Webhooks: https://www.standardwebhooks.com/.
- Prefer its reference libraries for verification against the raw request body.
- Handle invalid signatures, replay attempts, duplicate events, out-of-order delivery, and consumer crashes.
- Validate webhook destinations against SSRF, including redirects and resolved addresses.
## Before finishing
- Update the API schema and examples alongside implementation changes.
- Preserve supported behavior; document breaking changes and their migration path.
- Test validation errors, authorization boundaries, state conflicts, concurrent retries, and partial failures relevant to the change.
- Check response status codes and bodies against the documented contract.
- Run the project's relevant checks and report what passed, what failed, and what was not run.
- Do not claim a guarantee or implemented feature without verifying it in the code and tests.Add your actual schema location, a representative endpoint, and the commands that run your checks. Those references give the assistant something concrete to follow. Keep the file current as decisions change, and review the generated code against the same contract you'd use in any other code review.
Try it from a fresh project
Before calling an endpoint finished, try using it with nothing but an API key and the quickstart. Create something, update it, make a deliberate mistake, and retry a request. See how far the responses get you.
Notice where you have to stop. Did a field name surprise you? Did an error send you searching through the docs? Did you have to inspect the server code to learn what happened?
Those pauses are useful feedback. Fixing one confusing response can save every developer who comes after you the same ten minutes.
Enjoyed this post? Share it.
