Error handling
How do you ensure your custom integrations and code are resilient to errors? This tutorial describes typical scenarios and related topics such as authentication, HTTP responses, and error handling with the Zuora v1 API.
Tutorial scope
This tutorial covers the following failure scenarios:
- Unable to obtain an OAuth token : Get a token, recover from when they expire.
- Order creation returns HTTP 200 but includes error code 53100320 : Zuora received your request and processed it without an HTTP error but something in your request is wrong. HTTP 200 doesn't mean your call succeeded , you have to read the response body to see if that's the case.
- Timeouts : Your code sends a create order request, but the network dies before you get a response. You don't know if the order was created. You retry, but what if the order was created the first time? Now there are two identical orders, which can result in downstream billing issues.
- Your requests start failing with 429 : You're hitting Zuora's rate limits.
Authentication and token management
Before you can make any API calls, you need credentials, for Zuora APIs that means OAuth 2.0 credentials. Use your Zuora login or ask an administrator of your Zuora tenant to create a client ID and secret for you. See Get started for a tutorial explaining this process along with a chart to figure out the correct REST endpoint for your tenant. Use your credentials to request a token from the Create an OAuth token operation. Tokens expire after one hour. When you get a 401 response, request a new token and retry. Cache tokens until near expiration to avoid unnecessary token requests.
Example OAuth token request:
import time
from typing import Optional
import requests
from requests.exceptions import RequestException
DEFAULT_EXPIRES_SECONDS = 3600
REFRESH_SKEW_SECONDS = 300
class AuthenticationError(Exception):
"""Raised when OAuth token acquisition fails."""
pass
class ZuoraAuth:
"""Minimal OAuth client-credentials token manager for Zuora."""
def __init__(self, client_id: str, client_secret: str, base_url: str):
self.client_id = client_id
self.client_secret = client_secret
self.base_url = base_url.rstrip("/")
self.token: Optional[str] = None
self.token_expires_at_monotonic: Optional[float] = None
def get_token(self) -> str:
# if unexpired token is available, return it
if self.token and self.token_expires_at_monotonic:
if time.monotonic() < self.token_expires_at_monotonic - REFRESH_SKEW_SECONDS:
return self.token
# request new token
try:
response = requests.post(
f"{self.base_url}/oauth/token",
data={
"client_id": self.client_id,
"client_secret": self.client_secret,
"grant_type": "client_credentials",
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=(5, 10),
)
except RequestException as e:
raise AuthenticationError(f"Token request failed: {e}") from e
if not response.ok:
raise AuthenticationError(f"Token request failed: {response.status_code}")
data = response.json()
self.token = data["access_token"]
expires_in = data.get("expires_in")
try:
expires_seconds = int(float(str(expires_in))) if expires_in is not None else DEFAULT_EXPIRES_SECONDS
except (ValueError, TypeError):
expires_seconds = DEFAULT_EXPIRES_SECONDS
self.token_expires_at_monotonic = time.monotonic() + expires_seconds
return self.tokenStore the client ID and secret securely using environment variables or a secrets manager. Rotate credentials regularly. If credentials are compromised, you can revoke them immediately on the same page where you created the OAuth client.
HTTP status codes
Zuora returns standard HTTP status codes:
-
2XX status codes:
- 200 means Zuora processed your request, but you may still have an error. See When status code 200 means a business logic failure
- 201 means a resource was created.
- 204 means the request worked but there's no body to return.
-
4XX status codes mean something is wrong:
- 400 means the request was malformed or missing required fields.
- 401 means the credentials are invalid or missing.
- 403 means you're authenticated but don't have permission for this action.
- 404 means the resource doesn't exist.
- 409 means your request conflicts with the current state. See Handling timeouts with idempotency keys for details.
-
5XX codes mean Zuora had a problem. Consult the
Command Center Tenant Management Board
for your tenant to see what issues have been reported or may be ongoing. If needed, file a Zuora support ticket. For more information, see
Getting help
.
- 500 is a generic server error.
- 502 and 503 usually mean the service is temporarily unavailable or overloaded.
When status code 200 means a business logic failure
HTTP 200 doesn't guarantee your call succeeded. A call that has an invalid field value may be syntactically correct and so return a 200 but your tenant traps the error and reports the details in the 200 response payload. Most Zuora Billing v1 REST API operations return a success boolean field in the response body but you need to verify this for each of your chosen endpoints.
As a rough guide, most POST, PUT, and PATCH operations include a success flag. For example:
-
Order operations (
/v1/orders) -
Payment method operations (
/v1/payment-methods) -
Account operations (
/v1/accounts)
Endpoints that generally don't include a success flag are as follows:
- GET operations that return lists or single resources
- File downloads (but these operations use HTTP 200 for success)
- Object Query operations, such as Object Query - List accounts and Object Query - Retrieve an account
-
Query operations, such as
POST /v1/action/queryandPOST /v1/object/account, that return data directly. Note that for historical reasons, other CRUD action calls such asPOST /v1/action/create(the Create operation) returnSuccessinstead ofsuccess. -
DELETE operations that typically return
204 No Contenton success
Example of correct success flag checking:
response = requests.post(
f"{base_url}/v1/orders",
json=order_payload,
headers=headers
)
if response.status_code == 200:
data = response.json()
# Check if this endpoint returns a success flag
if "success" in data:
if data["success"]:
# Success - extract your data
order_number = data.get("orderNumber")
print(f"Created order {order_number}")
else:
# Business logic error - check reasons
if "reasons" in data and data["reasons"]:
error = data["reasons"][0]
error_code = error.get("code")
error_message = error.get("message")
print(f"Order failed: {error_code} - {error_message}")
else:
# success=False but no reasons provided
print("Order failed with no error details")
else:
# No success flag - this is a GET or query operation
# The presence of data means success
print(f"Retrieved {len(data)} items")
else:
print(f"HTTP error: {response.status_code}")Bad example:
# WRONG - assumes 200 means success
response = requests.post(f"{base_url}/v1/orders", json=order_payload, headers=headers)
if response.status_code == 200:
data = response.json()
order_number = data["orderNumber"] # May not exist if success=false!
return order_numberHandling HTTP 200 errors
When success is false, the response includes a reasons array with one or more errors. Each error has the following fields:
-
code: 8-digit error code (explained below) -
message: Human-readable description of what went wrong
Example error response:
{
"success": false,
"processId": "7F2E4C89A1B3C4D5",
"reasons": [
{
"code": 53100320,
"message": "Invalid value for field termType: must be TERMED or EVERGREEN"
},
{
"code": 53100321,
"message": "Required field 'accountId' is missing"
}
]
}The processId field appears in error responses and helps Zuora support trace your request. Always include it when opening support tickets.
Error code 53100320 breaks down as:
-
531003: The first six digits are REST API resource code, which indicates thetermTypefield of the Subscription object. -
20: The last two digits are error category code, which indicates invalid value for that field.
Error type suffixes:
| Suffix | Description |
|---|---|
| 10 | Permission or access denied |
| 11 | Authentication failed |
| 20 | Invalid format or value |
| 21 | Unknown field in request |
| 22 | Missing required field |
| 23 | Missing required parameter |
| 27 | Invalid query parameter |
| 30 | Rule restriction (Business Rule Violation) |
| 40 | Resource not found |
| 45 | Unsupported request method |
| 50 | Locking contention |
| 60 | Internal error |
| 61 | Temporary error |
| 70 | Request exceeded limit (Concurrency) |
| 90 | Malformed request (JSON Syntax Error) |
| 99 | Integration error (External System) |
The following are some recommendations for addressing errors when success is false:
-
If the error type is
10or11, this is an authentication issue rather than a bug in your code. Verify your tenant and access. -
If the error type is
2x(various invalid values),30(missing required field), or90(malformed request that's likely a JSON error), fix your payload and try again. -
If it's
40(resource not found),45(unsupported request), or90(malformed request), review your code logic as it's generating an invalid endpoint. -
Error code
50(locking) or70(concurrency limit exceeded) are discussed below. -
60(internal error),61(temporary error), and99(integration error) are probably worth retrying a very limited number of times before stopping and raising an error and contacting Support. These should not occur with any frequency.
Code snippet for trapping multiple reported errors:
for error in data["reasons"]:
error_code = error.get("code")
error_type = error_code % 100 if error_code else 0
error_msg = error.get("message", "Unknown error")
# Log each error
logger.error(f"Error {error_code}: {error_msg}")
# Determine if any errors are retryable
if error_type not in [50, 60, 61, 70, 99]:
# Permanent error - don't retry
raise PermanentError(f"Invalid request: {error_msg}")The Zuora v1 API Reference documents the valid values for the call for any standard field. Note that your tenant may have custom fields that expect valid values and these are not documented in Zuora Developer Center, you should review the custom field definitions in your tenant. For more information, see Custom field management.
Transient vs permanent errors
As discussed in the previous section, transient errors should be retried, ideally using an exponential backoff strategy (described later in this document). The following are some typical use cases:
- Network timeouts
-
HTTP 200 and success false combined with error codes ending in
50,61,70and99are likely worth retrying - HTTP 401 (if caused by expired token - get a new token and retry once)
- HTTP 429 (rate limit reached)
- HTTP 500, 502, 503, 504 (server errors)
Otherwise review your code instead of retrying. To reduce the number of business logic failures (error codes ending in 2X or 30), you might consider validating your request payload against known constraints before sending the request. This allows you to fail fast with clearer error messages in your application logs.
Logging
Log strategically. Do not log everything, but capture enough to debug production issues. Do not log Personal Identifying Information, Credit Card, or Bank Account details.
Validate payloads before sending (for example, required fields and basic enum checks) to avoid 2X or 30 errors. Use the API Reference as the source of truth.
Always log:
- Request start with method, endpoint, and correlation ID
- Response status code and Zuora-Request-Id
- All errors with full error codes and messages
- Retry attempts with attempt number and backoff delay
- Circuit breaker state changes
Never log:
- Full request or response bodies if they contain sensitive data
- API tokens or credentials
- Credit card numbers or CVV codes
- Customer passwords or SSNs
- Personal Identifying Information
Conditionally log:
- Request bodies in development only, with sensitive fields redacted
- Response bodies for errors (often contains useful diagnostic info)
Collection of logging functions to help ensure private information isn't leaked into the logs:
import logging
import json
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
def sanitize_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
"""Remove sensitive fields from payload for logging"""
sensitive_fields = [
"creditCardNumber", "cardSecurityCode", "securityCode",
"password", "apiAccessKeyId", "apiSecretAccessKey",
"access_token", "client_secret", "work_email", "email",
"address1", "address2"
]
sanitized = payload.copy()
for field in sensitive_fields:
if field in sanitized:
sanitized[field] = "***REDACTED***"
return sanitized
def format_token_preview(token: str) -> str:
"""Format access token for safe display (first 20 chars + last 4 chars)"""
if not token:
return "***REDACTED***"
if len(token) <= 24:
return "***REDACTED***"
return f"{token[:20]}...{token[-4:]}"
def log_request(method: str, url: str, correlation_id: str, payload: Dict = None,
zuora_track_id: Optional[str] = None) -> None:
"""Log outgoing API request"""
log_msg = f"[{correlation_id}] {method} {url}"
if zuora_track_id:
log_msg += f" track_id={zuora_track_id}"
if payload and logger.isEnabledFor(logging.DEBUG):
sanitized = sanitize_payload(payload)
log_msg += f" payload={json.dumps(sanitized)}"
logger.info(log_msg)
def log_response(correlation_id: str, status_code: int,
zuora_request_id: str, success: Optional[bool] = None,
zuora_track_id: Optional[str] = None,
payload: Optional[Dict[str, Any]] = None) -> None:
"""Log API response"""
log_msg = f"[{correlation_id}] Response {status_code} "
log_msg += f"success={success} request_id={zuora_request_id}"
if zuora_track_id:
log_msg += f" track_id={zuora_track_id}"
if payload and logger.isEnabledFor(logging.DEBUG):
sanitized = sanitize_payload(payload)
log_msg += f" payload={json.dumps(sanitized)}"
logger.info(log_msg)
def log_error(correlation_id: str, error_code: int, error_msg: str,
zuora_request_id: Optional[str] = None,
zuora_track_id: Optional[str] = None,
payload: Optional[Dict[str, Any]] = None) -> None:
"""Log API error with context"""
log_msg = f"[{correlation_id}] Error {error_code}: {error_msg}"
if zuora_request_id:
log_msg += f" request_id={zuora_request_id}"
if zuora_track_id:
log_msg += f" track_id={zuora_track_id}"
if payload and logger.isEnabledFor(logging.DEBUG):
sanitized = sanitize_payload(payload)
log_msg += f" payload={json.dumps(sanitized)}"
logger.error(log_msg)Set up structured logging in production. Use JSON format so you can search and filter logs. Include timestamps, log levels, correlation IDs, and error codes as structured fields, not just in message strings.
Request tracking and correlation
You can specify your own correlation ids in Zuora API call headers using Zuora-Track-Id. It is returned to you in the same Zuora-Track-Id header with the response payload. Create a unique id, include this in your calls and save this ID with your logs. When contacting Zuora support, provide this ID along with the processId from error responses.
Generating your own correlation IDs to track requests across your systems:
import uuid
import logging
# Create a correlation ID for this business operation
correlation_id = str(uuid.uuid4())
# Add to all log messages
logger.info(f"[{zuora-track-id}] Starting order creation")
# Include in custom headers (optional)
headers = {
"Authorization": f"Bearer {token}",
"Zuora-Track-Id": correlation_id
}
response = requests.post(url, json=payload, headers=headers)
# Log both IDs
returned_track_id = response.headers.get("Zuora-Track-Id")
logger.info(f"[{returned_track_id}]: Response payload: {response.json()}")Handling timeouts with idempotency keys
You sent an order but the network died. Was your order processed? This is why Zuora supports idempotency keys. You generate a UUID v4 and include it in the Idempotency-Key header of your request, for example Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000. Zuora stores the result with that key. If you retry the same request with the same key, you get the original result back as a 409 HTTP response code. This is not an error — it means your operation is complete. Extract the result from the response body if needed and continue.
Use idempotency keys for all POST and PATCH requests. Don't include the header for GET, HEAD, OPTIONS, PUT, or DELETE as those operations are already idempotent by nature.
Obviously change the idempotency key when the payload changes, and don't reuse keys across operations.
Rate limits and concurrency limits
Zuora enforces two limits, rate limits and concurrency limits.
Rate limits cap how many requests you can send over the specified period of time:
- 50,000 requests per minute
- 2.25 million per hour
- 27 million per day
Concurrency limits cap how many simultaneous requests you can have in flight. The default is 40 (doubled for Performance Boost customers). If you process high-volume transactions (like creating orders or capturing credit cards), the limit is 200 concurrent requests, again doubled for Performance Booster customers. Object Queries have their own concurrency limit of 80 concurrent requests.
Zuora API responses include headers that indicate where your call is within these limits. For example, there is a header Concurrency-Limit-Remaining, that tells you the remaining number of permitted concurrent requests. Hitting rate or concurrency limits should never be a surprise.
When you do hit a limit, Zuora returns HTTP 429 with a Retry-After header, how many seconds to wait before retrying.
Log Rate-Limit-* and Concurrency-Limit-*, alert on low headroom, and honor Retry-After on 429/503 before backoff.
Despite this, most developers respond to 429 with a fixed delay: "I'll wait 60 seconds and try again." This can result in request spikes once the delay ends, causing a new problem. Instead, use exponential backoff with jitter. The formula is simple:
sleep_seconds = random_between(0, min(max_cap, base_delay * (2 ** attempt_number)))If your base delay is 1 second and you're on attempt 3, you might wait anywhere from 0 to 8 seconds (randomness prevents synchronized retries). If you're on attempt 5 and your cap is 60, you wait between 0 and 60 seconds.
Here's what this looks like in code:
import random
import time
import requests
from typing import Callable, Iterable, Optional
class RetryExhaustedError(Exception):
def __init__(self, message: str, last_response: Optional[requests.Response] = None):
super().__init__(message)
self.last_response = last_response
def _retry_after_seconds(response: requests.Response) -> Optional[int]:
value = response.headers.get("Retry-After")
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
#
# For clarity this function is missing other guardrails such as idempotency keys
#
def retry_with_backoff(
request_func: Callable[[], requests.Response],
max_attempts: int = 5,
base_delay: float = 1.0,
max_delay: float = 60.0,
retry_statuses: Iterable[int] = (429, 500, 502, 503, 504),
) -> requests.Response:
if max_attempts < 1:
raise ValueError("max_attempts must be >= 1")
last_response: Optional[requests.Response] = None
for attempt in range(max_attempts):
try:
response = request_func()
last_response = response
if response.status_code not in retry_statuses:
return response
if attempt < max_attempts - 1:
retry_after = _retry_after_seconds(response) or 0
jitter = random.uniform(0, min(max_delay, base_delay * (2 ** attempt)))
time.sleep(max(retry_after, jitter))
except (requests.Timeout, requests.ConnectionError) as exc:
if attempt < max_attempts - 1:
jitter = random.uniform(0, min(max_delay, base_delay * (2 ** attempt)))
time.sleep(jitter)
else:
raise RetryExhaustedError("Max retries exceeded", last_response=None) from exc
raise RetryExhaustedError("Max retries exceeded", last_response=last_response)If you consistently hit rate limits, you need to consider alternative mitigation solutions. Can you optimize your code and avoid unnecessary API calls? For example, you can submit multiple orders in a single call. Zuora also supports asynchronous batch requests for major calls. See Asynchronous requests for more information. Is your implementation stale and can be refactored to be more efficient? Or do you need Performance Booster?
Log those response headers and set up alerts so you know before you hit the limits. For more information, see Configure notifications for the API Performance event.
Troubleshooting
The following table describes common API errors and the corresponding actions you can take.
| API error | Reason and resolution |
|---|---|
| HTTP 200 with an error code in the response body | Check the error type suffix. If it's 20 (invalid value), look at the response error message and fix your data. If it's 30 (missing field), you left something out—check the API documentation for required fields. If it's 10 (permission denied), this is a configuration issue on your account. |
| HTTP 401 | Your credentials are invalid, missing, or expired. If using OAuth, request a new token and retry once. If the error persists, verify your client credentials. Check that your OAuth client hasn't been revoked in Zuora Settings. |
| HTTP 403 | You're authenticated but you don't have permission for this action. This usually means your Zuora account doesn't have the required feature or settings. Contact your Zuora account team. |
| HTTP 404 | The resource you're looking for doesn't exist. Did you use the right ID? Is the resource in the right tenant? Are you looking in production when you mean to be in a sandbox? |
| Timeout with no response | The network connection died or took too long. Retry with the same idempotency-key. If you get 409 Conflict on retry, your original request succeeded—extract the result and continue. |
| HTTP 429 | You hit a rate or concurrency limit. Implement exponential backoff. If this happens regularly, optimize your code to make fewer requests or spread them over time. If optimization doesn't help, contact your Zuora account team. |
| HTTP 500, 502, or 503 | Zuora had a server error. Retry with exponential backoff. If this persists beyond a few minutes, check the Zuora status page or contact support. |
Check the Zuora status page to see if there's an active incident.
If still stuck, check the status, then open a support ticket. See Getting help for more information.
Getting help
If you're seeing consistent errors and the troubleshooting section didn't help, open a Zuora support ticket with the following information:
- The error code (if you have one)
-
The
Zuora-Request-Idfrom the response headers -
The
processIdfrom the error response body - The timestamp
- The API operation you were attempting
Zuora Support can look up what happened on their end, but logs are not kept indefinitely, so report early.
Contact your Zuora account team if you think you need higher rate limits, more concurrency capacity, or help with your tenant configuration.
Check the Zuora status page to see if there's an active incident.