Overview
The OpenFX v1 API uses HTTP status codes to indicate success or failure. There is no status envelope wrapper — successful responses return the resource directly, and error responses return a structured error object.
Every error response includes a requestId field. Include this value in any support requests to help the OpenFX team quickly locate and diagnose the issue.
Error Response Shape
All 4xx and 5xx responses return a consistent error envelope:
{
"error": {
"type": "invalid_request_error",
"code": "insufficient_funds",
"message": "The source account has insufficient funds to complete this payment.",
"status": 422,
"requestId": "req_01953e1a5f4b7b01",
"retryable": false,
"documentationUrl": "https://docs.openfx.com/guides/concepts/error-handling",
"details": {},
"fieldErrors": []
}
}
| Field | Type | Required | Description |
|---|
type | string | Yes | Category of error. One of the 9 error types listed below. |
code | string | Yes | Machine-readable error code (e.g., insufficient_funds, quote_expired). |
message | string | Yes | Human-readable description of what went wrong. |
status | integer | Yes | HTTP status code (mirrors the response status). |
requestId | string | Yes | Unique identifier for this request (uses req_ prefix). |
retryable | boolean | Yes | Whether retrying the request with the same parameters could succeed. |
documentationUrl | string | No | Link to relevant documentation for this error. |
details | object | No | Additional context about the error. |
fieldErrors | array | No | Per-field validation errors. Present on 400 validation failures. |
Error Types
Every error response includes a type field that categorizes the error:
| Type | HTTP Status | Description |
|---|
invalid_request_error | 400 | Request body or query parameters are malformed or contain invalid values. |
authentication_error | 401 | Authentication credentials are missing, invalid, or expired. |
authorization_error | 403 | Valid credentials but insufficient permissions for this operation. |
not_found_error | 404 | The requested resource does not exist or is not accessible. |
conflict_error | 409 | State conflict — the resource is not in a valid state for this operation (e.g., payment already submitted). |
idempotency_error | 409 | Idempotency key conflict — either reused with a different body or the original request is still in flight. |
rate_limit_error | 429 | Too many requests. Slow down and respect the Retry-After header. |
business_rule_error | 422 | The request is well-formed but violates a business rule (e.g., insufficient funds, KYB not completed). |
api_error | 500 | Internal server error. These are always retryable. |
HTTP Status Code Summary
| Status | Meaning | Typical Cause |
|---|
200 | OK | Successful GET, PATCH, or action. |
201 | Created | Resource successfully created (POST). |
202 | Accepted | Request accepted for async processing (e.g., payment creation). |
204 | No Content | Successful DELETE. |
400 | Bad Request | Validation failure, malformed JSON, missing required fields. |
401 | Unauthorized | Missing or invalid API key, bad signature, expired timestamp. |
403 | Forbidden | Valid credentials but insufficient permissions. |
404 | Not Found | Resource does not exist or you do not have access. |
409 | Conflict | State conflict or idempotency key conflict. |
422 | Unprocessable Entity | Business rule violation (e.g., insufficient funds). |
429 | Too Many Requests | Rate limit exceeded. |
500 | Internal Server Error | Unexpected server error. Always retryable. |
Field Validation Errors
When a 400 invalid_request_error is caused by specific field-level issues, the fieldErrors array provides granular detail:
{
"error": {
"type": "invalid_request_error",
"code": "validation_error",
"message": "Request body contains invalid fields.",
"status": 400,
"requestId": "req_01953e1a5f4b7b03",
"retryable": false,
"fieldErrors": [
{
"field": "address.postalCode",
"code": "required",
"message": "Postal code is required for US addresses."
},
{
"field": "sendAmount.amount",
"code": "out_of_range",
"message": "Amount must be greater than 0."
}
]
}
}
Each field error contains:
| Field | Type | Description |
|---|
field | string | Dot-path to the invalid field (e.g., address.postalCode, sendAmount.currency). |
code | string | Machine-readable error code for programmatic handling. |
message | string | Human-readable description of the validation failure. |
Field Error Codes
| Code | Description |
|---|
required | Field is required but was not provided. |
invalid_format | Field value does not match the expected format (e.g., invalid email, bad date). |
out_of_range | Numeric value is outside the allowed range. |
not_found | A referenced resource (by ID) does not exist. |
already_exists | A unique value is already in use (e.g., duplicate account number). |
immutable | Field cannot be changed after creation. |
too_long | String value exceeds the maximum allowed length. |
too_short | String value is shorter than the minimum required length. |
invalid_enum_value | Value is not one of the allowed enum options. |
mutually_exclusive | Field cannot be provided alongside another specified field. |
Example Error Responses
400 — Validation Error
{
"error": {
"type": "invalid_request_error",
"code": "validation_error",
"message": "Request body contains invalid fields.",
"status": 400,
"requestId": "req_01953e1a5f4b7b04",
"retryable": false,
"fieldErrors": [
{
"field": "rail",
"code": "invalid_enum_value",
"message": "Rail must be one of: ach, fedwire, swift, fednow, crypto, sepa, fps, open."
}
]
}
}
401 — Authentication Error
{
"error": {
"type": "authentication_error",
"code": "invalid_signature",
"message": "The request signature in the X-Signature header is invalid. Verify your signing key and algorithm.",
"status": 401,
"requestId": "req_01953e1a5f4b7b05",
"retryable": false
}
}
404 — Not Found
{
"error": {
"type": "not_found_error",
"code": "payment_not_found",
"message": "No payment found with ID pmt_01953e1a5f4b7999.",
"status": 404,
"requestId": "req_01953e1a5f4b7b06",
"retryable": false
}
}
409 — State Conflict
{
"error": {
"type": "conflict_error",
"code": "payment_not_cancelable",
"message": "This payment has already been completed and cannot be canceled.",
"status": 409,
"requestId": "req_01953e1a5f4b7b07",
"retryable": false
}
}
422 — Business Rule Error
{
"error": {
"type": "business_rule_error",
"code": "insufficient_funds",
"message": "The source account has insufficient funds to complete this payment.",
"status": 422,
"requestId": "req_01953e1a5f4b7b08",
"retryable": false
}
}
429 — Rate Limit Exceeded
{
"error": {
"type": "rate_limit_error",
"code": "rate_limit_exceeded",
"message": "Too many requests. Please retry after the delay indicated in the Retry-After header.",
"status": 429,
"requestId": "req_01953e1a5f4b7b09",
"retryable": true
}
}
The response includes a Retry-After header indicating how many seconds to wait:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Retry Strategy
Not all errors are retryable. Use the retryable field to determine whether to retry:
| Error Type | Retryable | Strategy |
|---|
invalid_request_error | No | Fix the request and resubmit. |
authentication_error | No | Check credentials and signing logic. |
authorization_error | No | Request appropriate permissions. |
not_found_error | No | Verify the resource ID. |
conflict_error | No | Re-read the resource state before retrying. |
idempotency_error (in_flight) | Yes | Wait for Retry-After seconds, then retry. |
idempotency_error (duplicate_key) | No | Generate a new idempotency key. |
rate_limit_error | Yes | Wait for Retry-After seconds, then retry. |
business_rule_error | No | Resolve the business condition (e.g., fund the account). |
api_error | Yes | Retry with exponential backoff. |
Recommended Retry Logic
import time
import requests
def api_request_with_retry(method, url, headers, max_retries=3, **kwargs):
"""Make an API request with automatic retry for retryable errors."""
for attempt in range(max_retries + 1):
response = method(url, headers=headers, **kwargs)
# Success
if response.status_code < 400:
return response
error_body = response.json()
error = error_body.get("error", {})
# Check if retryable
if not error.get("retryable", False) or attempt == max_retries:
response.raise_for_status()
# Respect Retry-After header if present
retry_after = response.headers.get("Retry-After")
if retry_after:
wait = int(retry_after)
else:
# Exponential backoff: 1s, 2s, 4s
wait = 2 ** attempt
print(f"Retryable error ({error.get('code')}), "
f"waiting {wait}s (attempt {attempt + 1}/{max_retries})")
time.sleep(wait)
return response
Best Practices
Always check the type field programmatically. Use type for broad error categorization and code for specific error handling logic. Use message for logging and display.
Log the requestId on every error. Include it in support tickets and internal monitoring dashboards. The requestId is the fastest way for the OpenFX support team to trace an issue.
Handle fieldErrors for form validation. Map field error dot-paths (e.g., address.postalCode) back to your UI form fields for inline error display.
Do not parse message for logic. The message text is human-readable and may change without notice. Always use type and code for programmatic decisions.