Mono Colombia

Payins

Simulate a payer paying your collections, and provoke the failures you need to test.

In production, money arrives when a real person decides to pay you. In the sandbox you play that person yourself: one request makes a payment land on one of your collections, and everything downstream behaves as if the money were real. It is how you build and test a collection from end to end before you ever touch production.

With Bre-B, Colombia's instant transfer network, the simulated payment runs the real rail. With PSE, where the payer approves at their bank's website, the sandbox plays that bank. Either way, you get the same states and webhooks as in production. Only the money is not real.

Before you start

You will need:

  • A sandbox API key, and requests pointed at https://api.sandbox.cuentamono.com.
  • The payins scope on your credentials.
  • Something that can be paid: a template or an intent. It is not payable the moment you create it: each method's section says when it is.

Sandbox only. In every other environment this endpoint answers 403 with feature_not_enabled, so a call that leaks into production code cannot mint payments.

One endpoint, two methods

Every simulation goes through the same endpoint, POST /v1/core/payins/sandbox/payments. What you send, and which failures you can provoke, depend on the payment method. Each method has its own section:

MethodWhat you can paySection
Bre-B (co_breb)A template or an intentSimulating a Bre-B payment
PSE (co_pse)An intentSimulating a PSE payment

Simulating a Bre-B payment

Bre-B can pay a template or an intent. Everything in this section applies to co_breb only.

What a simulation actually does

Mono operates directly on Bre-B, so the sandbox does not fake the payment: it drives the same acceptance flow a real transfer goes through. You name a target and an amount, and Mono builds an incoming payment against that target's Bre-B key, exactly as if a payer had scanned its QR.

The response is a receipt, not a verdict. It tells you a payin exists; the payment's outcome reaches you a few seconds later over the payin webhooks, the same way a real one does.

When a Bre-B target can be paid

This is the step that trips people up. A template or an intent is not payable the moment you create it. Mono still has to register its Bre-B key with the network. Wait for the payin_template_ready or payin_intent_ready_to_pay webhook first: the template is then ready, and the intent ready_to_pay. Simulating against a target with no key yet answers 400 with payment_token_not_found.

The two shapes of a payment

Which shape you send depends on what you are paying. A template accepts many payments and knows nothing about this particular one, so the request has to carry its own method and amount. An intent was declared for one specific payment and already fixes both.

Paying a...SendBecause
Templatetemplate_id + method + amountThe template accepts many payments; this one brings its own
Intentintent_idThe intent already fixed the method and the amount

The two shapes are exclusive. Naming both targets, or sending a method alongside an intent_id, answers 422.

Paying a template

POST /v1/core/payins/sandbox/payments
{
  "template_id": "cpitpl_5tgliBmzjZ6mpQPRbQjfKj",
  "method": "co_breb",
  "amount": { "amount": 5000000, "currency": "COP" }
}

method is co_breb, the only method a template payment takes. The template must accept it, or the call answers 400 with method_not_allowed. Amounts are integers in cents, so the example above is 50,000 COP.

Paying an intent

POST /v1/core/payins/sandbox/payments
{
  "intent_id": "cpint_5tgliBmzjZ6mpQPRbQjfKj"
}

That is the whole request. The intent supplies the method and the amount, which is exactly what makes it useful for testing: send this and you are verifying the amount the intent enforces, not one you retyped.

Reading the response

The status code tells you whether a payin was born:

StatusBodyWhat happened
201{ "payin_id": "cpin_...", "state_reason": null }The payment entered. Follow it over the webhooks.
201{ "payin_id": "cpin_...", "state_reason": "bank_rejected" }A payin exists and was already rejected on its own rules.
200{ "payin_id": null, "state_reason": "amount_below_minimum" }The payment was refused before a payin existed.

That 200 deserves attention. When Mono's payment authorization refuses the payment (an amount outside the template's bounds, a payer it does not expect), no payin is created and no webhook fires. In production the payer's own banking app is what reports the refusal, so this response is the only place your system can learn what happened. Provoking those refusals is a large part of what this endpoint is for.

Choosing who pays

Leave payer out and Mono invents one: a random Colombian name, a random 10-digit CC document. Fine for most tests. Send it when the payer is the point of the test:

