Skip to content

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 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.

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.
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.
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.
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.

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.

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.

RateLimit-* headers are on every response, not only 429s. You can steer on them before you are throttled rather than after.

RateLimit-Limit: 120
RateLimit-Remaining: 87
RateLimit-Reset: 34

On 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.

import time
import 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 retry

Then 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.