Developer guide
VEXPay siteAPI reference
Browse documentation

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 a channel: pse, bancolombia_transfer, bancolombia_qr, breb, nequi or daviplata. Cards (card) are taken only on the hosted checkout. Amounts are whole pesos (amountCop), at least COP 20,000 per payment; less answers 400 amount_below_minimum with minimumCop.
  • GET /v1/cop/channels lists 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 for payment.completed / payment.failed, or read it with GET /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/balance reports availableCop, heldCop and nextReleaseAt. See Balances, holds and payouts.
  • COP payouts are sent by bank transfer in Colombia; contact VEX Pay to request one. You receive payout.completed with currency: "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

channelBuyer doesBuyer dataRefund
cardPays with Visa or Mastercard on the hosted checkout (1 installment)Collected by the checkoutFull, within 24 hours
pseIs redirected to their bank and authorizes the debitbuyer + firstName, lastName, personType; pse.bankCodeNot through the API
bancolombia_transferIs redirected to Bancolombia and authorizes the transferbuyerNot through the API
bancolombia_qrScans a QR with the Bancolombia appbuyerNot through the API
brebScans the QR or enters the transfer key in any Colombian bank app (~15 min)OptionalFull, within 96 hours
nequiApproves the push notification in the Nequi app (~5 min)buyerDepends on your account
daviplataTypes an SMS code, or authorizes on a Daviplata page (follow next.type)buyer; documentType CC, CE or TIDepends 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.

curlbash
curl "${API_URL}/v1/cop/channels" \
  --header "x-api-key: ${VEXPAY_API_KEY}"
Responsejson
{
  "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

MovementFee
COP payment completed4.5% of the amount + COP 1,500, in pesos (never more than the amount)
Full refundThe 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.

curlbash
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 carry amountUsd and copRate, plus the usual checkoutSession block (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.

curlbash
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" }'
Node SDKjavascript
const payment = await vexpay.cop.payments.create(
  { amountCop: 100000, channel: 'breb', reference: 'order-1042' },
  { idempotencyKey: 'order-1042' },
);
// payment.next.qrPngBase64, payment.next.transferKey
Python SDKpython
payment = vexpay.cop.payments.create(
    {"amountCop": 100000, "channel": "breb", "reference": "order-1042"},
    idempotency_key="order-1042",
)
PHP SDKphp
$payment = $vexpay->cop->payments->create(
    ['amountCop' => 100000, 'channel' => 'breb', 'reference' => 'order-1042'],
    ['idempotency_key' => 'order-1042'],
);
Responsejson
{
  "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)

curlbash
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 }
  }'
Node SDKjavascript
await vexpay.cop.payments.create({
  amountCop: 50000,
  channel: 'nequi',
  buyer: { email: 'juan@example.com', phone: '3001234567', documentType: 'CC', documentNumber: '1234567890', termsAccepted: true },
});
Python SDKpython
vexpay.cop.payments.create({
    "amountCop": 50000,
    "channel": "nequi",
    "buyer": {"email": "juan@example.com", "phone": "3001234567", "documentType": "CC", "documentNumber": "1234567890", "termsAccepted": True},
})
PHP SDKphp
$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).

curlbash
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" }'
Node SDKjavascript
await vexpay.cop.payments.submitOtp(payment.id, { otp: '123456' });
Python SDKpython
vexpay.cop.payments.submit_otp(payment.id, {"otp": "123456"})
PHP SDKphp
$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.

curlbash
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
    }
  }'
Responsejson
{
  "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

statusMeaning
pendingWaiting for the buyer.
completedPaid and credited to your COP balance (feeCop is set).
failedfailureCode: "expired" (not paid in time) or "rejected" (buyer, bank or card issuer declined).
canceledYou canceled it with POST /v1/cop/payments/:id/cancel before it was paid.
refundedFully 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 }.

payment.completedjson
{
  "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:

ChannelBuyer dataWhat happens
brebNoneThe sandbox returns a QR and a transfer key. Confirm by polling GET /v1/cop/payments/:id or by webhook.
nequiPhone 3991111111, plus any valid document and emailCompletes on its own within about a minute. 3992222222 is declined.
daviplataSame as Nequi; documentType CC, CE or TI onlyWith 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 CVCApproved. 4111 1111 1111 1111 is declined.
pseAny valid buyer dataBank code 1 approves, 2 declines.
bancolombia_transferAny valid buyer dataOpen 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.