Komoju Context & Fintech Security Topics

Two parts:

  1. What Komoju actually sells (the payment method landscape) — so you can talk about it like an insider.
  2. Security and fintech topics likely to come up — payments shops always probe security thinking.

Part 1 — Komoju's payment method landscape

Why so many methods?

Japan + APAC payment markets are very fragmented. Every country has its own ecosystem, and within each country there are many brands (different convenience store chains, different e-wallet apps, different mobile carriers). A merchant in Japan who only accepts Visa/Mastercard misses 40-60% of customers who prefer konbini cash, PayPay, carrier billing, BNPL, etc.

Komoju's value prop: one integration, all these methods. That is why the count is high — they aggregate a fragmented market.

Real numbers

From Komoju's own docs (doc.komoju.com): ~32 distinct methods across 5 regions. Marketing pages say things like "all major methods popular in Japan, Korea, China, APAC and Europe" — they don't usually quote 100+.

If you count brand variants separately (5 card schemes × multiple regions, or 6 konbini chains, or 4 carrier brands), the count goes higher. Ballpark: 30-50 methods depending on how you count.

Breakdown by region

Japan (~16 methods) — the core market

  • Cards: Visa, Mastercard, JCB, Amex, Diners
  • Konbini (cash at convenience stores) — pay later at 50,000+ stores like 7-Eleven, FamilyMart, Lawson. Very Japan-specific. Customer can pay days after the order.
  • Bank transfer (Furikomi)
  • Pay-Easy (linked to bank apps)
  • E-wallets: PayPay, Rakuten Pay, Merpay, LINE Pay (now merged into PayPay)
  • Prepaid: WebMoney, BitCash, NET CASH (popular for gaming, anime, manga)
  • BNPL: Paidy
  • Carrier billing: NTT Docomo, au, SoftBank — charge appears on monthly mobile bill

Korea (~6 methods)

  • Local credit cards (a separate ecosystem from international Visa/Mastercard)
  • Wallets: Toss, PAYCO
  • Prepaid: Culture Voucher, Happy Money
  • Mobile carrier billing

China (~3 methods)

  • Wallets: Alipay, WeChat Pay (the dominant pair — together ~80% of Chinese mobile payments)
  • Card: UnionPay

Southeast Asia (~5 methods)

  • DOKU Wallet (Indonesia)
  • OVO (Indonesia) — first fintech unicorn there
  • eNETS (Singapore bank-linked)
  • FPX (Malaysia bank-linked, ~50% of Malaysian e-commerce)
  • Dragonpay (Philippines)
  • GrabPay (Singapore)

Europe (~9 methods)

  • iDEAL (Netherlands), Bancontact (Belgium), Giropay (Germany), EPS (Austria), Przelewy24 + BLIK (Poland), Multibanco (Portugal)
  • Sofort, MyBank
  • Paysafecard / Paysafecash (prepaid voucher)

Why this matters for an Android engineer

Different methods have very different UX flows:

Method type Flow Settlement timing
Card Form + 3DS2 challenge Seconds
Konbini Show voucher code → customer walks to store → pays cash Hours to days
Bank transfer Show bank details → wait for confirmation Minutes to hours
QR wallet (PayPay, Alipay) Deep link to wallet app OR show QR for in-app scan Seconds
BNPL (Paidy) Redirect to provider for KYC + approval Minutes
Carrier billing Phone number + OTP Seconds

The state machine unifies all of them: PENDING → PAID / EXPIRED / FAILED. Your take-home sits exactly on this abstraction. If they ask "how would you scale to all methods?", point at the state machine and say "the polling and state transitions are the same; what changes is the screen variant and the polling interval — konbini needs a long-running, low-frequency poll because customers can pay tomorrow; card needs a tight poll because the answer comes in seconds."

But the state machine alone isn't enough for long-settlement methods. The take-home design assumes the merchant watches the QR screen until the payment resolves. For card or QR wallet, that's seconds — fine. For konbini or bank transfer, the customer might pay tomorrow or in three days. The merchant can't sit on one screen.

So the architecture has to grow two more pieces:

  1. A "pending payments" list / dashboard. The merchant closes the QR screen after the customer leaves, and the session moves into a list of pending payments. The merchant can come back to it any time, see status, cancel, or print the voucher again. The list is a query over sessions where status == PENDING.

  2. Push notification on resolution. When a long-pending session flips to PAID / EXPIRED / FAILED on the server, the backend sends an FCM push to the merchant device. The app shows a notification ("Payment received: ¥1,200, session sess_abc"), and on tap, opens the result screen. This is the only realistic UX for konbini and bank transfer.

So the production architecture is: state machine + pending list + webhook-driven push. Polling is a fallback when push is missing or delayed. Komoju's question #2 in the smart-questions list below is exactly about this — that's why it's there.

If they ask, mention: "For methods where settlement takes hours or days, the client should not poll — it should subscribe via push, with polling as a fallback for unreliable networks. The pending-list view is what unifies short and long settlement in one merchant UX."

Refunds — the reverse flow

The take-home only covers charging. Real POS apps must also refund. Example: merchant types ¥12,000 instead of ¥1,200, customer pays, mistake spotted ten seconds later. The merchant needs to refund — full or partial — from the same app.

