DEV Community

Cover image for Designing Idempotent APIs for Mobile Clients: Retry Logic, Idempotency Keys, and the Patterns That Prevent Double Charges
SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

Designing Idempotent APIs for Mobile Clients: Retry Logic, Idempotency Keys, and the Patterns That Prevent Double Charges

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

Enter fullscreen mode Exit fullscreen mode


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.

Enter fullscreen mode Exit fullscreen mode


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

}


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

Enter fullscreen mode Exit fullscreen mode


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.

Enter fullscreen mode Exit fullscreen mode


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.

Enter fullscreen mode Exit fullscreen mode


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

Top comments (0)