Skip to content
DEMO PAYdocs
PTEN
Go to dashboard

Errors

Every Demo Pay 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_amount",
    "message": "amount deve ser um inteiro positivo em centavos.",
    "param": "amount",
    "details": []
  },
  "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 field-scopedIdentifies the single failing field, when unambiguous.
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

HTTPerror.typeRecommended client action
200
201Store the returned id and continue.
400invalid_request_errorFix the body/headers and retry. Don't retry blindly.
401authentication_errorVerify the apikey header carries a correct, active key.
403permission_errorBlocked by scope or the anti-fraud/velocity guard. Review the customer or contact support.
404not_found_errorCheck the id you supplied.
409conflict_errorSame Idempotency-Key used with a different body — use a fresh key.
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 unless a more specific code applies (e.g. invalid_amount); error.details lists every failing field.

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 (POST /v1/charges, POST /v1/pix/charges). The /v1/payments routes (including refund) read only the body field idempotencyKey, not this header — a 400 there means that body field is missing or invalid.

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 apikey: dm_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 always 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. If the block looks wrong, email suporte@demo.zentry.cloud 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. Demo Pay refuses to silently overwrite.

Action Use a fresh Idempotency-Key. Common cause: re-using an order id after the customer changed the cart.


#Business-rule rejections

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

json
{
  "error": {
    "type": "invalid_request_error",
    "code": "unprocessable_entity",
    "message": "no provider for PIX"
  },
  "request_id": "req_01J..."
}

Action Read 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 Demo Pay can't reach your webhook endpoint, the failure shows up in GET /v1/webhooks/deliveries:

json
{
  "id": "wd_...",
  "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. Demo Pay still retries up to maxAttempts — fix and replay if needed.
5xxYour endpoint is down. Demo Pay retries with exponential backoff.
429 / Retry-AfterDemo Pay 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. Demo Pay 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.

#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@demo.zentry.cloud (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.