Accept payments
Direct API
Take the raw payment payload and render it in your own interface.
Same endpoint as Checkout, POST /v1/transactions. The difference is which field you use: instead of sending the buyer to checkout_url, you read the payment payload and draw the screen yourself. You own the design, the copy, and the error states.
One endpoint, not one per method
If you have integrated a payment gateway before, you may expect a separate endpoint per method — one path to create a virtual account, another to generate a QR, another to charge a card. That is how the underlying bank rails are standardised, and it is why adding a method to such an integration means writing new code every time.
Here every method is POST /v1/transactions. The method is a value you send, not a path you call, and the difference between them arrives in the payment object of the response. We do the per-rail work behind that one endpoint so that adding a method to your integration is, at most, one branch.
The exceptions are separate operations, not separate methods: GET /v1/payment_methods for what your account can accept, and POST /v1/refunds — which only some methods support, and answers 422 with a clear code on the rest.
Ask for one method
Send payment_methods with a single code — ["va_bca"] — and the response carries that method's payment object straight away, with nothing for the buyer to choose. Send several and you are building the picker yourself, from GET /v1/payment_methods.
Three shapes, however many methods
However many methods we support, a payment is only ever presented in one of three ways. Handle the three shapes and the method list can grow without touching your code.
payment.type | You get | You render | Methods |
|---|---|---|---|
qr | qr_string | A QR image | QRIS |
payment_code | payment_code, bank | The number + how to pay | Virtual Account, minimarket |
redirect | redirect_url | Send the buyer there | Cards, e-wallet, paylater |
// A Direct API integration is one switch, and it stays one
// switch as methods are added.
switch (trx.payment.type) {
case "qr": return <Qr value={trx.payment.qr_string} />;
case "payment_code": return <Code value={trx.payment.payment_code} />;
case "redirect": return redirect(trx.payment.redirect_url);
}The buyer's details are yours to collect
There is no page of ours to ask the buyer anything, so whatever a method requires about them has to arrive in customer on the create. A create naming a method whose required field is missing is rejected 422 with the field named — see payment methods for which method needs what. If you would rather not build that form, Checkout collects it for you.
What you take on
- Expiry. Hide the payment at
expires_atand create a new request if the buyer still wants to pay. A code or QR used after that moment fails at the bank, not at us. - Instructions. Every response carries an
instructionsblock, written for the buyer in Indonesian. Print it verbatim rather than writing your own — it is the difference between a buyer who pays and one who calls you. - New methods. A method whose shape you have not implemented will not render. Each new shape is one branch; a new method in an existing shape is none.
Card details never reach your server on either integration. A card is a redirect shape: you send the buyer to payment.redirect_url, the processor's page takes the card and 3-D Secure, and the buyer comes back to your return_url — so you stay outside PCI DSS scope. See cards.
Confirming the payment
Identical to Checkout: listen for the payment.paid webhook, or poll GET /v1/transactions/:id. Nothing on the buyer's screen is evidence of payment — only our record is.