The refund flow has the same shape as the payment flow, but mirrored:

  1. Refund is a separate state machine on top of a PAID session.

    • Pre-condition: the session must be in PAID (or PARTIALLY_REFUNDED). You cannot refund a PENDING payment — you cancel it instead. Cancel and refund are different APIs.
    • States: REFUND_PENDING → REFUNDED / REFUND_FAILED. Partial refunds: track refunded_amount on the session; allow more refund requests until refunded_amount == amount.
  2. Backend-driven, not client-driven. The client sends POST /sessions/{id}/refunds with { amount, reason, idempotency_key }. The server validates merchant permission, calls the upstream rail (card network, wallet, konbini provider), and persists the refund. The client polls or waits for a push for the result, exactly like the original payment.

  3. Idempotency is critical here too — arguably more critical than for charging. A double-refund means the merchant loses money. Same pattern: client generates a UUID Idempotency-Key before the first request, persists it via SavedStateHandle, reuses on every retry. Server returns the cached response on retry instead of issuing a second refund.

  4. Authorization. Refunds need a higher bar than charging. In production: a merchant role check (only managers can refund), and often a step-up — PIN, biometric, or manager card swipe — to confirm intent. Audit log: who refunded, when, why, how much, original session ID.

  5. Settlement timing differs by rail. Card refunds go through the same network as the charge but settle in 3–7 business days on the customer's statement, even though the API call returns instantly. Konbini and bank-transfer refunds need a customer bank account or store voucher and can take longer or require a separate flow. QR wallets (PayPay, Alipay) usually refund within minutes back to the wallet balance. The UI must set the right expectation per method ("refund issued — will appear on customer's card in 3–7 business days").

  6. Time windows. Most rails only allow refund for a limited time after the charge — common limits are 30, 60, 90, or 180 days depending on the method and contract. After that window, the merchant must do an off-rail refund (bank transfer to the customer). The app should surface "refundable until YYYY-MM-DD" on the session.

  7. Pending-list UX again. A refunded or partially-refunded session shows up in the same pending/history list with a refund badge. Tap → see refund history (timestamps, amounts, who issued). Push notification when the refund settles, same channel as payment notifications.

If they ask: "Refund is the same architecture as payment, mirrored. State machine on the session, idempotency key per refund attempt, backend-driven with the client polling or subscribing, role-gated with an audit trail. The hard part isn't the protocol — it's the per-method settlement timing and the time-window rules. The UI has to set the right expectation per method, otherwise merchants and customers think the refund is broken when it's actually just the bank rail being slow."

Point cards & split tender — partial payment with loyalty points

Japan runs on loyalty points. Rakuten Points (~100M members), d-Points, Ponta, V-Points (formerly T-Points), PayPay Points — most consumers carry several. A common POS interaction: "ring up ¥1,000 → apply 300 points → customer pays the remaining ¥700 by card." That's split tender, and it changes the session data model.

  1. One session, multiple tenders. The session grows from { amount, paymentMethod } to { amount, tenders: [{ type, amount, status, idempotencyKey }] } where type is points | card | qr_wallet | cash. Session status is derived: PENDING until all tenders resolve, PAID if all succeed, rollback otherwise.

  2. Two-phase commit (saga pattern). You can't deduct points and hope the card succeeds. Flow: reserve points + authorize card → if both succeed, commit both → if any commit fails, compensate the other leg (release the point reservation, reverse the card auth). The backend owns this orchestration; the client triggers steps and renders state.

  3. Identification + balance check. Customer presents the point card via NFC tap, QR, app barcode, or phone number entry. UI handles all of these per network. Before showing a redemption field, query the network for current balance — must be low-latency, the cashier is waiting.

  4. Earning is a separate flow. Most networks award points on the charged amount (after redemption), not the gross. Earning happens after the charge settles, usually via webhook from the rail. Idempotent on transaction ID.

  5. Refund attribution. Refunding a split-tender session: return points first or refund proportionally? Common default is proportional refund per tender, with a manager-only "refund to one method" override. Each leg is its own state machine. Audit log tracks the breakdown.

  6. Tax on gross, not net. Japanese consumption tax (10%) is generally calculated on the gross amount — points are a discount, not a payment. Applying tax to the net is a common bug that creates ledger drift. The session must carry both numbers.

  7. One program per transaction. Point networks contractually require exclusive use. The POS UI offers one program per session — that choice happens before tender entry, not during.

If they ask: "It's a tender-list extension to the session — same state machine, but with multiple legs that must succeed together. The hard parts are the saga across two networks, the per-network identification UX, and the refund attribution rules. Tax stays on gross. The backend owns the saga; the client orchestrates UI state and triggers each step."

Smart questions to ask Komoju

These show you researched the product:

  1. "How does the SDK handle method-specific UI — one SDK with a generic schema, or per-method modules to keep the SDK size small?"
  2. "For long-pending methods like konbini, do you keep client-side polling alive, or push the resolution to a webhook + push notification flow?"
  3. "What's the policy for adding a new payment method to the SDK? How do you handle the rollout to existing merchants?"
  4. "How do you handle method availability per merchant? Some merchants only accept a subset — is that filtered server-side or client-side?"
  5. "For 3DS2 challenges, do you embed the issuer's ACS UI in the SDK or redirect to a WebView?"

Part 2 — Security & fintech Q&A bank

A payments company will probe security. Be ready with crisp answers.

1. PCI-DSS scope on Android

What PCI-DSS is. Payment Card Industry Data Security Standard. A set of ~300 security rules written by Visa, Mastercard, Amex, JCB, Discover (the PCI Security Standards Council). Not a law — a contract. Any business that touches raw card data (PAN, CVV) must follow it; non-compliance means fines and losing the right to accept cards. A full audit costs $50k–$500k/year, so businesses try hard to stay out of scope.

SAQ levels (Self-Assessment Questionnaire — how a merchant proves compliance):

