All articles
Developer Experience

API Error Handling Best Practices for Developers

Learn API error handling best practices with a reusable Node.js/Express pattern that reduces integration time, support load, and developer frustration.

API Error Handling Best Practices for Developers cover
14 min read

TL;DR

Most API errors are designed from the server's perspective: they capture what went wrong internally, not what a developer needs to know externally. A well-designed error response answers four questions: what happened, what kind of thing happened, whose fault it is, and what the developer should do next. This article establishes a consistent error schema, implements it as a reusable Express middleware, covers the status code decisions where teams consistently get it wrong, and shows the multi-field validation pattern that every tutorial skips.

The idempotency article and webhook article in this series showed error codes in context. This article explains the design decisions behind them. If you've shipped the rate limiting middleware from the previous article, the 429 handling here connects directly to that.

When an API returns a well-structured error, a developer can diagnose and fix the problem in seconds. When it returns {"error": true, "message": "Something went wrong"}, they spend the next hour on GitHub issues. This article defines the four-question error schema, implements it as reusable Express middleware, covers the status code decisions teams most often get wrong, and shows the multi-field validation pattern that prevents unnecessary round trips.

Two APIs, identical features, different integration times

A developer is choosing between two payment APIs. Same feature set. Similar pricing. Similar documentation coverage. They start integrating both in parallel.

The first API returns structured errors on every failure: a machine-readable code, a human-readable message, the specific field that failed, and a doc_url linking to the exact error in the documentation. When something breaks, the developer knows immediately what happened, whether it was their fault, and where to read more.

The second API returns HTTP status codes and a string message. The strings are inconsistent across endpoints. Some failures return 400, others return 500 for the same category of error. One endpoint returns {"error": true, "message": "Something went wrong"}. The error isn't in the documentation. There's a GitHub issue from six months ago where another developer figured out it's a validation error on a field that isn't in the request schema docs.

The first API takes two hours to integrate. The second takes most of a day because it is not a worse API, but every failure mode has to be discovered in production.

That difference is entirely a design choice. Error design is the part of API DX that controls integration time most directly, and most teams make it while thinking about the server, not the developer on the other side.

Note

What you'll need: Node.js 18+, Express 4+ or 5+, and TypeScript configured for your API codebase.

What bad error design looks like

Bad error design has two failure modes, and they're different problems.

The answer-nothing error:

The most common form looks like this:

// What most APIs return on failure
{ "error": true, "message": "Something went wrong" }

A developer receiving this cannot tell whether the error is retryable, which field failed, or whose fault it is. The error answers none of the questions needed to keep moving.

The inconsistent-schema problem:

This is quieter but causes more damage at scale. Same status code, three different shapes:

// Endpoint A - user service
{ "error": "not_found" }
 
// Endpoint B - payments service
{ "status": 404, "msg": "Resource does not exist" }
 
// Endpoint C - notifications service
{ "code": 404, "description": "Not Found" }

An integration developer now needs three separate error-handling branches for what should be one failure class. As endpoints grow, complexity and support load grow with them.

The four questions every error must answer

A well-designed error response answers exactly four questions. Every field in the schema maps to one of them.

1. What happened? A human-readable message that a developer can read and understand without opening the documentation. Written in plain English, not internal system language.

2. What kind of thing happened? A machine-readable code in snake_case. This is what developers use in their error handling logic, such as if (error.code === 'validation_error'), without parsing strings.

3. Whose fault is it? The HTTP status code carries this signal. 4xx means the caller did something wrong. 5xx means the server is at fault. This determines whether the developer fixes their code or waits for the API to recover.

4. What should the developer do next? A doc_url linking to the specific error in the documentation. Stripe has shipped this as a standard field for years. It is the difference between a developer spending thirty seconds reading the right page and spending thirty minutes on a GitHub issue.

Here's the before and after for a validation failure:

// Before: answers nothing
{ "error": true, "message": "Invalid request" }
 
// After: answers all four questions
{
  "error": {
    "code": "validation_error",       // what kind of thing happened
    "message": "The 'amount' field must be a positive integer.",  // what happened
    "field": "amount",               // specific context
    "doc_url": "https://api.example.com/docs/errors#validation_error"  // what to do next
  }
  // HTTP 422 carries: whose fault it is
}

The status code answers question three. The body answers the other three. That's the complete contract.

The error schema

Step 1: Define the error contract and factory

Create a shared lib/errors.ts module that defines the response shape and a factory function for constructing it:

lib/errors.ts
interface ApiErrorBody {
  error: {
    code: string;
    message: string;
    field?: string;
    fields?: Array<{ field: string; message: string }>;
    doc_url?: string;
  };
}
 
interface ApiErrorResponse {
  status: number;
  body: ApiErrorBody;
}
 
