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.

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 payins scope on your credentials.
  • Something that can be paid: a template in state ready, or an intent in state ready_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...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 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

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.

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.

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.
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.
422The 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:

  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.

Next steps

On this page