Docs
Errors
One error envelope, a namespaced registry of codes, and what a caller should actually do with each.
One envelope
{
"error": {
"code": "path.request.expired",
"message": "This request has expired",
"detail": { "expired_at": "2026-09-08T10:00:00Z" },
"request_id": "req_8f21c4"
}
}Every error, everywhere. A caller writes one handler.
code is stable and machine-readable. message is for a developer reading a log and MUST NOT
be parsed. detail is optional and structured. request_id is echoed in the Path-Request-Id
header and is what you quote in a support conversation.
Codes are part of the protocol
Namespaced path.<area>.<condition>, so the code alone tells you which part of the specification to
open.
Adding a code is additive. Changing what one means is breaking, and requires a major protocol bump.
The registry
Envelope and versioning
| Code | Status | What it means | What to do |
|---|---|---|---|
path.core.unsupported_version | 400 | Unknown API version | Pin a version the operator lists in discovery |
path.core.malformed_request | 400 | The body failed validation | Read detail; fix the caller |
path.core.not_found | 404 | No such object | Not retryable |
path.core.rate_limited | 429 | Too many requests | Back off, then retry |
path.core.internal | 500 | Unexpected failure | Retry with backoff; quote request_id |
Authentication and membership
| Code | Status | What it means | What to do |
|---|---|---|---|
path.auth.unauthenticated | 401 | Missing, stale or invalid signature | Check clock skew first — it is usually the timestamp window |
path.auth.credential_revoked | 401 | The credential was revoked | Stop retrying. Get a new one |
path.auth.member_not_active | 403 | The member is suspended or excluded | A membership matter, not a technical one |
Reachability
| Code | Status | What it means | What to do |
|---|---|---|---|
path.finder.budget_exhausted | 429 | Reciprocity budget spent | Not a rate limit — do not simply retry slower. Reduce lookups or discuss the budget |
path.finder.key_unresolvable | 404 | Nothing reachable at this key | Indistinguishable from "not visible to you", by design |
path.finder.holder_unavailable | 503 | The holder is listed but not answering | Retryable. Distinct from not found — someone does hold this |
Address
| Code | Status | What it means | What to do |
|---|---|---|---|
path.address.revoked | 410 | The address was revoked | Ask the recipient for a current one. Not the same as unknown |
path.address.commitment_mismatch | 409 | The answer diverges from its commitment | Do not send. Treat as a potentially compromised resolver |
Request
| Code | Status | What it means | What to do |
|---|---|---|---|
path.request.expired | 410 | Past its expiry | Ask the issuer for a new one |
path.request.already_paid | 409 | Already paid | Not an error to retry — check your own state |
path.request.revoked | 410 | Revoked by the issuer | Stop |
path.request.amount_required | 400 | Open amount, and none supplied | Prompt the payer |
Connect
| Code | Status | What it means | What to do |
|---|---|---|---|
path.connect.delegation_out_of_scope | 403 | Outside the granted capabilities | Request a wider grant, explicitly |
path.connect.delegation_limit_reached | 403 | The counter is spent | Surface it to the user; do not retry |
Settlement
| Code | Status | What it means | What to do |
|---|---|---|---|
path.settlement.receipt_unverifiable | 422 | The signature does not verify | Refetch the issuer's keys — an unknown kid usually means a rotation |
What errors never contain
No stack traces. Ever, in any environment reachable from outside.
No hint about why a lookup found nothing. key_unresolvable is returned identically for a key
that does not exist and for one that exists but is not visible to the caller. Distinguishing them
would make the endpoint an oracle for "is this person a customer of somebody", which is worth money
to a scammer before anyone learns which provider.
That constraint extends to timing and to status codes. A faster negative is still an answer.
Retrying
if (err instanceof PathApiError && err.retryable) {
// 5xx and rate limits: exponential backoff, full jitter
}retryable covers 5xx and path.core.rate_limited. Everything else is a decision, not a hiccup —
retrying an expired request produces the same answer more expensively.
path.finder.budget_exhausted deserves special mention: it looks like a rate limit and is not one.
Retrying more slowly does not help, because the allowance is monthly and tied to what you contribute
to the index. The fix is to look up fewer things, or to talk to the network.