export function apiError({
  code,
  message,
  field,
  fields,
  docUrl,
  status,
}: {
  code: string;
  message: string;
  field?: string;
  fields?: Array<{ field: string; message: string }>;
  docUrl?: string;
  status: number;
}): ApiErrorResponse {
  return {
    status,
    body: {
      error: {
        code,
        message,
        ...(field && { field }),
        ...(fields && fields.length > 0 && { fields }),
        ...(docUrl && { doc_url: docUrl }),
      },
    },
  };
}
 
export const Errors = {
  notFound: (resource: string) =>
    apiError({
      code: "not_found",
      message: `The requested ${resource} could not be found.`,
      status: 404,
    }),
 
  unauthorized: () =>
    apiError({
      code: "unauthorized",
      message: "Authentication is required to access this resource.",
      status: 401,
    }),
 
  forbidden: () =>
    apiError({
      code: "forbidden",
      message: "You do not have permission to perform this action.",
      status: 403,
    }),
 
  rateLimitExceeded: (retryAfter: number) =>
    apiError({
      code: "rate_limit_exceeded",
      message: `Too many requests. Please retry after ${retryAfter} seconds.`,
      status: 429,
    }),
 
  internal: () =>
    apiError({
      code: "internal_error",
      message: "An unexpected error occurred. Our team has been notified.",
      status: 500,
    }),
} as const;

Step 2: Create the error handler middleware

Extend the base Error class so application code can throw structured errors that the middleware catches. Register this handler last in the Express chain.

middleware/error-handler.ts
import type { Request, Response, NextFunction, ErrorRequestHandler } from "express";
import { apiError, Errors } from "../lib/errors";
 
export class ApiError extends Error {
  public readonly status: number;
  public readonly body: ReturnType<typeof apiError>["body"];
  public readonly isApiError = true;
 
  constructor(response: ReturnType<typeof apiError>) {
    super(response.body.error.message);
    this.status = response.status;
    this.body = response.body;
  }
}
 
export const errorHandler: ErrorRequestHandler = (
  err: unknown,
  req: Request,
  res: Response,
  // next must be declared even if unused; Express requires all four params
  _next: NextFunction
) => {
  if (err instanceof ApiError) {
    return res.status(err.status).json(err.body);
  }
 
  // Log the real cause server-side; return nothing useful externally.
  // Never leak stack traces, database messages, internal IDs, or file paths.
  console.error({
    message: "Unhandled error",
    error: err instanceof Error ? err.message : String(err),
    stack: err instanceof Error ? err.stack : undefined,
    path: req.path,
    method: req.method,
  });
 
  return res.status(500).json(Errors.internal().body);
};

Step 3: Register the middleware

Add the handler after all routes so Express routes errors through it:

app.ts
import express from "express";
import { errorHandler } from "./middleware/error-handler";
import { paymentsRouter } from "./routes/payments";
import { usersRouter } from "./routes/users";
 
const app = express();
app.use(express.json());
 
app.use("/api/payments", paymentsRouter);
app.use("/api/users", usersRouter);
 
// Error handler must be registered after all routes
app.use(errorHandler);

Step 4: Throw structured errors from routes

Any route handler can now throw an ApiError directly. The middleware catches it and sends the structured response:

routes/payments.ts
import { Router } from "express";
import { ApiError } from "../middleware/error-handler";
import { apiError } from "../lib/errors";
 
const router = Router();
 
router.post("/charge", async (req, res, next) => {
  try {
    const { amount, currency } = req.body;
 
    if (!amount || typeof amount !== "number" || amount <= 0) {
      throw new ApiError(
        apiError({
          code: "validation_error",
          message: "The 'amount' field must be a positive integer.",
          field: "amount",
          docUrl: "https://api.example.com/docs/errors#validation_error",
          status: 422,
        })
      );
    }
 
    // ... process payment
  } catch (err) {
    next(err); // passes to errorHandler
  }
});
 
export { router as paymentsRouter };

Danger

Never put err.stack, database error messages, internal identifiers, or file paths in a 500 response body. Log them. Return only the stable, generic internal_error response. A leaked stack trace tells an attacker your framework, your file structure, and which dependencies you're running. OWASP A10:2025 names this exact pattern as a top-ten API security failure.

Validation errors: the multi-field case

Single-field errors are straightforward. The schema handles them with the field property. The more important case is a request that fails validation on multiple fields simultaneously.

Most APIs return only the first validation error they encounter. The developer fixes amount, submits again, gets a new error on currency, fixes that, submits again, gets a third error on idempotency_key. Each round trip is friction that compounds across the integration.

Return all validation errors in a single response:

lib/validate.ts
import { ApiError } from "../middleware/error-handler";
import { apiError } from "./errors";
 
interface ValidationRule {
  field: string;
  value: unknown;
  validate: (v: unknown) => boolean;
  message: string;
}
 
export function validateRequest(rules: ValidationRule[]): void {
  const failures = rules
    .filter((rule) => !rule.validate(rule.value))
    .map(({ field, message }) => ({ field, message }));
 
  if (failures.length === 0) return;
 
  throw new ApiError(
    apiError({
      code: "validation_error",
      message: "The request contains invalid fields.",
      fields: failures,
      docUrl: "https://api.example.com/docs/errors#validation_error",
      status: 422,
    })
  );
}

