On this page
Error Handling
Handle Merchant API errors consistently: response envelope, HTTP status groups, machine-readable codes, request limits, and retry guidance.
The API uses standard HTTP status codes. Successful responses return the resource body directly. Errors follow RFC 9457 Problem Details for HTTP APIs (application/problem+json) with two extension members: a top-level code you switch on programmatically, and a top-level field (snake_case) on single-field validation errors. When several fields fail at once, field is replaced by an errors[] array (a Paymos extension member, §3.2) carrying the per-field breakdown.
Switch on code for programmatic handling and localization — never on title or detail text. The string fields may be reworded between releases; code is the stable contract.
{
"type": "https://paymos.io/docs/errors/codes#insufficient_balance",
"title": "Conflict",
"status": 409,
"detail": "Merchant balance is insufficient.",
"code": "insufficient_balance"
}
Every error response follows RFC 9457 Problem Details for HTTP APIs, served as Content-Type: application/problem+json.
Single error (most responses)
{
"type": "https://paymos.io/docs/errors/codes#insufficient_balance",
"title": "Conflict",
"status": 409,
"detail": "Merchant balance is insufficient.",
"code": "insufficient_balance"
}
Multi-field validation
Only emitted when several fields fail validation at once. The top-level code is always validation_failed; per-field detail lives in errors[]:
{
"type": "https://paymos.io/docs/errors/codes#validation_failed",
"title": "Bad Request",
"status": 400,
"detail": "Validation failed.",
"code": "validation_failed",
"errors": [
{ "code": "field_required", "field": "currency", "message": "Currency is required." },
{ "code": "field_must_be_positive", "field": "amount", "message": "Amount must be > 0." }
]
}
Field reference
| Field | Type | RFC | Description |
|---|---|---|---|
type |
string (URI) | RFC 9457 §3.1.1 | Deep link to the documentation row for the specific code. Stable per code — never changes once published. |
title |
string | RFC 9457 §3.1.3 | Short HTTP status title ("Bad Request", "Conflict", …). |
status |
integer | RFC 9457 §3.1.2 | HTTP status code (mirrors the response status). |
detail |
string | RFC 9457 §3.1.4 | Human-readable English explanation of this specific occurrence. |
code |
string | extension (§3.2) | Stable machine-readable identifier — see Error Codes. Switch on this in your integration. |
field |
string | extension (§3.2) | Wire-format field name (snake_case) for single-field validation errors. Omitted entirely for non-field errors — the key is absent from the JSON, never null. Present only when errors[] is absent. |
errors |
array | extension (§3.2) | Present only when several fields fail at once. Each entry: { code, field, message }. |
Why structured codes (and not just message)
message and detail text may evolve between versions, get localized, or be expanded with context. code is the stable contract — once a code is published, its semantics never change. Switch on code for programmatic handling and localization.
import { RateLimitError, ValidationError } from '@paymos/sdk';
try {
await paymos.withdrawals.create(request);
} catch (error) {
if (error instanceof ValidationError) {
for (const item of error.errors) highlight(item.field, item.message);
} else if (error instanceof RateLimitError) {
scheduleRetry(error.retryAfterSeconds);
}
throw error;
}
use paymos::{ApiErrorKind, Error};
match paymos.withdrawals().create(&request).await {
Ok(withdrawal) => process(withdrawal),
Err(Error::Api(error)) if error.kind == ApiErrorKind::Validation => {
for item in &error.errors {
highlight(item.field.as_deref(), &item.message);
}
}
Err(Error::Api(error)) if error.kind == ApiErrorKind::RateLimit => {
schedule_retry(error.retry_after);
}
Err(error) => return Err(error),
}
Error types
Each status ships in the error envelope with a title and a machine-readable code. One status can cover several scenarios — branch on the code, not the status.
| HTTP Code | title |
Description |
|---|---|---|
400 |
Bad Request |
Input validation failed or the request body is invalid. Single-field failures carry code + field at the top level; multi-field failures populate errors[]. |
401 |
Unauthorized |
HMAC credentials are missing, malformed, expired, or invalid. See code for the specific reason (invalid_credentials, timestamp_expired, authorization_malformed, …). |
403 |
Forbidden |
Authenticated, but the key type, environment, or scope does not allow the operation. See code for the specific reason (merchant_suspended, whitelist_required, etc). |
404 |
Not Found |
Resource does not exist, isn't visible to the caller (a resource owned by another merchant returns not_found, never forbidden), or no endpoint matches the request path (route_not_found). |
405 |
Method Not Allowed |
The path exists but not for this HTTP method. Carries the method_not_allowed code. |
409 |
Conflict |
The request clashes with current state — an invalid transition, a resource that already exists, or a guard that blocks the action. See code for the specific reason (insufficient_balance, withdrawal_quota_exceeded, invoice_cannot_be_cancelled, …). |
410 |
Gone |
Resource can no longer be used (for example, an expired or cancelled invoice). |
413 |
Payload Too Large |
Request body exceeds the size limit (1 MiB). Carries the payload_too_large code. |
415 |
Unsupported Media Type |
Content-Type is not application/json. Carries the unsupported_media_type code. |
429 |
Too Many Requests |
Per-merchant rate limit exceeded. Read the Retry-After header (seconds) before retrying. |
500 |
Internal Server Error |
Something failed on our side. Retry with exponential backoff. |
503 |
Service Unavailable |
Temporary dependency or processing issue (exchange_rate_unavailable, acceptance_disabled, outbound_frozen, …). Retry with backoff. |
Always use code (not the HTTP status) to drive client logic — multiple distinct business scenarios share one HTTP status, and the wire code distinguishes them.
Rate limits
The API allows 30 requests per second per merchant by default, with a stricter limit on POST /v1/invoices (5 req/sec). Limits are counted per merchant, per one-second window — every API key you hold draws on the same budget — and both are configurable per merchant. When the limit is exceeded, you'll receive a 429 Too Many Requests response with a Retry-After: 1 header.
{
"type": "https://paymos.io/docs/errors/codes#rate_limited",
"title": "Too Many Requests",
"status": 429,
"detail": "Rate limit exceeded (30 req/sec). Retry after 1 second.",
"code": "rate_limited"
}