<!-- ZenPays documentation · https://docs.zenpayz.com/docs/rest-api/errors -->

# Error Handling

The ZenPays REST API uses standard HTTP status codes and returns detailed error information in JSON format.

## Error Response Format

All error responses follow this structure:

```json
{
  "success": false,
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable error message",
    "details": {
      // Additional error context (optional)
    }
  }
}
```

## HTTP Status Codes

| Status | Description |
|--------|-------------|
| `200` | Success |
| `201` | Resource created |
| `400` | Bad Request - Invalid parameters |
| `401` | Unauthorized - Invalid or missing authentication |
| `403` | Forbidden - Insufficient permissions |
| `404` | Not Found - Resource doesn't exist |
| `409` | Conflict - Resource state conflict |
| `422` | Unprocessable Entity - Validation error |
| `429` | Too Many Requests - Rate limit exceeded |
| `500` | Internal Server Error |
| `502` | Bad Gateway - TSP provider error |
| `503` | Service Unavailable |

## Error Codes

### Authentication Errors (401)

| Code | Description |
|------|-------------|
| `UNAUTHORIZED` | Missing or invalid API key |
| `INVALID_SIGNATURE` | HMAC signature verification failed |
| `TIMESTAMP_EXPIRED` | Request timestamp outside allowed window |
| `API_KEY_REVOKED` | API key has been revoked |
| `API_KEY_EXPIRED` | API key has expired |

### Authorization Errors (403)

| Code | Description |
|------|-------------|
| `FORBIDDEN` | Access denied |
| `IP_NOT_WHITELISTED` | Request from unauthorized IP |
| `INSUFFICIENT_PERMISSIONS` | API key lacks required scope |
| `MERCHANT_SUSPENDED` | Merchant account is suspended |

### Validation Errors (400/422)

| Code | Description |
|------|-------------|
| `INVALID_REQUEST` | Request format is invalid |
| `VALIDATION_ERROR` | One or more fields failed validation |
| `INVALID_AMOUNT` | Amount is invalid or out of range |
| `INVALID_CURRENCY` | Currency code not supported |
| `INVALID_PAYMENT_METHOD` | Payment method not available |
| `MISSING_REQUIRED_FIELD` | Required field is missing |

### Resource Errors (404/409)

| Code | Description |
|------|-------------|
| `RESOURCE_NOT_FOUND` | Requested resource doesn't exist |
| `PAYMENT_INTENT_NOT_FOUND` | Payment intent ID is invalid |
| `TRANSACTION_NOT_FOUND` | Transaction ID is invalid |
| `CUSTOMER_NOT_FOUND` | Customer ID is invalid |
| `REFUND_NOT_FOUND` | Refund ID is invalid |
| `DUPLICATE_REQUEST` | Request with same idempotency key exists |
| `STATE_CONFLICT` | Resource is in conflicting state |

### Payment Errors

| Code | Description |
|------|-------------|
| `PAYMENT_FAILED` | Payment processing failed |
| `PAYMENT_DECLINED` | Payment was declined by provider |
| `PAYMENT_EXPIRED` | Payment intent has expired |
| `INSUFFICIENT_FUNDS` | Customer has insufficient funds |
| `CARD_DECLINED` | Card was declined |
| `FRAUD_DETECTED` | Suspicious activity detected |
| `LIMIT_EXCEEDED` | Transaction limit exceeded |

### Channel Limit Errors (422)

These errors occur during payment confirmation when the amount violates min/max limits configured per payment method and currency.

| Code | Description |
|------|-------------|
| `CHANNEL_LIMIT_MIN_EXCEEDED` | Amount is below the minimum for the selected payment method |
| `CHANNEL_LIMIT_MAX_EXCEEDED` | Amount exceeds the maximum for the selected payment method |

```json
{
  "success": false,
  "error": {
    "code": "CHANNEL_LIMIT_MIN_EXCEEDED",
    "message": "The payment amount of 100 INR is below the minimum limit of 500 INR for UPI. Please use a different payment method or increase the amount.",
    "type": "validation_error",
    "details": {
      "amount": 100,
      "currency": "INR",
      "paymentMethod": "UPI",
      "minimumAmount": 500,
      "maximumAmount": 49967
    }
  }
}
```

### Refund Errors

