Concepts
problem+json errors
The RFC 9457 format of gateway errors, the table of codes and types, the equivalent exception in each SDK, and how to read validation errors and engine rejections.
On this page
Every non-2xx response arrives as application/problem+json
(RFC 9457), with the same shape across all 13 modules:
{
"type": "https://errors.compense.co/gateway/validation",
"title": "Validation error",
"status": 422,
"detail": "One or more fields are invalid.",
"trace_id": "9f1c2e7a4b8d4e6fa1c0d2e3f4a5b6c7",
"errors": [
{ "field": "term_months", "message": "Input should be greater than or equal to 1" }
]
}| Field | What it is |
|---|---|
type | URI of the error type (or about:blank). Use it to tell apart cases that share a code. |
title | Short summary in English. |
status | The HTTP code. |
detail | Explanation. For engine rejections it carries the engine’s message in Spanish (for example «Saldo insuficiente»); when the engine sends no message, the gateway quotes its code ((code: INSUFFICIENT_FUNDS)). |
trace_id | Same as the X-Request-ID header. Always give it to support. |
errors | Only on 422: a list of {field, message}. |
The gateway never forwards the engine’s raw body or stack trace: on 502/504 the detail is
generic and the real error is logged under the trace_id.
Codes, types and exceptions by SDK #
| HTTP | type | When | Python / JS | PHP / .NET | Go (errors.Is) |
|---|---|---|---|---|---|
400 | about:blank | Malformed request; Idempotency-Key missing where it’s required. | CompenseAPIError | CompenseApiException | *APIError |
401 | about:blank | X-API-Key or the high-value signature is missing. | AuthError | AuthException | ErrAuth |
403 | about:blank | Invalid/revoked/expired key, missing scope, module not contracted or IP not allowed. | AuthError | AuthException | ErrAuth |
404 | about:blank | The resource doesn’t exist or belongs to another tenant. | NotFoundError | NotFoundException | ErrNotFound |
409 | …/idempotency-conflict | The original request with that key is still being processed. | IdempotencyConflictError | IdempotencyConflictException | ErrIdempotencyConflict |
409 | other | Business rejection (BILL_ALREADY_PAID, SLOT_NOT_AVAILABLE, REFERENCE_REUSED…). | IdempotencyConflictError | IdempotencyConflictException | ErrIdempotencyConflict |
413 | about:blank | Body larger than 1 MB (25 MB for file uploads). | CompenseAPIError | CompenseApiException | *APIError |
422 | …/validation | Invalid body; see errors. Also engine business rejections. | ValidationError | ValidationException | ErrValidation |
429 | …/rate-limited | Per-minute limit or monthly quota; see Retry-After. | RateLimitError | RateLimitException | ErrRateLimited |
500 | about:blank | Unexpected gateway error. | ServerError | ServerException | ErrServer |
502 | …/upstream-error | The engine returned an error. | UpstreamError | UpstreamException | ErrUpstream |
504 | …/upstream-error | The engine didn’t respond in time. | UpstreamError | UpstreamException | ErrUpstream |
All HTTP exceptions inherit from CompenseAPIError (Python, JS) or CompenseApiException (PHP,
.NET), which in turn inherit from CompenseError / CompenseException; in Go they’re all
*compense.APIError and there are helpers (compense.IsNotFound, IsValidationError, IsRateLimited,
IsAuthError, IsIdempotencyConflict, IsUpstreamError, IsServerError). Configuration
errors (empty key, unknown environment) are thrown before anything is sent:
CompenseConfigError / CompenseConfigException / *compense.ConfigError.
Errors with no HTTP response aren’t CompenseAPIError: Python lets httpx errors through,
JS throws CompenseConnectionError (and CompenseTimeoutError), PHP TransportException, .NET
HttpRequestException / TaskCanceledException, and Go returns the transport or context error.
Full handling #
from compense_gateway import (AuthError, CompenseAPIError, IdempotencyConflictError,
NotFoundError, RateLimitError, ValidationError)
try:
client.wallet.p2p.transfer(document="1032456789", resolution_token=token,
amount="30000", idempotency_key="nova-p2p-2026-11-02-000123")
except ValidationError as e:
for err in e.errors: # 422: per-field errors
print(err["field"], err["message"])
print(e.detail) # or the engine's message
except IdempotencyConflictError as e:
if e.problem.type.endswith("/idempotency-conflict"):
... # in progress: retry with the same key
else:
print("Engine rejection:", e.detail) # e.g. expired token or insufficient funds
except RateLimitError as e:
print("Retry in", e.retry_after, "s")
except (AuthError, NotFoundError):
raise
except CompenseAPIError as e:
log.error("Compense %s %s trace=%s", e.status, e.title, e.trace_id)
raiseimport {
AuthError, CompenseAPIError, CompenseConnectionError, IdempotencyConflictError,
RateLimitError, ValidationError,
} from "@compense/sdk";
try {
await client.wallet.p2p.transfer({ document: "1032456789", resolutionToken: token,
amount: "30000", idempotencyKey: "nova-p2p-2026-11-02-000123" });
} catch (err) {
if (err instanceof ValidationError) {
for (const e of err.errors) console.log(e.field, e.message);
} else if (err instanceof IdempotencyConflictError) {
if (err.problem.type.endsWith("/idempotency-conflict")) { /* retry with the same key */ }
else console.log("Engine rejection:", err.detail);
} else if (err instanceof RateLimitError) {
console.log("Retry in", err.retryAfter, "s");
} else if (err instanceof AuthError) {
throw err;
} else if (err instanceof CompenseAPIError) {
console.error(`Compense ${err.status} ${err.title} trace=${err.traceId}`);
throw err;
} else if (err instanceof CompenseConnectionError) {
// no response: resend with the SAME idempotencyKey
} else throw err;
}use Compense\Sdk\Exception\AuthException;
use Compense\Sdk\Exception\CompenseApiException;
use Compense\Sdk\Exception\IdempotencyConflictException;
use Compense\Sdk\Exception\RateLimitException;
use Compense\Sdk\Exception\TransportException;
use Compense\Sdk\Exception\ValidationException;
try {
$client->wallet->p2p->transfer(document: '1032456789', resolutionToken: $token,
amount: '30000', idempotencyKey: 'nova-p2p-2026-11-02-000123');
} catch (ValidationException $e) {
foreach ($e->errors() as $err) {
echo $err['field'], ': ', $err['message'], PHP_EOL;
}
} catch (IdempotencyConflictException $e) {
if (str_ends_with($e->problem->type, '/idempotency-conflict')) {
// in progress: retry with the same key
} else {
echo 'Engine rejection: ', $e->detail, PHP_EOL;
}
} catch (RateLimitException $e) {
echo 'Retry in ', $e->retryAfter, ' s', PHP_EOL;
} catch (AuthException $e) {
throw $e;
} catch (CompenseApiException $e) {
error_log("Compense {$e->status} {$e->title} trace={$e->traceId}");
throw $e;
} catch (TransportException $e) {
// no response: resend with the SAME idempotencyKey
}try
{
await client.Wallet.P2P.TransferAsync("1032456789", token, "30000",
idempotencyKey: "nova-p2p-2026-11-02-000123");
}
catch (ValidationException e)
{
foreach (var err in e.Errors) Console.WriteLine($"{err["field"]}: {err["message"]}");
}
catch (IdempotencyConflictException e) when (e.Problem.Type.EndsWith("/idempotency-conflict"))
{
// in progress: retry with the same key
}
catch (IdempotencyConflictException e)
{
Console.WriteLine($"Engine rejection: {e.Detail}");
}
catch (RateLimitException e)
{
Console.WriteLine($"Retry in {e.RetryAfter}");
}
catch (CompenseApiException e) when (e is not AuthException)
{
logger.LogError("Compense {Status} {Title} trace={TraceId}", e.Status, e.Title, e.TraceId);
throw;
}_, err := client.Wallet.P2P.Transfer(ctx, &compense.WalletTransferParams{
Document: "1032456789", ResolutionToken: token, Amount: "30000",
}, compense.WithIdempotencyKey("nova-p2p-2026-11-02-000123"))
var apiErr *compense.APIError
switch {
case err == nil:
case compense.IsValidationError(err):
errors.As(err, &apiErr)
for _, fe := range apiErr.Errors() {
fmt.Println(fe["field"], fe["message"])
}
case compense.IsIdempotencyConflict(err):
errors.As(err, &apiErr)
if strings.HasSuffix(apiErr.Problem.Type, "/idempotency-conflict") {
// in progress: retry with the same key
} else {
fmt.Println("engine rejection:", apiErr.Detail)
}
case compense.IsRateLimited(err):
errors.As(err, &apiErr)
fmt.Println("retry in", *apiErr.RetryAfter)
case errors.As(err, &apiErr):
log.Printf("compense %d %s trace=%s", apiErr.StatusCode, apiErr.Title, apiErr.TraceID)
default:
// transport or context: resend with the SAME key
}curl -s -X POST https://sandbox.dev.compense.co/gw/v1/gateway/wallet/p2p/transfers/ \
-H "X-API-Key: $COMPENSE_API_KEY" -H "Idempotency-Key: nova-p2p-2026-11-02-000123" \
-H "Content-Type: application/json" \
-d '{"document":"1032456789","resolution_token":"vencido","amount":"30000"}' \
-w '\nHTTP %{http_code}\n'
# {"type":"…","title":"…","status":409,"detail":"…","trace_id":"…"}Validation errors (422) #
errors contains one item per field. Use them to show the problem in your form or to
log which value from the ERP was wrong. The message text is in English and may change: don’t
compare it literally; use field.
Engine rejections #
When the request is valid but the business rejects it (insufficient funds, bill already paid,
slot taken, duplicate reference, closed period), the gateway returns the appropriate code
(usually 409 or 422) with the engine’s message in detail. Each module guide lists its
typical rejections: wallet, loans,
health, PQRS…
Always log the trace_id
Every exception exposes the trace_id (e.trace_id, err.traceId, $e->traceId,
e.TraceId, apiErr.TraceID). Store it in your logs alongside your own operation id: with it, support
finds the exact request in seconds.