Skip to main content

Overview

A production-quality integration needs to handle every possible failure case. This guide covers strategies for dealing with API errors, network issues, rate limits, and edge cases.

Error response format

All errors follow a consistent structure:
{
  "error": {
    "code": 422,
    "type": "validation_error",
    "message": "Amount must be greater than 0",
    "details": [
      {
        "field": "amount",
        "message": "Must be a positive decimal string"
      }
    ]
  }
}

Retry strategy

Not all errors should be retried. Here is how to decide:
Status CodeActionRetry?
400 Bad RequestFix the request payloadNo
401 UnauthorizedCheck your API keyNo
403 ForbiddenCheck permissionsNo
404 Not FoundCheck the resource IDNo
409 ConflictCheck resource stateNo
422 ValidationFix the validation errorNo
429 Rate LimitedWait and retryYes — respect Retry-After
500 Server ErrorRetry with backoffYes
502 Bad GatewayRetry with backoffYes
503 UnavailableRetry with backoffYes

Rate limit handling

The API enforces per-merchant rate limits (1,000 requests/minute). When you exceed the limit, the API returns 429 Too Many Requests with a Retry-After header indicating how many seconds to wait.
HTTP/1.1 429 Too Many Requests
Retry-After: 12
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1708000012
{
  "error": {
    "code": 429,
    "type": "rate_limit_exceeded",
    "message": "Rate limit exceeded. Retry after 12 seconds."
  }
}
Handling strategy:
  1. Read the Retry-After header value (in seconds)
  2. Wait for that duration before retrying
  3. If you are consistently hitting rate limits, reduce your request frequency or batch operations where possible

Exponential backoff

For retryable errors (5xx and network failures), use exponential backoff with jitter:
async function fetchWithRetry(url, options, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);

      // Don't retry client errors (except 429)
      if (response.status >= 400 && response.status < 500 && response.status !== 429) {
        return response;
      }

      // Retry rate limits
      if (response.status === 429) {
        const retryAfter = parseInt(response.headers.get("Retry-After") || "5");
        await sleep(retryAfter * 1000);
        continue;
      }

      // Retry server errors
      if (response.status >= 500) {
        if (attempt < maxRetries) {
          await sleep(getBackoff(attempt));
          continue;
        }
      }

      return response;
    } catch (err) {
      // Network error -- retry
      if (attempt < maxRetries) {
        await sleep(getBackoff(attempt));
        continue;
      }
      throw err;
    }
  }
}

function getBackoff(attempt) {
  const base = 1000 * Math.pow(2, attempt); // 1s, 2s, 4s, 8s...
  const jitter = Math.random() * 1000;       // 0-1s random jitter
  return base + jitter;
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

Payout-specific error handling

Insufficient balance

{
  "error": {
    "code": 400,
    "message": "Insufficient balance: available USD 5000.00, required USD 10000.00"
  }
}
Do not retry. Instead:
  1. Check your current balance via the Balances API
  2. Wait for pending payouts to complete (releasing held funds)
  3. Fund your account if needed
  4. Then retry the payout

Duplicate idempotency key

{
  "error": {
    "code": 409,
    "message": "Idempotency key already used with different request body"
  }
}
Do not retry with the same key. This means you reused an idempotency key with a different payload. Generate a new key for the new request.

Invalid payee

{
  "error": {
    "code": 404,
    "message": "Payee not found: pye_invalid"
  }
}
Do not retry. Verify the payee ID exists and belongs to your merchant account. Ensure you are using the correct environment (sandbox vs production).

Invalid state transition

{
  "error": {
    "code": 409,
    "message": "Cannot cancel payout in status 'completed'"
  }
}
Do not retry. The payout has already progressed past the point where this action is valid. Fetch the current payout status to understand the actual state.

Circuit breaker pattern

If you are making many API calls, implement a circuit breaker to fail fast when the API is having issues:
class CircuitBreaker {
  constructor(threshold = 5, resetTimeout = 60000) {
    this.failures = 0;
    this.threshold = threshold;
    this.resetTimeout = resetTimeout;
    this.state = "closed"; // closed = normal, open = failing
    this.nextAttempt = 0;
  }

  async call(fn) {
    if (this.state === "open") {
      if (Date.now() < this.nextAttempt) {
        throw new Error("Circuit breaker is open -- API unavailable");
      }
      this.state = "half-open";
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (err) {
      this.onFailure();
      throw err;
    }
  }

  onSuccess() {
    this.failures = 0;
    this.state = "closed";
  }

  onFailure() {
    this.failures++;
    if (this.failures >= this.threshold) {
      this.state = "open";
      this.nextAttempt = Date.now() + this.resetTimeout;
    }
  }
}

// Usage
const breaker = new CircuitBreaker(5, 60000);

async function createPayout(payload) {
  return breaker.call(() =>
    fetchWithRetry("https://api.antonpayments.dev/v1/payouts", {
      method: "POST",
      headers: {
        "Authorization": "Bearer ak_test_...",
        "Content-Type": "application/json",
        "Idempotency-Key": payload.reference,
      },
      body: JSON.stringify(payload),
    })
  );
}

Testing error scenarios

In sandbox, you can trigger specific error conditions to test your error handling:
ScenarioHow to trigger
Insufficient balanceCreate a payout larger than your sandbox balance
Invalid payeeUse a non-existent payee ID (e.g., pye_invalid)
Invalid instrumentUse a non-existent instrument ID
Validation errorOmit required fields or use invalid values
Rate limitingSend many requests rapidly from the same API key
Idempotency conflictReuse an idempotency key with a different payload