Call it from any route handler by passing a list of field rules:

// Usage in a route handler
validateRequest([
  {
    field: "amount",
    value: req.body.amount,
    validate: (v) => typeof v === "number" && v > 0,
    message: "Must be a positive integer.",
  },
  {
    field: "currency",
    value: req.body.currency,
    validate: (v) => typeof v === "string" && /^[A-Z]{3}$/.test(v as string),
    message: "Must be a valid ISO 4217 currency code.",
  },
  {
    field: "idempotency_key",
    value: req.headers["idempotency-key"],
    validate: (v) => typeof v === "string" && v.length >= 16,
    message: "Must be a string of at least 16 characters.",
  },
]);

The response the developer receives:

{
  "error": {
    "code": "validation_error",
    "message": "The request contains invalid fields.",
    "fields": [
      { "field": "amount", "message": "Must be a positive integer." },
      { "field": "currency", "message": "Must be a valid ISO 4217 currency code." },
      { "field": "idempotency_key", "message": "Must be a string of at least 16 characters." }
    ],
    "doc_url": "https://api.example.com/docs/errors#validation_error"
  }
}

One response. Three problems fixed. Zero additional round trips.

Status code decisions that matter

Most articles list all status codes. What matters in practice is five decisions teams consistently get wrong.

DecisionCorrect useCommon mistake
400 vs 422400 for parsing failures (malformed JSON, bad content-type). 422 for semantic failures (valid JSON, failed business rules).Using 400 for everything.
401 vs 403401 when identity is missing or invalid. 403 when identity is known but not permitted.Using 401 for permission failures; sends developers to fix the wrong thing.
404 vs 403 on private resources403 is accurate but leaks that the resource exists. 404 hides existence and reduces enumeration risk.Always returning 403 on private resources.
503 vs 500503 for transient outages where retry is expected; include Retry-After. 500 for actual server failures.Using 500 for everything, which signals "don't retry" when retry is appropriate.

429 and Retry-After. A 429 without Retry-After gives clients no reliable backoff target. Always include it as integer seconds, not an HTTP date. This is what modern clients expect and is resilient to clock skew.

// In your rate limiting middleware
if (!result.allowed) {
  const retryAfter = result.resetAt - Math.floor(Date.now() / 1000);
  res.setHeader("Retry-After", retryAfter); // integer seconds, not HTTP date
  res.setHeader("X-RateLimit-Limit", limit);
  res.setHeader("X-RateLimit-Remaining", 0);
  res.setHeader("X-RateLimit-Reset", result.resetAt);
 
  throw new ApiError(Errors.rateLimitExceeded(retryAfter));
}

Machine-readable error codes

HTTP status codes are coarse. 400 covers dozens of different failure modes. Machine-readable error codes let integration developers handle specific cases without parsing human-readable strings.

Design principles that make codes usable:

  • snake_case strings, not integers. validation_error is self-documenting in a log, a Sentry trace, or a client-side error handler. 4001 is not.
  • Namespace by domain. payment_method_declined is unambiguous. declined is not.
  • Be specific without being verbose. card_insufficient_funds is right. the_card_was_declined_due_to_insufficient_funds is not.

The exercise of defining your error code catalog forces you to enumerate every failure mode before they're discovered in production. That list becomes your error documentation, your QA checklist, and your on-call runbook simultaneously.

What changes for the developer

With this schema, the second API from the opening now returns:

{
  "error": {
    "code": "validation_error",
    "message": "The 'amount' field must be a positive integer.",
    "field": "amount",
    "doc_url": "https://api.example.com/docs/errors#validation_error"
  }
}

The developer who spent most of a day on the second API can now classify the failure (code), fix the right field (field), and read the exact doc page in thirty seconds (doc_url). That is the integration experience that takes two hours, not a day.

When the standard schema doesn't fit

Streaming APIs. A streaming response that errors mid-stream cannot return a JSON body in the standard shape because the HTTP headers have already been sent with a 200 status. The convention is to emit an error event in the stream or a trailing error frame in the final chunk. The error object inside the event or frame should follow the same schema with code, message, and doc_url, even though the transport is different.

Note

GraphQL. This transport model is different: HTTP status is often 200, and errors live in the response errors array. The same principles still apply and should be mapped through each error's extensions.

API versioning. Changing your error schema is a breaking change. If you ship v1 with an inconsistent schema, clients will write error handling branches against whatever shape you shipped. Changing it requires a new API version. Establish the schema before v1 ships. If you are already past that point, version the error schema independently. Add the new fields without removing the old ones until a major version gives you the clean break.

Share𝕏

Writer

  • Wale Bashir

    Technical content writer and full-stack engineer with experience across Web3, AI, and backend systems.

Need help with your technical content?

We help B2B SaaS teams turn complex products into clear documentation and content that developers actually use.

Book a call
API Error Handling Best Practices for Developers | Reclear