DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

Idempotent API Design for Mobile Payment Flows

---
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.

Enter fullscreen mode Exit fullscreen mode


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:

Enter fullscreen mode Exit fullscreen mode


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.

Enter fullscreen mode Exit fullscreen mode


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)
Enter fullscreen mode Exit fullscreen mode

}


## 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:

Enter fullscreen mode Exit fullscreen mode


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:

Enter fullscreen mode Exit fullscreen mode


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.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)