Collect Colombian pesos
Charge buyers in Colombia by card, PSE, Bancolombia, Bre-B, Nequi or Daviplata. Payments settle in Colombian pesos in their own COP balance — never converted to bolívares or USD.
How COP works
- Create a payment with
POST /v1/cop/payments, choosing achannel:pse,bancolombia_transfer,bancolombia_qr,breb,nequiordaviplata. Cards (card) are taken only on the hosted checkout. Amounts are whole pesos (amountCop), at least COP 20,000 per payment; less answers 400amount_below_minimumwithminimumCop. GET /v1/cop/channelslists the channels your account can use right now, the PSE banks, and the terms links some channels require. Build your channel picker from it.- The response says what the buyer does next (
next.type). The payment completes asynchronously — listen forpayment.completed/payment.failed, or read it withGET /v1/cop/payments/:id. - Completed payments are credited to your COP balance (
GET /v1/cop/balance) net of the COP fee. COP is kept apart from your VES and stablecoin balances. - New pesos are held for 24 hours:
GET /v1/cop/balancereportsavailableCop,heldCopandnextReleaseAt. See Balances, holds and payouts. - COP payouts are sent by bank transfer in Colombia; contact VEX Pay to request one. You receive
payout.completedwithcurrency: "COP"when it is paid.
The Node (vexpay.cop), Python (vexpay.cop), PHP ($vexpay->cop) and Laravel (VexPay::cop()) SDKs cover every COP endpoint: payments.create, retrieve, submitOtp, cancel, refund and balance.retrieve.
Channels
| channel | Buyer does | Buyer data | Refund |
|---|---|---|---|
card | Pays with Visa or Mastercard on the hosted checkout (1 installment) | Collected by the checkout | Full, within 24 hours |
pse | Is redirected to their bank and authorizes the debit | buyer + firstName, lastName, personType; pse.bankCode | Not through the API |
bancolombia_transfer | Is redirected to Bancolombia and authorizes the transfer | buyer | Not through the API |
bancolombia_qr | Scans a QR with the Bancolombia app | buyer | Not through the API |
breb | Scans the QR or enters the transfer key in any Colombian bank app (~15 min) | Optional | Full, within 96 hours |
nequi | Approves the push notification in the Nequi app (~5 min) | buyer | Depends on your account |
daviplata | Types an SMS code, or authorizes on a Daviplata page (follow next.type) | buyer; documentType CC, CE or TI | Depends on your account |
buyer means email, phone, documentType and documentNumber. buyer.phone is a Colombian mobile number (10 digits starting with 3; +57 optional). documentType is one of CC, CE, TI, NIT, PP. personType (PSE) is natural or juridica.
Channels whose catalog entry has needsTermsAcceptance: true need the buyer to accept the payment network's terms: show the two terms links next to a checkbox and send buyer.termsAccepted: true. Without it the payment answers 400 invalid_data naming buyer.termsAccepted.
curl "${API_URL}/v1/cop/channels" \
--header "x-api-key: ${VEXPAY_API_KEY}"{
"channels": [
{ "channel": "pse", "label": "PSE", "needsBuyer": true, "needsTermsAcceptance": true, "checkoutOnly": false, "minAmountCop": 20000 },
{ "channel": "card", "label": "Tarjeta", "needsBuyer": true, "needsTermsAcceptance": true, "checkoutOnly": true, "minAmountCop": 20000 },
{ "channel": "breb", "label": "Bre-B", "needsBuyer": false, "needsTermsAcceptance": false, "checkoutOnly": false, "minAmountCop": 20000 }
],
"pseBanks": [{ "code": "1007", "name": "Bancolombia" }],
"terms": { "termsUrl": "https://…", "personalDataUrl": "https://…" }
}Pricing
| Movement | Fee |
|---|---|
| COP payment completed | 4.5% of the amount + COP 1,500, in pesos (never more than the amount) |
| Full refund | The fee is returned with the refund |
Example: a buyer pays 100.000 COP → fee 6.000 (4.500 + 1.500) → 94.000 COP credited. The minimum payment is 20.000 COP.
Hosted checkout
Payment links and checkout sessions can collect COP with no buyer UI on your side. The hosted page lets the buyer choose the currency (bolívares, crypto or Colombian pesos), shows the total in each, and runs the card, PSE, Bancolombia, Bre-B, Nequi or Daviplata step itself. Add cop to methods when you create a session, or leave methods empty: COP is offered whenever it is enabled on your account.
curl -X POST "${API_URL}/v1/checkout/sessions" \
--header "x-api-key: ${VEXPAY_API_KEY}" \
--header "content-type: application/json" \
--data '{ "amountUsd": 25.00, "methods": ["c2p", "usdt", "cop"], "reference": "order-1042" }'- The session is priced in USD. The peso amount is the USD amount × VEX Pay's USDT/COP rate plus a 2.5% FX margin, rounded up to whole pesos, and is fixed when the buyer starts paying.
- When no current rate is available, the page hides the pesos option; bolívares and crypto keep working.
- Cards are only taken here: the card number goes from the buyer's browser straight to the payment network, never to you or VEX Pay. A declined card lets the buyer try another card or channel.
- PSE and Bancolombia send the buyer to their bank and back to the same checkout, which picks the payment up again and shows the result.
- Marketplace seller (merchant) links offer COP too: the payment is credited to that seller's COP balance, net of the COP fee, after the later of your seller hold and the 24h balance hold.
- COP is hidden when the peso total is under the COP 20,000 minimum (on a link where the buyer chooses a quantity, it stays visible but disabled until the total reaches it).
- The COP fee applies as for any COP payment.
payment.*webhooks for checkout payments also carryamountUsdandcopRate, plus the usualcheckoutSessionblock (id,reference,metadata).
Step 01
Bre-B (QR or transfer key)
Show the QR image (next.qrPngBase64, a base64 PNG) and the transfer key (next.transferKey) as a copyable fallback. They are returned only in this response.
curl -X POST "${API_URL}/v1/cop/payments" \
--header "x-api-key: ${VEXPAY_API_KEY}" \
--header "content-type: application/json" \
--header "Idempotency-Key: order-1042" \
--data '{ "amountCop": 100000, "channel": "breb", "reference": "order-1042" }'const payment = await vexpay.cop.payments.create(
{ amountCop: 100000, channel: 'breb', reference: 'order-1042' },
{ idempotencyKey: 'order-1042' },
);
// payment.next.qrPngBase64, payment.next.transferKeypayment = vexpay.cop.payments.create(
{"amountCop": 100000, "channel": "breb", "reference": "order-1042"},
idempotency_key="order-1042",
)$payment = $vexpay->cop->payments->create(
['amountCop' => 100000, 'channel' => 'breb', 'reference' => 'order-1042'],
['idempotency_key' => 'order-1042'],
);{
"id": "6f1c…",
"status": "pending",
"method": "COP",
"channel": "breb",
"amountCop": 100000,
"reference": "order-1042",
"metadata": {},
"expiresAt": "2026-10-07T20:15:00.000Z",
"createdAt": "2026-10-07T20:00:00.000Z",
"next": { "type": "show_qr", "qrPngBase64": "iVBORw0KGgo…", "transferKey": "BREB-…" }
}Render the QR with <img src="data:image/png;base64,{qrPngBase64}">.
Step 02
Nequi (push)
curl -X POST "${API_URL}/v1/cop/payments" \
--header "x-api-key: ${VEXPAY_API_KEY}" \
--header "content-type: application/json" \
--data '{
"amountCop": 50000,
"channel": "nequi",
"buyer": { "email": "juan@example.com", "phone": "3001234567", "documentType": "CC", "documentNumber": "1234567890", "termsAccepted": true }
}'await vexpay.cop.payments.create({
amountCop: 50000,
channel: 'nequi',
buyer: { email: 'juan@example.com', phone: '3001234567', documentType: 'CC', documentNumber: '1234567890', termsAccepted: true },
});vexpay.cop.payments.create({
"amountCop": 50000,
"channel": "nequi",
"buyer": {"email": "juan@example.com", "phone": "3001234567", "documentType": "CC", "documentNumber": "1234567890", "termsAccepted": True},
})$vexpay->cop->payments->create([
'amountCop' => 50000,
'channel' => 'nequi',
'buyer' => ['email' => 'juan@example.com', 'phone' => '3001234567', 'documentType' => 'CC', 'documentNumber' => '1234567890', 'termsAccepted' => true],
]);The response has next: { "type": "approve_in_app", "phoneMasked": "****567" }. Tell the buyer to open Nequi and approve. Send termsAccepted: true when the channel catalog says needsTermsAcceptance (it is ignored otherwise).
Step 03
Daviplata (SMS code)
Create the payment with channel: "daviplata" and a buyer. With next.type: "submit_otp", the buyer receives a code by SMS; submit it to complete the payment. A wrong code answers 400 invalid_otp and the payment stays pending until it expires. Some accounts answer next.type: "redirect" instead: send the buyer to next.url as for PSE (the OTP endpoint then answers 409).
curl -X POST "${API_URL}/v1/cop/payments/{id}/otp" \
--header "x-api-key: ${VEXPAY_API_KEY}" \
--header "content-type: application/json" \
--data '{ "otp": "123456" }'await vexpay.cop.payments.submitOtp(payment.id, { otp: '123456' });vexpay.cop.payments.submit_otp(payment.id, {"otp": "123456"})$vexpay->cop->payments->submitOtp($payment->id, ['otp' => '123456']);Step 04
PSE and Bancolombia (bank redirect)
Create the payment, then send the buyer to next.url (next.type: "redirect"). The bank's URL can take a few seconds to exist: if next.type is redirect_pending, read the payment again (GET /v1/cop/payments/:id) until it carries the redirect. After authorizing, the buyer lands on a VEX Pay page that tells them they can close the tab; you learn the result by webhook.
curl -X POST "${API_URL}/v1/cop/payments" \
--header "x-api-key: ${VEXPAY_API_KEY}" \
--header "content-type: application/json" \
--header "Idempotency-Key: order-1043" \
--data '{
"amountCop": 80000,
"channel": "pse",
"pse": { "bankCode": "1007" },
"buyer": {
"firstName": "Juan", "lastName": "Pérez", "personType": "natural",
"email": "juan@example.com", "phone": "3001234567",
"documentType": "CC", "documentNumber": "1234567890",
"termsAccepted": true
}
}'{
"id": "9a2e…",
"status": "pending",
"method": "COP",
"channel": "pse",
"amountCop": 80000,
"next": { "type": "redirect", "url": "https://…" }
}bancolombia_transfer works the same with only buyer. bancolombia_qr answers next: { "type": "show_qr", "qrSvgBase64": "…" }: render it with <img src="data:image/svg+xml;base64,{qrSvgBase64}"> and wait for the webhook.
Statuses, cancel and refunds
| status | Meaning |
|---|---|
pending | Waiting for the buyer. |
completed | Paid and credited to your COP balance (feeCop is set). |
failed | failureCode: "expired" (not paid in time) or "rejected" (buyer, bank or card issuer declined). |
canceled | You canceled it with POST /v1/cop/payments/:id/cancel before it was paid. |
refunded | Fully refunded with POST /v1/cop/payments/:id/refund, or a card chargeback (failureCode: "chargeback"). |
Cancel works while the payment is pending. For bank-redirect channels (PSE, Bancolombia) the cancel is recorded at VEX Pay; if the bank approves it anyway, VEX Pay reviews it and refunds the buyer or credits you.
Refunds are full only, and depend on the channel (see the Channels table): cards within 24 hours, Bre-B within 96 hours (409 refund_window_expired after that). A channel that cannot be refunded through the API answers 409 refund_not_supported; contact VEX Pay to return the money to the buyer. The amount credited to you is debited from your COP balance (a sale still under the 24h hold is taken from the hold) and the fee is returned; if available plus held pesos do not cover it the answer is 409 insufficient_balance. A refund can also answer 503 processor_unavailable (temporarily unavailable) shortly after the payment — nothing is debited; retry later.
Webhooks
COP payments send payment.completed, payment.failed, payment.canceled and payment.reversed (refund, or reason: "chargeback" when a card buyer disputes the charge with their bank — the amount is debited from your COP balance). The payload carries method: "COP", channel, amountCop, feeCop, reference and metadata. Card payments (in webhooks and GET /v1/cop/payments/:id) also carry card: { brand, last4 }.
{
"event": "payment.completed",
"data": {
"paymentId": "6f1c…",
"status": "completed",
"method": "COP",
"currency": "COP",
"channel": "nequi",
"amountCop": 100000,
"feeCop": 6000,
"reference": "order-1042",
"metadata": {},
"livemode": true
}
}Test mode
Test API keys (and test-mode tenants) send COP payments to the processor sandbox. Use these values:
| Channel | Buyer data | What happens |
|---|---|---|
breb | None | The sandbox returns a QR and a transfer key. Confirm by polling GET /v1/cop/payments/:id or by webhook. |
nequi | Phone 3991111111, plus any valid document and email | Completes on its own within about a minute. 3992222222 is declined. |
daviplata | Same as Nequi; documentType CC, CE or TI only | With next.type: "submit_otp", OTP 123456, 000000 or 111111 completes it; any other code answers 400 invalid_otp. With redirect, open next.url and follow the test page. |
card (checkout) | Card 4242 4242 4242 4242, any future expiry, any CVC | Approved. 4111 1111 1111 1111 is declined. |
pse | Any valid buyer data | Bank code 1 approves, 2 declines. |
bancolombia_transfer | Any valid buyer data | Open next.url and pick the outcome on the test page. |
Document numbers and phones are only format-checked in the sandbox: phone is 10 digits starting with 3 (+57 optional), documentNumber 3–20 letters, digits or dashes.