Errors
Every error response from the RekberPay API follows the same shape:
json
{
"success": false,
"error": {
"code": "INVALID_SIGNATURE",
"message": "Signature does not match canonical string."
}
}The HTTP status code tells you the category. The code field gives you something stable to switch on.
HTTP status categories
| Status | Category | Retry? |
|---|---|---|
400 | Validation / bad input | No — fix the input |
401 | Unauthenticated | No — log in / get credentials |
403 | Forbidden / signature invalid | No — fix permissions / re-sign |
404 | Not found | No |
409 | Conflict (duplicate, idempotency mismatch) | Maybe — see specific code |
410 | Gone (expired) | No |
413 | Payload too large | No — shrink the body |
429 | Rate limited | Yes — back off |
500 | Internal error | Yes — retry with same idempotency key |
502 / 503 | Upstream gateway down | Yes — retry with backoff |
Error codes
Authentication & signing
| Code | Status | Cause | Recovery |
|---|---|---|---|
INVALID_SIGNATURE | 403 | sig doesn't match the canonical string | Re-sign with correct field order, encoding, secret |
LINK_EXPIRED | 422 | expires is in the past | Generate a fresh link with future expires |
SIGNATURE_REQUIRED | 400 | Partner parameters present without a sig | Sign the link — unsigned partner links are rejected |
UNAUTHORIZED | 401 | No session, expired session, or no API key | Re-authenticate |
Idempotency
| Code | Status | Cause | Recovery |
|---|---|---|---|
IDEMPOTENCY_KEY_INVALID | 400 | Header value isn't a UUID v4 | Use randomUUID() / uuid.uuid4() |
IDEMPOTENCY_KEY_CONFLICT | 422 | Same key, different request body | Generate a fresh key for the new operation |
IDEMPOTENCY_KEY_IN_FLIGHT | 409 | An earlier request with this key is still processing | Wait and retry the same key; do not generate a new one |
IDEMPOTENCY_LOOKUP_FAILED | 500 | Key store unreachable | Retry with the same key |
IDEMPOTENCY_INSERT_FAILED | 500 | Key could not be registered | Retry with the same key |
Validation
| Code | Status | Cause | Recovery |
|---|---|---|---|
INVALID_BODY | 400 | Body failed Zod schema validation | Check required fields and types |
INVALID_AMOUNT | 400 | amount < 10,000 or > 100,000,000 IDR | Clamp to allowed range |
INVALID_METHOD | 400 | Payment method not recognized | Use one returned by /api/gateways/available-methods |
MISSING_METHOD | 400 | No method supplied | Send a method from /api/gateways/available-methods |
UNSUPPORTED_METHOD | 400 | No enabled gateway serves this method right now | Re-fetch /api/gateways/available-methods and pick another |
INVALID_USERNAME | 400 | Username doesn't match ^[a-z0-9_.]{3,30}$ | Lowercase alphanumeric + underscore/dot |
WEAK_PASSWORD | 400 | Password fails strength rules | 8+ chars, mixed case, digit |
INVALID_ACTION | 400 | Admin escrow action isn't in the allow-list | Use update-status, admin_force_release, cancel, release, refund, resolve_release, resolve_refund, resolve_hold. For dispute rulings use POST /admin/disputes/:id/resolve |
MISSING_FIELDS | 400 | Required action or reason is absent/empty | Send a non-empty reason with every admin escrow action |
REASON_TOO_LONG | 400 | Admin action reason exceeds 2000 chars | Trim the reason to ≤ 2000 characters |
Resource state
| Code | Status | Cause | Recovery |
|---|---|---|---|
NOT_FOUND | 404 | Resource ID doesn't exist | Verify the ID |
SELLER_NOT_FOUND | 422 | seller UUID isn't a real user | Confirm with support |
BUYER_MISMATCH | 422 | Link buyer isn't the authenticated user | Sign the link for the user who will open it |
INVALID_STATE | 409 | Escrow can't transition (e.g., paying an already-paid escrow) | Refresh and check current status |
INVALID_TRANSITION | 400 | State machine forbids this action for this role | See escrow lifecycle |
Authorization
| Code | Status | Cause | Recovery |
|---|---|---|---|
FORBIDDEN | 403 | Authenticated but not allowed | Check role / ownership |
NOT_A_PARTY | 403 | Trying to read/post in an escrow you're not a party to | Buyer or seller only |
NOT_BUYER | 403 | Buyer-only action attempted by seller/admin | Right user, wrong role |
SUSPENDED | 403 | User account is suspended | Contact support |
Rate limiting & body size
| Code | Status | Cause | Recovery |
|---|---|---|---|
RATE_LIMITED | 429 | Too many requests in 1 minute | Honor Retry-After, back off |
FST_ERR_CTP_BODY_TOO_LARGE | 413 | Request body > 1 MiB | Trim the body — chat attachments use /api/upload |
Server-side
| Code | Status | Cause | Recovery |
|---|---|---|---|
INTERNAL_ERROR | 500 | Unhandled server error | Retry with same idempotency key |
GATEWAY_ERROR | 502 | The payment gateway rejected or failed the charge request | Retry; if it persists, pick another method |
VALIDATION_FAILED | 400 | Request failed schema validation at the framework layer | Check body and params against the endpoint |
Jasa Pembayaran
| Code | Status | Cause | Recovery |
|---|---|---|---|
FEATURE_DISABLED | 503 | Jasa Pembayaran is switched off | Retry later; check GET /api/jasabayar/config |
AMOUNT_TOO_LARGE | 400 | item_price above max_item_price_idr | Lower the amount or split the order |
DECRYPT_FAILED | 500 | Stored credential payload could not be decrypted | Contact support with the order ID |
DB_ERROR | 500 | Order was not created; no funds were taken | Safe to retry |
Reading error responses
Every error response is JSON. Treat anything else as a network/proxy issue, not a RekberPay error.
js
const res = await fetch(url, options);
if (!res.ok) {
const ct = res.headers.get('content-type') ?? '';
if (!ct.includes('application/json')) {
throw new Error(`Network error: ${res.status} ${res.statusText}`);
}
const { error } = await res.json();
throw new ApiError(error.code, error.message, res.status);
}Recovery patterns
Idempotent retry on 5xx / network failure
js
async function withRetry(fn, max = 5) {
let attempt = 0;
while (attempt < max) {
try {
return await fn();
} catch (err) {
if (err.status && err.status < 500) throw err; // 4xx — don't retry
if (attempt === max - 1) throw err;
await sleep(1000 * Math.pow(2, attempt++));
}
}
}Backoff on 429
The response includes a Retry-After header (seconds):
js
if (res.status === 429) {
const wait = Number(res.headers.get('Retry-After') ?? 60);
await sleep(wait * 1000);
return retry();
}Refresh on INVALID_STATE
js
if (err.code === 'INVALID_STATE') {
// Fetch the latest state and decide what to do
const { data } = await fetch(`/api/escrows/${id}/payment-status`).then(r => r.json());
if (data.status === 'paid') {
// Already paid — treat as success
return;
}
throw err;
}Reporting bugs
If you hit a 500 you can't explain, email [email protected] with:
escrow_id(or other resource ID)- Approximate timestamp (UTC)
- Request body (redact secrets)
- Response body
We log everything server-side and can usually identify the cause within minutes.
See also
- Idempotency — make retries safe
- Reference: Escrows — endpoint-specific errors
- Reference: Webhooks — verifying signatures