Mono Colombia

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.

CodeDescriptionCauseSuggested fix
expiredWindow closedNobody 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_failedKey registration failedRegistering 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_failedPayment token setup failedThe 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_failedPayment failedA 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.
unknownUnknownThe 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:

GroupCodes
Amount limitsamount_below_minimum, amount_exceeds_maximum, amount_below_total_minimum, amount_exceeds_total_maximum, intent_amount_mismatch
Payer restrictionspayer_not_allowed
Bank refusalbank_rejected
Reported by the payment railrisk_control, provider_unavailable, internal_error
Reported by PSEEvery PSE reason, such as payer_insufficient_funds, payer_did_not_complete, or pse_rejected
Template conditionstemplate_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:

QuestionEventWhere the reason lives
Why did this attempt fail?payin_rejected / payin_failedThe payin's state_reason (see Payin errors).
Why can this order never be paid?payin_intent_failed / payin_intent_expiredThe 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

On this page