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

REST API Design in Practice — The Decisions That Determine Developer Experience

REST APIs are built once and integrated against indefinitely. The design decisions made in the first hour — resource modeling, error shapes, versioning, pagination — determine how much friction every integration will carry forever.

Read more

TSMC and MediaTek Built Taipei's Engineering Culture Around Hardware — Software Backend Is an Afterthought

Taiwan produces some of the world's best engineers. Most of them are building chips, not backend systems — and that shapes the hiring market in ways Taipei software startups feel immediately.

Read more

How to Run Your Spring Boot App and Database Together With Docker Compose

Getting a Spring Boot application and PostgreSQL to start together correctly in Docker Compose requires more than just listing both services — you need health checks, proper dependency ordering, and connection URL configuration that works inside a container network.

Read more

Why Singapore Tech Startups Hire Async Backend Contractors From Across Southeast Asia

Your backend roadmap has six months of work on it. Your team has two engineers, and one of them just gave notice.

Read more