handling all key scenarios in a payment API using idempotency
Let’s sketch something solid without pretending to predict every edge case in the universe. You want a block of code that:
Accepts an idempotency key.
Prevents double-charging.
Handles timeout/retry.
Deals with “processing-in-progress.”
Returns the same final response for repeated calls.
Survives partial failures.
A practical pattern for payment APIs → idempotency store + status machine.
Below is a compact, production-friendly example in C# (ASP.NET Core) using Redis, covering the usual payment flow.
---
✅ Complete pattern: Idempotent Payment Endpoint
Idempotency record model
public class IdempotencyRecord
{
public string Key { get; set; }
public string Status { get; set; } // Pending, Completed, Failed
public int HttpStatus { get; set; }
public string ResponseJson { get; set; }
public DateTime UpdatedAt { get; set; }
}
---
✅ Payment endpoint with full scenario handling
[HttpPost("pay")]
public async Task<IActionResult> ProcessPayment(
[FromHeader(Name = "Idempotency-Key")] string key,
[FromBody] PaymentRequest request)
{
if (string.IsNullOrWhiteSpace(key))
return BadRequest("Idempotency-Key header is required.");
var record = await _store.GetAsync<IdempotencyRecord>(key);
// ✅ Scenario 1: Request already completed — return cached response immediately
if (record is { Status: "Completed" })
return StatusCode(record.HttpStatus, JsonConvert.DeserializeObject(record.ResponseJson));
// ✅ Scenario 2: Request still processing — let client retry later
if (record is { Status: "Pending" })
return StatusCode(409, "Payment is still being processed. Try again.");
// ✅ Scenario 3: First call — create a pending record (locks the operation)
var newRecord = new IdempotencyRecord
{
Key = key,
Status = "Pending",
UpdatedAt = DateTime.UtcNow
};
await _store.SetAsync(key, newRecord, ttl: TimeSpan.FromHours(12));
try
{
// ✅ Call payment gateway (this may timeout or succeed)
var paymentResult = await _paymentGateway.ChargeAsync(request);
// ✅ Update success
newRecord.Status = "Completed";
newRecord.HttpStatus = 200;
newRecord.ResponseJson = JsonConvert.SerializeObject(paymentResult);
await _store.SetAsync(key, newRecord);
return Ok(paymentResult);
}
catch (PaymentDeclinedException ex)
{
// ✅ Declined → store failure and return same failure on retries
newRecord.Status = "Failed";
newRecord.HttpStatus = 402;
newRecord.ResponseJson = JsonConvert.SerializeObject(new { error = ex.Message });
await _store.SetAsync(key, newRecord);
return StatusCode(402, new { error = ex.Message });
}
catch (Exception ex)
{
// ✅ Unknown error → do NOT mark as failed, keep pending
// This prevents double-charge if client retries.
return StatusCode(500, new { error = "Temporary payment error.", detail = ex.Message });
}
}
---
✅ What this code actually handles
✅ 1. User double-clicks “Pay”
Same idempotency key → same stored response → never double-charged.
✅ 2. Mobile network drops mid-payment
Client retries with same key →
If charge finished → same response returned
If charge still running → 409 processing
If gateway timed out but succeeded → stored success is returned → no double charge
✅ 3. Payment declined
Stored as Failed, repeated calls return the same decline message.
✅ 4. Server exception
Status stays Pending, so the client doesn’t get stuck with a false “Failed” state.
✅ 5. Race conditions
The “Pending → Completed → Failed” flow makes sure only one attempt can ever charge.
---
If you want, I can:
Add SQL Server instead of Redis
Add retry-safe gateway wrapper
Add middleware to handle idempotency globally.
Comments
Post a Comment