API response codes: examples and error-handling strategies

How to handle every HTTP status code your API integration will encounter. From 2xx edge cases to 504 write-operation unknowns, this guide covers retry strategies, idempotency keys, circuit breakers, and structured error logging for accounting and financial integrations.

Kateryna PoryvayKateryna Poryvay

Kateryna Poryvay

12 min read
API response codes: examples and error-handling strategies

When you make an API call, the status code in the response is the first thing your code should read. That three-digit number tells you whether to parse the response or fix what you sent. Most applications handle this badly: they check for 200, maybe catch a generic exception, and log everything else as "API error." That works until it doesn't, which is usually the moment a customer's data is silently wrong.

This guide covers the codes you'll actually encounter when building accounting and financial integrations, and the error-handling patterns that make the difference between applications that degrade gracefully and ones that corrupt state.

A minimal response handler looks like this:

async function apiRequest(url, options) {
  const res = await fetch(url, options);

  if (res.ok) return res.json();

  const body = await res.json().catch(() => ({}));

  switch (res.status) {
    case 400: throw new BadRequestError(body);
    case 401: throw new UnauthorizedError(body);
    case 403: throw new ForbiddenError(body);
    case 404: throw new NotFoundError(body);
    case 409: throw new ConflictError(body);
    case 422: throw new ValidationError(body);
    case 429: throw new RateLimitError(res.headers, body);
    default:  throw new ApiError(res.status, body);
  }
}

The rest of this guide explains what each of those branches means and how to handle it correctly.

The 2xx range: success has more than one shape

200 OK is the simple case. The request worked, the response body contains what you asked for. For GET requests that means data; for PATCH or PUT, it typically means a representation of the updated resource.

201 Created means the request worked and a new resource exists on the server. POST requests to create invoices or transactions should return 201, not 200. If you're using a provider that returns 200 for POST requests, that's a design choice, not an error, but it means you can't distinguish creation from retrieval by status code alone.

202 Accepted is the one developers most often mishandle. The server got your request and will process it, but it hasn't finished yet. Sync operations for accounting platforms frequently use this pattern: you kick off a sync, get a 202, and then poll a status endpoint to know when it's complete. Read the response body for a job ID, store it, and poll at a sensible interval with a maximum retry count. Polling every 100ms until you get a result is not a sensible interval.

async function triggerSyncAndWait(connectionId) {
  // Kick off the sync
  const { jobId } = await apideck.accounting.triggerSync(connectionId);

  // Poll until complete or max attempts reached
  const MAX_POLLS = 20;
  const POLL_INTERVAL_MS = 3000;

  for (let i = 0; i < MAX_POLLS; i++) {
    await sleep(POLL_INTERVAL_MS);
    const { status } = await apideck.accounting.getSyncStatus(jobId);
    if (status === 'complete') return;
    if (status === 'failed') throw new Error(`Sync ${jobId} failed`);
  }

  throw new Error(`Sync ${jobId} did not complete after ${MAX_POLLS} polls`);
}

The 3xx range: redirects you shouldn't ignore

301 Moved Permanently and 302 Found are largely handled by HTTP libraries, but they matter for one specific reason: if you're hardcoding API endpoints rather than building them from a base URL config, you'll hit 301s when providers update their URL structure and your library might follow the redirect silently without updating your stored endpoint. The right response to a 301 is to update your configuration, not just let the redirect happen forever.

The 4xx range: your code has a problem

This is where most integration bugs live.

400 Bad Request means the server couldn't parse or accept the request as sent. This might mean malformed JSON or missing required fields, but it can also be a type mismatch between what you sent and what the endpoint expects. The response body usually contains an error message worth logging verbatim. Handling 400 generically as "invalid request" leaves you debugging blind.

422 Unprocessable Entity is often confused with 400, but the distinction matters for how you respond. 400 is a structural problem: the request couldn't be parsed or is missing required shape. 422 is semantic: the request was well-formed but the data failed business-rule validation. A date range where the end date precedes the start date is a 422. An account ID reference that doesn't exist in this company is a 422. An invoice total that doesn't reconcile with its line items is a 422. Some providers use 400 for both; when that happens you need to parse the error body to distinguish them, because the fix is different in each case.

