Developer guide
Errors
Standard HTTP status codes plus a typed JSON envelope.
1 / 3
Error envelope
Every error response has the same shape: the error.code field is the source of truth — the message may change, the codes won't. Quote the requestId when contacting support.
json
{
"error": {
"code": "card_declined",
"type": "invalid_request_error",
"message": "The card was declined by the issuer.",
"param": "source.number",
"decline_code": "insufficient_funds",
"requestId": "req_3kf91… "
}
}HTTP statuses
Mapping of the HTTP codes the API returns. The response status is consistent with the error type (4xx = client problem, 5xx = our problem).
| Status | Meaning | Description |
|---|---|---|
200 | OK | Everything worked. |
201 | Created | A new resource was created. |
400 | Bad request | Request is malformed or missing required parameters. |
401 | Unauthorized | Missing or invalid API key. |
402 | Payment failed | Provider declined the charge. |
403 | Forbidden | Key kind is not allowed for this endpoint. |
404 | Not found | Resource does not exist. |
409 | Conflict | Idempotency-Key reused with a different request body. |
415 | Unsupported media type | Content-Type must be application/json. |
422 | Unprocessable entity | No provider in the cascade can accept this charge. |
429 | Too many requests | Rate limit exceeded — back off and retry with jitter. |
500 | Server error | Internal failure. Safe to retry idempotently. |
503 | Service unavailable | Temporary maintenance / upstream outage. |
Error codes
Exhaustive list of every error.code that can appear in a /v1 response, grouped by type. These codes are stable — they're versioned with the API. If you get one that isn't here, open an issue. Codes marked reserved are documented for forward compatibility and are not emitted today.
What an error looks like. Every 4xx/5xx is the same envelope:
{ "error": { "code", "type", "message", "requestId", "docs_url", "details"? } }. message is one short English sentence saying what failed; docs_url links to the code's entry on this page; details only carries machine-readable keys, for example missing: ["documentId"]. The same requestId is sent as the X-Request-Id header, and a 429 always carries Retry-After.Transport. Always call the API over
https://. A request sent over plain http:// is answered with a 308 redirect and an empty body; most HTTP clients do not follow redirects on POST, so the call looks like it "got no response". Every endpoint lives under /api/v1 (for example POST /api/v1/auth/token); a path outside it, such as /api/auth/token or /v1/auth/token, is answered with 404 endpoint_not_found and, when it can be inferred, the correct path in details.expected.Authentication errors · 10
| Code | HTTP | When it's returned |
|---|---|---|
missing_credentials | 401 | The Authorization header is missing or empty. |
invalid_api_key | 401 | The key does not exist, was rotated, or belongs to the other environment. |
invalid_token | 401 | The JWT is malformed, tampered with, or signed with another secret. |
token_expiredreserved | 401 | The JWT expired after 1 hour. Obtain a new token with POST /api/v1/auth/token. |
production_key_refusedreserved | 400 | Sandbox rejects production credentials. |
environment_mismatch | 403 | The shop or account changed environment but your credentials did not. Switch to the correct key; retrying unchanged will not help. |
shop_required | 401 | The token has no shopId, probably because it uses legacy merchant-level credentials. |
mobile_auth_failedreserved | 401 | Mobile login was rejected. details.reason identifies invalid_credentials, totp_required, totp_invalid, ip_not_allowed or portal_mismatch so the app can request the missing step. |
mobile_session_invalidreserved | 401 | The mobile token expired, the session was revoked, the principal changed, or the refresh token was reused. Check details.reason and authenticate again with a password. |
signature_invalid | 401 | La firma del callback no cuadra con las credenciales de la instancia; el evento no se aplica. |
Permission errors · 11
| Code | HTTP | When it's returned |
|---|---|---|
key_kind_not_allowed | 403 | A pk_* key was used for an operation requiring sk_*. |
forbidden | 403 | The resource belongs to another merchant or shop, or your role does not allow access. |
permission_deniedreserved | 403 | Your shop role lacks this permission. Ask a user with Manage users permission to grant it. |
merchant_suspended | 403 | An administrator suspended the account. Reactivate it in /admin/merchants to resume processing. |
shop_suspended | 403 | An administrator suspended the shop. Reactivate it in /admin/shops to resume processing. |
ip_not_allowed | 403 | La IP desde la que llamás no está en la lista de IPs autorizadas del shop. `details.ip` es la IP que vimos y `details.shopId` el shop. No es transitorio: pedí a soporte que la agregue (o un rango CIDR si tu salida cambia) y no reintentes desde la misma IP. |
ip_allowlist_required | 403 | El shop es nuevo y todavía no tiene IPs registradas: la clave secreta de producción no autentica hasta que soporte registre las IPs de tus servidores. Sandbox no se ve afectado; integrá ahí mientras tanto. |
method_not_enabled_for_shop | 403 | The selected method is not enabled for the shop. GET /api/v1/payment-methods is authoritative. A shop with no enabled methods cannot start checkout payments. Enable the rail in /admin/shops under Payment methods & fees. |
qr_establishment_suspended | 403 | The establishment is suspended and cannot create new charges. Existing orders continue normally. |
csrf_blocked | 403 | La petición llegó con cookie de sesión desde un origen no permitido (CSRF). Las integraciones usan Bearer; el panel manda el Origin del portal. |
shop_not_owned | 403 | El shopId enviado pertenece a otro comercio. |
Invalid request errors · 62
| Code | HTTP | When it's returned |
|---|---|---|
content_type_required | 415 | The request used form-urlencoded, multipart or another format. Set Content-Type: application/json. |
invalid_json | 400 | The body could not be parsed. Check commas and quotation marks. |
invalid_request | 400 | One or more fields fail schema validation. See details.issues. |
missing_field | 400 | A required field is missing. param names the field. |
unsupported_methodreserved | 400 | The requested method is not allowed for the shop. List available methods with GET /api/v1/payment-methods. |
unsupported_countryreserved | 400 | The selected method does not apply to the user's country. |
unsupported_currency | 400 | Check the method's currencyLimits for supported currencies. |
amount_invalid | 400 | The amount is empty, zero, negative or not numeric. |
amount_below_minimumreserved | 400 | The amount is below the provider's minimum for this method. |
amount_above_maximumreserved | 400 | The amount exceeds the provider's maximum for this method. |
idempotency_conflict | 409 | Idempotency-Key was reused with a different body. Generate a new key or repeat the original body. |
card_declinedreserved | 402 | The issuer declined the charge. decline_code gives the specific reason. |
insufficient_fundsreserved | 402 | A subtype of card_declined. |
fraud_blocked | 402 | The platform or provider risk engine flagged the operation. |
kyc_requiredreserved | 402 | The amount or method requires buyer verification. Request KYC from the Customers module. |
three_ds_requiredreserved | 402 | Redirect the user to the redirectUrl returned in the response. |
three_ds_failedreserved | 402 | The user did not complete the challenge or failed it. The transaction remains failed. |
expired_cardreserved | 402 | A subtype of card_declined. |
acceptance_required | 409 | El shop requiere la aceptación expresa de los documentos publicados. Muestra el modal del checkout y envía la aceptación del cliente antes de iniciar el pago. |
use_evidence_link | 409 | Desde 2026-09-10 la documentación de un claim se aporta ÚNICAMENTE por el enlace único de respuesta del claim (`/claim/<token>`), que pide exactamente lo configurado en KYC Levels → Claims. El panel muestra el enlace en cada claim; `details.url` lo trae para redirigir. Sólo una clave API (integración servidor a servidor) puede seguir subiendo evidencia por este endpoint. |
endpoint_not_found | 404 | La ruta no existe. Todos los endpoints del API viven bajo `https://<api-host>/api/v1/...` (por ejemplo `POST /api/v1/auth/token`, `POST /api/v1/payments`). Los errores más comunes: omitir `/v1` (`/api/auth/token` es la ruta de sesión del portal, no del API) u omitir `/api` (`/v1/auth/token`). Cuando la ruta correcta se puede inferir, `details.expected` la trae; `details.path` es lo que se recibió. La referencia completa de rutas está en /docs/endpoints. |
card_data_not_accepted | 400 | PCI DSS (decisión 2026-09-10): el número de tarjeta (PAN) y el CVV nunca pasan por los servidores de Key2Pay. Un cuerpo que contenga un número de tarjeta o un CVV se rechaza ANTES de validarlo, persistirlo o registrarlo, y la respuesta no repite el valor. `details.fields` lista las rutas del cuerpo donde se detectó (por ejemplo `card.number`). Cuando exista cobro con tarjeta, se enviará el token del adquirente, no el número. |
missing_required_fields | 422 | The method requires missing or malformed buyer details. details.missingFields or details.malformedFields lists each key, type, label and validation rule. Collect them and retry the charge with the same Idempotency-Key; errors are not cached. |
amount_out_of_limits | 422 | The local-currency amount (after FX conversion) is below the minimum OR above the maximum the upstream rail accepts. Each method advertises its bounds in GET /api/v1/payment-methods → `currencyLimits[]`. The error response includes the exact limit (`details.limit.min`, `details.limit.max`) and which bound was violated (`details.boundViolated`). Adjust the `amount` (USD) so the converted local-currency value sits within the range, or pick a different method. |
balance_insufficient | 422 | The requested withdrawal, refund or payout exceeds the available balance. |
fx_unavailable | 422 | The charge was initiated in a non-USD `currency` (presentment) but our FX cache has no rate for that currency, so we cannot convert it to the canonical USD amount. We NEVER guess an FX rate. `details.currency` names the currency. Retry with `currency: "USD"` or a currency we price, or wait for the FX cache to refresh. |
currency_not_funded | 422 | Payouts debit the balance in the method's currency. Swap USD to MXN before an MXN payout if needed. Check GET /api/v1/me/payout/balance. |
payout_method_unavailable | 422 | The methodId is not in the catalog. List methods with GET /api/v1/me/payout/methods. |
payout_not_found | 404 | No payout with that ID exists for your account. |
topup_not_found | 404 | No funding operation with that ID exists for your account. |
qr_establishment_not_found | 404 | The shop is not enabled for QR payments. Configure it in /admin/qrpay. If already enabled, retry shortly; a database interruption may temporarily return an empty result. |
qr_order_not_found | 404 | No order has that ID or token. Orders belonging to another establishment return the same 404 without revealing their existence. |
qr_order_not_pending | 409 | The order was already paid, expired or cancelled. The response includes its current state. |
deposit_account_not_found | 404 | No deposit account with that ID exists in your shop. Another shop's ID returns the same 404. |
deposit_accounts_unavailable | 422 | Fixed-CLABE deposits are not enabled for your shop. This is not transient; contact support to enable them. |
deposit_method_required | 422 | Your shop has multiple deposit methods. The response lists the enabled options in details.depositMethods, matching GET /api/v1/me/deposit-methods. Resend with depositMethodId; no method is selected automatically. |
deposit_method_unavailablereserved | 422 | The depositMethodId is not enabled for your shop or is not fully configured. No fallback method is selected. Check details.depositMethods or GET /api/v1/me/deposit-methods. |
deposit_account_conflict | 409 | Multiple deposit accounts exist for this endUserRef in the shop, so none is selected at random. No new account was created. |
payout_proof_unavailable | 404 | A receipt is available only for a completed payout on a provider that issues one, such as Pagsmile PIX, SPEI or Transfiya. Processing or failed payouts have no downloadable receipt. |
settlement_already_paidreserved | 409 | The same settlement cannot be paid twice. |
claim_already_resolvedreserved | 409 | Terminal claims accept no further evidence or state changes. |
fund_sandbox_only | 400 | POST /v1/me/payout/fund-sandbox sólo existe en sandbox: acredita saldo de prueba. En producción el saldo de payout entra por top-up o por traspaso desde pay-in. |
simulate_sandbox_only | 400 | Los endpoints /simulate (payouts, top-ups) sólo actúan sobre operaciones de sandbox. En producción el estado lo fija el proveedor. |
invalid_simulate_action | 400 | La acción pedida a /simulate no existe. Las aceptadas van en details.allowed. |
topup_not_pending | 409 | Un top-up sólo se puede simular mientras está pendiente. |
invalid_currency | 400 | La moneda pedida no está entre las que admite la operación (details.allowed). |
amount_too_large | 400 | El importe supera el máximo de la operación (details.max cuando aplica). |
invalid_range | 400 | El rango de fechas del reporte es inválido o está invertido. |
no_shop | 404 | La cuenta todavía no tiene ningún shop: sin shop no hay claves ni enlaces de pago. |
invalid_asset | 400 | La wallet de payout sólo admite los activos listados en details.allowed. |
missing_address | 400 | Falta la dirección de la wallet de payout. |
invalid_address | 400 | La dirección no tiene el formato de la red del activo (TRON o Ethereum). |
transaction_required | 400 | La categoría de soporte elegida exige una transacción asociada. |
ticket_not_found | 404 | El ticket no existe o pertenece a otro comercio. |
attachment_not_found | 404 | El adjunto no existe en ese ticket. |
file_required | 400 | La subida llegó sin fichero. |
file_too_large | 413 | El fichero supera el tamaño máximo permitido. |
mime_type_not_allowed | 415 | El tipo de fichero no está entre los permitidos. |
inbox_not_found | 410 | La URL del inbox no corresponde a ninguna suscripción activa; el dispatcher deja de reintentar. |
unknown_payout_driver | 404 | El segmento del driver en /api/webhooks/payouts/{driver} no corresponde a ningún proveedor de payout. |
missing_custom_code | 400 | El callback del proveedor no trae el identificador del payout (custom_code / order_id). |
no_payout_instance | 400 | No hay instancia del proveedor para el entorno del payout referenciado. |
Rate limit errors · 1
| Code | HTTP | When it's returned |
|---|---|---|
rate_limited | 429 | The tier rate limit was exceeded. Retry-After indicates how long to wait. |
API / server errors · 21
| Code | HTTP | When it's returned |
|---|---|---|
shop_not_found | 404 | The shop was deleted or never existed. Check its ID in /admin/shops. |
merchant_not_found | 404 | The referenced merchant does not exist. |
transaction_not_found | 404 | The transaction ID does not exist or belongs to another merchant. |
checkout_session_not_found | 404 | The checkout token does not exist or expired after 24 hours. Create a new session with POST /api/v1/checkout/sessions. |
checkout_session_already_completed | 409 | The session already created a transaction. Create a new session to retry with another method. |
settlement_not_found | 404 | The settlement does not exist or was archived. |
claim_not_found | 404 | The claim does not exist or belongs to another merchant. |
wallet_not_found | 404 | The payout wallet does not exist for this merchant. |
withdrawal_not_found | 404 | The withdrawal does not exist or belongs to another merchant. |
webhook_not_found | 404 | The webhook subscription does not exist or belongs to another merchant. Check GET /api/v1/webhooks. |
webhook_delivery_not_found | 404 | The delivery ID does not belong to this subscription. List deliveries with GET /api/v1/webhooks/{id}/deliveries. |
cascade_exhausted | 422 | All configured providers declined or are disabled. Check attempts in the response. |
payment_instructions_unavailable | 502 | The provider accepted the charge but returned neither paymentFormUrl nor usable payment instructions. No transaction was created. This is an incomplete provider response or configuration issue, not missing request data. Retry; if it persists, report details.provider to the operator. |
payout_dispatch_failed | 422 | The provider rejected the payout. details.failureReason contains the real reason. The debit was reversed. Correct beneficiary details or contact support for credential/configuration issues, then retry. |
deposit_account_provisioning_failed | 502 | The provider returned no usable deposit account. No account was created. Retry once; if it persists, contact support with requestId to fix the deposit method configuration. |
topup_address_unavailable | 503 | The deposit-address pool is exhausted for this asset and network. The operator must add addresses. Sandbox generates a synthetic address automatically. |
pdf_not_ready | 409 | El comprobante se genera en segundo plano tras cerrar la liquidación. Reintentá en unos minutos. |
pdf_missing_on_disk | 404 | La liquidación figura con PDF pero el fichero no está en el servidor. Soporte lo regenera. |
shop_method_not_configured | 422 | Al shop le falta el settlement hold (T+N) de ese método en «Payment methods & fees». Se rechaza ANTES de llamar al proveedor: no queda ninguna orden abierta. No es un error del integrador: lo configura el operador. |
service_unavailable | 503 | Maintenance or an upstream outage. Retry with exponential backoff. |
internal_error | 500 | An internal server failure occurred. Send requestId to support. |
How to handle errors in your integration: we recommend a
switch on error.code to decide the action (retry, show a message to the user, escalate to support). Never parse the message: that text can change between versions.