{
  "template_id": "cpitpl_5tgliBmzjZ6mpQPRbQjfKj",
  "method": "co_breb",
  "amount": { "amount": 5000000, "currency": "COP" },
  "payer": {
    "document": { "type": "CC", "number": "1234567890" },
    "name": "Juan Garcia"
  }
}

This is how you exercise a template's expected_payers list. Send a document on the list to confirm the payment goes through; send one that is not to confirm you get payer_not_allowed. Document type accepts CC, CE, NIT, PA, and TI.

Provoking failures

There are two kinds of failure, and you provoke them in two different ways. Getting this distinction right saves a lot of confusion.

Rail faults: the error field

These are infrastructure conditions the payment rail reports after the payment was already accepted. Name one in error and the sandbox asks the rail to produce it:

{
  "template_id": "cpitpl_5tgliBmzjZ6mpQPRbQjfKj",
  "method": "co_breb",
  "amount": { "amount": 5000000, "currency": "COP" },
  "error": "risk_control"
}

The call still answers 201 with a payin_id and a null state_reason: the payment was accepted, so a payin exists. The failure arrives 2 to 5 seconds later, on the webhook:

errorThe payin endsMeaning
risk_controlfailed / risk_controlThe provider's risk controls stopped it
provider_unavailablefailed / provider_unavailableThe payment provider was out of service
breb_timeoutfailed / breb_timeoutThe Bre-B rail did not answer in time
unknownfailed / unknownAn unclassified fault

All four land the payin in failed and reach you as payin_failed. If the payment was addressed to an intent, the intent reports separately: it re-arms for a retry with payin_intent_attempt_failed, or gives up with payin_intent_failed.

These four are the only ones Bre-B takes. Any other error answers 400 with payin_simulation_error_not_supported.

Business refusals: by construction

Everything Mono's own validation catches has no lever, and needs none: you provoke it by sending a payment the target genuinely refuses. These are refused synchronously, so they come back on the response itself as a 200 with no payin.

To getSend
amount_below_minimumAn amount under the template's minimum_payin_amount
amount_exceeds_maximumAn amount over its maximum_payin_amount
amount_exceeds_total_maximumAn amount that pushes it past total_maximum_amount
payer_not_allowedA payer document absent from expected_payers
template_expired / intent_expiredA payment after the target's expires_at
template_paidA second payment to a single_use template
template_disabledA payment to a template you updated to enabled: false
template_deletedA payment to a template you deleted

Every code, with its cause and what to do about it, is in Payin errors.

One reason you cannot reach from here

intent_amount_mismatch is out of reach in the sandbox. A payment to an intent always takes the amount from the intent itself, so there is no way to send a different one. Trust the intent's own validation for that case.

Simulating the QR the payer scanned

A Bre-B payment can carry the identifier of the QR code it came from. Send it when your reconciliation reads that field:

{
  "template_id": "cpitpl_5tgliBmzjZ6mpQPRbQjfKj",
  "method": "co_breb",
  "amount": { "amount": 5000000, "currency": "COP" },
  "method_params": { "payment_id": "CO.COM.CRB.TRXIDP1234567891234" }
}

Omit it and the rail assigns one. method_params is method-specific: for co_breb the only field is payment_id.

A full Bre-B test

Putting it together, a collection you can run in one sitting:

  1. Create a template. Wait for payin_template_ready.
  2. Simulate a payment under the minimum. Expect 200, amount_below_minimum, no webhook.
  3. Simulate a valid payment. Expect 201, then payin_created.
  4. A few seconds later, expect payin_approved_in_provider and payin_subaccount_credited.
  5. Simulate another with "error": "breb_timeout". Expect 201, then payin_failed.
  6. Read the template. Its paid_amount counts step 3 and not steps 2 or 5.

If your integration handles all six, it handles a real collection.

Simulating a PSE payment

PSE works differently. In production, the payer approves the payment on their bank's website, and the bank tells Mono the outcome. The sandbox has no real bank, so Mono plays it: it sends the answer the bank would send. From that answer on, everything runs as in production: the payin settles, the subaccount is credited, and the same webhooks fire.

You can give that answer in two ways. Both run the same code, so the result is the same:

