MP. Chennai, India
← All articles
Architecture in Motion · Episode 003

Payment Idempotency in Spring Boot: Handling Timeouts and Retries

The gateway authorizes a payment but checkout never receives the response. Explore how stable keys, database uniqueness and reconciliation make retries safer.

Java 21Spring BootPostgreSQLPaymentsDistributed systemsE-commerce
One stable payment key connects a PostgreSQL claim to gateway authorization and reconciliation.
Episode 003 · The timeout trap

The spinner stops. The authorization may not.

A gateway authorizes a payment, but its reply never reaches checkout. The shopper retries. A new request identity can create a second authorization for the same intended purchase.

The solution has four parts: reuse one attempt key, claim it atomically in PostgreSQL, use the provider’s supported idempotency contract, and reconcile uncertain outcomes. Authorization is not capture or settlement.

Watch · Architecture in motion

See the problem.
Understand the rule.

65-second animated lesson · synthetic English narration

Payment Idempotency in Spring Boot: Handling Timeouts and Retries

Watch the idea in motion, then explore the model below. The article explains the implementation and its trade-offs.

Use the player controls to pause, seek, or enter fullscreen. The written explanation below is available at your own pace.

Open video separately ↗

Interactive payment lab · illustrative, no real money

Lose the response. Compare what happens next.

Select an experiment, then play it or advance one step at a time. The counts describe the current step—not measured provider traffic.

Payment submissions
0
Attempt rows
0
Gateway authorizations
0
Shopper
Spring Boot API
PostgreSQL
Gateway

Choose Next event to begin

Step 0 of 4Ready
Ready to begin

Choose Next event or Auto-play to follow the request.

Key: attempt-A

Stored state: READY

Response: Not sent

Status lookups are separate from payment submissions. This model assumes provider idempotency is supported and its retention window has not expired. It demonstrates the design; it does not prove real cross-replica or provider behavior.

The gateway request timed out. What should checkout do?

Choose an answer.

Architecture in Motion · Episode 003 · October 29, 2026

The checkout spinner freezes. The shopper taps Pay again. Your application reports a timeout, but the payment gateway already authorized the first attempt. Do you know whether a retry will create a second charge?

Watch the narrated 9:16 architecture animation, then use the interactive failure lab. This is an illustrative production-style incident, not a claim about an actual merchant outage or payment provider.

The real-world problem: a successful payment with a missing response

Imagine a shopper paying $129.00 for an order. The backend sends POST /payments to a gateway. The gateway authorizes the card, but the HTTP response is lost on the return path. The shopper sees Something went wrong and taps Pay again. A naive implementation creates a new payment request. The result can be two authorizations for one order.

The key insight is that a network timeout means the result is unknown; it does not prove the payment failed. Blindly retrying with a new identifier is unsafe. A payment's authorization, capture, settlement, and refund are distinct operations: do not describe an authorization as a settled charge.

What the broken code looks like

java
@PostMapping("/payments")
public Receipt pay(@RequestBody PaymentRequest request) {
    // Dangerous: every retry can create a fresh gateway operation.
    return gateway.authorize(request.orderId(), request.paymentToken(),
                             request.amountMinor(), UUID.randomUUID().toString());
}

This is especially risky when the customer double-taps, the mobile network is flaky, a load balancer retries, or two Spring Boot instances receive the same request.

The solution: one business operation, one stable identity

Give every logical payment attempt a high-entropy idempotency key generated by the client and reused for retries of that same attempt. The server persists a record keyed by tenant + operation + idempotency key, with a canonical payload fingerprint and a stable gateway request key.

Illustrative payment path: stable attempt key, PostgreSQL claim, gateway and reconciliation.

Boundary 1 — Database: a PostgreSQL uniqueness constraint elects one owner for a key, even across service replicas. Boundary 2 — Gateway: pass the same stable provider idempotency key for safe retries if the provider and endpoint support it. Boundary 3 — Recovery: an unknown outcome requires reconciliation, not an assumption of failure.

Step 1 — Model the attempt and enforce uniqueness

sql
CREATE TABLE payment_attempt (
    tenant_id           text        NOT NULL,
    operation           text        NOT NULL, -- e.g. AUTHORIZE
    idempotency_key     text        NOT NULL,
    request_sha256      char(64)    NOT NULL,
    provider_key        text        NOT NULL,
    state               text        NOT NULL CHECK
                        (state IN ('PROCESSING','UNKNOWN','AUTHORIZED','DECLINED')),
    provider_payment_id text,
    amount_minor        bigint      NOT NULL CHECK (amount_minor > 0),
    currency            char(3)     NOT NULL,
    updated_at          timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (tenant_id, operation, idempotency_key)
);