Level When Pain
SAQ-A Merchant never sees card data — all entry hosted by a PCI-compliant provider ~22 questions. Lightest.
SAQ-A-EP Merchant page renders provider's JS form, posts directly to provider ~139 questions. Medium.
SAQ-D Merchant's own code receives the raw PAN/CVV, even in memory ~329 questions + full audit + network segmentation. The full pain.

The whole reason merchants use Komoju is to stay in SAQ-A.

Q: How do you keep the merchant app out of PCI-DSS scope?

Tokenize at source. The merchant app's process memory must never contain a raw PAN or CVV. Two patterns to guarantee that:

  1. Hosted card form in a WebView — the form HTML is served by Komoju, runs in Komoju's domain. Customer types card → Komoju JS posts straight to Komoju → the WebView returns a token like tok_xxx. The merchant app only sees the token.
  2. Komoju SDK with a card-input component — a UI element provided and signed by Komoju. Card data leaves the SDK only as an encrypted payload to Komoju's server. The SDK is in Komoju's scope, not the merchant's.

Either way, the merchant integrates against the token, not the card. Token is useless to a thief — it can only be charged through Komoju, only by this merchant.

Anti-patterns that drop you into SAQ-D: building your own card EditText and POSTing to your own server, logging card numbers "for debugging," storing them anywhere. Even one log line is enough to bring the whole app into scope.

My take-home is naturally SAQ-A — the app never sees a PAN. The QR redirects to Komoju's hosted page; the app only polls session status. That's a feature, not an accident — it's why payment providers push toward QR/redirect/hosted forms.

The Komoju SDK itself is in scope — that's where hardening matters: certificate pinning, no logs of card data, R8 obfuscation, secure dependency review.

2. Token storage

The two flavors of token. After login the server returns:

  • Access token — short-lived (5–60 min), sent on every API call as Authorization: Bearer ....
  • Refresh token — longer-lived (days/weeks), used only to mint a new access token. More dangerous if stolen → store more carefully.

Android storage options, ranked by safety:

Option Safe? Why
BuildConfig / string resources / hardcoded in Kotlin No APK is shipped to attackers. apktool d app.apk && grep -r SECRET finds it in 30 seconds.
Plain SharedPreferences Partial Other apps can't read it on a non-rooted phone, but root = full access.
External storage / SD card No Historically world-readable. Never.
EncryptedSharedPreferences Yes SharedPrefs with values encrypted by a Keystore-backed master key. Standard answer.
Android Keystore directly Best Keys live in a hardware chip (TEE / StrongBox); cannot be extracted, only used.
In-memory only Safest Gone when process dies. Often used for the access token, with refresh token persisted.

The Android Keystore — the part that's new to most engineers.

Think of it as a sealed safe inside the phone. You ask it to generate a key. The key never leaves the safe. You hand the safe data and say "encrypt with key #5"; it hands back ciphertext. You can never extract key #5 itself.

On modern devices the safe is a separate chip — TEE (Trusted Execution Environment) or StrongBox on newer Pixels and Samsungs. Even if an attacker roots the phone, they can't pull the key — only ask the chip to use it.

EncryptedSharedPreferences does this dance for you: generates a Keystore master key, encrypts every entry with it, stores ciphertext in the prefs file. If someone steals the file off the device, all they get is gibberish.

Bound keys — the next level. When generating a Keystore key you can set:

  • setUserAuthenticationRequired(true) — key only works after PIN/biometric within the last N seconds. Pattern for "biometric to confirm payment."
  • setInvalidatedByBiometricEnrollment(true) — adding a new fingerprint kills the key. Defends against an attacker enrolling their own fingerprint on a stolen unlocked phone.
  • setUnlockedDeviceRequired(true) — only usable while the screen is unlocked.

These tie the key's life to the user's continued presence, not just to the app being installed.

Token lifecycle is half the answer — storage is the other half:

  • Short access-token TTL (5–15 min in payments). Stolen token → small damage window.
  • Refresh token rotation: every refresh issues a new refresh token and invalidates the old. If the thief and the legit app both try to refresh, one is denied — that's the detection signal for compromise.
  • Server-side revocation: logout, "sign out everywhere," password change → server denylist or rotates signing key.
  • Bind tokens to the device via DPoP, mTLS, or a device fingerprint. Stolen token on a different device fails.

Q: How would you store an auth token in production?

Access token in EncryptedSharedPreferences (or in-memory only, repopulated by refresh on cold start). Refresh token under a Keystore-bound key with setUserAuthenticationRequired(true) so a stolen unlocked phone can't silently refresh. Short access-token TTL (~10 min) with rotation on every refresh. Server-side revocation on logout. Hard rule: no secrets in code, no secrets in BuildConfig, no secrets in string resources, no secrets in .so files via strings. Anyone can do apktool d app.apk && grep -r KEY in 30 seconds. The APK is shipped to attackers — treat it like a public document. For my take-home: the hardcoded sandbox token was fine for a demo but in production becomes an OAuth/merchant-login flow with the storage pattern above.

3. Certificate pinning

The HTTPS recap. When the app talks to api.komoju.com, TLS encrypts the traffic. The server proves it's really Komoju by sending a certificate signed by a Certificate Authority (CA). Android trusts about 150 root CAs out of the box; if any one of them signs a cert for api.komoju.com, the app trusts it.

The problem. Two real attack paths break that trust:

  1. A CA gets compromised or coerced. Has happened — DigiNotar in 2011, government pressure cases. Attackers issue a fake but "valid" cert for your domain and run a man-in-the-middle proxy.
  2. A user-installed CA on the device. Corporate MDM, Charles Proxy, malware. Now any cert signed by that CA is "valid," and an attacker can read decrypted payment traffic.