// QuickBooks returns 400 for validation errors; Xero returns 422.
// Parse the body to determine the actual problem.
function handleValidationError(status, body) {
  const message = body?.Fault?.Error?.[0]?.Message    // QuickBooks
    ?? body?.Elements?.[0]?.ValidationErrors?.[0]?.Message // Xero
    ?? body?.message
    ?? 'Unknown validation error';

  console.error(`Validation error (${status}):`, message);
  // Surface to user, do not retry
}

401 Unauthorized means the request didn't include valid credentials, or the credentials have expired. For OAuth-based integrations, a 401 typically means your access token needs refreshing. Most libraries handle token refresh automatically, but the failure mode when they don't is silent: the refresh fails, the 401 propagates, and whatever data operation you were trying to do gets skipped. Treat 401 responses as a trigger for explicit token refresh logic, not just a retry.

async function requestWithRefresh(url, options, connection) {
  let res = await fetch(url, withAuth(options, connection.accessToken));

  if (res.status === 401) {
    // Attempt token refresh once
    connection.accessToken = await refreshAccessToken(connection.refreshToken);
    await saveConnection(connection);
    res = await fetch(url, withAuth(options, connection.accessToken));
  }

  if (!res.ok) throw new ApiError(res.status, await res.json().catch(() => ({})));
  return res.json();
}

403 Forbidden is different from 401. The credentials are valid, but they don't have permission to do what you're asking. This comes up regularly when a user connects an accounting platform with a read-only role and your integration tries to write data. A retry won't help. Surface a meaningful error to the user explaining what permissions they need to grant, not a silent failure.

404 Not Found is usually one of three things: a typo in your endpoint URL, a resource that was deleted, or an ID that doesn't exist in that account. For accounting integrations, deleted resources come up regularly. Customers archive invoices and deactivate accounts without warning. Your application needs to handle 404s gracefully rather than throwing an unhandled exception.

409 Conflict means the request conflicts with the current state of the resource. Duplicate invoice creation is a common trigger, as is concurrent modification. If your integration retries a POST after a timeout, you may get a 409 on the second attempt because the first request actually succeeded. For financial data, 409 handling requires explicit deduplication logic, typically using idempotency keys if the provider supports them.

429 Too Many Requests means you've hit a rate limit. The response usually includes a Retry-After header telling you how long to wait, or X-RateLimit-Remaining and X-RateLimit-Reset headers showing the current window state. Many implementations ignore these and retry after a fixed delay, which either under-waits (triggering another 429 immediately) or over-waits unnecessarily. When Retry-After is present, use it. When it's absent, start short and increase exponentially.

async function handleRateLimit(res) {
  // Prefer Retry-After if present
  const retryAfter = res.headers.get('Retry-After');
  if (retryAfter) {
    const delayMs = isNaN(retryAfter)
      ? new Date(retryAfter) - Date.now()  // HTTP-date format
      : Number(retryAfter) * 1000;         // delta-seconds format
    await sleep(Math.max(delayMs, 0));
    return;
  }

  // Fall back to X-RateLimit-Reset if available
  const resetAt = res.headers.get('X-RateLimit-Reset');
  if (resetAt) {
    await sleep(Math.max(Number(resetAt) * 1000 - Date.now(), 0));
    return;
  }

  // No header: start with 1 second (caller handles backoff)
  await sleep(1000);
}

The 5xx range: the server has a problem

500 Internal Server Error means something broke on the provider's side. The response body often contains a request ID or trace ID. Log it. If you report the issue to the provider's support team, that ID is how they'll find it in their logs. Retrying a 500 sometimes works if it was a transient failure; cap the retries and log each attempt.

502 Bad Gateway and 503 Service Unavailable are both forms of temporary unavailability. 502 typically means a proxy received an invalid response from an upstream server. 503 means the server is explicitly unavailable, often due to maintenance or overload. Both are candidates for retry with exponential backoff. The practical difference is that 503 responses often include a Retry-After header while 502 responses rarely do.

504 Gateway Timeout creates a specific problem for write operations. If a POST to create a transaction times out with a 504, you don't know whether the transaction was created or not. Treat 504s on write operations as "state unknown," check for the resource explicitly before retrying, and use an idempotency key on the retry so that if the original request succeeded, the retry returns the original response rather than creating a duplicate.

