What a Useful API Error Response Actually Looks Like

by Eric Hanson, Backend Developer at Clean Systems Consulting

Start with what developers actually need

When a request fails, a developer needs to answer four questions in sequence: Did I do something wrong? What specifically? Is it worth retrying? Who do I contact if it is not me?

A useful error response answers all four without requiring the developer to open a browser tab. Here is a complete example for a validation failure:

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "One or more fields failed validation.",
    "details": [
      {
        "field": "billing.postal_code",
        "issue": "Must match pattern ^[0-9]{5}(-[0-9]{4})?$ for US addresses",
        "received": "SW1A 1AA"
      }
    ],
    "request_id": "req_01HZQK7P3WVXBN4Y9MRDTJC8E6",
    "timestamp": "2026-04-19T08:42:11Z",
    "docs_url": "https://api.example.com/errors/VALIDATION_FAILED"
  }
}

Let's go field by field.

The fields and why they matter

code — A stable, machine-readable string constant. Not an integer, not a freeform message. VALIDATION_FAILED is a code. "Invalid request" is not. Codes allow clients to write if (error.code === 'RATE_LIMIT_EXCEEDED') without parsing a human-readable string that might change. Define your codes in a registry and never rename them.

message — A human-readable sentence that summarizes the failure. It should be the most useful single sentence you can write about what happened. It is for developers reading logs or a dashboard, not for end users.

details — An array for errors where more than one thing went wrong simultaneously (validation being the obvious case). Each entry should name the field path in dot-notation, describe the constraint that was violated with the actual rule (not just "invalid"), and include the received value. Seeing "received": "SW1A 1AA" alongside a US postal code regex tells the developer immediately that their address normalization is sending UK postal codes to a US-only endpoint.

request_id — A unique identifier for the specific request that failed. Use a format that sorts lexicographically and encodes time — ULIDs work well for this. The developer pastes this into your support channel or your internal logging system and you can pull the full trace immediately. Without this, support becomes "can you tell me roughly when it happened and what the payload was?" — a guessing game.

timestamp — ISO 8601 UTC. Useful when the developer is correlating against their own logs.

docs_url — A permalink to the documentation for this specific error code. This is cheap to add and saves real time. The developer clicks through and sees the full explanation, common causes, and resolution steps without waiting for a response from your support team.

For retryable errors, add retry guidance

{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "You have exceeded 1000 requests per minute on the /orders endpoint.",
    "retryable": true,
    "retry_after": "2026-04-19T08:43:00Z",
    "limit": {
      "window": "60s",
      "max_requests": 1000,
      "remaining": 0,
      "reset_at": "2026-04-19T08:43:00Z"
    },
    "request_id": "req_01HZQK9MVRB2N7D4E5FSXCJ1P0",
    "docs_url": "https://api.example.com/errors/RATE_LIMIT_EXCEEDED"
  }
}

retryable: true is a boolean contract with the client. Set it to true only when retrying the same request after a wait is likely to succeed. Set it to false for errors where the client needs to change something before retrying.

retry_after as an ISO timestamp is more reliable than a seconds integer. If the developer's code processes the response slowly, a seconds-based countdown has already partially elapsed. An absolute timestamp does not have this problem.

For auth errors, be precise about the category

{
  "error": {
    "code": "INSUFFICIENT_SCOPE",
    "message": "The token provided does not have the 'invoices:write' scope required for this endpoint.",
    "required_scopes": ["invoices:write"],
    "provided_scopes": ["invoices:read", "customers:read"],
    "request_id": "req_01HZQKBRW4X9L3F7G2ESHYD0M8"
  }
}

Distinguish UNAUTHENTICATED (no valid token), TOKEN_EXPIRED (valid structure, past expiry), INSUFFICIENT_SCOPE (valid token, wrong permissions), and ACCOUNT_SUSPENDED (valid token, account issue). These require completely different actions from the developer. Conflating them into "Unauthorized" wastes their time.

What to leave out

Stack traces — never in production. Useful locally, a security risk in production, and unreadable to anyone who is not you.

Internal database errorsduplicate key value violates unique constraint "users_email_key" tells the developer your schema. Return EMAIL_ALREADY_REGISTERED instead.

Unstable messages — if your message field changes based on environment, locale, or code version, clients cannot rely on it. Keep messages stable or acknowledge they are not machine-parseable.

Excessive nesting — three levels deep is a practical limit. Deeper than that and clients need to write complex traversal logic.

The shape is a contract

Once you ship an error format, clients depend on it. error.code, error.details[].field — these become keys in client code. Changing them is a breaking change, the same as renaming a response field on a success path. Treat your error schema with the same stability guarantees as the rest of your API contract.

Define it once, document it, enforce it at the framework level, and do not touch the field names.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

When You Push Code Thinking It’s Safe (It Wasn’t)

We all think our code is harmless—until it isn’t. Here’s a candid look at what happens when a “safe” push turns into a full-blown problem.

Read more

How Smart Startups Use Timezone Differences as a Development Advantage

Most founders treat timezone gaps as a cost to manage. The ones moving fastest have figured out how to make them work in their favor.

Read more

Why Tallinn's Digital-First Startups Are the Most Natural Fit for Async Remote Backend Contractors

Estonia built its entire national infrastructure on the assumption that digital-first is just how things work. Its startups carry that assumption into how they operate — and it makes async contracting a natural fit.

Read more

Boston Produces World-Class Engineers — Then Biotech and Finance Take Them All

MIT, Northeastern, BU — Boston graduates some of the best developers in the country. Most of them never work at a startup.

Read more