Handling errors
Every error under /v1 is application/problem+json (RFC 9457) - including 404s and
including errors nobody anticipated. There is no path that returns a bare string or
an HTML error page, so your error handling never needs a fallback branch for “the
response wasn’t JSON”.
{ "type": "https://docs.pacepayments.ai/errors/negotiation_exceeds_policy", "title": "Negotiation latitude exceeds the permitted corridor", "status": 422, "code": "negotiation_exceeds_policy", "detail": "installments.max_count 12 exceeds the order type maximum of 6.", "instance": "/v1/call-orders", "errors": [ { "pointer": "/negotiation/installments/max_count", "code": "exceeds_policy", "message": "Maximum permitted by order type `mahnung` is 6." } ]}Branch on code, never on title or detail. code is stable. The other two
are human-readable prose that may be reworded at any time, and a client that matches
on their text breaks on a copy edit.
The one exception
Section titled “The one exception”The token endpoint at auth.pacepayments.ai answers in the OAuth2 error format from
RFC 6749:
{ "error": "invalid_client", "error_description": "Unknown client or bad secret." }It sits on a separate host and is spoken to by generic OAuth2 libraries, which read
the error field. Everything under /v1 uses problem+json.
The registry
Section titled “The registry”4xx - your request
Section titled “4xx - your request”| Code | Status | Meaning |
|---|---|---|
validation_failed |
422 | The request did not validate. See errors[]. |
unauthorized |
401 | No valid access token. |
insufficient_scope |
403 | The grant does not cover this operation. |
ip_not_allowlisted |
403 | Source address is not on the registration’s allowlist. |
feature_not_enabled |
403 | The feature is not enabled for this tenant. |
simulate_not_allowed |
403 | A simulate block was sent to the live host. |
not_found |
404 | No such resource in this tenant. |
precondition_failed |
412 | The If-Match ETag is stale. |
payload_too_large |
413 | Payload exceeds the per-order limit. |
409 - state conflicts
Section titled “409 - state conflicts”| Code | Meaning |
|---|---|
idempotency_key_conflict |
The key was reused with a different request. |
dedupe_key_conflict |
A dedupe_key was reused with a different signal. |
order_not_cancelable |
The order has left accepted/scheduled. |
suppression_not_deletable |
The entry records an in-call objection. |
sequence_version_not_activatable |
Not a draft, or another version is active. |
enrollment_not_controllable |
The enrollment is in a terminal state. |
422 - business rules
Section titled “422 - business rules”| Code | Meaning |
|---|---|
unknown_order_type |
No such order type. Check the key - they are German. |
order_type_archived |
The type exists but no longer accepts orders. |
missing_required_context |
The order type requires context you did not send. |
negotiation_exceeds_policy |
Your corridor is wider than the order type permits. |
subject_suppressed |
The subject is on the do-not-call list. |
window_unsatisfiable |
No permissible dial instant exists in your window. |
on_window_violation |
Your window violates the permitted window, and you asked for reject. |
replay_range_invalid |
The replay range is unbounded or outside retention. |
429 and 5xx
Section titled “429 and 5xx”| Code | Status | Meaning |
|---|---|---|
rate_limited |
429 | HTTP rate limit exceeded. |
quota_exceeded |
429 | Execution capacity quota exceeded. |
backlog_full |
429 | The projected capacity backlog is too deep. |
internal_error |
500 | An internal error. |
upstream_unavailable |
503 | An upstream dependency is unavailable. |
Field errors
Section titled “Field errors”On a 422, errors[] names what was wrong.
pointer is an RFC 6901 pointer into the request body. Errors outside the body carry
their source as the first segment - /query/limit, /header/If-Match - so a single
handler can report any of them without special cases.
errors[].code comes from its own registry, separate from the top-level code:
required · unknown_field · wrong_type · too_long · too_short ·
out_of_range · malformed · phone_not_e164 · window_in_past ·
window_inverted · not_in_enum · exceeds_policy · archived_reference ·
unknown_reference
message is human-readable, not stable, and must not be parsed.
Surface pointer and code in your own logs. “Order rejected” costs someone twenty
minutes; /subject/address/postal_code required costs them none.
What to retry
Section titled “What to retry”This is the part worth getting right, because the wrong answer is expensive in both directions.
| Status | Retry? | Why |
|---|---|---|
| 401 | Once, with a fresh token | Might be expiry. Immediately again means bad credentials - stop. |
| 403 | Never | A configuration problem. Retrying cannot fix a missing scope. |
| 404 | Never | |
| 409 | Never without changing something | The state conflicts. See the specific code. |
| 412 | Yes, after re-reading | Re-fetch, take the new ETag, re-apply. |
| 422 | Never | The request is wrong. It will be wrong next time too. |
| 429 | Yes | Honour Retry-After. |
| 500 | Yes, with backoff | |
| 503 | Yes, with backoff | Honour Retry-After when present. |
| Timeout / network | Yes - with the same Idempotency-Key |
See below. |
Rate limits
Section titled “Rate limits”RateLimit-* headers are on every response, not only 429s. You can steer on
them before you are throttled rather than after.
RateLimit-Limit: 120RateLimit-Remaining: 87RateLimit-Reset: 34On a 429, Retry-After gives the seconds to wait and is dependable - use it rather
than a backoff schedule of your own invention.
Limits are per tenant and per endpoint class, so a batch of order submissions does not starve your webhook management calls.
A worked handler
Section titled “A worked handler”import timeimport requests
class PaceError(Exception): def __init__(self, problem): self.code = problem.get("code", "unknown") self.problem = problem super().__init__(f"{self.code}: {problem.get('detail', '')}")
RETRYABLE = {429, 500, 502, 503, 504}
def call(method, url, *, token, idempotency_key=None, attempts=4, **kwargs): headers = {"Authorization": f"Bearer {token}", **kwargs.pop("headers", {})} if idempotency_key: headers["Idempotency-Key"] = idempotency_key # the SAME key every attempt
for attempt in range(attempts): try: r = requests.request(method, url, headers=headers, timeout=15, **kwargs) except requests.Timeout: if attempt == attempts - 1: raise time.sleep(2 ** attempt) # safe: same key continue
if r.status_code < 400: return r.json()
problem = r.json()
if r.status_code in RETRYABLE and attempt < attempts - 1: wait = int(r.headers.get("Retry-After", 2 ** attempt)) time.sleep(wait) continue
raise PaceError(problem) # 4xx: do not retryThen branch on code, not on status:
try: order = call("POST", f"{BASE}/v1/call-orders", token=token, idempotency_key=str(uuid4()), json=payload)except PaceError as e: if e.code == "subject_suppressed": mark_do_not_call(case_id) # expected, not an incident elif e.code == "window_unsatisfiable": widen_window_and_resubmit(case_id) elif e.code in ("unknown_order_type", "missing_required_context"): raise # our bug - fail loudly else: queue_for_review(case_id, e.problem)subject_suppressed is the case most worth handling explicitly. It is not an
incident - it is the do-not-call list doing its job - but an integration that treats
every 422 alike will page somebody about it at three in the morning.