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.
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.
See the problem.
Understand the rule.
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 ↗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
Choose Next event 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.
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
@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.
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
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 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
// 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.
PROCESSING ── definitive authorization ──> AUTHORIZED
│ ▲
└── timeout / worker crash ──> UNKNOWN │
│ │
└── reconciliation
PROCESSING ── definitive decline ──> DECLINEDClient 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.
- Naive retry: send the first authorization, lose the response, then retry with a new key. Observe two gateway authorizations.
- Protected retry: repeat with the same key. Observe one authorization and a pending/replayed result.
- Concurrent double tap: two requests arrive together at different API replicas. PostgreSQL uniqueness selects one owner; the other receives a pending result.
- Changed payload: reuse the key but change
$129.00to$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
- PostgreSQL INSERT / ON CONFLICT — conflict handling and uniqueness.
- Spring Framework
@Transactionalsettings — transactional boundaries and rollback defaults. - Stripe API idempotent requests — one provider's concrete key and replay semantics; do not generalize to every gateway.
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
Related articles
Kafka Retry Topics and Dead-Letter Queues: Handling Poison Messages in Spring Boot
One poison event can delay healthy orders. Learn bounded retries, dead-letter triage, idempotent processing and the ordering trade-off with an interactive Kafka failure lab.
JavaTransactional Outbox in Spring Boot and Kafka: Reliable Order Events
Checkout succeeds, but fulfilment and confirmation never start. Explore the dual-write gap, then recover delivery with a transactional outbox and idempotent consumers.
Java 21Pessimistic vs Optimistic Locking in Spring Boot: Preventing Inventory Overselling
Two shoppers try to reserve the last item. Compare row locks, version checks, and atomic SQL updates with a four-mode interactive race simulator.