Skip to content
~/rifqi_abrory

Pages

Home
About
Stack
Experience
Projects
Writing
Contact
All projects

Projects

Avrist PINTAR — Agent Portal
Avrist e-Application
Avrist Academy — LMS CMS
Avrist e-Payment
PRUHub
SIYAP

Articles

Angular signals for everyday admin pages
Typed reactive forms in Angular, without the any
Consistent API errors in ASP.NET Core with ProblemDetails
Passing CancellationToken all the way down in ASP.NET Core
Graceful shutdown for Go HTTP services
Idempotency keys: making payment APIs safe to retry
A spam-resistant contact form with Next.js Server Actions
Offline-friendly forms in React Native: drafts and a submit queue
Request validation and error handling in Spring Boot 3
Keyset pagination in SQL Server: when OFFSET gets slow

Actions

Switch to dark mode
Read in Bahasa Indonesia
Copy email address
All articles

Idempotency keys: making payment APIs safe to retry

Why retries cause double charges, and how an Idempotency-Key header backed by a unique constraint prevents them in Spring Boot.

3 min read

On this page

The double-payment problem

A mobile app sends POST /payments. The server charges the customer, but the response is lost on a weak connection. The app sees a timeout and does the reasonable thing: it retries. Without protection, the customer is charged twice.

Retries are not a bug; on mobile networks they are guaranteed. The fix is to make the endpoint idempotent: sending the same request twice has the same effect as sending it once.

The contract

The client generates a unique key per user action (a UUID is fine) and sends it with every attempt of that action:

POST /api/payments HTTP/1.1
Idempotency-Key: 6f1c2a8e-3b7d-4c55-9a0e-2d8f4b1e7c90
Content-Type: application/json

{ "policyNumber": "P-001", "amount": 250000.00 }

The server promises three things:

  1. The first request with a key is processed normally.
  2. A repeat with the same key and same body returns the stored response, without charging again.
  3. A repeat with the same key but a different body is rejected with 422, because it is almost certainly a client bug.

Store keys with a unique constraint

The database does the hard part. A unique constraint guarantees that only one request can claim a key, even when two retries arrive at the same moment on different instances:

CREATE TABLE idempotency_keys (
    idempotency_key  VARCHAR(64)   NOT NULL PRIMARY KEY,
    request_hash     CHAR(64)      NOT NULL,
    status           VARCHAR(16)   NOT NULL, -- IN_PROGRESS | COMPLETED
    response_status  INT           NULL,
    response_body    NVARCHAR(MAX) NULL,
    created_at       DATETIME2     NOT NULL DEFAULT SYSUTCDATETIME()
);

The service flow

@Service
class IdempotentPaymentService {

    private final IdempotencyKeyRepository keys;
    private final PaymentService payments;
    private final ObjectMapper json;

    IdempotentPaymentService(IdempotencyKeyRepository keys, PaymentService payments, ObjectMapper json) {
        this.keys = keys;
        this.payments = payments;
        this.json = json;
    }

    ResponseEntity<String> create(String key, CreatePaymentRequest request) throws Exception {
        var hash = sha256(json.writeValueAsString(request));

        // 1. Claim the key. Only one concurrent request can win this insert.
        if (!keys.tryInsert(key, hash)) {
            var existing = keys.findById(key).orElseThrow();

            if (!existing.getRequestHash().equals(hash)) {
                throw new BusinessRuleException("IDEMPOTENCY_KEY_REUSED",
                        "This Idempotency-Key was already used with a different request.");
            }
            if (existing.isInProgress()) {
                // The first attempt is still running; tell the client to try again shortly.
                return ResponseEntity.status(HttpStatus.CONFLICT).build();
            }
            // 2. Same request, already done: replay the stored response.
            return ResponseEntity.status(existing.getResponseStatus()).body(existing.getResponseBody());
        }

        // 3. First attempt: do the work and remember the result.
        var result = payments.create(request);
        var body = json.writeValueAsString(result);
        keys.complete(key, HttpStatus.CREATED.value(), body);

        return ResponseEntity.status(HttpStatus.CREATED).body(body);
    }
}

tryInsert is a plain INSERT that returns false when the primary key already exists:

@Repository
class IdempotencyKeyRepository {

    private final JdbcTemplate jdbc;

    IdempotencyKeyRepository(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    boolean tryInsert(String key, String hash) {
        try {
            jdbc.update("""
                    INSERT INTO idempotency_keys (idempotency_key, request_hash, status)
                    VALUES (?, ?, 'IN_PROGRESS')
                    """, key, hash);
            return true;
        } catch (DuplicateKeyException e) {
            return false;
        }
    }

    // findById(...) and complete(...) are ordinary SELECT and UPDATE statements.
}

The controller reads the header and delegates. The @Size check on a header only runs when the controller class is annotated with @Validated:

@PostMapping
ResponseEntity<String> create(
        @RequestHeader("Idempotency-Key") @Size(min = 8, max = 64) String key,
        @Valid @RequestBody CreatePaymentRequest request) throws Exception {
    return idempotentPayments.create(key, request);
}

Details that matter

  • Failures. If the payment fails with a validation or business error, store that response too. Replaying a 422 is correct. For unexpected crashes, delete the IN_PROGRESS row so the client can retry safely.
  • Calls to a payment provider. Pass the same key (or one derived from it) to the provider if it supports idempotency. Then even a crash between "charged" and "recorded" does not double-charge.
  • Expiry. Keys do not need to live forever. A scheduled job that deletes rows older than a day or a week keeps the table small.
  • Scope. Make the key unique per client or per user, for example by storing user_id in the primary key, so one user can never replay another user's response.

On the client

Generate the key when the user taps Pay, not per HTTP attempt, and keep it until you get a final answer. If the app is closed mid-request, persisting the key with the pending payment lets the next launch retry with the same key instead of creating a new charge.

Summary

  • Retries are normal; design write endpoints for them.
  • An Idempotency-Key header plus a unique constraint stops duplicates, even across instances.
  • Same key and same body: replay. Same key, different body: reject.
  • Generate the key once per user action, not once per request.