Case Study: SiTepat — Enterprise Islamic Bank's Customer-...
Engineering Case Study: Project SiTepat
"The best technology disappears — the customer only sees trust." — Don Norman
Context
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.
🏔️ 1. The Challenge
Conditions Before SiTepat
Before SiTepat existed, customers only had traditional ways to monitor their financing status:
- Physical installment books — manually recorded by field officers, prone to errors and loss.
- Contacting officers (Community Officer) — customers had to call or wait for officer visits to ask about balances and schedules.
- Visiting branches — consuming time and transportation costs, especially for customers in remote areas.
- No notifications — customers often forgot due dates, causing unintentional late payments.
Business Impact
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.
Engineering Challenges
Building an app for banking customers presents challenges that are fundamentally different from internal apps (Tepati):
- Security Surface Area: The app is exposed to the public via Play Store — the attack surface is much larger compared to internal apps distributed via MDM.
- User Diversity: Target users are micro-customers — market traders, farmers, tailors — who might be using a smartphone for banking services for the first time.
- Real-time Accuracy: Customers expect balances to update instantly after payment. A delay of just 5 seconds can trigger questions to the Call Center.
- Legacy Integration: The bank's Core Banking System uses the ISO 8583 protocol (ATM/POS transaction standard from 1987) — needing to be bridged to modern REST APIs.
- Scale Pattern: Traffic is bursty — massive spikes occur on due dates (the 1st, 5th, 10th, 15th of every month).
- Device Fragmentation: Customers use Android devices with highly varied specs — from 1GB RAM to flagships, Android 5.0 to the latest.
🏗️ 2. Architecture Overview
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.
Key Architecture Decisions:
- API Gateway as a single entry point — all requests from the mobile app enter through one gateway handling auth, rate limiting, and request routing.
- ISO 8583 Bridge Service — a dedicated service that translates REST API calls into the ISO 8583 format understood by Core Banking, and vice versa.
- Redis Caching Layer — data that rarely changes (customer profiles, tenor details) is cached to reduce load on the Core Banking System.
- Circuit Breaker — if the Core Banking System is slow or down, the circuit breaker activates and displays data from the last cache, not an error.
🛠️ 3. Technology Stack
Backend (Built by me)
- Golang: API Gateway and microservices — chosen for low-latency and built-in concurrency to handle bursty traffic.
- Microsoft SQL Server: Primary database for transactional data.
- MongoDB: NoSQL database for storing massive customer audit logs.
- Redis: Distributed caching — TTL-based for balance data (30 seconds), longer TTL for profiles (5 minutes).
- Firebase Cloud Messaging (FCM): Push notification engine for payment reminders.
- OAuth 2.0 + JWT: Authentication and authorization framework.
Frontend (Mobile)
- React Native: Cross-platform app deployed to Android via Play Store.
- Secure Storage: Encrypted keychain to store tokens and MPIN hashes.
- Firebase Analytics & Crashlytics: Monitoring adoption and app stability.
Integration Layer
- ISO 8583 Adapter: Custom-built adapter to communicate with legacy Core Banking (Temenos/T24-based).
- Message Queue: For async processing of notification schedules and audit logging.
🔍 4. Feature Deep Dives
Feature #1: Core Banking Integration via ISO 8583 Bridge
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).
// 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:
- TCP Connection Pool: Maintain persistent TCP connections to Core Banking — avoid handshake overhead per request.
- Circuit Breaker Pattern: If Core Banking response time > 10 seconds or error rate > 50%, automatically fallback to cached data.
- STAN (System Trace Audit Number): Unique identifier per transaction for reconciliation and debugging.
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 | — |
Feature #2: Real-Time Financing Dashboard
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:
- Stale-While-Revalidate: Show data from cache first for instant UX, then update in the background. Customers don't need to see loading spinners.
- Payment Progress Ring: Visual indicator of what percentage of installments are complete — provides psychological motivation for customers.
- Overdue Alert: If there's a late installment, the financing card turns red with a clear call-to-action.
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% |
Feature #3: Smart Payment Reminder Engine
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.
// 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:
- Batch Processing: Scheduler runs every morning at 06:00 WIB — processing all due customers in batch, not one by one.
- Idempotent: If scheduler runs twice (e.g., due to restart), customers won't receive duplicate notifications — deduplication based on
customer_id + template + date. - Overdue Escalation: If a customer is D+1 and hasn't paid, notification is also sent via SMS as a fallback — not just push notification.
- Skip Logic: Customers who have already paid are automatically skipped, avoiding annoying notifications.
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% |
Feature #4: Banking-Grade Security Layer
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.
// 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 |
Feature #5: Mutation History & Transaction Search
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% |
🚀 5. Overall Business Impact
The implementation of SiTepat changed how customers interact with the bank:
Key Numbers
- Adoption rate: 680,000+ downloads on Play Store - Monthly Active Users: ~320,000 active customers per month - Call Center volume: down 62% (from ~8,500 → ~3,200 calls/month) - Late payment (forgotten): down from 15% → 4% (↓73%) - NPL impact: contributed to NPL reduction from 1.8% → 1.2% (together with Tepati) - Branch visit reduction: estimated ~45% of customers no longer need to visit branches for routine info - Notification cost savings: Rp 0 vs average Rp 350/SMS × 320,000 customers = saving ~Rp 112 million/month
🏢 6. Post-Launch: Challenges & Evolution
6.1 Device Fragmentation Nightmare
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:
- WorkManager for notification scheduling — more reliable than AlarmManager on Android 8+.
- Lite Mode: Reducing animations and image resolution for low-end devices.
- Adaptive Network: Detecting network quality and adjusting payload size.
6.2 Digital Adoption by Micro-Customers
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:
- Onboarding Tutorial: Step-by-step interactive flow when opening the app for the first time.
- Simple Language: Not using technical banking terms. Example: "Loan Remaining" instead of "Outstanding Balance".
- Large Font Size: Minimum 16sp for readability, considering many customers are aged 40+.
- Community Officer-assisted registration: Field officers help customers register for the first time (bridging Tepati and SiTepat).
6.3 Play Store Compliance
Publishing a banking app on the Play Store requires additional compliance:
- Financial Services Policy: Must meet Google Play's financial services requirements.
- Target API Level: Mandatory update to the latest target API level every year — impacting permission models and background restrictions.
- Data Safety Section: Transparent declaration about data collection and encryption.
🎓 7. Lessons Learned
What Went Well
- Circuit breaker pattern saved UX when the Core Banking System underwent maintenance windows — customers could still view data from cache.
- Free Push Notifications replaced paid SMS — ROI was instantly felt in cost savings.
- Modular ISO 8583 Bridge made it easy to add new use cases (mutations, transfers, etc.) without changing Core Banking.
- Comprehensive Audit Logging helped the fraud investigation team resolve cases in hours, not days.
- Stale-while-revalidate caching made the UX feel instant even though Core Banking response times were variable.
What I'd Do Differently
- Start with GraphQL from the beginning for the Financing Dashboard — too many REST endpoints eventually returning similar data.
- Implement Feature Flags earlier — currently A/B testing and gradual rollouts are still quite manual.
- Invest in synthetic monitoring to proactively detect Core Banking latency degradation before customers feel it.
- gRPC between internal services — JSON serialization overhead was felt during traffic peaks.
- React Native New Architecture (Fabric + TurboModules) to reduce bridge overhead on low-end devices.
Remaining Technical Debt
- Migration to Jetpack Compose for native security modules.
- Implementing a more robust offline mode — currently fallback is only from cache, no local database layer like Tepati.
- Standardizing error taxonomy between ISO 8583 response codes and HTTP status codes.
- End-to-end encryption for in-app chat support.
- Optimizing app size — currently ~45MB, target < 30MB for customers with limited storage.
🎉 Conclusion
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.
Engineering Philosophy
"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.