| Code | Description |
|------|-------------|
| `REFUND_FAILED` | Refund processing failed |
| `REFUND_NOT_ALLOWED` | Transaction cannot be refunded |
| `REFUND_AMOUNT_EXCEEDED` | Refund amount exceeds original |
| `REFUND_WINDOW_CLOSED` | Refund period has expired |

### Rate Limit Errors (429)

| Code | Description |
|------|-------------|
| `RATE_LIMIT_EXCEEDED` | Too many requests |

Response includes retry information:

```json
{
  "success": false,
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Rate limit exceeded. Retry after 60 seconds",
    "details": {
      "limit": 1000,
      "remaining": 0,
      "resetAt": "2024-01-15T10:31:00.000Z"
    }
  }
}
```

### Provider Errors (502)

| Code | Description |
|------|-------------|
| `TSP_ERROR` | Payment provider returned an error |
| `TSP_TIMEOUT` | Payment provider request timed out |
| `TSP_UNAVAILABLE` | Payment provider is unavailable |

### Operational Errors

These codes describe higher-level "we couldn't even attempt this" failures —
distinct from numeric transaction-state codes (bank decline, insufficient
funds, etc.). They surface in `error.code` of the standard error envelope and
the checkout SDK's `payment-failed` socket event.

| Code | HTTP | Customer-facing message | What it means | Suggested action |
|------|:----:|-------------------------|---------------|------------------|
| `NO_TSP_AVAILABLE` | 503 | "We can't process this payment right now. Please try again in a few minutes, or contact support if the issue persists." | No active payment routes are configured to fulfill the request. | Retry after a short delay; escalate to support if persistent. |
| `UNSUPPORTED_REGION` | 400 | "This payment route isn't available in your region right now. Please try a different method or contact support." | TSPs exist but none support the customer's country. | Offer an alternate method or surface a region-specific support flow. |
| `UNSUPPORTED_CURRENCY_PAIR` | 400 | "We couldn't set up this payment with our provider. Try another currency or contact support." | The provider rejected the source/destination currency combination. | Suggest a supported currency. |
| `PROVIDER_VALIDATION_ERROR` | 400 | "We hit a problem setting up your payment. Please double-check your details and try again, or contact support." | Upstream provider returned a 4xx the platform couldn't normalize further. | Inspect `details.providerResponse` server-side; ask the customer to retry. |
| `PROVIDER_UNAVAILABLE` | 502 | "Our payment partner is having trouble right now. Please try again in a few minutes." | Upstream provider returned 5xx or timed out. | Retry with backoff; consider failover routing. |
| `PAYMENT_STUCK` | 200 | "This is taking longer than usual. We're still trying — feel free to wait, or come back via your dashboard." | The intent has been in `processing` past the heartbeat threshold (~90s) with no transitions. Advisory only — *not* a terminal failure. | Show a friendlier "still working" UI; keep the subscription alive. |
| `PAYMENT_RETRIES_EXHAUSTED` | 400 | "We tried several times but couldn't complete this payment. Please try a different payment method or contact support." | Retry budget exhausted; intent moved to `canceled`. | Offer a different method; investigate the last TSP failure for the underlying cause. |
| `TSP_PAYMENT_FAILED` | 400 | "Your payment couldn't be completed. Please try a different payment method or contact your bank." | TSP / webhook reported a terminal payment failure. | Surface to the customer; inspect `details.providerResponse` for upstream reason. |
| `CRYPTO_DEPOSIT_FAILED` | 400 | "We couldn't confirm your crypto deposit. If you've already sent funds, contact support with the transaction hash." | Crypto deposit reported failure (chain reorg, address mismatch, amount divergence). | Capture the tx hash and route to support. |

These codes also appear as `code` on the WebSocket `payment-failed` event
(see [Payment Webhooks](../guides/webhooks/payment-webhooks)).

## Validation Error Details

Validation errors include field-specific details:

```json
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "details": {
      "fields": [
        {
          "field": "amount",
          "message": "Amount must be greater than 0",
          "code": "min"
        },
        {
          "field": "currency",
          "message": "Currency must be a valid ISO 4217 code",
          "code": "invalid"
        }
      ]
    }
  }
}
```

## Error Handling Examples

<Tabs groupId="language">
  <TabItem value="javascript" label="JavaScript" default>

