Skip to content

API

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"
}