// Case study
Payment Gateway ARINDO - Secure Payment Platform
A comprehensive payment gateway platform for PT Arthaprada Indonesia, featuring secure transaction processing, client management, and third-party banking integrations including BRI, BNI, and Kredibel.
Table of Contents
Five months, a team of four to five, and one non-negotiable: other people's money has to move correctly. ARINDO is the payment gateway platform I helped build for PT Arthaprada Indonesia — virtual accounts, e-wallet rails, and the controls that make a bank willing to connect.
The brief
PT Arthaprada Indonesia wanted its own payment gateway in the mould of Flip: a platform where its enterprise clients are onboarded, issued API credentials, and handed a dashboard, while the platform does the unglamorous work of moving money. Concretely, that meant:
- Collection rails — virtual-account payments through BRI and BNI, e-wallet support for GoPay, OVO and Dana, with bank-transfer and card methods alongside.
- Client management — onboarding, role-based access control, and API key issuance and revocation, run from an operations dashboard.
- Risk input — credit checks against Kredibel, a third-party scoring service, during client onboarding.
- Operational truth — live transaction monitoring, success-rate tracking, failure analytics, and alerts on suspicious transaction patterns.
I worked as a full-stack developer on a team of four to five. The build ran five months: a Vue.js dashboard on the front, a Laravel API behind it, MySQL and Redis underneath.
Constraints
Payment platforms earn their credibility in the constraints, so I will be specific about ours.
- Two banks, two contracts. BRI and BNI each publish their own API: different authentication models (bearer tokens on one side, a signature computed over each virtual-account request on the other), different VA creation flows, different status-inquiry semantics. A gateway does not get to negotiate a bank's spec — it absorbs the differences or it does not launch.
- Sensitive data beyond the obvious. Payment requests carry bank account numbers; Kredibel credit checks carry NIK (Indonesian national identity numbers), full names and dates of birth. None of that could be allowed to sit readable in application logs or at rest.
- Asynchronous truth. Final payment status arrives from the banks as callbacks — on their schedule, with no exactly-once guarantee. The ledger had to stay consistent through duplicates, retries and timeouts.
- A public attack surface. A payment API invites credential stuffing, enumeration and volumetric abuse from day one. Rate limiting and a full audit trail were entry requirements, not enhancements: bank partners and enterprise customers expect bank-grade controls before they connect.
- A fixed team and clock. Four to five people and five months, in a stack the team could move fast in — Vue 2 with Vuetify, Laravel 9 on PHP 8.1 — chosen for fluency, not novelty.
Approach & decisions
The architecture is a deliberately boring core with hard edges: one Laravel API in front of MySQL and Redis, a Vue dashboard on top, and every external money rail isolated behind an adapter.
One adapter per bank, not a universal client
The tempting move was a single configurable bank client — parameterise the URLs,
map the field names, ship. We rejected it. The differences between BRI and BNI
were behavioural, not cosmetic: one authenticates with bearer tokens, the other
requires a signature computed for every virtual-account request, and their
payment lifecycles do not line up. A generic client would have decayed into a
pile of conditionals needing re-testing against every bank on every change.
Instead, each bank got its own service implementing one BankInterface contract
— create a virtual account, check payment status — and owning its own
authentication and signing. Adding a rail became additive work rather than a
refactor.
Encrypt the payload, not just the pipe
All traffic ran HTTPS-only on TLS 1.3, with certificate pinning on outbound bank calls — and we still rejected transport encryption as the protection boundary. TLS ends at termination; application logs, queues and database rows do not inherit it. Sensitive fields — account numbers, identity data — were sealed with RSA-2048 asymmetric encryption (OAEP padding) at the application layer before they touched storage, so a leaked log line or database dump exposes ciphertext rather than account numbers.
Treat duplicate callbacks as normal weather
Banks deliver payment status when they deliver it, and sometimes more than once. Every state-changing operation ran inside a database transaction with explicit rollback, and transactions carried idempotency keys, so a replayed callback converges on the same final state instead of crediting twice. Idempotency was a design decision here, not a patch — retrofitting it onto a live ledger is the kind of surgery you want to avoid.
Stateless sessions, throttled edges, everything written down
Authentication is JWT with refresh tokens — stateless, so API nodes stay interchangeable. Every endpoint sits behind IP-based rate limiting keyed on address and path, with a default ceiling of 60 requests per minute, answering abuse with 429s rather than degradation. And every transaction leaves an audit trail, because in payments the question is never whether something odd will happen but whether you can reconstruct it afterwards.
Make the dashboard tell the truth
The operations dashboard was half the product. It tracks transactions live, plots success rates per bank, surfaces failure analytics, and raises alerts on suspicious patterns — with role-based access control deciding who sees and does what, and API key management handled in the same place. Reads that hurt MySQL were cached in Redis; hot columns were indexed and queries eager-loaded; the Vue SPA code-splits by route.
Evidence
Read this section the way a careful CTO would — claims sorted by how much trust they have earned.
Verifiable system facts. RSA-2048 (OAEP) encryption of sensitive payloads; per-request signing on BNI calls; JWT authentication with refresh tokens; IP-and-path rate limiting; an audit trail on transactions; and shipped integrations with BRI and BNI virtual accounts, GoPay, OVO and Dana e-wallets, and Kredibel credit scoring.
Design goals. The platform was built to process thousands of transactions a day and designed for 99.9% availability — Dockerised services behind Nginx, Supervisor-managed workers, Redis caching and indexed, eager-loaded queries exist to serve those targets. I state them as targets rather than measured outcomes because I do not operate the production system, and I will not quote dashboards I cannot show you.
What I will not claim. "Zero security incidents" — no payments engineer can prove that sentence, and you should distrust anyone who offers it. What I can show is the control set built to make incidents rare, contained and visible: payload encryption, request signing, rate limits, audit logs.
Stack notes
Vue 2 with Vuetify, Vuex and Vue Router on the front end; Laravel 9 on PHP 8.1 behind it; MySQL as the system of record with Redis for caching; JWT for authentication; Docker, Nginx and Supervisor in deployment. Nothing in that list is exotic, which is the point — in a payment system every novel component is a novel failure mode standing between a merchant and their money.