-- Claim the logical attempt; exactly one concurrent insert wins.
INSERT INTO payment_attempt
  (tenant_id, operation, idempotency_key, request_sha256,
   provider_key, state, amount_minor, currency)
VALUES (?, 'AUTHORIZE', ?, ?, ?, 'PROCESSING', ?, ?)
ON CONFLICT (tenant_id, operation, idempotency_key) DO NOTHING;

INSERT ... ON CONFLICT DO NOTHING is backed by a real unique index/constraint, not an in-memory synchronized block. A competing insert can wait for the first transaction to commit or abort. After a conflict, read the committed row in a subsequent statement under an appropriate isolation/retry policy; do not assume the conflicting row is visible in the same statement snapshot. Commit this claim before calling the external gateway; do not hold a database transaction or row lock open across an unpredictable network call.

Step 2 — Use a deterministic, immutable request fingerprint

Hash a canonical representation of the business operation, such as tenant, order ID, operation, amount in minor units, currency, and payment-method token reference. Do not hash raw JSON with unstable property ordering, and never store raw card details or CVV. Scope keys by tenant and operation; prevent a reused key from silently changing the amount or destination.

java
// Java 21 / Spring Boot 3.x — illustrative API contract
public record AuthorizeRequest(String orderId, long amountMinor,
                               String currency, String paymentToken) {}

public record PaymentReply(String state, String paymentId, String message) {}

@RestController
@RequestMapping("/api/payments")
class PaymentController {
    private final PaymentService service;
    PaymentController(PaymentService service) { this.service = service; }

    @PostMapping("/authorize")
    ResponseEntity<PaymentReply> authorize(
            @RequestHeader("Idempotency-Key") String key,
            @RequestBody AuthorizeRequest request,
            Principal principal) {
        PaymentResult result = service.authorize(resolveAuthorizedTenant(principal), key, request);
        return ResponseEntity.status(result.httpStatus())
                .header("Retry-After", result.retryAfterSeconds())
                .body(result.reply());
    }
}

Here, resolveAuthorizedTenant is an omitted application-specific authorization helper, not a Spring built-in. A principal name alone is not proof of tenant membership.

A production controller must validate request fields, authenticate tenant membership, and avoid emitting an empty Retry-After header for terminal outcomes. The response mapping above is intentionally shortened to focus on the contract.

Step 3 — Claim, inspect, then call the gateway outside the transaction

java
// Pseudocode with JDBC-backed repository methods; intentionally compact.
PaymentResult authorize(String tenant, String key, AuthorizeRequest request) {
    validateKey(key);  // length, entropy and character policy
    String fingerprint = canonicalSha256(tenant, "AUTHORIZE", request);
    String providerKey = stableProviderKey(tenant, "AUTHORIZE", key);

    boolean claimed = repository.tryInsertProcessing(
        tenant, "AUTHORIZE", key, fingerprint, providerKey,
        request.amountMinor(), request.currency());
    // tryInsertProcessing commits the INSERT before returning.

    if (!claimed) {
        PaymentAttempt existing = repository.find(tenant, "AUTHORIZE", key);
        if (!existing.requestSha256().equals(fingerprint)) {
            return conflict("Key reused with a different payment request"); // 409
        }
        return switch (existing.state()) {
            case AUTHORIZED -> authorized(existing.providerPaymentId()); // cached 200
            case DECLINED -> declined(); // replay terminal decline
            case PROCESSING, UNKNOWN -> pending(); // 202 + status URL
        };
    }

    try {
        GatewayAuthorization result = gateway.authorize(
            request.paymentToken(), request.amountMinor(),
            request.currency(), providerKey);
        repository.markAuthorized(tenant, "AUTHORIZE", key, result.paymentId());
        return authorized(result.paymentId());
    } catch (DefinitiveDeclineException ex) {
        repository.markDeclined(tenant, "AUTHORIZE", key);
        return declined();
    } catch (GatewayTimeoutOrAmbiguousException ex) {
        repository.markUnknown(tenant, "AUTHORIZE", key);
        scheduleReconciliation(tenant, "AUTHORIZE", key);
        return pending(); // never call this "declined" or "failed"
    }
}

