What a Useful API Error Response Actually Looks Like

by Arif Ikhsanudin, Backend Developer

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

System Design Is Not About Drawing Pretty Diagrams

Most system design conversations produce polished diagrams that look great in a slide deck and fall apart in production. The diagram is not the design — the decisions behind it are.

Read more

You Don't Need a Complex Pipeline to Start. You Need a Working One.

Over-engineered pipelines built before teams understand their actual needs are a major source of CI/CD dysfunction. The path to a mature pipeline runs through a simple, working one — not around it.

Read more

Race Conditions and Visibility in Java — What the Memory Model Actually Guarantees

The Java Memory Model defines precisely which writes are visible to which reads, and under what conditions. Without understanding it, thread-safe code is guesswork. With it, the correct tool for each situation becomes clear.

Read more

Hiring a Senior Backend Developer in Singapore Takes 9 Weeks and S$120K — There Is a Better Way

Nine weeks ago you posted the job. Today you have two maybes, one lowball counteroffer, and a product launch that can't wait any longer.

Read more