```javascript
async function createPayment(amount, currency) {
  try {
    const response = await fetch('https://api.zenpayz.com/api/v1/payment-intents', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${apiKey}`,
        'X-Timestamp': timestamp,
        'X-Signature': signature,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ amount, currency }),
    });

    const data = await response.json();

    if (!response.ok) {
      // Handle specific error codes
      switch (data.error?.code) {
        case 'RATE_LIMIT_EXCEEDED':
          const retryAfter = data.error.details?.resetAt;
          console.log(`Rate limited. Retry after: ${retryAfter}`);
          break;
        case 'VALIDATION_ERROR':
          data.error.details?.fields?.forEach(field => {
            console.log(`${field.field}: ${field.message}`);
          });
          break;
        case 'UNAUTHORIZED':
        case 'INVALID_SIGNATURE':
          console.log('Authentication failed. Check your API key and signature.');
          break;
        default:
          console.log(`Error: ${data.error?.message}`);
      }
      return null;
    }

    return data.data;
  } catch (error) {
    console.error('Network error:', error.message);
    return null;
  }
}
```

  </TabItem>
  <TabItem value="python" label="Python">

```python
import requests

def create_payment(amount: int, currency: str):
    try:
        response = requests.post(
            "https://api.zenpayz.com/api/v1/payment-intents",
            headers={
                "Authorization": f"Bearer {api_key}",
                "X-Timestamp": timestamp,
                "X-Signature": signature,
                "Content-Type": "application/json",
            },
            json={"amount": amount, "currency": currency},
        )

        data = response.json()

        if not response.ok:
            error = data.get("error", {})
            code = error.get("code")

            if code == "RATE_LIMIT_EXCEEDED":
                retry_after = error.get("details", {}).get("resetAt")
                print(f"Rate limited. Retry after: {retry_after}")
            elif code == "VALIDATION_ERROR":
                for field in error.get("details", {}).get("fields", []):
                    print(f"{field['field']}: {field['message']}")
            elif code in ("UNAUTHORIZED", "INVALID_SIGNATURE"):
                print("Authentication failed. Check your API key and signature.")
            else:
                print(f"Error: {error.get('message')}")
            return None

        return data.get("data")

    except requests.exceptions.RequestException as e:
        print(f"Network error: {e}")
        return None
```

  </TabItem>
</Tabs>

## Retry Strategy

For transient errors, implement exponential backoff:

<Tabs groupId="language">
  <TabItem value="javascript" label="JavaScript" default>

```javascript
async function requestWithRetry(fn, maxRetries = 3) {
  let lastError;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;
      const code = error.code;

      // Only retry on transient errors
      const retriableErrors = [
        'RATE_LIMIT_EXCEEDED',
        'TSP_TIMEOUT',
        'TSP_UNAVAILABLE',
      ];

      if (!retriableErrors.includes(code)) {
        throw error;
      }

      // Exponential backoff: 1s, 2s, 4s...
      const delay = Math.pow(2, attempt) * 1000;
      console.log(`Retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }

  throw lastError;
}
```

  </TabItem>
  <TabItem value="python" label="Python">

```python
import time

def request_with_retry(fn, max_retries=3):
    last_error = None

    for attempt in range(max_retries):
        try:
            return fn()
        except Exception as error:
            last_error = error
            code = getattr(error, "code", None)

            # Only retry on transient errors
            retriable_errors = [
                "RATE_LIMIT_EXCEEDED",
                "TSP_TIMEOUT",
                "TSP_UNAVAILABLE",
            ]

            if code not in retriable_errors:
                raise error

            # Exponential backoff: 1s, 2s, 4s...
            delay = (2 ** attempt)
            print(f"Retrying in {delay}s (attempt {attempt + 1}/{max_retries})")
            time.sleep(delay)

    raise last_error
```

  </TabItem>
</Tabs>

## Idempotency

To prevent duplicate operations, include an `X-Idempotency-Key` header:

```bash
curl -X POST https://api.zenpayz.com/api/v1/payment-intents \
  -H "Authorization: Bearer zp_live_xxxxx" \
  -H "X-Idempotency-Key: unique-request-id-123" \
  -H "Content-Type: application/json" \
  -d '{"amount": 1000, "currency": "USD"}'
```

If the same idempotency key is used within 24 hours, the API returns the original response instead of creating a duplicate.

## Next Steps

- [Authentication](/docs/rest-api/authentication) - Learn about request signing
- [Error Handling Guide](/docs/guides/error-handling) - SDK error handling
- [Webhooks](/docs/guides/webhooks) - Handle async notifications
