Implementation Guide
Accept a local bank-transfer payment
Collect a PSE bank-transfer payment in Colombia and confirm it from webhook evidence and current status.
The rule to remember: the payer coming back to your site proves nothing about the money. The
webhook is evidence that something happened. Read GET /v4/payments/{paymentId}/status
and fulfil only on completed.
The job
An online store in Colombia sells an order for 185,000 COP. Many of its customers prefer to pay straight from their bank account with PSE instead of using a card. The store sends the payer to PSE, the payer chooses a bank and approves the transfer there, and the store needs to know, reliably and once, whether the order is paid.
Before you start
- An API key for the sandbox. Sandbox access is provided during evaluation.
-
A pay-in route for
bank_transfer.pseconfigured for your tenant. Here the provider is Kushki, shown as Production on the integrations page. - PSE settles in COP only.
- The payer details PSE requires (identity document and similar) depend on the provider. They are confirmed with you during evaluation. There is no public endpoint that lists banks: the payer picks the bank on the PSE page.
- An HTTPS endpoint for webhooks and a secret to sign them.
Flow
- Create status pending
- Redirect Payer chooses a bank
- Bank approval Outside your site
- Webhook Evidence of the event
- Confirm Read current status
- completed Fulfil the order
- failed Offer another way to pay
Implementation
1. Create the payment
Name the product, not the provider: product_key is bank_transfer.pse and
channel_type is redirect. Orangepill uses the first active route for that product by
priority. Amounts are strings in minor units, so 185,000.00 COP is "18500000". Put your own order
reference in metadata, and register the webhook in callback.
curl -X POST "$ORANGEPILL_API/v4/checkout/payments" \
-H "Authorization: Bearer $ORANGEPILL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": "18500000",
"currency": "COP",
"product_key": "bank_transfer.pse",
"channel_type": "redirect",
"description": "Order 4471",
"return_url": "https://shop.example.co/orders/4471/return",
"customer": { "email": "[email protected]", "name": "<payer name>" },
"metadata": { "order_ref": "order-4471" },
"callback": {
"url": "https://shop.example.co/webhooks/orangepill",
"events": ["payment.succeeded", "payment.failed"],
"secret": "<your webhook signing secret>"
}
}' {
"payment_id": "3c9b1f62-…",
"status": "pending",
"provider_payment_id": "…",
"next_action": {
"type": "redirect",
"data": { "redirect_url": "https://…" }
},
"provider_key": "kushki",
"created_at": "2026-10-11T15:20:04Z",
"callback": { "configured": true, "events": ["payment.succeeded", "payment.failed"] }
}
Store payment_id against the order before you do anything else. This route does not take an
idempotency key, so a second request creates a second payment. If the create call times out, look for the
payment in Console or through GET /v4/payments before creating another one.
2. Send the payer to PSE
Redirect the browser to next_action.data.redirect_url. The payer chooses a bank and approves the
transfer in their bank's channel. Afterwards they may come back to your return_url, or they may
close the tab. Show a "we are confirming your payment" page on return and do not mark the order paid yet.
3. Take the webhook as evidence
When the provider reports the outcome, the payment moves from pending to completed or
failed, and Orangepill sends payment.succeeded or payment.failed to your
callback URL.
POST /webhooks/orangepill
X-Orangepill-Event: payment.succeeded
X-Orangepill-Event-Id: <event id>
X-Orangepill-Delivery-Id: <delivery id>
X-Orangepill-Timestamp: <unix seconds>
X-Orangepill-Signature: sha256=<hex>
{
"id": "<event id>",
"event": "payment.succeeded",
"version": "…",
"timestamp": "2026-10-11T15:23:40Z",
"data": {
"payment_id": "3c9b1f62-…",
"amount": "…",
"currency": "COP",
"status": "completed",
"method": "…",
"provider": "kushki",
"order_id": "…",
"customer_id": "…"
},
"meta": { "correlation_id": "…" }
}
Delivery is at least once and not ordered. Verify the signature, which is an HMAC-SHA256 of
{timestamp}.{raw body} with your secret, and reject timestamps outside a few
minutes. Deduplicate on X-Orangepill-Event-Id. Then read the current status rather than trusting
the body alone. Details are in
Process payment webhooks safely.
// Express-style sketch. Verify, record, then confirm current state.
app.post('/webhooks/orangepill', rawBody, async (req, res) => {
if (!verifySignature(req)) return res.status(400).end(); // HMAC + timestamp check
const eventId = req.get('X-Orangepill-Event-Id');
if (await events.seen(eventId)) return res.status(200).end(); // at-least-once: duplicates happen
await events.record(eventId);
res.status(200).end(); // acknowledge fast, work async
const { payment_id } = JSON.parse(req.body).data;
const r = await fetch(`${API}/v4/payments/${payment_id}/status`, { headers: auth });
const { status } = await r.json();
if (status === 'completed') await orders.markPaid(payment_id); // idempotent on your side
if (status === 'failed') await orders.offerAnotherMethod(payment_id);
}); 4. Confirm the current status
curl "$ORANGEPILL_API/v4/payments/$PAYMENT_ID/status" \
-H "Authorization: Bearer $ORANGEPILL_API_KEY" { "status": "completed" }
Use the same call if no webhook arrives in the time you expect for PSE: a scheduled check of payments still
pending covers lost deliveries.
5. See what the provider did
Each call to a provider is an attempt. Attempts are what support needs when a payer says "my bank charged me".
curl "$ORANGEPILL_API/v4/payments/$PAYMENT_ID/attempts" \
-H "Authorization: Bearer $ORANGEPILL_API_KEY" {
"attempts": [
{
"id": "a1e0…",
"attemptNumber": 1,
"status": "completed",
"provider": "kushki",
"providerPaymentId": "…",
"nextActionType": "redirect",
"errorCode": null,
"errorMessage": null,
"createdAt": "2026-10-11T15:20:04Z",
"completedAt": "2026-10-11T15:23:39Z"
}
]
} 6. Check the financial effect
When the provider confirms the payment, a journal referencing it is posted to the ledger. The financial explain endpoint shows how the payment reached its state.
curl "$ORANGEPILL_API/v4/ledger/entries/by-reference?referenceType=payment&referenceId=$PAYMENT_ID" \
-H "Authorization: Bearer $ORANGEPILL_API_KEY"
curl "$ORANGEPILL_API/v4/financial/explain?subjectType=payment&subjectId=$PAYMENT_ID" \
-H "Authorization: Bearer $ORANGEPILL_API_KEY" Second rail: Bre-B Private Preview
Bre-B, Colombia's instant-payment system, is available in Private Preview through Movii Bre-B and Kamin. The
payment is created the same way with bank_transfer.bre_b and channel_type: "qr". It
returns next_action.type: "wait", and you then create a payment request that returns rendering data
for the payer: a QR image, a key to type, or a deep link. Status, webhooks and confirmation work as above.
# 1. Create the payment on the Bre-B product. The response has next_action.type "wait".
curl -X POST "$ORANGEPILL_API/v4/checkout/payments" \
-H "Authorization: Bearer $ORANGEPILL_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "amount": "18500000", "currency": "COP",
"product_key": "bank_transfer.bre_b", "channel_type": "qr" }'
# 2. Ask for something the payer can act on: a dynamic QR or a key, valid for 300 s.
curl -X POST "$ORANGEPILL_API/v4/checkout/payment-requests" \
-H "Authorization: Bearer $ORANGEPILL_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "payment_id": "<payment id>", "mode": "dynamic_qr", "expiry_seconds": 300 }' What can go wrong
- The payer returns but the status is still
pending. Normal. The bank has not reported yet. Keep the order open and wait for the webhook or your scheduled check. - The payer abandons the PSE page. The payment stays
pendinguntil the provider reports an outcome. Do not fulfil. If the payer wants to try again, check that the first payment isfailedbefore offering a new one, or you risk two paid transfers for one order. - The same webhook arrives twice, or events arrive out of order. Deduplicate by event id, and act on the current status from the API, not on arrival order.
- Your endpoint is down. Delivery is retried on
5xx,408and429, immediately and then after 10 seconds, 60 seconds and 5 minutes. After that the delivery is dead-lettered. Your scheduled status check covers the gap. - The create request times out. A payment may exist. There is no idempotency key on this route, so find it before creating another.
- The payer asks for a refund. PSE through this provider is not refundable through the API. Refunds are handled manually.
What Orangepill guarantees today
- One payment lifecycle for the order, whatever the provider:
pendingtocompletedorfailed. - Webhooks are signed when you set a secret, and carry an event id you can deduplicate on.
- Every provider call is recorded as an attempt you can read.
- A provider-confirmed payment posts a journal that references the payment.
What Orangepill does not guarantee
- Webhook ordering, or exactly-once delivery. Delivery is at least once.
- Idempotent creation on
POST /v4/checkout/payments. Deduplicate on your side. - Automatic failover to another provider on this route. One provider handles each payment.
- How long a bank takes to report. That depends on the payer's bank and the rail.
Production considerations
- Production uses its own API key, its own PSE route and its own provider credentials. Nothing carries over from sandbox.
- Persist the
payment_idwith the order, every processed event id, and the last status you read. - Alert on payments still
pendingwell past the time you normally see for PSE, and on dead-lettered deliveries. Delivery history is atGET /v4/checkout/payments/{paymentId}/callback-deliveries. - Test in sandbox: success, failure, a duplicate webhook, a webhook your endpoint rejects, and a payer who never comes back.
- Your support team should be able to open a payment and see its status, attempts and the provider's payment id.
- Confirm the payer fields your PSE provider requires during evaluation, and collect them in your checkout.
The full list is in the production checklist.
Build this with us.
Sandbox access and API credentials are provided during evaluation.