The Anatomy of Payment Idempotency: Preventing Double Charges without Redis
Imagine this scenario: A customer buys a $200 item on your checkout page. They hit "Pay Now". Their browser sends an HTTP POST request to your payment gateway. Your server successfully charges their credit card with Stri
Imagine this scenario: A customer buys a $200 item on your checkout page. They hit "Pay Now". Their browser sends an HTTP POST request to your payment gateway. Your server successfully charges their credit card with Stripe or Adyen, but right before sending the HTTP 200 response back, the clientβs mobile connection drops.
Β
To the customerβs browser, the request timed out. Naturally, the customer hits "Pay Now" again. Or worse, an automated client-side SDK retries the request with exponential backoff.
Β
If your payment API is not idempotent, that customer just got charged twice.
Β
Request 1 (Drop) ββ> [ Charge $200 ] ββX (Network Drop)
Request 2 (Retry) β> [ Charge $200 ] ββ> Customer billed $400! π¨
Β
In payment engineering, duplicate requests are not an edge caseβthey are an absolute certainty. The standard solution is Idempotency.When teams decide to implement idempotency, the default knee-jerk reaction is often: "Let's deploy a Redis cluster and use Redlock or distributed keys."
Β
While Redis is fast, introducing a separate distributed caching tier adds operational complexity, network hops, memory management concerns, and split-brain lock risks. But if you are already using a relational database like PostgreSQL, you already possess a battle-tested, ACID-compliant, durable concurrency engine.
Β
In this article, weβll explore how to build a production-grade, Stripe-style idempotency system using C# / .NET 8 and PostgreSQL , with zero external caching infrastructure.
Β
1. What Makes an API Truly Idempotent?
An operation is idempotent if performing it once produces the exact same outcome as performing it multiple times with the same input.
For payment gateways, idempotency requires four fundamental guarantees:
Atomic Lock Acquisition: If five requests arrive with the same Idempotency-Key simultaneously, exactly one must be allowed to execute downstream payment rails.Payload Integrity (Fingerprinting): If a client uses the same key for two different requests (e.g., trying to charge $10, then reusing the key to charge $500), the system must reject it with 422 Unprocessable Entity.In-Flight Synchronization: If Request B arrives while Request A is still actively processing, Request B should gracefully wait rather than fail immediately or execute concurrently.Deterministic Replay: Once Request A finishes, any subsequent requests with that key must return the exact same HTTP status code and response body without touching payment rails again.
Β
Incoming Request + Idempotency-Key
β
βΌ
Compute SHA-256 Payload Hash
β
βΌ
Atomic INSERT INTO idempotency_records
β
βββββββββββββββββββββββββ΄ββββββββββββββββββββββββ
βΌ βΌ
[ Lock Acquired ] [ Key Already Exists ]
β β
Execute Payment Rails Check Record Status
β β
βββββββββββββ΄ββββββββββββ βββββββββββ΄ββββββββββ
βΌ βΌ βΌ βΌ
[ Success ] [ Failure ] [ COMPLETED ] [ PENDING ]
Save 200 + Response Delete Record Replay Cached JSON Poll DB (up to 5s)
Β
2. Designing the PostgreSQL Schema
Rather than keeping state in ephemeral memory, we persist idempotency state directly in PostgreSQL.
Β
Here is the schema for the idempotency_records table:
Β
CREATE TABLE IF NOT EXISTS idempotency_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
merchant_id UUID NOT NULL REFERENCES merchants(id) ON DELETE CASCADE,
idempotency_key VARCHAR(255) NOT NULL,
request_path VARCHAR(64) NOT NULL DEFAULT '/v1/payments',
request_method VARCHAR(10) NOT NULL DEFAULT 'POST',
request_hash VARCHAR(64) NOT NULL, -- SHA-256 digest of the request payload
status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING | COMPLETED
locked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
response_code INT,
response_headers JSONB,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hour',
CONSTRAINT uq_merchant_idempotency_key UNIQUE (merchant_id, idempotency_key)
);
CREATE INDEX idx_idem_merchant_lookup ON idempotency_records(merchant_id, idempotency_key);
CREATE INDEX idx_idem_expires_at ON idempotency_records(expires_at) WHERE status = 'COMPLETED';
Β
Why This Schema Works
Multi-Tenant Composite Key (uq_merchant_idempotency_key):
Never make idempotency_key globally unique. If Merchant A generates order-1001 and Merchant B happens to generate order-1001, a global key constraint causes a collision. Idempotency must always be scoped per tenant (merchant_id).
Βrequest_hash (SHA-256):
We don't need to store the entire raw request payload indefinitely. Storing a 64-character SHA-256 hex digest is lightweight, fast to index, and sufficient to detect payload tampering.
Βresponse_body JSONB:
PostgreSQL's binary JSON format (JSONB) allows us to store the exact JSON payload returned by our payment handler and replay it seamlessly without serialization discrepancies.
Βexpires_at with Partial Index:
Idempotency records don't need to live forever (Stripe keys typically expire after 24 hours). The partial index on expires_at WHERE status = 'COMPLETED' ensures a background cleanup worker can purge old records in milliseconds without scanning active locks.
Β
3. Modeling the State in C
In .NET 8, we can model the outcome of an idempotency check cleanly using a Discriminated Union pattern with C# records:
Β
namespace Features.Idempotency;
public abstract record IdempotencyResult
{
/// <summary>The lock was acquired; caller should proceed with execution.</summary>
public sealed record Acquired(string RequestHash) : IdempotencyResult;
/// <summary>Request was already processed; replay this cached response.</summary>
public sealed record Replay(int StatusCode, string ResponseBody) : IdempotencyResult;
/// <summary>Key was reused, but payload differs from original request.</summary>
public sealed record PayloadMismatch : IdempotencyResult;
/// <summary>Concurrent request did not complete within the polling window.</summary>
public sealed record Timeout : IdempotencyResult;
}
Β
4. The Core Algorithm: Atomic Locking & Short-Polling
The heart of our solution is BeginOrWaitAsync. It coordinates atomic acquisition, conflict resolution, payload verification, and in-flight polling.
Β
Step 1: Payload Fingerprinting
First, compute a SHA-256 hash of the incoming JSON body:
private static string ComputeSha256(string rawData)
{
byte[] bytes = SHA256.HashData(Encoding.UTF8.GetBytes(rawData));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
Β
Step 2: Atomic Lock Acquisition via ON CONFLICT DO NOTHING
Rather than issuing a SELECT followed by an INSERT (which introduces race conditions), we execute an atomic PostgreSQL insert:
public async Task<IdempotencyResult> BeginOrWaitAsync(
Guid merchantId,
string idempotencyKey,
string requestPayload,
CancellationToken ct)
{
string requestHash = ComputeSha256(requestPayload);
await using var conn = await dataSource.OpenConnectionAsync(ct);
const string insertSql = """
INSERT INTO idempotency_records(
merchant_id,
idempotency_key,
request_hash,
status,
created_at,
expires_at
)
VALUES(
$1, $2, $3, 'PENDING', NOW(), NOW() + INTERVAL '24 hours'
)
ON CONFLICT (merchant_id, idempotency_key) DO NOTHING;
""";
await using (var insertCmd = new NpgsqlCommand(insertSql, conn))
{
insertCmd.Parameters.AddWithValue(merchantId);
insertCmd.Parameters.AddWithValue(idempotencyKey);
insertCmd.Parameters.AddWithValue(requestHash);
int rowsAffected = await insertCmd.ExecuteNonQueryAsync(ct);
// If a row was inserted, WE OWN THE LOCK!
if (rowsAffected > 0)
{
return new IdempotencyResult.Acquired(requestHash);
}
}
// ... If rowsAffected == 0, another request already inserted the key!
Β
Why this is powerful:
PostgreSQL's unique index engine guarantees that even if 50 threads try to insert the exact same (merchant_id, idempotency_key) at the exact same microsecond, only one succeeds (rowsAffected == 1). The remaining 49 return rowsAffected == 0. No distributed lock library required.
Β
Step 3: Handling Concurrent In-Flight Requests (Short-Polling)
If rowsAffected == 0, a previous request is already in progress or has finished. We enter a polling loop bounded by a deadline (e.g., 5 seconds):
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
var pollingInterval = TimeSpan.FromMilliseconds(100);
while (DateTime.UtcNow < deadline)
{
ct.ThrowIfCancellationRequested();
const string selectSql = """
SELECT request_hash, status, response_code, response_body::text
FROM idempotency_records
WHERE merchant_id = $1 AND idempotency_key = $2
LIMIT 1;
""";
await using var selectCmd = new NpgsqlCommand(selectSql, conn);
selectCmd.Parameters.AddWithValue(merchantId);
selectCmd.Parameters.AddWithValue(idempotencyKey);
await using var reader = await selectCmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
{
string existingHash = reader.GetString(0);
string status = reader.GetString(1);
// 1. Check for payload tampering / mismatch
if (!string.Equals(existingHash, requestHash, StringComparison.OrdinalIgnoreCase))
{
return new IdempotencyResult.PayloadMismatch();
}
// 2. If the first request has finished, replay the cached response!
if (status == "COMPLETED")
{
int code = reader.GetInt32(2);
string body = reader.GetString(3);
return new IdempotencyResult.Replay(code, body);
}
}
// Wait 100ms before checking again
await Task.Delay(pollingInterval, ct);
}
// If the active request took longer than 5 seconds, return timeout
return new IdempotencyResult.Timeout();
}
Β
5. Completing and Failing the Lifecycle
Once the payment logic finishes, we must transition the state:
Β
Success: Cache the HTTP Response
public async Task CompleteAsync(
Guid merchantId,
string idempotencyKey,
int statusCode,
string responseBody,
CancellationToken ct)
{
const string updateSql = """
UPDATE idempotency_records
SET status = 'COMPLETED',
response_code = $1,
response_body = $2::jsonb,
updated_at = NOW()
WHERE merchant_id = $3 AND idempotency_key = $4;
""";
await using var conn = await dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(updateSql, conn);
cmd.Parameters.AddWithValue(statusCode);
cmd.Parameters.AddWithValue(responseBody);
cmd.Parameters.AddWithValue(merchantId);
cmd.Parameters.AddWithValue(idempotencyKey);
await cmd.ExecuteNonQueryAsync(ct);
}
Β
Failure: Release the Lock for Retries
What happens if our payment service throws an unhandled exception before reaching the bank (e.g., transient network failure, internal database error)?
We must not leave the key stuck in PENDING forever. Deleting the pending record allows the merchant to retry immediately:
public async Task FailAsync(Guid merchantId, string idempotencyKey, CancellationToken ct)
{
const string deleteSql = """
DELETE FROM idempotency_records
WHERE merchant_id = $1 AND idempotency_key = $2 AND status = 'PENDING';
""";
await using var conn = await dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(deleteSql, conn);
cmd.Parameters.AddWithValue(merchantId);
cmd.Parameters.AddWithValue(idempotencyKey);
await cmd.ExecuteNonQueryAsync(ct);
}
Β
6. Wiring It Into Minimal APIs
Here is how the API endpoint handles the idempotency lifecycle cleanly:
app.MapPost("/v1/payments", async (
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
[FromBody] CreateChargeRequest request,
IdempotencyService idempotencyService,
PaymentProcessor paymentProcessor,
CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(idempotencyKey))
{
return Results.BadRequest(new { error = "Idempotency-Key header is required." });
}
var merchantId = GetCurrentMerchantId();
string payloadJson = JsonSerializer.Serialize(request);
// 1. Begin or Wait
var result = await idempotencyService.BeginOrWaitAsync(
merchantId, idempotencyKey, payloadJson, ct);
// 2. Handle Edge Cases
if (result is IdempotencyResult.PayloadMismatch)
{
return Results.UnprocessableEntity(new {
error = "Idempotency key reused with a different request payload."
});
}
if (result is IdempotencyResult.Timeout)
{
return Results.Conflict(new {
error = "Another request with this key is currently in progress. Please retry shortly."
});
}
// 3. Replay Cached Response
if (result is IdempotencyResult.Replay replay)
{
return Results.Content(replay.ResponseBody, "application/json", statusCode: replay.StatusCode);
}
// 4. Execute the Actual Payment
try
{
var chargeResponse = await paymentProcessor.ProcessChargeAsync(merchantId, request, ct);
// 5. Complete & Cache Response
await idempotencyService.CompleteAsync(
merchantId,
idempotencyKey,
StatusCodes.Status201Created,
JsonSerializer.Serialize(chargeResponse),
ct);
return Results.Created($"/v1/payments/{chargeResponse.Id}", chargeResponse);
}
catch (Exception ex)
{
// 6. Fail & Unlock
await idempotencyService.FailAsync(merchantId, idempotencyKey, CancellationToken.None);
throw;
}
});
Β
7. Real-World Engineering Trade-Offs
Before shipping this to production, there are architectural trade-offs every engineer should understand:
Β
Trade-Off 1: Short-Polling vs. PostgreSQL LISTEN / NOTIFY
-
Short-Polling (100ms interval): Simple, requires no persistent connection pool listener, and handles short payment execution windows (500msβ2s) with minimal database overhead. Β -
LISTEN / NOTIFY: For systems handling thousands of concurrent retries per second, you can replace polling with PostgreSQLβs built-in pub/sub (LISTEN idempotency_channel; NOTIFY idempotency_channel;). When CompleteAsync fires, it notifies waiting connections instantly.
Β
Trade-Off 2: What If the Server Hard-Crashes Mid-Flight?
If your server process gets SIGKILL'd while a record is PENDING, FailAsync will never run. The record would remain stuck in PENDING until expires_at.
Β
The Solution:
Add a lease duration check:
-- Allow acquiring the lock if previous lock is abandoned for > 2 minutes
INSERT INTO idempotency_records (...)
...
ON CONFLICT (merchant_id, idempotency_key)
DO UPDATE SET locked_at = NOW(), status = 'PENDING'
WHERE idempotency_records.status = 'PENDING'
AND idempotency_records.locked_at < NOW() - INTERVAL '2 minutes';
Β
Trade-Off 3: PostgreSQL vs. Redis
Here is the high level comparison between our current implementation and using external caching such as Redis in this particular use case:
Conclusion
Idempotency is one of the most critical safety nets in financial software. While many distributed locking tutorials reach for Redis by default, PostgreSQL provides all the primitives you need:
Β
Unique Constraints provide atomic locking.
SHA-256 Hashing prevents payload tampering.
JSONB Columns serve as a durable response replay cache.
Timeouts & Cleanups keep your data lean and resilient.
Β
By keeping your idempotency layer in PostgreSQL, you eliminate an entire class of distributed system failure modes and ensure your customers are never double-charged. Thank you for your time and hopefully this method will help you in your projects in the future! Cheers :D
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.