// For writes, check before retrying after a 504
async function createInvoiceSafe(payload) {
  const idempotencyKey = payload.externalRef; // a stable ID from your system

  try {
    return await apideck.accounting.createInvoice(payload, { idempotencyKey });
  } catch (err) {
    if (err.status !== 504) throw err;

    // State unknown: check whether the invoice was actually created
    const existing = await apideck.accounting.getInvoices({
      filter: { externalRef: payload.externalRef }
    });

    if (existing.length > 0) return existing[0]; // already created

    // Not found: safe to retry with the same idempotency key
    return apideck.accounting.createInvoice(payload, { idempotencyKey });
  }
}

Error-handling strategies

When retrying, use exponential backoff with jitter: a 1-second base delay that doubles on each retry, with a random offset added to prevent multiple clients from retrying in lockstep. Set a maximum retry count and a ceiling on the wait time. On which codes to retry: 400 and 422 errors rarely benefit from a retry without also changing the request. 401 warrants a token refresh first. 403 warrants a user-facing error. The 5xx codes and 429 are worth retrying with backoff, respecting any Retry-After header that's present.

const RETRYABLE = new Set([429, 500, 502, 503, 504]);

async function withRetry(fn, maxAttempts = 4) {
  let attempt = 0;

  while (true) {
    try {
      return await fn();
    } catch (err) {
      attempt++;
      if (!RETRYABLE.has(err.status) || attempt >= maxAttempts) throw err;

      // Respect Retry-After if present
      if (err.retryAfterMs != null) {
        await sleep(err.retryAfterMs);
        continue;
      }

      // Exponential backoff with full jitter
      const cap = 30_000;
      const base = 1_000 * Math.pow(2, attempt - 1);
      const delay = Math.random() * Math.min(cap, base);
      await sleep(delay);
    }
  }
}

For POST requests that create financial records, include an idempotency key if the provider supports it. Xero and Stripe both support idempotency keys natively. If a request with a given key succeeds, subsequent requests with the same key return the original response rather than creating a duplicate. This makes retries on network timeouts safe without risking duplicate invoices or transactions in the customer's accounting system.

// Xero: pass idempotency key as a header
const res = await fetch('https://api.xero.com/api.xro/2.0/Invoices', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': invoice.externalRef, // stable ID from your system
  },
  body: JSON.stringify(invoice),
});

If an endpoint is returning 5xx errors consistently, stop retrying it. A circuit breaker watches for consecutive failures and automatically pauses requests to the failing endpoint for a configured period before attempting a probe request to see if it has recovered. Libraries like Resilience4j in Java or Polly in .NET handle this well. For simpler setups, a flag that disables retries after 10 consecutive errors is better than a loop that hammers a failing endpoint.

Structured logging is the investment teams undervalue until they're in an incident. For every non-2xx response, log the status code and the request ID returned by the provider, along with the endpoint and HTTP method. Include enough of the response body to understand what failed. Avoid logging full request payloads (API keys, PII) in production, but log enough to reconstruct what happened.

function logApiError(method, url, status, responseBody) {
  const requestId =
    responseBody?.requestId ??
    responseBody?.id ??
    responseBody?.Fault?.Error?.[0]?.Detail ?? // QuickBooks
    null;

  console.error(JSON.stringify({
    level: 'error',
    method,
    url,
    status,
    requestId,
    message: responseBody?.message ?? responseBody?.detail ?? null,
    ts: new Date().toISOString(),
  }));
}

For teams building against multiple accounting APIs, provider inconsistency is a real operational cost. QuickBooks returns a 400 where Xero returns a 422 for the same class of validation error, and rate limit handling diverges just as sharply across providers. Apideck's Accounting API normalizes these differences before they reach your application, so your error-handling logic doesn't need to branch by provider.

The status code is the first word in every API conversation. If your application isn't listening to it, it's guessing.

Ready to get started?

Scale your integration strategy and deliver the integrations your customers need in record time.

Ready to get started?
Talk to an expert

Frequently asked questions

Trusted by fast-moving product & engineering teams

JobNimbus
Blue Zinc
Exact
Drata
Octa
Apideck Blog

Insights, guides, and updates from Apideck

Discover company news, API insights, and expert blog posts. Explore practical integration guides and tech articles to make the most of Apideck's platform.