Pinning = "I don't trust 150 CAs. I only trust THIS specific public key for api.komoju.com." Even with a perfect fake cert from a different CA, the app rejects it.

How. OkHttp CertificatePinner:

CertificatePinner.Builder()
    .add("api.komoju.com", "sha256/AAAA...primary", "sha256/BBBB...backup")
    .build()

The hash is over the certificate's Subject Public Key Info (SPKI), not the whole cert — survives cert renewal as long as the key stays the same.

The cost. When Komoju rotates the cert and you forgot to ship the new pin, every app in the field stops working. This is not a small risk — it has bricked apps in the wild.

Mitigations:

  • Ship at least one backup pin for the next cert before rotation.
  • Kill switch via remote config — fetch a flag from a non-pinned endpoint to disable pinning if rotation goes wrong.
  • Monitor cert expiry — alert 30 days before the cert dies.
  • For non-payment endpoints (analytics, crash reporting), pinning is usually overkill.

Q: Would you pin certificates? Pros and cons?

Yes for payment endpoints, with backup pins and a remote kill switch. The honest pro/con: pinning defeats CA-compromise and corporate MITM, but a missed rotation bricks the app. Manage the rotation with backup pins shipped early and a kill flag served from an unpinned endpoint.

4. Rooted devices / Play Integrity

What "rooted" means. A rooted Android device is one where the user (or attacker) has gained admin (root) access — they can read any file in any app's sandbox, attach a debugger to any process, and replace system binaries. The Android security model assumes apps are isolated from each other; root breaks that assumption.

Why payments care. On a rooted phone, an attacker can:

  • Read your EncryptedSharedPreferences file (the encryption key in Keystore is still safe, but Frida can hook in-memory decryption).
  • Use Frida or Xposed to hook into your app at runtime — patch out checks, dump memory, intercept API calls and tokens.
  • Replace the Komoju SDK in memory with a modified version that exfiltrates card data.

Detection — Play Integrity API (replaced SafetyNet, deprecated 2024). The app asks Google to attest the device. Google returns three verdicts:

  • Device integrity: MEETS_BASIC_INTEGRITY (some ROM, untrusted), MEETS_DEVICE_INTEGRITY (genuine Android), MEETS_STRONG_INTEGRITY (genuine + recent security patches + locked bootloader).
  • App integrity: the APK is the one you signed and uploaded to Play.
  • Account integrity: the user has a real Play account.

The catch: detection is a probabilistic signal, not absolute. Skilled attackers bypass Play Integrity with patched ROMs. Treat it as "raises the bar," not "perfect filter."

Policy — risk-based, not blanket. Many advanced users root their phones for legitimate reasons (custom ROM, ad blocking, dev work). Blanket-blocking lights up support tickets and earns 1-star reviews.

  • Allow read-only flows (view receipt, browse history) on weak integrity.
  • Step up to biometric or PIN for medium-risk flows (small refund, view full PAN).
  • Block STRONG-only flows like adding a new card or large refunds when integrity fails.

Q: How do you handle rooted or compromised devices?

Use Play Integrity (replaced SafetyNet) for device + app + account verdicts. Risk-based response: read-only flows OK on weak integrity; step-up auth on medium-risk; block on STRONG-only flows like adding a card. Don't blanket-block — many users root legitimately, and detection is probabilistic anyway. The real defense is server-side: assume the client is hostile, validate everything that matters on the server.

5. 3D Secure 2 (3DS2)