Important: this is architecture-level pseudocode, not a complete compile-ready payment integration. In a real implementation, protect transitions with compare-and-set SQL, handle database failures after a successful gateway response, authenticate access to status endpoints, and use a durable reconciliation queue/scheduler. If a worker crashes after the gateway authorizes but before markAuthorized, the persisted state may remain PROCESSING; stale PROCESSING records must also be reconciled.

Step 4 — Recover safely from uncertain outcomes

A reconciliation worker queries the gateway using the provider's stable payment identifier, merchant reference, or supported idempotency lookup. If the provider supports retrying the same operation with the same key within its documented retention window, that may be a safe fallback. If the key expired or the provider does not support such semantics, do not blindly replay. Escalate to a provider-specific lookup or manual review.

text
PROCESSING ── definitive authorization ──> AUTHORIZED
     │                                      ▲
     └── timeout / worker crash ──> UNKNOWN │
                                   │        │
                                   └── reconciliation
PROCESSING ── definitive decline ──> DECLINED

Client behavior: retry a network request with the same key; if the API says 202 Pending, poll GET /payments/attempts/{key} with authorization. Do not create a fresh key merely because the first HTTP response disappeared. Generate a new key only for a genuinely new payment attempt after its previous outcome has been resolved.

Interactive failure lab — four experiments

The embedded lab is a deterministic teaching simulation, not a real payment gateway or benchmark.

  1. Naive retry: send the first authorization, lose the response, then retry with a new key. Observe two gateway authorizations.
  2. Protected retry: repeat with the same key. Observe one authorization and a pending/replayed result.
  3. Concurrent double tap: two requests arrive together at different API replicas. PostgreSQL uniqueness selects one owner; the other receives a pending result.
  4. Changed payload: reuse the key but change $129.00 to $139.00. The API rejects it with 409 Conflict before contacting the gateway.

The simulation uses an illustrative gateway that honors idempotency keys and keeps the key for the duration of the experiment. Real providers have different API behavior, retention periods, and error handling.

Why this architecture is stronger

Concern Naive implementation Retry-safe design
Duplicate taps May authorize twice One logical attempt per stable key
Multiple replicas In-memory locks are ineffective Database uniqueness coordinates replicas
Lost HTTP response Assumed failure UNKNOWN + reconciliation
Same key, new amount Could mutate meaning Fingerprint mismatch → 409
Gateway response persisted late Can lose local result Durable attempt + stale-state recovery
Provider idempotency expires Dangerous blind replay Provider lookup / manual review
Observability Only HTTP status Attempt state, key hash, provider reference, reconciliation age

Production hardening checklist

  • Use a gateway-specific contract for authorization, capture, refund, and void: each operation needs its own idempotency semantics.
  • Store only tokens and masked identifiers; keep cardholder data out of application logs.
  • Protect against an attacker reusing another user's key by scoping to authenticated tenant and operation.
  • Rate-limit abusive retries; expire local keys only after business reconciliation and provider-retention requirements are satisfied.
  • Use a durable recovery job, bounded retries with jitter, dead-letter handling, alerts for aged UNKNOWN states, and correlation IDs.
  • Test two app instances against the same PostgreSQL database with concurrent requests; a single-process unit test cannot prove cross-replica safety.
  • Do not confuse API idempotency with exactly-once settlement. Gateways, card networks, and webhook consumers have separate boundaries.

Interview questions — architect-level answers

Q1. Why not use synchronized in Java? It protects only one JVM; two Spring Boot replicas do not share a monitor. A database unique constraint gives a shared concurrency boundary.

Q2. Does @Transactional make the gateway call atomic with PostgreSQL? No. It manages the configured transactional resource, not the external payment provider. The provider can succeed while the database write fails.

Q3. What does HTTP 504 mean after payment authorization? It means the caller did not get a timely response. The business outcome is unknown until reconciled.

Q4. What if the same key arrives with a different amount? Reject it (for example HTTP 409) before a gateway side effect. A key identifies one immutable logical attempt.

Q5. How do you guarantee no double charge forever? You cannot make a universal guarantee with only a local idempotency table. You need provider-supported idempotency, reconciliation, business controls, and explicit handling of expiry and unknown outcomes.

References and further reading

Editorial note: Architecture, incident, amounts, timings, and simulator counters are illustrative, not measured production metrics.

Keep exploring

Questions about these trade-offs or a topic for the next article?

Start a conversation ↗Share on LinkedIn ↗

Join the conversation

Was this lesson helpful? Leave a like or share a question.

Comments

Comments appear after approval. Your name and comment will be public; please leave out private information.

Related articles

Music off · Animations playing