Intent errors
Why an intent ends failed or expired, and which reasons it inherits from the payment that closed it.
An intent carries state_reason in its two unsuccessful states:
failed: there is nothing left to pay. Either the payment token never made it, or a payment closed the intent for good.expired: the window closed with nobody paying.
You read the code in the payin_intent_failed and payin_intent_expired webhooks, and on the intent itself via get or list.
Example webhook payload
{
"timestamp": "2026-02-11T14:22:07.913204Z",
"event": {
"type": "payin_intent_failed",
"data": {
"id": "cpii_034EASMIAFVq06j2NxhnIJ",
"state": "failed",
"state_reason": "payment_token_registration_failed",
"external_id": "order-88213",
"template_id": "cpit_034EASMHlq36oZISbc9W4b"
}
}
}The intent's own reasons
What can happen to an intent before a payment exists, plus the catch-all for a payment that failed without an explanation worth handing the payer.
| Code | Description | Cause | Suggested fix |
|---|---|---|---|
expired | Window closed | Nobody paid before expires_at. For Bre-B the window is 30 minutes. A PSE intent only expires on the hosted checkout, when the payer does not send the form within 20 minutes. | Create a new intent. The previous one releases its external_id when it expires. |
key_registration_failed | Key registration failed | Registering the intent's own payment key with the network exhausted its retries. | Create the intent again. If it repeats, contact support with the intent id. |
payment_token_registration_failed | Payment token setup failed | The key registered, but the payment token could not be assembled from it. A PSE link that cannot be generated keeps the payin's reason instead: see Reasons inherited from the payment. | Create the intent again. If it repeats, contact support with the intent id. |
payment_failed | Payment failed | A payment closed the intent for a reason the intent does not name, such as a timeout. A failed PSE payment keeps the payin's reason instead. | Read the payin's own state_reason for the detail; create a new intent to retry. |
unknown | Unknown | The payment that closed the intent failed without a known reason. | Contact support with the intent id. |
Reasons inherited from the payment
When a payment closes an intent for a reason the payer or the merchant can act on, the intent adopts the payin's reason under the same name instead of flattening it into payment_failed. Each of these means exactly what it means on the payin, so the table there is the reference:
| Group | Codes |
|---|---|
| Amount limits | amount_below_minimum, amount_exceeds_maximum, amount_below_total_minimum, amount_exceeds_total_maximum, intent_amount_mismatch |
| Payer restrictions | payer_not_allowed |
| Bank refusal | bank_rejected |
| Reported by the payment rail | risk_control, provider_unavailable, internal_error |
| Reported by PSE | Every PSE reason, such as payer_insufficient_funds, payer_did_not_complete, or pse_rejected |
| Template conditions | template_paid, template_expired, template_disabled, template_deleted, template_not_found, template_invalid_state |
The split is deliberate. A reason keeps its name when someone can act on it: the payer (the amount, their account, trying again later) or the merchant (a collection that stopped taking payments, an error to escalate to Mono). The rest collapse into payment_failed, such as the Bre-B timeouts, which never close an intent.
Reachable with PSE, not with Bre-B
These inherited reasons only appear for payment methods that do not re-arm an intent after
a failed payment. Bre-B re-arms: a failed payment returns the intent to ready_to_pay, so the
payer can retry with the same key, and the intent is not closed at all. PSE does not re-arm,
so a failed PSE payment closes the intent, and the intent ends with the payin's reason. The
same happens when the PSE payment link cannot be generated: the intent ends with the payin's
risk_control, provider_unavailable, internal_error, or unknown.
A failed payment is not always a failed intent
Most payment failures do not end the intent. With a re-arming method the intent goes back to ready_to_pay and emits payin_intent_attempt_failed. The failed attempt itself is reported by the payin's own payin_rejected or payin_failed webhook, which carries the real reason.
With PSE, a failed payment always ends the intent. PSE does not re-arm, so payin_intent_attempt_failed never fires: the payin's payin_failed and the intent's payin_intent_failed arrive instead.
So there are two different questions, and two different events:
| Question | Event | Where the reason lives |
|---|---|---|
| Why did this attempt fail? | payin_rejected / payin_failed | The payin's state_reason (see Payin errors). |
| Why can this order never be paid? | payin_intent_failed / payin_intent_expired | The intent's state_reason (this page). |
Handling them
app.post('/webhook', (req, res) => {
const { event } = req.body;
// Bre-B only: the order can still be paid, the payer just has to retry
if (event.type === 'payin_intent_attempt_failed') {
// Keep the checkout open; the key is still valid
}
// The order is over
if (event.type === 'payin_intent_failed' || event.type === 'payin_intent_expired') {
const { external_id, state_reason } = event.data;
switch (state_reason) {
case 'expired':
// Nobody paid in time: offer a new checkout
break;
case 'key_registration_failed':
case 'payment_token_registration_failed':
// The intent never became payable: create it again
break;
default:
// A payment closed it. With PSE, `state_reason` is the payin's own reason: show it, then issue a new intent
}
}
res.status(200).send();
});Checking whether an order was paid
Do not infer payment from the absence of a failure. GET /v1/core/payins/intents/find_successful_by?external_id=... answers it directly: at most one
successful intent exists per identifier.
Next steps
- Payin errors: the reason behind every failed attempt.
- Template errors: why the template behind the intent stopped being payable.
- Collect with intents: the end-to-end flow.