Skip to main content
Version: v2

Idempotency & Retries

Overview

The v2 payment API uses merchantTransactionId as an idempotency key to prevent duplicate payment processing. This mechanism is critical for reliability in a distributed system where network failures, timeouts, and retries are expected.


How Idempotency Works


Idempotency Rules

RuleDetail
KeymerchantTransactionId + merchantId (composite uniqueness)
Max attempts5 total (1 initial + 4 retries)
Behavior on retryReturns the existing payment resource with its current status
No re-processingA retry does not create a new Stripe PaymentIntent
After limitReturns 422 UNPROCESSABLE_ENTITY; merchant must use a new merchantTransactionId
ScopePer-merchant — same merchantTransactionId from different merchants creates separate payments

Request & Response Examples

First Request (Attempt 1)

Request:

curl -X POST ".../v2/payments" \
-H "X-Merchant-Id: b955db5e-aef2-47de-bbb9-c80b9cc16e8f" \
-d '{ "merchantTransactionId": "order-123", "amount": 15000, ... }'

Response: 202 Accepted

{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"merchantTransactionId": "order-123",
"status": "INITIATED"
}
}

Retry (Attempt 2-5) — Same merchantTransactionId

Response: 202 Accepted (returns current state — might be COMPLETED or FAILED by now)

{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"merchantTransactionId": "order-123",
"status": "COMPLETED"
}
}

Attempt 6+ — Retry Limit Exceeded

Response: 422 Unprocessable Entity

{
"title": "UNPROCESSABLE_ENTITY",
"status": 422,
"detail": "Retry limit exceeded for merchantTransactionId. Please use a new merchantTransactionId."
}

Database-Level Dedup

ColumnTableIndex TypePurpose
merchant_transaction_idpaymentUnique (composite with merchant_id)Prevents duplicate inserts
retry_countpaymentTracks attempt count

PROCESSING_DEDUP_CHECK State

In the pay-and-save flow, an additional dedup check occurs to prevent saving a duplicate payment method:


Best Practices for Engineers

PracticeWhy
Always use the composite key (merchantTransactionId + merchantId) for dedup lookupsPrevents cross-merchant collisions
Increment retryCount atomicallyUse UPDATE ... SET retryCount = retryCount + 1 WHERE retryCount < 5 to prevent race conditions
Return the full current state on retryDon't return stale data — query the latest payment state
Forward merchantTransactionId to Stripe as the Stripe idempotency keyEnsures Stripe-level dedup matches our dedup
Never re-process a terminal paymentIf status is COMPLETED, FAILED, or CANCELLED, return as-is
Log retries distinctlyInclude retryCount in log entries to distinguish first attempts from retries

Stripe-Level Idempotency

The Stripe adapter passes merchantTransactionId as Stripe's Idempotency-Key header. This provides a second layer of protection:

LayerKeyProtection
Service layermerchantTransactionId + merchantIdDatabase unique constraint
Stripe layermerchantTransactionId as Idempotency-KeyStripe returns cached response for duplicate calls

Both layers must agree. If our database shows no record but Stripe has one (e.g., after a partial failure), the Stripe response takes precedence and the service reconciles the state.