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
payinsscope 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:
| Method | What you can pay | Section |
|---|---|---|
Bre-B (co_breb) | A template or an intent | Simulating a Bre-B payment |
PSE (co_pse) | An intent | Simulating 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... | Send | Because |
|---|---|---|
| Template | template_id + method + amount | The template accepts many payments; this one brings its own |
| Intent | intent_id | The 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
{
"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
{
"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:
| Status | Body | What 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:
error | The payin ends | Meaning |
|---|---|---|
risk_control | failed / risk_control | The provider's risk controls stopped it |
provider_unavailable | failed / provider_unavailable | The payment provider was out of service |
breb_timeout | failed / breb_timeout | The Bre-B rail did not answer in time |
unknown | failed / unknown | An 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 get | Send |
|---|---|
amount_below_minimum | An amount under the template's minimum_payin_amount |
amount_exceeds_maximum | An amount over its maximum_payin_amount |
amount_exceeds_total_maximum | An amount that pushes it past total_maximum_amount |
payer_not_allowed | A payer document absent from expected_payers |
template_expired / intent_expired | A payment after the target's expires_at |
template_paid | A second payment to a single_use template |
template_disabled | A payment to a template you updated to enabled: false |
template_deleted | A 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:
- Create a template. Wait for
payin_template_ready. - Simulate a payment under the minimum. Expect
200,amount_below_minimum, no webhook. - Simulate a valid payment. Expect
201, thenpayin_created. - A few seconds later, expect
payin_approved_in_providerandpayin_subaccount_credited. - Simulate another with
"error": "breb_timeout". Expect201, thenpayin_failed. - Read the template. Its
paid_amountcounts 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 test | Use |
|---|---|
| Only API calls, such as an automated backend test | This endpoint, with the intent_id |
| A web checkout, with the redirect to the bank | The 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_codefrom 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 inawaiting_payment.
The request
{
"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_idwith"method": "co_pse"answers400withpayin_simulation_intent_required. - A
payeranswers400withpayin_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:
error | Reason on the bank page |
|---|---|
payer_did_not_authorize | The user did not accept the transaction |
payer_did_not_complete | The user did not complete the transaction |
payer_account_inactive | Transaction rejected by the issuing bank |
authorization_denied | Unauthorized transaction |
pse_rejected | Transaction rejected by PSE |
risk_control | Transaction declined by the pre-authorizer |
provider_unavailable | Technical failures in PSE or the user's bank |
unknown | None |
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:
- Create a PSE intent. Wait for
payin_intent_ready_to_pay. - Simulate its payment with only the
intent_id. Expect201, thenpayin_approved_in_providerandpayin_intent_successful. - Create a second intent and simulate it with
"error": "payer_did_not_complete". Expectpayin_failedandpayin_intent_failed, both with that reason. - Create a third, open its
pse_urlin a browser, and approve. Check that you land on yourredirect_urlwithpayin_idandintent_id.
Errors from the endpoint itself
These are faults in the request, not simulated payment outcomes.
| Status | Code | Cause |
|---|---|---|
400 | method_not_allowed | The template does not accept the method you sent. |
400 | payin_invalid_method_params | The method_params do not match the method. |
400 | payment_token_not_found | The target has no Bre-B key yet: it is not ready / ready_to_pay. |
400 | payin_target_not_payable | The PSE intent is not in awaiting_payment: its link is not ready, or the payment already resolved. |
400 | payin_simulation_error_not_supported | The target's method cannot provoke that error, such as breb_timeout on a PSE intent. |
400 | payin_simulation_intent_required | A PSE payment names a template. It must name an intent. |
400 | payin_simulation_payer_not_supported | A PSE payment brings a payer. The intent already names one. |
400 | simulation_not_supported | The method cannot be simulated in this environment. |
401 | missing_authorization_header, invalid_token | Credential problem. |
403 | feature_not_enabled | Not a sandbox environment, or payins is not enabled for your account. |
404 | template_not_found, payin_intent_not_found | No such target. Another tenant's target reads as one that does not exist. |
422 | N/A | The request violates the schema: both targets, or a missing field. |
Next steps
- Collect with a template: the flow this endpoint tests.
- Collect with intents: the same, one declared payment at a time.
- Collect with PSE: the flow a PSE simulation tests.
- Payin errors: every reason a payment ends badly.
- Simulates a payment: the endpoint reference.