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.
The simulated payment runs the real payment rail — Bre-B, Colombia's instant transfer network. Same states, same webhooks, same timing. Only the money is not real.
Before you start
You will need:
- A sandbox API key, and requests pointed at
https://sandbox.api.cuentamono.com. - The
payinsscope on your credentials. - Something that can be paid: a template in state
ready, or an intent in stateready_to_pay.
That last point is the one 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. Simulating against a target with no key yet answers 400 with
payment_token_not_found.
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.
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.
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 must be one the template accepts, or the call answers 400 with method_not_allowed.
Today co_breb is the only payment method Core Payins offers. 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.
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.
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. |
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 | — | The request violates the schema: both targets, or a missing field. |
A full test, end to end
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.
Next steps
- Collect with a template — the flow this endpoint tests.
- Collect with intents — the same, one declared payment at a time.
- Payin errors — every reason a payment ends badly.
- Simulates a payment — the endpoint reference.