What 3DS is. A protocol run by the card networks (Visa, Mastercard, JCB, Amex) for authenticating the cardholder during an online transaction. The merchant says "charge ¥10,000 to card 4111..."; before approving, the issuer (the customer's bank) can challenge the customer to prove it's really them.

Two outcomes:

  • Frictionless — the issuer's risk model says "this looks fine" based on device fingerprint, location, history. No user prompt. Most transactions take this path.
  • Challenge — issuer asks for OTP, app push approval, biometric, or password. User completes the challenge and the transaction proceeds.

3DS1 vs 3DS2.

  • 3DS1 (early 2000s) — redirect to the issuer's web page, ugly popup, mobile UX broken. Cart abandonment was high.
  • 3DS2 (2017+) — designed for mobile. The merchant's SDK collects device-fingerprint data and sends it to the issuer's ACS (Access Control Server) before the transaction. The ACS uses that data for risk scoring; if challenge is needed, it can render natively inside the app via the EMV 3DS SDK spec rather than redirecting.

Liability shift — why merchants want it. If 3DS is used and fraud still happens, the issuer (the customer's bank) eats the loss, not the merchant. Without 3DS, the merchant takes the chargeback. This is why every major payment provider pushes 3DS2.

Japan-specific. EMV 3DS 2.0 became mandatory for online card payments under the Installment Sales Act (割賦販売法) in 2025. Komoju has to handle this for every Japanese card transaction. Failing to implement 3DS = the merchant cannot legally accept online cards in Japan.

Q: How does 3DS2 work on mobile?

SDK collects device fingerprint data and sends it to the issuer's ACS. ACS decides frictionless (silent approval) or challenge (OTP, biometric). Modern SDKs render the challenge inside the app via the EMV 3DS SDK spec, not a redirect. Liability shifts to the issuer when 3DS is used — that's the business reason. In Japan, 3DS2 is legally required for online card payments since 2025, so Komoju implements it for every Japanese card transaction.

6. Idempotency

The plain-English meaning. Idempotent = "doing it twice gives the same result as doing it once." Reading a webpage is idempotent — refresh ten times, same page. Charging a card is not idempotent by default — sending the request twice could charge twice.

Why payments need it — the failure mode. The phone sends "charge ¥1,200" to the server. The server processes it and sends back "OK." On the way back, the response gets dropped (bad WiFi, train tunnel, OS killed the socket). The phone times out and retries. Without help, the server treats the retry as a brand-new charge → customer charged twice.

The fix. The client generates a unique key (UUID v4) before the first request. Every retry uses the same key. The server keeps a small table:

Idempotency-Key: 7f3a-... → response: { id: "sess_abc", status: "PENDING" }

On retry: server sees the key, returns the cached response without re-charging. The window is usually 24h, sometimes longer.

The lifecycle pitfall. The key must survive things like:

  • App backgrounded → process killed by Android while the request was in flight.
  • User force-quits and relaunches.
  • Phone reboot.

→ Persist the key in SavedStateHandle or equivalent, not just an in-memory variable.

Concrete example for the take-home. Your code creates a session via POST /sessions. Without an Idempotency-Key header, a flaky network on the create call could create two sessions for the same order — and your NOTES.md already flags this as a TODO. Production version: generate UUID when the user taps Pay, store via SavedStateHandle, send on every retry.

Server-side note. The server has to be careful too — if two concurrent requests with the same key arrive, only one should proceed; the other waits or gets the cached result. Usually a row-lock on the idempotency table.

Q: How and why do you use idempotency keys in payment APIs?

Client generates a stable Idempotency-Key (UUID v4) before the first request, reuses it on every retry, and persists it via SavedStateHandle so it survives process death. Server stores key→response for ~24h and returns the cached response on retry instead of re-charging. Without this, a network blip during the response could double-charge a customer — and there's no client-side way to tell whether the original charge succeeded. My take-home flags this as a production TODO on the create-session POST.

7. Webhook security (server-side, but you should know)

What a webhook is. A server-to-server callback. Komoju's server calls your merchant's server URL (e.g., POST https://merchant.com/komoju-webhook) when something happens — payment captured, refund settled, session expired. Webhooks are how the merchant backend learns about events the user triggered elsewhere (paying at a konbini, completing 3DS).

Why this is hard. Anyone on the internet can hit merchant.com/komoju-webhook and send fake JSON. How do you know the request is really from Komoju and not an attacker faking a "payment captured" event to ship goods for free?

The fix — HMAC signature.

  • Komoju and the merchant share a secret (set up at integration time).
  • For each webhook, Komoju computes signature = hex(hmac_sha256(secret, body)) and sends it in a header like X-Komoju-Signature.
  • The merchant's server computes the same signature locally over the received body and compares.
  • Same secret + same body → same signature. Different → reject.

The subtle "constant-time" rule. Comparing strings with == short-circuits on the first byte that mismatches. Attackers can measure response times to learn the signature byte-by-byte. Use MessageDigest.isEqual() or hmac.compare_digest() — runs in constant time regardless of which byte mismatched.

Replay protection. An attacker who captures one valid webhook can replay it forever. Defense: include a timestamp in the signed payload. Reject if older than ~5 minutes. Now a captured webhook expires quickly.

Idempotency on the receiver too. Webhooks retry on 5xx errors, so the same event can arrive multiple times. Each event has a unique ID; the merchant server records which event IDs it has processed and dedupes. Without this, "payment.captured" arriving twice could trigger two shipping orders.

Q: How do you secure incoming webhooks?

HMAC signature with shared secret, constant-time comparison to defeat timing attacks, timestamp in the signed payload with a ~5-minute tolerance to prevent replay, and dedupe by event ID on the receiver because webhooks retry. The HMAC proves "from Komoju," the timestamp proves "recent," the dedupe handles "delivered more than once."

8. PII / logging

What PII is. Personally Identifiable Information — anything that can identify or be linked to a real person. Names, phone, email, address, government IDs, IP, geolocation, biometric data. In payments, the card number (PAN) and CVV are extra-sensitive PCI-regulated data, and PII overlaps with that.

Two reasons not to log it:

  1. Compliance. PCI-DSS forbids logging full PAN or CVV. GDPR and Japan's APPI require minimal data, lawful purpose, retention limits, and breach notification. A logger that captures everything turns into a compliance liability.
  2. Logs leak. Crashlytics, Sentry, Logcat snapshots, support-uploaded log bundles, S3 buckets — all become breach vectors. The most common PII leaks come not from databases but from log pipelines that the team forgot were collecting sensitive fields.

Concrete don't-log list:

  • Full PAN, CVV, expiry, cardholder name.
  • The combination of phone + amount + merchant (re-identifies a person buying something specific).
  • Auth tokens, refresh tokens, idempotency keys (lets attackers replay).
  • Free-form user input (might contain anything).

OK to log:

  • Tokenized payment method ID (pm_xxx).
  • Masked PAN (****-****-****-1234).
  • Session ID, status, status transitions, error codes.
  • Timestamps, latencies, response codes.

Practical pattern. A logger wrapper that strips known sensitive fields by name (password, token, pan, cvv) and truncates anything that looks like a card-number regex. Code-review checklist line: "Did this PR log anything sensitive?"

Japan: APPI (Act on Protection of Personal Information). Roughly Japan's GDPR. Key requirements: explicit purpose, consent, minimum data, retention limits, cross-border transfer rules (need extra consent or adequacy decision to send Japanese personal data abroad). For Komoju, EU customers add GDPR; both must be handled.

Q: What can and cannot be logged in a payment app?

Cannot log raw PAN, CVV, expiry, cardholder name, or combinations that re-identify (phone + amount + merchant), and never log auth tokens or idempotency keys. Can log tokenized IDs, masked PAN last-4, session IDs, status transitions, error codes, timestamps. Use a logger wrapper that strips sensitive fields by name. The compliance angle: PCI-DSS forbids PAN in logs, APPI in Japan + GDPR in EU set additional rules — and logs leak via Crashlytics/Sentry/log bundles, so this isn't theoretical.

9. Code obfuscation & APK hardening

The starting point. An Android APK is just a zip file. Anyone can:

apktool d app.apk          # decompile resources + smali
jadx app.apk               # decompile to readable Java

…and read your code. Names, strings, logic. Not encrypted, not protected — just lightly compressed.

What R8 / ProGuard do.

  • Rename classes, methods, and fields to a, b, c. Code still works (the renames are consistent), but reading it is harder.
  • Strip debug info (line numbers, parameter names, source filenames).
  • Tree-shake — remove unused code so attackers have less surface to read.
  • Inline small methods so the call graph is harder to follow.

R8 is the modern default in the Android Gradle Plugin; ProGuard was the predecessor. Same idea, R8 is faster and Kotlin-aware.

Native code (NDK) — one level harder. C/C++ compiled to ARM bytecode. Can still be reversed with Ghidra or IDA Pro, but takes longer than reading decompiled Kotlin. Use for the most sensitive logic — encryption helpers, license checks, anti-tamper. Don't put business logic there or maintenance becomes painful.

Anti-Frida / anti-debugger checks. Frida is the standard dynamic-analysis tool; it injects into a running app and lets attackers hook any function. Apps can detect Frida by checking for telltale process names, mapped libraries, or ports. Same for ptrace-based debuggers. Adds friction; doesn't stop a determined attacker who patches out the check.

The honest framing — and the senior answer. You cannot fully protect a client. It runs on hardware the attacker controls. Obfuscation slows them; native code slows them more; anti-Frida slows them more. None of it stops them.

Real defense is server-side. Validate every important decision on the server: amounts, balances, refund eligibility, rate limits, fraud signals. The client is an untrusted UI; the server is the source of truth. Treat the APK like a public document — any check that only runs on the client can be patched out.

Q: How do you protect a payment SDK from reverse engineering?

Layered: R8 to obfuscate names + strip debug info + tree-shake; NDK for the most sensitive primitives so it's bytecode not Kotlin; anti-Frida and anti-debugger checks on high-value paths. But the honest framing is: you slow attackers, you don't stop them. The APK is a public document. The real defense is server-side validation — any decision that matters runs on the server, the client is treated as hostile by default.

10. SDK API stability

Why this matters specifically for an SDK. A payment SDK is consumed by thousands of merchant apps. Every breaking change forces all of those apps to update — months of pain across the ecosystem. SDKs live and die by API stability; Stripe is the gold standard here precisely because they almost never break the public API.

Semver in one line. MAJOR.MINOR.PATCH — bump MAJOR for breaking changes, MINOR for additions, PATCH for fixes. Bumping MAJOR is a cost imposed on every consumer; treat it as a last resort.

Sealed classes for state — the additive trick. If your SDK exposes a sealed class for payment state and a merchant writes:

when (state) {
  is Pending -> ...
  is Paid -> ...
  is Failed -> ...
}

…then adding a new state subtype like Refunded does break their when (Kotlin compile error: "exhaustive when needs new branch"). Mitigations: don't rely on a single sealed hierarchy for forward compat; provide an Unknown fallback, or accept that adding states is a MAJOR bump and plan accordingly.

Minimal public surface. Anything public is a contract you have to keep working. Mark everything internal by default; promote to public only when needed. Annotate experimental APIs with @RequiresOptIn so consumers know they might break.

Don't leak transitive dependencies. If your SDK exposes okhttp3.Request in a public method signature, then upgrading OkHttp from 4.x to 5.x becomes a SDK breaking change — even if your code didn't change. Wrap everything: define your own KomojuHttpRequest data class, convert internally.

Multi-version support. When you do bump MAJOR, plan to maintain two majors at once for 6–12 months. Merchants migrate at different speeds; force-cutoffs hurt them.

Deprecation policy. @Deprecated(level = WARNING) for at least 6 months before level = ERROR, and another release before removal. Each deprecation has a migration guide ("use X instead, here's how").

Q: How would you design a payment SDK API for long-term stability?

Strict semver with MAJOR bumps treated as a last resort. Minimal public surface (everything internal by default). Don't leak transitive deps — wrap OkHttp, don't expose it. Sealed classes for state with explicit forward-compat strategy. 6-month minimum deprecation window with migration guides. Support two majors at once during a migration. Stripe is the reference here — their API stability is part of the product.

11. Process death / payment in flight

What process death is. Android can kill your app's process while it's in the background to free memory. Your Activity, ViewModel, and singletons are all gone. When the user taps the app icon again, Android creates a fresh process — but restores the task and the top Activity's state bundle.

The bug pattern. State held in a ViewModel survives configuration changes (rotation) but does not survive process death unless backed by SavedStateHandle. So:

  • User taps Pay → app creates session → backgrounds the app to check a SMS code.
  • Android kills the process to make room for another app.
  • User returns 30 seconds later. Fresh process. ViewModel is empty. App "forgot" the session ID.
  • App lets user tap Pay again → second session created, customer charged twice if the first one paid.

The fix. Persist what matters in SavedStateHandle: session ID, idempotency key, anything needed to resume. On onCreate after kill, re-poll the session by ID. The server is authoritative — let the client rejoin whatever state the server reports.

Edge cases the resume logic must handle:

  • Session paid while killed → jump to the result/success screen, not "tap Pay."
  • Session expired while killed → show expiry UX, not a generic error.
  • Session still pending → resume polling from where it left off.
  • Network down on resume → show retry, but don't lose the session ID.

Why this matters in payments specifically. Other apps lose UI state and the user re-enters a search query. Payments lose UI state and the user pays twice. Process death + payment = a real money bug, not a UX nit.

Q: User is mid-payment, OS kills the app. How do you handle it?

Persist the session ID and idempotency key via SavedStateHandle so they survive process death. On resume, re-poll the session — do not create a new one. The server is authoritative; the client rejoins whatever state the server reports. Handle three resume cases: paid (jump to result), expired (expiry UX), still pending (resume polling). My take-home is exactly this design — the Use Case + state machine is built around survive-then-resume, not start-over.

12. App backgrounding / sensitive UI

The leak. When the user taps the recent-apps button (or swipes up on gesture nav), Android shows a thumbnail of every app's last screen. For a payment screen showing card data, amounts, customer info — that thumbnail is now visible to anyone holding the phone, and it's cached in /data/system/recent_images/ until cleared.

Same risk for screenshots and screen recording — by default any app can be screenshotted by the user, and accessibility services can record the screen.

The fix — one line.

window.setFlags(
  WindowManager.LayoutParams.FLAG_SECURE,
  WindowManager.LayoutParams.FLAG_SECURE
)

This makes the OS:

  • Skip the recent-apps thumbnail (you see a blank or generic placeholder).
  • Block user screenshots.
  • Block screen recording.
  • Block screen mirroring to non-secure displays (some streaming dongles).

Where to apply. Every screen showing card data, amounts, customer phone/email, refund flows. Banking and password apps use it on every screen. For a POS, at minimum: payment screen, card-input screen, refund authorization, anywhere the merchant's auth token could be visible.

What it doesn't stop. A determined attacker with ADB and developer mode, or someone holding a second camera at the screen. Don't oversell the protection — it's about casual leakage and accidental thumbnails.

Q: Should the payment screen show in the recent apps preview?

No. Set FLAG_SECURE on every payment-related screen — blocks recent-apps thumbnail, screenshots, and screen recording. Standard for payment, banking, and password apps. Doesn't stop a determined attacker with ADB, but stops casual leakage and is one line of code.

13. Biometric step-up

What BiometricPrompt is. Android's standard API (since API 28) for fingerprint, face unlock, iris. Replaced the older FingerprintManager. Works across vendors with one API.

Two strength tiers — Class 2 vs Class 3.

  • Class 2 (Weak) — face unlock on devices without a secure camera, low-quality fingerprint sensors. Can prompt the user, but cannot unlock a Keystore key.
  • Class 3 (Strong) — modern fingerprint sensors, secure face. Can be tied to a Keystore key. This is what payments require.

"Step-up" — the policy. Don't biometric-gate everything; biometric on every action is annoying and trains users to tap through. Reserve it for high-risk:

  • Refunds and voids.
  • Transactions above a threshold.
  • Adding a new card.
  • Logging in on a new device.
  • Suspicious-context flags (different country, unusual time).

The crypto pattern — not just "did the user authenticate." Instead of asking "did biometric succeed?" (a boolean an attacker can patch out), tie the biometric to a cryptographic operation:

  1. Generate a Keystore signing key with setUserAuthenticationRequired(true). Key is locked until biometric.
  2. On step-up, build a BiometricPrompt.CryptoObject wrapping the key.
  3. After successful biometric, the key is unlocked; sign the transaction request body with it.
  4. Server verifies the signature — this proves the biometric happened, not just that someone clicked Approve.

This defeats replay even on a rooted phone where the boolean check could be bypassed: without the biometric, the signing key can't sign, so the server-side check fails.

Q: Where would you use BiometricPrompt in a payment app?

Step-up auth for high-risk flows: refunds, large transactions, adding a new card, login on a new device. Use Class 3 biometric tied to a Keystore key — setUserAuthenticationRequired(true) on key generation, then sign the transaction body with the key after biometric unlock. Server verifies the signature, so a rooted attacker who patches the boolean check still can't forge the signature. Don't biometric-gate routine actions; reserve it for things where the cost of a mistaken approval is real money.

14. SCA / regional compliance nuances

SCA in plain English. Strong Customer Authentication is an EU rule from PSD2 (Payment Services Directive 2). For most online payments by EU customers, the issuer must verify two of three factors:

  • Knowledge — something you know (PIN, password).
  • Possession — something you have (phone, hardware token).
  • Inherence — something you are (biometric).

A card number alone fails — it's only "knowledge." Card + OTP to phone passes (knowledge + possession). Card + biometric passes too.

Exemptions — common in practice:

  • Low-value — under €30, exempt up to 5 consecutive transactions or €100 cumulative.
  • Trusted merchant — customer adds the merchant to their issuer's allowlist.
  • Recurring payments — first one needs SCA, subsequent ones don't.
  • Merchant-initiated transactions — subscriptions, where the customer isn't present.

How SCA is delivered for cards: 3DS2. That's why 3DS2 was rolled out — it's the technical mechanism for satisfying SCA on card transactions in the EU.

Japan does not enforce SCA the same way. Japan's parallel is the Installment Sales Act (割賦販売法), which mandates EMV 3DS 2.0 for online card transactions since 2025. Different legal foundation, similar end result for card payments. Other Japanese payment methods (konbini, bank transfer, carrier billing) have their own auth flows that aren't SCA-shaped.

APAC is fragmented. Some markets require strong auth on cards (mostly via 3DS), others rely on the wallet's own auth (PayPay opens the wallet app, biometric there). Each method has its own auth flow.

What this means for Komoju. A multi-region SDK has to handle:

  • 3DS2 (mandatory in Japan, EU SCA mechanism).
  • Wallet-specific deep links for PayPay/Alipay/WeChat (auth happens in the wallet app).
  • Lighter flows for konbini, bank transfer, carrier billing.

Q: What's SCA, and does it apply in Japan?

SCA is EU PSD2's "two of three factors" rule (knowledge / possession / inherence). It's delivered via 3DS2 on card transactions. Japan does not enforce SCA directly — Japan's Installment Sales Act mandates EMV 3DS 2.0 for online cards since 2025, similar end result. APAC is fragmented; many markets rely on wallet-app auth instead. So a multi-region SDK has to handle 3DS2 for cards, wallet deep links for PayPay/Alipay/WeChat, and lighter flows for konbini and carrier billing.

15. Network Security Config

What it is. An XML file at res/xml/network_security_config.xml referenced from the manifest. Declares the app's TLS policy: which domains allow cleartext, which CAs are trusted, where pins live. Lets you change network security without rewriting code.

Default behavior on modern Android. Since Android 9 (API 28), cleartext is blocked by default — apps cannot make plain HTTP requests unless explicitly allowed. Good baseline.

Why you still need a custom config.

<network-security-config>
  <base-config cleartextTrafficPermitted="false">
    <trust-anchors>
      <certificates src="system" />
      <!-- NOT user-installed -->
    </trust-anchors>
  </base-config>

  <domain-config>
    <domain includeSubdomains="true">api.komoju.com</domain>
    <pin-set expiration="2026-12-31">
      <pin digest="SHA-256">AAAA...primary</pin>
      <pin digest="SHA-256">BBBB...backup</pin>
    </pin-set>
  </domain-config>

  <debug-overrides>
    <trust-anchors>
      <certificates src="user" />
    </trust-anchors>
  </debug-overrides>
</network-security-config>

What this gives you.

  • cleartextTrafficPermitted="false" — blocks any HTTP, even from third-party libraries that try.
  • No user in production trust anchors — corporate MITM proxies (Charles Proxy, Fiddler, Zscaler) installed via custom CA cannot decrypt payment traffic on production builds. This is important: in many corporate environments, devices have a corporate CA installed for content inspection, which would otherwise let IT or any malware-on-device read the merchant's auth tokens.
  • domain-config with pin-set — the declarative form of certificate pinning with explicit expiry.
  • <debug-overrides> — only the debug build trusts user-installed CAs, so your dev environment can use Charles Proxy without weakening production.

Q: Anything beyond usesCleartextTraffic="false"?

Yes — a full network_security_config.xml that blocks cleartext globally, pins certs per-domain (with backup pins and expiry), excludes user-installed CAs from production trust anchors so corporate MITM proxies can't intercept payment traffic, and uses <debug-overrides> to allow Charles Proxy only on debug builds. The corporate-MITM exclusion is the part most engineers miss — without it, any IT-managed device with a corporate CA can read your traffic.

16. Stolen / lost POS device

Q: What if the POS is stolen?

Threat model first: a stolen POS gives the thief the merchant's auth token (so they can impersonate the merchant) and the ability to attempt fake sessions or refund fraud. Consumer-phone advice doesn't quite apply — a POS is fixed-purpose, often deployed as a fleet, and the valuable attack paths are different.

The strongest single control is at the rail, not the app: refunds settle back to the original payment method only. The thief can't redirect a refund to a card they control. That kills the most obvious monetization path before any app-level defense kicks in.

Layered defenses on top:

  1. App + device. App PIN or biometric on launch, separate from device unlock. Auto-lock on idle. Refresh token under a Keystore-bound key with setUserAuthenticationRequired(true) — even with root, a thief cannot silently mint new access tokens. Access token short-lived (~10 min). Step-up auth (manager PIN or biometric) before any refund or void. No card data on the device — PCI keeps it out.

  2. Remote revocation. Merchant dashboard lists every logged-in device. One tap revokes that device's refresh token; the next refresh fails and the app drops to login. This is the kill switch — works without MDM, and it's the first thing the merchant should reach for.

  3. Backend anomaly detection. Velocity limits on refunds (max per hour, max per day), geofencing if the merchant has fixed stores, unusual-hours flags, full per-device audit log. Combined with the rail-level refund rule, this catches edge cases that survive the device controls.

  4. MDM if it's a deployed fleet. Locked-down kiosk mode, remote wipe, remote lock from an admin console. Standard for retail POS.

What I'd avoid: relying on the device PIN as the only barrier, storing the refresh token in plain SharedPreferences, or keeping the audit log client-side only. Each of those fails under root or device imaging.

The senior framing is the order: threat model → strongest control (rail-level) → layered defenses → what to avoid. Most candidates jump straight to "use biometric and encrypt storage" — that's defense without naming what's being defended.


Part 3 — Tying it back to your take-home

If they ask "what would you change for production?", you can hit several of these in one answer:

  • Auth: replace hardcoded sandbox token with OAuth / sign-in. Store access token in EncryptedSharedPreferences (already a comment in your code), refresh token in Keystore.
  • Idempotency-Key on the create-session POST — already a TODO in your code.
  • Certificate pinning on payment endpoints via OkHttp CertificatePinner.
  • FLAG_SECURE on the QR code screen and any card-input screen.
  • Play Integrity check before high-value flows.
  • Network Security Config locking down cleartext + pin backups.
  • State machine + SavedStateHandle for survival across process death — already done in your code.
  • Tracking of state transitions (already done) — observability is essential for debugging payment funnels.

That answer alone shows you think like a payments engineer.

results matching ""

    No results matching ""