I Built a Cyber Insurance Platform for Australia's Largest Insurer from Indonesia — Here's the Full Architecture

How I integrated a customer-facing app directly into Core Banking without middleware — serving millions of micro-finance customers with real-time accuracy, maintaining zero downtime even when traffic spiked 5x during Eid season.
"The best technology disappears — the customer only sees trust." — Don Norman
I was responsible for the backend engineering of this application — designing and building the API layer, Core Banking integration, notification engine, and security middleware. This app is the customer-facing side of the Tepati ecosystem — while Tepati is used by internal officers, SiTepat is used directly by customers.
SiTepat is a mobile application that provides independent access for customers of (Undisclosed Enterprise Bank) to monitor their financing status, savings, and installment schedules — without needing to visit a branch or contact an officer.
This article dissects the engineering challenges and architectural decisions behind developing a customer-facing application for the ultra-micro banking sector.
Before SiTepat existed, customers only had traditional ways to monitor their financing status:
Before SiTepat, ~15% of installment delays were caused by customers forgetting the due date — not because they couldn't pay. Every delay potentially increases the Non-Performing Loan (NPL) ratio and lowers the customer's credit score.
Building an app for banking customers presents challenges that are fundamentally different from internal apps (Tepati):
Unlike Tepati, which has an offline-first architecture with message brokers, SiTepat uses an API Gateway pattern that abstracts the complexity of the Core Banking System from the mobile client.
graph TD
A[Customer] -->|Open App| B[SiTepat Mobile App]
B -->|HTTPS + Cert Pinning| C[API Gateway - Golang]
C -->|Auth Check| D[Auth Service]
D -->|Validate| E[OAuth 2.0 + MPIN/Biometric]
C -->|Route| F[Account Service]
C -->|Route| G[Financing Service]
C -->|Route| H[Notification Service]
F -->|ISO 8583| I[Core Banking System]
G -->|ISO 8583| I
H -->|FCM| B
F -->|Cache| J[(Redis)]
G -->|Cache| J
I -->|Persist| K[(SQL Server)]
C -->|Audit| L[(MongoDB)]Key Architecture Decisions:
Problem: The Core Banking System uses the ISO 8583 protocol — a fixed-length binary format designed in 1987 for ATM transactions. all balance, mutation, and financing data can only be accessed via this protocol. The mobile app needs a REST API.
Solution: I built an ISO 8583 Bridge Service — a Golang service acting as a translator between REST API (JSON) and ISO 8583 (binary).
graph LR
A[Mobile App] -->|REST JSON| B[API Gateway]
B -->|REST JSON| C[ISO 8583 Bridge]
C -->|Pack to ISO 8583| D[Core Banking]
D -->|ISO 8583 Response| C
C -->|Unpack to JSON| B
B -->|JSON Response| A// ISO 8583 Bridge - Simplified Balance Inquiry
type BalanceInquiry struct {
PAN string `iso8583:"2"` // Primary Account Number
ProcessCode string `iso8583:"3"` // 310000 = Balance Inquiry
Amount int64 `iso8583:"4"`
STAN string `iso8583:"11"` // System Trace Audit Number
LocalTime string `iso8583:"12"`
LocalDate string `iso8583:"13"`
MerchantID string `iso8583:"42"`
}
func (s *BridgeService) GetBalance(ctx context.Context, accountID string) (*BalanceResponse, error) {
// Build ISO 8583 message
msg := &BalanceInquiry{
PAN: accountID,
ProcessCode: "310000",
STAN: generateSTAN(),
LocalTime: time.Now().Format("150405"),
LocalDate: time.Now().Format("0102"),
}
packed, err := s.packer.Pack(msg)
if err != nil {
return nil, fmt.Errorf("failed to pack ISO message: %w", err)
}
// Send to Core Banking via TCP socket
resp, err := s.conn.SendWithTimeout(ctx, packed, 10*time.Second)
if err != nil {
// Circuit breaker: fallback to cached balance
cached, cacheErr := s.cache.Get(ctx, fmt.Sprintf("balance:%s", accountID))
if cacheErr == nil {
return cached.(*BalanceResponse), nil
}
return nil, fmt.Errorf("core banking unavailable: %w", err)
}
balance, err := s.packer.Unpack(resp)
if err != nil {
return nil, fmt.Errorf("failed to unpack response: %w", err)
}
// Cache the result
s.cache.Set(ctx, fmt.Sprintf("balance:%s", accountID), balance, 30*time.Second)
return balance, nil
}Key Engineering Decisions:
Results:
| Metric | Before (Manual) | After (SiTepat) | Improvement |
|---|---|---|---|
| Balance inquiry response time | N/A (visit branch) | < 2 seconds | — |
| Core Banking hit rate | 100% (no cache) | 35% (65% cache hit) | ↓ 65% load |
| API availability | N/A | 99.7% (with circuit breaker) | — |
| Daily active inquiries | 0 (manual only) | ~120,000 | — |
Problem: Customers had no visibility over their financing status. Information like remaining ceiling, installment schedules, and payment history could only be seen in physical installment books — which were often lost or not updated.
Solution: A Financing Dashboard displaying comprehensive financing data in real-time.
// Financing Dashboard - Data Structure (React Native)
interface FinancingDetail {
contractId: string;
productName: string;
disbursementDate: string;
maturityDate: string;
plafond: number;
outstandingBalance: number;
marginRate: number;
tenor: number;
installment: {
monthly: number;
nextDueDate: string;
remainingInstallments: number;
paidInstallments: number;
};
paymentHistory: PaymentRecord[];
status: "active" | "completed" | "overdue" | "restructured";
}
interface PaymentRecord {
date: string;
amount: number;
method: "auto_debit" | "cash_to_officer" | "transfer";
status: "paid" | "late" | "partial";
lateDays: number;
}
// Fetch with caching strategy
async function fetchFinancingDetail(
contractId: string,
): Promise<FinancingDetail> {
// Try local cache first (stale-while-revalidate pattern)
const cached = await SecureStorage.get(`financing:${contractId}`);
if (cached && !isStale(cached, 60_000)) {
return cached.data;
}
try {
const response = await api.get(`/financing/${contractId}`, {
headers: { Authorization: `Bearer ${await getToken()}` },
});
await SecureStorage.set(`financing:${contractId}`, {
data: response.data,
timestamp: Date.now(),
});
return response.data;
} catch (error) {
// Offline fallback: return cached data even if stale
if (cached) return cached.data;
throw new FinancingFetchError(error);
}
}Key Design:
Results:
| Metric | Before | After | Improvement |
|---|---|---|---|
| Customer inquiry calls (Call Center) | ~8,500/month | ~3,200/month | ↓ 62% |
| Avg time to check financing status | 15-30 mins (visit/call) | < 5 seconds | ↓ 99% |
| Customer satisfaction (survey) | 3.2/5 | 4.5/5 | ↑ 41% |
| Installment book replacement requests | ~2,100/month | ~400/month | ↓ 81% |
Problem: ~15% of late installments were caused by forgetting, not inability to pay. Previously, the SMS reminder system was ineffective as messages often went into spam folders and delivery couldn't be tracked.
Solution: Multi-channel smart reminder engine with configurable scheduling.
graph TD
A[Scheduler Service] -->|Scan D-3| B{Due Date Check}
B -->|D-3| C[Reminder: Gentle]
B -->|D-1| D[Reminder: Urgent]
B -->|D+0| E[Reminder: Due Today]
B -->|D+1| F[Reminder: Overdue]
C --> G[FCM Push Notification]
D --> G
E --> G
F --> G
G --> H[SiTepat App]
A --> I[(PostgreSQL - Schedule DB)]
G --> J[Notification Log]// Smart Reminder Scheduler (Golang)
type ReminderSchedule struct {
DaysBefore int
Template string
Priority string
Channel string
}
var reminderSchedules = []ReminderSchedule{
{DaysBefore: 3, Template: "reminder_gentle", Priority: "normal", Channel: "push"},
{DaysBefore: 1, Template: "reminder_urgent", Priority: "high", Channel: "push"},
{DaysBefore: 0, Template: "reminder_dueday", Priority: "high", Channel: "push"},
{DaysBefore: -1, Template: "reminder_overdue", Priority: "critical", Channel: "push+sms"},
}
func (s *ReminderService) ProcessDailyReminders(ctx context.Context) error {
today := time.Now().Truncate(24 * time.Hour)
for _, schedule := range reminderSchedules {
targetDate := today.AddDate(0, 0, schedule.DaysBefore)
customers, err := s.repo.FindByDueDate(ctx, targetDate)
if err != nil {
return fmt.Errorf("failed to fetch customers for %s: %w", targetDate, err)
}
for _, customer := range customers {
// Skip if already paid
if customer.IsPaidForCurrentPeriod() {
continue
}
notification := Notification{
CustomerID: customer.ID,
DeviceToken: customer.FCMToken,
Template: schedule.Template,
Priority: schedule.Priority,
Data: map[string]string{
"contract_id": customer.ContractID,
"amount": formatCurrency(customer.InstallmentAmount),
"due_date": targetDate.Format("2 January 2006"),
},
}
if err := s.notifier.Send(ctx, notification); err != nil {
s.logger.Error("failed to send reminder",
"customer_id", customer.ID,
"error", err,
)
// Don't fail entire batch for one customer
continue
}
s.repo.LogNotification(ctx, notification)
}
s.logger.Info("reminders processed",
"schedule", schedule.Template,
"target_date", targetDate,
"count", len(customers),
)
}
return nil
}Key Engineering:
customer_id + template + date.Results:
| Metric | Before (SMS) | After (SiTepat) | Improvement |
|---|---|---|---|
| Late payment (due to forgetting) | ~15% | ~4% | ↓ 73% |
| Notification delivery rate | 62% (SMS) | 94% (Push) | ↑ 52% |
| Customers paying before due date | 58% | 81% | ↑ 40% |
| Cost per notification | Rp 350 (SMS) | Rp 0 (Push) | ↓ 100% |
Problem: Banking apps are exposed to the public — every vulnerability could lead to theft of customer financial data. OJK and PBI (Bank Indonesia Regulation) require extremely high security standards.
Solution: Multi-layered security framework that I implemented on the backend and coordinated with the mobile team.
graph TD
A[User Login] --> B{Biometric / MPIN}
B -->|Valid| C[Generate JWT]
C --> D[API Request]
D --> E{Certificate Pinning Check}
E -->|Valid Cert| F{Rate Limiter}
F -->|Under Limit| G{JWT Validation}
G -->|Valid Token| H{Device Integrity Check}
H -->|Clean Device| I[Process Request]
H -->|Rooted Device| J[Block + Alert]
E -->|Invalid Cert| K[Reject - MITM Detected]
F -->|Over Limit| L[429 Too Many Requests]
G -->|Invalid Token| M[401 Unauthorized]// Security Middleware Stack (Golang)
func SecurityMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 1. Rate Limiting — per account, sliding window
accountID := extractAccountID(r)
if !rateLimiter.Allow(accountID, 30, time.Minute) {
http.Error(w, "too many requests", http.StatusTooManyRequests)
auditLog(ctx, "rate_limit_exceeded", accountID)
return
}
// 2. JWT Validation with rotation support
claims, err := validateJWT(r.Header.Get("Authorization"))
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
auditLog(ctx, "invalid_token", accountID)
return
}
// 3. Device Integrity Check
deviceFingerprint := r.Header.Get("X-Device-Fingerprint")
if isRootedDevice(deviceFingerprint) {
http.Error(w, "device not supported", http.StatusForbidden)
auditLog(ctx, "rooted_device_blocked", accountID)
alertSecurityTeam(ctx, accountID, "rooted_device_detected")
return
}
// 4. Session Binding — token must be from the same device
if claims.DeviceID != deviceFingerprint {
http.Error(w, "session invalid", http.StatusUnauthorized)
auditLog(ctx, "device_mismatch", accountID)
return
}
// 5. Request Signing Verification
if !verifyRequestSignature(r) {
http.Error(w, "invalid signature", http.StatusBadRequest)
auditLog(ctx, "invalid_signature", accountID)
return
}
ctx = context.WithValue(ctx, "claims", claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}Security Layers Implemented:
| Layer | Mechanism | Purpose |
|---|---|---|
| Transport | TLS 1.3 + Certificate Pinning | Prevent MITM attacks |
| Authentication | MPIN + Biometric + OAuth 2.0 | Multi-factor verification |
| Authorization | JWT with device binding | Token valid only on original device |
| Integrity | Root/Jailbreak detection | Block compromised devices |
| Rate Limiting | Sliding window per account | Prevent brute force and DDoS |
| Audit | Immutable audit log | Compliance and forensics |
| Data at Rest | AES-256 encryption | Protect local data on device |
| Request Signing | HMAC-SHA256 | Ensure request hasn't been tampered |
Results:
| Metric | Target (OJK/PBI) | Achieved | Status |
|---|---|---|---|
| Security incidents (data breach) | 0 | 0 | ✅ Compliant |
| Unauthorized access attempts blocked | — | ~2,400/month | Auto-blocked |
| Brute force MPIN attempts blocked | — | ~850/month | Auto-locked |
| OJK penetration test | Pass | Pass | ✅ Certified |
Problem: Customers often need transaction proof for their business needs — for example, proof of installment payment to apply for loans elsewhere, or ensuring deposits from officers have entered the account.
Solution: Searchable transaction history with filter and export capability.
// Transaction History Service (Golang)
type MutationFilter struct {
AccountID string `json:"account_id"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
Type string `json:"type"` // "credit", "debit", "all"
MinAmount *int64 `json:"min_amount,omitempty"`
MaxAmount *int64 `json:"max_amount,omitempty"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
func (s *MutationService) GetMutations(ctx context.Context, filter MutationFilter) (*MutationResponse, error) {
// Validate date range — max 90 days per query (OJK requirement)
if filter.EndDate.Sub(filter.StartDate) > 90*24*time.Hour {
return nil, fmt.Errorf("date range exceeds 90 days maximum")
}
// Check cache for recent mutations (< 24 hours)
cacheKey := fmt.Sprintf("mutations:%s:%s:%s",
filter.AccountID,
filter.StartDate.Format("20060102"),
filter.EndDate.Format("20060102"),
)
if cached, err := s.cache.Get(ctx, cacheKey); err == nil {
return cached.(*MutationResponse), nil
}
// Fetch from Core Banking via ISO 8583
rawMutations, err := s.bridge.FetchMutations(ctx, filter.AccountID, filter.StartDate, filter.EndDate)
if err != nil {
return nil, fmt.Errorf("failed to fetch mutations: %w", err)
}
// Transform & enrich data
mutations := make([]Mutation, 0, len(rawMutations))
for _, raw := range rawMutations {
mutations = append(mutations, Mutation{
Date: raw.TransactionDate,
Description: enrichDescription(raw.Description),
Amount: raw.Amount,
Type: categorizeTransaction(raw.DebitCredit),
Balance: raw.RunningBalance,
Reference: raw.ReferenceNumber,
})
}
// Apply client-side filters
filtered := applyFilters(mutations, filter)
// Paginate
result := paginate(filtered, filter.Page, filter.PageSize)
// Cache for 5 minutes
s.cache.Set(ctx, cacheKey, result, 5*time.Minute)
return result, nil
}Results:
| Metric | Before | After | Improvement |
|---|---|---|---|
| Transaction history access time | Visit branch (1-2 hours) | < 3 seconds | ↓ 99% |
| Statement printing at branch | ~4,200 requests/month | ~1,100 requests/month | ↓ 74% |
| Dispute resolution time | 3-5 working days | < 24 hours | ↓ 80% |
The implementation of SiTepat changed how customers interact with the bank:
One of the biggest post-launch challenges was Android device fragmentation used by customers. Unlike Tepati where devices were standardized by the bank, SiTepat runs on customers' personal devices:
| Device Segment | Distribution | Problems |
|---|---|---|
| RAM < 2GB, Android 5-7 | ~35% | App often killed by OS, push notifications don't arrive |
| RAM 2-3GB, Android 8-9 | ~40% | Relatively stable, occasional ANR (Application Not Responding) |
| RAM > 3GB, Android 10+ | ~25% | Optimal performance, battery optimization sometimes blocks background service |
Implemented Solutions:
A unique challenge not found in most banking apps: many micro-customers are using a banking application for the first time. Collaboration between engineering and product that we did:
Publishing a banking app on the Play Store requires additional compliance:
Project SiTepat is not just about building a banking app. It is proof that legacy technology (1980s) can coexist with modern expectations (2020s) if bridged with the right architecture.
As engineers, it's easy for us to get trapped chasing the latest technology hype. However, SiTepat taught me that the highest value of an engineer lies not in how "bleeding edge" the stack used is, but in the ability to solve real problems within existing constraints.
"The best technology disappears."
For management, this app is just an efficiency tool. For the market traders who are our customers, this app is peace of mind.
Being able to build a bridge that is secure, fast, and reliable between two different worlds (Legacy vs Modern, Tech-Savvy vs Layman) is the professional contribution I am most proud of in my career so far.


