Skip to content
MOONIPdocs
IntegrationManual
PTEN
Go to dashboard

Errors

Every Moonip error is a JSON object with a stable shape — code your handler against it once and stop guessing. Every response, success or error, also carries the X-Request-Id header (generated when you don't send one, echoed verbatim when you do).

#Shape

json
{
  "error": {
    "type": "invalid_request_error",
    "code": "invalid_request",
    "message": "Validation failed",
    "details": [
      { "param": "amount", "message": "must be a positive integer" }
    ]
  },
  "request_id": "req_01J..."
}
FieldAlways presentDescription
error.typeyesStable category — one of invalid_request_error, authentication_error, permission_error, not_found_error, conflict_error, rate_limit_error, api_error.
error.codeyesStable, snake_case machine code. This is what your handler should switch on, not message.
error.messageyesHuman-readable explanation. Safe to log; not a contract for automation.
error.paramwhen exactly one field failsIdentifies that single failing field. With several failing fields it is absent and only error.details lists them.
error.detailson validation failuresStructured { param, message }[] for multi-field validation errors.
request_idyesQuote this when contacting support — it traces every log, span and Kafka message. Also echoed on the X-Request-Id response header.

Compatibility aliases: the response body also carries a few flat top-level fields — statusCode, message, requestId, and (when present) code / fieldErrors — alongside the error object, for clients (including the dashboard) that read them directly. Read the nested error object; the flat aliases are not guaranteed to survive a future major version.

#Status code map

#Charge creation with an unknown outcome

409 idempotency_outcome_unknown means the original create may have reached the payment provider, but its result is not yet confirmed. Retry with the same Idempotency-Key and request body, using backoff. Do not generate a new key for that charge: it could create another payment.

The original operation is retained even after five minutes or 24 hours. A retry retrieves its saved result when available; otherwise it returns this error without issuing another create. A missing lookup result is not proof that the charge was never created. Contact support with the request_id if the result remains unknown. Completed responses remain available for at least 24 hours.

#HTTP statuses

HTTPerror.typeRecommended client action
200——
201—Store the returned id and continue.
400invalid_request_errorFix the body/headers and retry. Don't retry blindly.
401authentication_errorVerify the Authorization: Bearer header carries a correct, active key.
403permission_errorBlocked by scope, the anti-fraud/velocity guard, an API-key refund attempt (message marker REFUND_ADMIN_ONLY), or the IP allowlist. Review the request or contact support.
404not_found_errorCheck the id you supplied.
409conflict_errorSame Idempotency-Key used with a different body (idempotency_key_reused), a first attempt still in flight (idempotency_key_in_flight), or an uncancellable state (payment_not_cancellable).
422invalid_request_errorBusiness-rule violation (still request-shaped) — read message, fix input, don't retry.
429rate_limit_errorBack off. Retry with exponential delay + jitter; honour Retry-After when present.
5xxapi_errorSafe to retry with the same Idempotency-Key — idempotency protects you from duplicates.

#Common errors

#400 Bad Request

Validation failed — error.code is invalid_request and error.details lists every failing field (error.param is present only when exactly one field fails).

json
{
  "error": {
    "type": "invalid_request_error",
    "code": "invalid_request",
    "message": "Validation failed",
    "details": [
      { "param": "amount", "message": "must be a positive integer" },
      { "param": "payment_method", "message": "Invalid enum value" }
    ]
  },
  "request_id": "req_01J..."
}

Also 400 when the Idempotency-Key header is missing from a financial write. On POST /v1/charges and POST /v1/pix/charges the header is the only channel. On POST /payments the body field idempotencyKey is accepted as an alias — if both are sent they must match (a mismatch is a 400) and the header value is what counts. POST /payments/{id}/refund is admin-panel-only, so it never takes either form from an API key.

Action Fix the request and retry. Never loop.


#401 Unauthorized

json
{
  "error": {
    "type": "authentication_error",
    "code": "unauthorized",
    "message": "Invalid API key"
  },
  "request_id": "req_01J..."
}

Possible causes:

  • Header missing — make sure Authorization: Bearer ak_live_... is set.
  • Key was rotated — check the dashboard.
  • Whitespace — keys are exact-match, no leading/trailing spaces.

Action Verify the key. If it's correct, the key may have been disabled — contact support.


#403 Forbidden

The fraud/velocity guard blocks a charge before it reaches the provider — error.code is fraud.*:

json
{
  "error": {
    "type": "permission_error",
    "code": "fraud.customer_velocity_exceeded",
    "message": "Too many payment attempts for this customer in a short window"
  },
  "request_id": "req_01J..."
}

A second fraud.* code covers the account's own velocity limit (message: "Too many payment attempts from this account in a short window").

Action Review the customer / slow down retries. Only live charges count toward the limit — expired or cancelled charges (e.g. a Pix QR that timed out unpaid) are ignored, so retrying those is safe. If the block looks wrong, email suporte@moonip.io with the request_id.

#Refunds — message marker REFUND_ADMIN_ONLY

POST /payments/{id}/refund refuses every API-key caller with 403, whatever the body is: refunds are issued from the Moonip admin panel only. This is not a code value — error.code is the plain "forbidden" and the token survives only as a message prefix, so branch on the code for the refusal and match the prefix only as a marker (e.g. message.startsWith("REFUND_ADMIN_ONLY")):

json
{
  "error": {
    "type": "permission_error",
    "code": "forbidden",
    "message": "REFUND_ADMIN_ONLY: reembolsos devem ser solicitados pelo painel/admin, ..."
  },
  "request_id": "req_01J..."
}

See API Reference — Refunds.

#IP allowlist

When the account has an IP allowlist configured, a request from an address outside it is refused with 403 — same permission_error envelope, error.code "forbidden". The check is a per-account guard on the core account routes (wallets, withdrawals, transactions, MED cases, api-keys and neighbours); the money-movement routes on the payment and webhook services (/v1/charges, /v1/pix/charges, /v1/payments, /v1/webhooks) are not covered by it. Whitelist every egress address your integrations use (including CI and failover hosts), or the request never reaches the key check. Details and how to update the list: API keys.

#account_suspended

The account is suspended or blocked: API-key requests to charges, payments, withdrawals, wallet and transactions are refused until support lifts it. The key itself is still valid — rotating it doesn't help.

json
{
  "error": {
    "type": "permission_error",
    "code": "account_suspended",
    "message": "Conta suspensa: a API não aceita requisições desta conta. Fale com o suporte."
  },
  "request_id": "req_01J..."
}

Action Don't retry. Email suporte@moonip.io with the request_id.


#404 Not Found

json
{
  "error": {
    "type": "not_found_error",
    "code": "not_found",
    "message": "Charge ch_a1b2c3d4-0000-0000-0000-000000000009 not found"
  },
  "request_id": "req_01J..."
}

Action Verify the id. If you just created the resource, wait 1–2 seconds and retry.


#409 Conflict

json
{
  "error": {
    "type": "conflict_error",
    "code": "idempotency_key_reused",
    "message": "Idempotency key 'ORD-7821' already used with a different request body"
  },
  "request_id": "req_01J..."
}

You replayed an Idempotency-Key with a body that differs from the original. Moonip refuses to silently overwrite.

Two other conflict_error codes land on 409:

error.codeWhenAction
idempotency_key_reusedSame key, different body.Use a fresh key — common cause is re-using an order id after the customer changed the cart.
idempotency_key_in_flightA first request with this key is still processing.Wait for it to finish (or retry after a few seconds) — do not switch keys.
payment_not_cancellablePOST /payments/{id}/cancel on a PROCESSING charge.Wait for the charge to settle, then refund from the admin panel instead.

#Business-rule rejections

Request-shape failures (missing/invalid fields) are 400, error.type: "invalid_request_error". Some downstream configuration failures — for example no provider configured for the requested payment_method and currency — are passed through from the internal orchestrator with its own status and a UPPER_SNAKE_CASE machine code, most commonly 422:

json
{
  "error": {
    "type": "invalid_request_error",
    "code": "NO_PROVIDER_CONFIGURED",
    "message": "No payment provider is configured for this payment method and currency."
  },
  "request_id": "req_01J..."
}

Charge-side codes of this family: PROVIDER_UNAVAILABLE, PROVIDER_ERROR, PAYMENT_REJECTED, NO_PROVIDER_CONFIGURED, UNKNOWN_METHOD, MOCK_PROVIDER_IN_PRODUCTION, PAYER_IDENTITY_REQUIRED (when expected_payer_tax_id locks the charge and the payer's document doesn't match).

Payout/withdrawal-side codes: WALLET_NOT_FOUND, WALLET_NOT_SPENDABLE, AMOUNT_BELOW_MINIMUM, AMOUNT_ABOVE_MAXIMUM, DAILY_PAYOUT_LIMIT_EXCEEDED (422), INSUFFICIENT_BALANCE, WITHDRAWAL_NOT_CANCELLABLE. Note the exception on the withdrawal path: INSUFFICIENT_BALANCE arrives as 400, not 422 — branch on error.code, not on the status.

Action Read error.code and message. Don't retry — fix the input or contact support if the account should have a provider configured.


#429 Too Many Requests

json
{
  "error": {
    "type": "rate_limit_error",
    "code": "rate_limited",
    "message": "Rate limit exceeded"
  },
  "request_id": "req_01J..."
}

Headers on this response (from Kong's rate-limiting plugin, scoped per API key — see Getting Started §4 for the current thresholds):

text
Retry-After: 12
X-RateLimit-Limit-Minute: 5000
X-RateLimit-Remaining-Minute: 0

Action Back off — honour Retry-After if present, otherwise exponential backoff with jitter.

js
async function withRetry(fn, attempts = 5) {
  for (let i = 0; i < attempts; i++) {
    try { return await fn(); }
    catch (err) {
      if (err.status !== 429 && err.status < 500) throw err;
      const base = Math.min(1000 * 2 ** i, 30_000);
      const jitter = Math.random() * 0.3 * base;
      await new Promise(r => setTimeout(r, base + jitter));
    }
  }
  throw new Error('exhausted retries');
}

#5xx — server-side

json
{
  "error": {
    "type": "api_error",
    "code": "service_unavailable",
    "message": "Upstream unavailable"
  },
  "request_id": "req_01J..."
}

Safe to retry. Use the same Idempotency-Key so a duplicate charge never gets created if processing actually succeeded but the response was lost.

#Webhook delivery errors (server → your endpoint)

When Moonip can't reach your webhook endpoint, the failure shows up in GET /v1/webhooks/deliveries:

json
{
  "id": "b2c3d4e5-f6a7-4890-9abc-def012345678",
  "status": "FAILED",
  "attempts": 3,
  "maxAttempts": 15,
  "lastStatusCode": 502,
  "lastError": "HTTP 502",
  "lastResponseBody": "Bad Gateway",
  "nextRetryAt": "2026-07-23T16:08:00.000Z"
}
lastStatusCode / lastErrorWhat it means
2xxDelivered. Won't retry.
4xxYour endpoint rejected the payload. Moonip still retries up to maxAttempts — fix and replay if needed.
5xxYour endpoint is down. Moonip retries with exponential backoff.
429 / Retry-AfterMoonip honours your Retry-After and reschedules.
Connection timeout / ETIMEDOUTYour endpoint took >30s to respond. Always 200 OK fast — queue async.
getaddrinfo ENOTFOUNDYour domain doesn't resolve. Update the registered URL.
self signed certificateTLS error. Moonip requires valid public certs.

After maxAttempts (default 15), the delivery is sent to DLQ and can be replayed manually. See Webhooks for full retry/DLQ semantics.

#Webhook management errors

Codes on the /v1/webhooks administration routes (permission_error / conflict_error / rate_limit_error depending on the code):

error.codeWhen
WEBHOOK_ENDPOINT_DUPLICATEThe URL is already registered on this account (409).
WEBHOOK_ENDPOINT_LIMITThe account reached its active-endpoint limit.
WEBHOOK_HTTPS_REQUIREDA non-https:// URL outside sandbox/staging.
WEBHOOK_OWN_DOMAINThe URL points at a Moonip domain.
WEBHOOK_TEST_RATE_LIMITToo many POST /endpoints/{id}/test calls in one hour (429).
WEBHOOK_ROLE_REQUIREDThe caller's role can't manage endpoints (access.canManage: false).
IMPERSONATION_VIEW_ONLYThe session is impersonating and may only read.
TENANT_NOT_ACTIVEThe account is not active.

#Decoding error.details

error.details is a flat array of { param, message } — renderable as-is:

jsx
{error.details.map(({ param, message }) => (
  <li key={param}>{param}: {message}</li>
))}

The fieldErrors alias is the same information reshaped as { field: reasons[] }:

json
{
  "fieldErrors": {
    "amount": ["must be a positive integer"]
  }
}

#When to email support

Contact suporte@moonip.io (or open a ticket) if:

  • You see a 5xx that persists more than 5 minutes
  • A webhook delivery is stuck in FAILED after maxAttempts
  • A 403 Forbidden arrives unexpectedly
  • The same Idempotency-Key returns different charges on different calls (this is a bug — should never happen)

Always quote the request_id — it lets us trace every log, span, and Kafka message in one query.

#Entity read limit

HTTP 429, error.type: rate_limit_error, error.code: entity_rate_limited. Wait the seconds indicated by Retry-After before reading this entity again. See Polling and limits.