---
title: "Idempotent API Design for Mobile Payments: Stop Double-Charging Your Users"
published: true
description: "Build a three-layer idempotency system with Kotlin — client-side request fingerprinting, PostgreSQL deduplication, and row-level locking for exactly-once payment processing."
tags: kotlin, api, postgresql, architecture
canonical_url: https://blog.mvp-factory.com/idempotent-api-design-mobile-payments
---
## What We're Building
By the end of this tutorial, you'll have a working three-layer idempotency system that prevents double charges on flaky mobile networks. We'll wire up an OkHttp interceptor on Android, a Ktor route handler with PostgreSQL upserts, and a concurrency guard using row-level locks. Let me show you a pattern I use in every project that handles real money.
## Prerequisites
- Kotlin and Ktor basics (routing, serialization)
- A PostgreSQL instance (local or Docker)
- Android project with OkHttp or Ktor HttpClient
- Familiarity with SQL transactions
## Step 1: Understand the Problem
The most dangerous HTTP response in a payment flow is *no response at all*. Your mobile client sends a charge request, the server processes it, the database commits — then the TCP connection drops before the 200 reaches the client. The client retries. The user gets charged twice.
This is not an edge case. Mobile networks exhibit timeout rates between 1–5% depending on carrier and region. For a payment system processing thousands of transactions daily, that translates to dozens of potential double charges — each one a support ticket, a chargeback risk, and a reason for users to stop trusting you.
Here is the minimal setup to get this working — three layers, each with a clear responsibility:
| Layer | Responsibility | Implementation |
|-------|---------------|----------------|
| Client | Generate + attach idempotency key | OkHttp/Ktor interceptor |
| Server gate | Deduplicate requests | PostgreSQL `ON CONFLICT` upsert |
| Concurrency guard | Serialize simultaneous duplicates | `SELECT ... FOR UPDATE` row lock |
## Step 2: Client-Side Idempotency Keys
The client generates a deterministic key *before* the first attempt and reuses it across retries. Here is the gotcha that will save you hours: derive the key from **business-level fields** (user ID, amount, merchant, timestamp bucket), not from a random UUID. A random UUID defeats the entire purpose on retry because each attempt generates a new one.
kotlin
// Android - OkHttp Interceptor
class IdempotencyInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (request.method == "POST" && request.url.encodedPath.contains("/payments")) {
val body = request.body?.let { it.toBufferedContent() } ?: return chain.proceed(request)
val key = body.sha256().hex()
val newRequest = request.newBuilder()
.header("Idempotency-Key", key)
.build()
return chain.proceed(newRequest)
}
return chain.proceed(request)
}
}
For Ktor HttpClient, attach the key at the call site:
kotlin
val client = HttpClient(OkHttp) {
install(DefaultRequest) {
// Idempotency key attached at call site
}
}
suspend fun submitPayment(payment: PaymentRequest): PaymentResponse {
val idempotencyKey = payment.hashFingerprint()
return client.post("/api/v1/payments") {
header("Idempotency-Key", idempotencyKey)
setBody(payment)
}.body()
}
## Step 3: Server-Side Deduplication with PostgreSQL
The Ktor backend intercepts the idempotency key and performs an atomic upsert before processing. The docs don't mention this, but `INSERT ... ON CONFLICT DO NOTHING` with a `RETURNING` clause gives you a clean signal: if no row comes back, someone else already claimed that key.
kotlin
// Ktor Backend - Route Handler
post("/api/v1/payments") {
val key = call.request.headers["Idempotency-Key"]
?: return@post call.respond(HttpStatusCode.BadRequest, "Missing Idempotency-Key")
val cached = transaction {
IdempotencyRecord.find { IdempotencyTable.key eq key }.firstOrNull()
}
if (cached != null && cached.status == "completed") {
return@post call.respond(HttpStatusCode.OK, cached.responseBody)
}
val claimed = transaction {
exec("""
INSERT INTO idempotency_keys (key, status, created_at)
VALUES (?, 'processing', NOW())
ON CONFLICT (key) DO NOTHING
RETURNING key
""".trimIndent(), listOf(key)) { it.next() }
}
if (claimed == null) {
return@post call.respond(HttpStatusCode.Conflict, "Request already in flight")
}
val result = paymentService.charge(call.receive<PaymentRequest>())
transaction {
exec("UPDATE idempotency_keys SET status='completed', response_body=? WHERE key=?",
listOf(Json.encodeToString(result), key))
}
call.respond(HttpStatusCode.OK, result)
}
## Step 4: Distributed Lock for Concurrent Duplicates
`ON CONFLICT DO NOTHING` handles sequential duplicates. But what about two identical requests arriving within milliseconds? `SELECT ... FOR UPDATE` serializes them at the row level:
sql
BEGIN;
SELECT * FROM idempotency_keys WHERE key = $1 FOR UPDATE;
-- Only one transaction proceeds; the other blocks until commit
COMMIT;
This row-level lock gives you exactly-once semantics even under concurrent pressure — without reaching for table-level locks or external distributed locks. PostgreSQL row locks are battle-tested and fast enough for the vast majority of payment volumes you'll actually encounter.
## Step 5: TTL-Based Cleanup
Idempotency records shouldn't live forever. A scheduled job prunes stale entries:
kotlin
fun Application.configureCleanup() {
launch {
while (isActive) {
delay(1.hours)
transaction {
exec("DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours'")
}
}
}
}
24 hours balances storage cost against retry windows. Most mobile retries resolve within seconds, but offline-first clients may queue requests for hours.
## Gotchas
| Mistake | Consequence | Fix |
|---------|-------------|-----|
| Random UUIDs as idempotency keys | Each retry treated as a new request | Derive key from request content hash |
| No server-side storage | Deduplication only works in-memory, lost on restart | Persist to PostgreSQL |
| Missing concurrency guard | Parallel duplicates both succeed | `FOR UPDATE` row locks |
| No TTL on idempotency records | Table grows unbounded | Scheduled cleanup with 24h window |
I've seen teams spend weeks debugging "phantom duplicates" that traced back to the random UUID mistake. Fingerprint your business fields — don't randomize.
## Wrapping Up
Make the database your single source of truth. PostgreSQL `ON CONFLICT` upserts give you atomic deduplication without external dependencies like Redis — one fewer system to operate and monitor. Fewer moving parts in the payment path means fewer 3 AM pages. Start with the interceptor, add the upsert, then layer in the row lock. Each piece works independently, but together they give you exactly-once payment processing that holds up on real-world mobile networks.
Top comments (0)