Handle checks, refusals and errors
Separate transport success from authorization, recover from failed checks, and use the evidence trail safely.
In short
1 callFor people and agentsThe same list heads this page's Markdown:/mandate/checks-results-and-errors.md
Mandate responses answer two separate questions:
- Could the API understand and process the request? The HTTP status answers this.
- Does the authority permit the requested action? A result boolean and
checksanswer this.
A robust integration handles both layers. HTTP 200 alone is never proof that the user authorized a charge.
The control flow
For issue:
- require HTTP 201,
- save
idandconfirm_expires_at.
For confirm:
- require HTTP 200,
- require
confirmed: true, - save
tokenunchanged.
For verify:
- require HTTP 200,
- require
valid: true, - require every returned check to have
ok: true.
Settlement applies the same pattern to exercise with exercised: true.
Do not collapse transport success and authorization into one “success” branch. Log the result boolean, failed checks, mandate ID, and reconciliation reference separately.
HTTP status reference
| Status | Meaning | Action |
|---|---|---|
| 200 | The request was processed. The body may still be a refusal. | Read the operation boolean, reason, and checks. |
| 201 | A mandate was issued. | Save id and confirm_expires_at; the mandate is still pending. |
| 401 | The API key or bearer token is missing or invalid. | Fix the credential; the request never reached the service. |
| 404 | The path mandate ID does not exist. | Stop retrying that ID. |
| 422 | Body or query validation failed. | Fix the field identified by detail; nothing was created. |
Successful responses do not use a shared envelope. The list endpoint returns { "user_id", "mandates" }; other operations return their resource or result directly.
Confirmation refusals
Confirm returns HTTP 200 with confirmed: false and no token when the draft cannot become authority:
reason | Returned status | Recovery |
|---|---|---|
already confirmed | Usually active | Retrieve the mandate and use its existing token. |
revoked | revoked | Issue a new mandate only after obtaining new user intent. |
confirmation window expired | expired | Issue a new proposal and present it again. |
Concurrent confirmation requests are serialized by a database uniqueness rule. One wins; the other receives already confirmed.
Verification order
For a token that decodes successfully, checks follow this order:
| Order | Check | Failure means |
|---|---|---|
| 1 | signature | The token is not a valid ES256 token for the configured issuer and key. |
| 2 | expiry | The JWT exp is in the past. |
| 3 | known | The token's jti is not a mandate in this service. |
| 4 | confirmed | No confirmation event exists. |
| 5 | revocation | A revocation event exists. |
| 6 | single_use | Closed only: an exercise event already exists. Open mandates skip this check. |
| 7 | agent_binding | The mandate names an agent_id and the authenticated caller does not match it. |
| 8 | one per constraint | The supplied charge violates a rule. Omitted when no charge is supplied. |
| 9 | currency, amount, or budget | The charge conflicts with top-level payment_amount. Added only on failure. |
| 10 | jti_match | Exercise only: the token ID differs from the mandate ID in the path. |
Open responses also carry spent and remaining.
Invalid or expired tokens stop early:
{
"valid": false,
"checks": [
{
"check": "signature",
"ok": false,
"reason": "<JWT validation error>"
}
]
}An expired token returns an expiry failure instead. Neither case includes mandate_id, and neither records a verified event.
Read the whole check set
A refusal can contain successful checks alongside the one that matters:
{
"valid": false,
"mandate_id": "mdt_0123456789abcdef0123456789abcdef",
"checks": [
{ "check": "signature", "ok": true, "reason": "valid ES256 signature" },
{ "check": "expiry", "ok": true, "reason": "not expired" },
{ "check": "known", "ok": true, "reason": "issued by this service" },
{ "check": "confirmed", "ok": true, "reason": "user confirmed" },
{ "check": "revocation", "ok": false, "reason": "revoked" },
{ "check": "single_use", "ok": true, "reason": "not yet exercised" }
]
}Do not stop at the first ok: true, and do not infer validity from the presence of mandate_id. Require the top-level boolean and every check.
Recovery by failed check
| Failed check | What to do |
|---|---|
signature | Reject the token. Do not edit or re-encode it. Confirm you received it from the expected environment. |
expiry | Obtain new user authority; do not extend the old token locally. |
known | Reject it in this environment. Check that issuer, database and deployment match. |
confirmed | Complete the user-confirmation flow before payment. |
revocation | Stop. New user intent requires a new mandate. |
single_use | Treat the closed mandate as already settled; reconcile rather than retrying exercise. |
agent_binding | Use the authenticated agent that owns the mandate or obtain a new mandate with the intended binding. |
| constraint name | Correct the charge or obtain authority with different user-approved terms. |
currency / amount / budget | Correct the charge or obtain a new/expanded mandate. |
jti_match | Pair the token with its own mandate ID. |
Never “fix” a refusal by deleting a constraint, changing claims, or substituting a different charge after confirmation.
Retry and idempotency boundaries
- Issue is not idempotent. Repeating it creates another mandate ID.
- Confirm can succeed once. A repeat returns
confirmed: falsewithalready confirmed. - Verify is repeatable, but not invisible. Every decodable token records a
verifiedevent, even if a later check fails. - Revoke is idempotent. Repeats return
already_revoked: true. - Closed exercise succeeds once. If the client loses the response, retrieve status and reconcile before retrying.
- Open exercise may succeed many times.
reference_idis stored for reconciliation but is not enforced as an idempotency key.
That last point matters: settlement must provide its own duplicate-protection strategy around network retries.
Audit trail
GET /v1/mandate/{id}/evidence returns:
- the draft or confirmed claims,
- the confirmed token when one exists,
- evidence supplied at issue, and
- ordered lifecycle events.
issued and revoked events may have detail: null. Non-null event details are returned as JSON-encoded strings, so consumers that need their structured contents must parse detail a second time.
A successful token decode records verified even when a later lifecycle or constraint check fails. Closed settlement records exercised; open settlement records charged. Together, these events show what the user approved, what callers attempted, and what settlement committed.