Errors
Every error uses the same envelope:
{
"error": {
"code": "invalid_request",
"message": "…",
"request_id": "req-1",
"details": [
{
"field": "return_url",
"reason": "invalid"
}
]
}
}details is optional and machine-readable. When an endpoint can say which
field it refused and why, it puts that here rather than expecting you to parse
message — message is prose and may be reworded.
Quote request_id to support. It is also returned as the x-request-id
response header, so you can log it without parsing a body — which matters most
on the responses you failed to parse.
Codes
| Status | code | Meaning |
|---|---|---|
| 400 | invalid_request | Unknown field, non-https URL, wrong-typed field, bad body |
| 400 | idempotency_key_required | A write that requires Idempotency-Key did not get one |
| 400 | idempotency_key_invalid | The key is longer than the cap |
| 401 | unauthorized | Missing, malformed or expired token |
| 403 | insufficient_scope | Valid token, but minted without the scope this endpoint needs |
| 403 | capability_not_enabled | The capability is not granted on this customer |
| 404 | resource_not_found | Resource does not exist or is not yours — the two are deliberately indistinguishable |
| 409 | verification_in_progress | The case is under review. Raise a requirement to reopen it for what you hold |
| 409 | capability_unavailable | Granted, but the merchant cannot currently use it (see details[].reason) |
| 409 | reference_conflict | The reference is already used (generic, ownerless) |
| 409 | not_refundable | The order cannot be refunded at all — payment link, or no processor-side payment |
| 409 | order_not_paid | Only a paid or completed order can be refunded |
| 409 | nothing_to_refund | The refundable balance is already zero |
| 409 | amount_exceeds_remaining | Over the remaining refundable balance; details[].remaining_refundable_cents |
| 409 | refund_full_value_only | This processor refunds the full remaining balance only; details[].remaining_refundable_cents |
| 409 | name_conflict | A named resource with that name already exists on the customer |
| 409 | limit_reached | A per-customer ceiling was hit |
| 409 | idempotency_key_reuse | Same key, different body or different path |
| 409 | idempotency_key_in_progress | The first attempt with this key is still running |
| 422 | validation_error | Service-level validation failed |
| 429 | limit_reached | A rate or daily cap was hit; details[].reset_at when known |
| 500 | internal_error | Unexpected server error |
| 502 | provider_error | The payment provider refused or failed |
| 503 | temporarily_unavailable | Transient failure. Retry — this is the only code that means that |
Two rules worth building against
404 never confirms existence. A resource belonging to another partner or
another merchant answers exactly like one that was never created. A 403 there
would confirm the id is real and turn a retrieve endpoint into a discovery
endpoint. So a 404 means “not yours, or not there” — check both.
Only 503 temporarily_unavailable means retry. Errors are classified before
they are returned: a dropped connection earns that code, a deterministic failure
does not. A client that retries on every 5xx will loop forever against a bug,
which is why a plain 500 deliberately does not invite one.
reference_conflictis deliberately generic — it does not reveal whose order holds the conflicting reference.
Unmatched paths under
/api/v1/partner/*return this JSON envelope too, not an HTML error page, so a client can parse a typo’d URL the same way it parses everything else.