Direct API
Virtual Account
A per-transaction account number the buyer transfers to from their bank.
The buyer gets a number, opens their banking app, and transfers to it. No app switching, no QR — the method Indonesians reach for when the amount is large or the phone is not the one holding the money. Every bank is the same call; only the code changes.
Integration steps
- Create the payment request.
- Display the payment code and the instructions on your page.
- Acknowledge the payment — the payment.paid webhook.
- Check status when you need certainty: GET /v1/transactions/:id.
Bank codes
| Code | Bank |
|---|---|
va_bca | BCA |
va_bri | BRI |
va_bni | BNI |
va_mandiri | Mandiri |
va_permata | Permata |
va_cimb | CIMB Niaga |
va_danamon | Danamon |
va_maybank | Maybank |
GET /v1/payment_methods returns the banks your account can use. Read it rather than hard-coding this table — a bank can be enabled or suspended without a release on your side.
1. Create the payment request
One code goes straight to that bank. Send several and the buyer chooses; omit payment_methods and every method your account has enabled is offered. A Virtual Account needs the payer's name: send customer.name, or the create is refused 422 customer_required.
curl https://pay.kasera.id/v1/transactions \
-H "Authorization: Bearer kp_live_..." \
-H "Idempotency-Key: order-1234" \
-H "Content-Type: application/json" \
-d '{
"amount": 150000,
"external_id": "ORD-1234",
"payment_methods": ["va_mandiri"],
"customer": { "name": "Budi" }
}'{
"id": "payreq_9b2f...",
"status": "pending",
"payment_method": "va_mandiri",
"payment": {
"type": "payment_code",
"payment_code": "8890812345678901",
"bank": "Mandiri",
"display_name": "Mandiri Virtual Account"
},
"instructions": { "title": "Cara membayar via Mandiri Virtual Account", "steps": ["..."] },
"expires_at": "2026-08-28T12:00:00+07:00"
}What customer.name may contain
Two limits apply, and they are not the same one. Kasera stores and returns the name you send, unchanged, under the general 120-character limit of the create request. The name forwarded to the bank is narrower: the bank prints it on the payer's transfer screen and will not take anything outside a plain Latin alphabet, so it is transliterated to ASCII letters and digits, punctuation and symbols are dropped, runs of whitespace collapse to a single space, capitalisation is left as typed, and the result is cut to 30 characters.
| You send | The bank receives |
|---|---|
Budi & Co. | Budi Co |
José | Jose |
O'Brien / Toko | OBrien Toko |
李明 | nothing survives — refused |
A name written entirely in characters with no Latin spelling — an emoji, CJK or punctuation only — leaves nothing a bank would accept. That create is refused before it reaches the provider, with the same 422 customer_required naming customer.name, because from the method's point of view the field it needs was not usable. Sending it anyway would earn the payer a failed Virtual Account and no payment code at all.
2. Display the payment code
Show payment.payment_code as large selectable text with a copy button, name the bank from payment.display_name, and print the instructions block underneath. Buyers mistype these numbers; a copy button removes most support tickets.
3. Acknowledge the payment
The payment.paid webhook fires when the transfer clears, which may be long after you created the request.
4. Check status
GET /v1/transactions/:id — worth polling on a slow schedule for VA specifically, because the gap between create and payment is measured in hours.
Good to know
| Virtual Account | |
|---|---|
| Expiry | Same rule as every method: 60 minutes by default, up to 24 hours via expires_in_minutes |
| Confirmation | When the transfer clears, not when you created it |
| Refundable | No — VA cannot be reversed |
A VA sits unpaid far longer than a QR. Do not hold stock or seats on pending alone, and fulfil only on the webhook — a buyer who transfers in the last hour of the deadline is normal, not an anomaly.