---
title: "Designing Idempotent APIs for Mobile Clients"
published: true
description: "A hands-on guide to implementing database-backed idempotency keys in Ktor, client-side retry with exponential backoff, and preventing double charges in mobile APIs."
tags: kotlin, android, api, architecture
canonical_url: https://blog.mvpfactory.co/designing-idempotent-apis-for-mobile-clients
---
## What We Will Build
In this workshop, we are building a Stripe-style idempotency layer for a mobile payment API. By the end, you will have a Ktor endpoint that deduplicates requests using PostgreSQL, a Kotlin retry utility with exponential backoff and jitter, and a pattern for handling partial failures across multi-step mutations.
Let me show you a pattern I use in every project that handles money.
## Prerequisites
- Kotlin 1.9+ and Ktor 2.x for the server
- A PostgreSQL instance (local or Docker)
- An Android or KMP client using Ktor Client or Retrofit
- Familiarity with coroutines and `suspend` functions
## Step 1: Define the Idempotency Record
The server stores a record per unique operation. On retry, it returns the stored response instead of re-executing.
kotlin
data class IdempotencyRecord(
val key: String,
val statusCode: Int,
val responseBody: String,
val createdAt: Instant,
val expiresAt: Instant = createdAt.plus(24, ChronoUnit.HOURS)
)
Twenty-four hours is the sweet spot for expiration — long enough to cover any retry window, short enough to bound storage growth.
## Step 2: Wire Up the Ktor Endpoint
Here is the minimal setup to get this working. The critical piece is using `INSERT ... ON CONFLICT DO NOTHING` as your lock. No distributed locking infrastructure needed — your database already gives you exactly-once semantics at the row level.
kotlin
fun Route.createPayment(db: Database, paymentService: PaymentService) {
post("/payments") {
val idempotencyKey = call.request.header("Idempotency-Key")
?: return@post call.respond(HttpStatusCode.BadRequest, "Missing Idempotency-Key")
val existing = db.findIdempotencyRecord(idempotencyKey)
if (existing != null) {
call.respond(HttpStatusCode.fromValue(existing.statusCode), existing.responseBody)
return@post
}
val locked = db.tryInsertIdempotencyLock(idempotencyKey)
if (!locked) {
call.respond(HttpStatusCode.Conflict, "Request in progress")
return@post
}
try {
val result = paymentService.processPayment(call.receive())
val response = Json.encodeToString(result)
db.completeIdempotencyRecord(idempotencyKey, 200, response)
call.respond(HttpStatusCode.OK, response)
} catch (e: Exception) {
db.deleteIdempotencyRecord(idempotencyKey)
throw e
}
}
}
Notice the `catch` block deletes the record on failure. This allows the client to retry cleanly rather than getting stuck with a locked but incomplete operation.
## Step 3: Build the Client-Side Retry Utility
kotlin
suspend fun retryWithBackoff(
maxRetries: Int = 3,
initialDelayMs: Long = 500,
maxDelayMs: Long = 10_000,
block: suspend () -> T
): T {
var currentDelay = initialDelayMs
repeat(maxRetries) { attempt ->
try {
return block()
} catch (e: IOException) {
if (attempt == maxRetries - 1) throw e
val jitter = Random.nextLong(0, currentDelay / 2)
delay(currentDelay + jitter)
currentDelay = (currentDelay * 2).coerceAtMost(maxDelayMs)
}
}
throw IllegalStateException("Unreachable")
}
The key on the call site: generate the idempotency key **once**, before the first attempt.
kotlin
val idempotencyKey = UUID.randomUUID().toString()
val payment = retryWithBackoff {
api.createPayment(
body = paymentRequest,
headers = mapOf("Idempotency-Key" to idempotencyKey)
)
}
## Step 4: Handle Multi-Step Mutations
A payment flow is rarely a single operation. It is charge, then create order, then send confirmation. Scope the idempotency key to the entire saga, not individual steps.
kotlin
suspend fun processOrder(key: String, request: OrderRequest): OrderResult {
return db.withIdempotency(key) {
val charge = paymentGateway.charge(request.amount)
try {
val order = orderRepo.create(charge.id, request)
emailService.sendConfirmation(order)
OrderResult(order.id, charge.id)
} catch (e: Exception) {
paymentGateway.refund(charge.id)
throw e
}
}
}
On retry, if the saga already completed, you return the stored result. If it failed, the compensation already ran, and the retry re-executes safely.
## Gotchas
**Jitter is not optional.** Without it, clients that failed at the same time retry at the same time. I have seen this thundering herd take down a staging environment on a Monday morning.
**Scope keys to user intent, not HTTP requests.** One key per button tap. Store the key before the first attempt so retries reuse it even across app restarts.
**Do not skip the lock step.** Two concurrent requests with the same key will race without `INSERT ON CONFLICT`. The database gives you atomicity for free — use it.
**Clean up expired records.** A `created_at` index with a periodic cleanup job keeps storage bounded. At one million requests per day with a 24-hour TTL, you are looking at roughly 100 MB. Trivial.
## Wrapping Up
The docs do not mention this, but idempotency is not a feature — it is infrastructure. Google's Android Vitals data shows 5–10% of mobile HTTP requests fail or timeout, rising above 20% in emerging markets. Build idempotency in from the start, implement your retry utility once in a shared module, and enforce it across your networking layer. Your future on-call self will thank you.
Top comments (0)