Your testUse
Only API calls, such as an automated backend testThis endpoint, with the intent_id
A web checkout, with the redirect to the bankThe intent's pse_url. In the sandbox, it opens Mono's simulated bank page

When a PSE intent can be paid

A PSE intent becomes payable once PSE generates its payment link. That happens right after payin_intent_ready_to_pay, when the intent moves to awaiting_payment. Before that, or once the payment resolved, the call answers 400 with payin_target_not_payable.

Create the intent as in production, with two sandbox details:

  • Take bank_code from the sandbox's own bank catalog, GET /v1/banks?kind=pse, not from production's.
  • Use whole pesos, such as 8990000 (89,900 COP). A simulated payment with cents does not resolve: it stays in awaiting_payment.

The request

POST /v1/core/payins/sandbox/payments
{
  "intent_id": "cpint_5tgliBmzjZ6mpQPRbQjfKj"
}

Only the intent shape works. A PSE payment always belongs to an intent, and the intent already names its payer:

  • A template_id with "method": "co_pse" answers 400 with payin_simulation_intent_required.
  • A payer answers 400 with payin_simulation_payer_not_supported.

The call answers 201 with the payin_id. That payin is not new: Mono created it with the intent. The outcome arrives a few seconds later, over the webhooks.

Choosing the outcome

Leave error out, or pick Approve on the bank page, and the bank approves the payment. You receive payin_approved_in_provider, payin_intent_successful, and the rest of the PSE sequence.

To reject it, send one of these in error, or pick Reject and the matching reason on the page:

errorReason on the bank page
payer_did_not_authorizeThe user did not accept the transaction
payer_did_not_completeThe user did not complete the transaction
payer_account_inactiveTransaction rejected by the issuing bank
authorization_deniedUnauthorized transaction
pse_rejectedTransaction rejected by PSE
risk_controlTransaction declined by the pre-authorizer
provider_unavailableTechnical failures in PSE or the user's bank
unknownNone

Every rejection ends the payin and the intent failed, both with that state_reason. You receive payin_failed and payin_intent_failed. To try again, create a new intent: PSE allows one attempt per intent.

Those eight are the only ones. The other PSE reasons, such as payer_insufficient_funds, cannot be provoked in the sandbox. An error PSE does not take, such as breb_timeout, answers 400 with payin_simulation_error_not_supported.

On the page, Confirm sends the payer back to your redirect_url, with payin_id and intent_id added, as their bank would. If nobody answers, the sandbox waits like a real bank. 20 minutes after the link was generated, it rejects the payment without a reason, and the payin and the intent end failed with unknown.

A full PSE test

Each test takes its own intent, because PSE allows one attempt per intent:

  1. Create a PSE intent. Wait for payin_intent_ready_to_pay.
  2. Simulate its payment with only the intent_id. Expect 201, then payin_approved_in_provider and payin_intent_successful.
  3. Create a second intent and simulate it with "error": "payer_did_not_complete". Expect payin_failed and payin_intent_failed, both with that reason.
  4. Create a third, open its pse_url in a browser, and approve. Check that you land on your redirect_url with payin_id and intent_id.

Errors from the endpoint itself

These are faults in the request, not simulated payment outcomes.

StatusCodeCause
400method_not_allowedThe template does not accept the method you sent.
400payin_invalid_method_paramsThe method_params do not match the method.
400payment_token_not_foundThe target has no Bre-B key yet: it is not ready / ready_to_pay.
400payin_target_not_payableThe PSE intent is not in awaiting_payment: its link is not ready, or the payment already resolved.
400payin_simulation_error_not_supportedThe target's method cannot provoke that error, such as breb_timeout on a PSE intent.
400payin_simulation_intent_requiredA PSE payment names a template. It must name an intent.
400payin_simulation_payer_not_supportedA PSE payment brings a payer. The intent already names one.
400simulation_not_supportedThe method cannot be simulated in this environment.
401missing_authorization_header, invalid_tokenCredential problem.
403feature_not_enabledNot a sandbox environment, or payins is not enabled for your account.
404template_not_found, payin_intent_not_foundNo such target. Another tenant's target reads as one that does not exist.
422N/AThe request violates the schema: both targets, or a missing field.

Next steps

On this page