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
| Rule | Detail |
|---|---|
| Key | merchantTransactionId + merchantId (composite uniqueness) |
| Max attempts | 5 total (1 initial + 4 retries) |
| Behavior on retry | Returns the existing payment resource with its current status |
| No re-processing | A retry does not create a new Stripe PaymentIntent |
| After limit | Returns 422 UNPROCESSABLE_ENTITY; merchant must use a new merchantTransactionId |
| Scope | Per-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
| Column | Table | Index Type | Purpose |
|---|---|---|---|
merchant_transaction_id | payment | Unique (composite with merchant_id) | Prevents duplicate inserts |
retry_count | payment | — | Tracks 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
| Practice | Why |
|---|---|
Always use the composite key (merchantTransactionId + merchantId) for dedup lookups | Prevents cross-merchant collisions |
Increment retryCount atomically | Use UPDATE ... SET retryCount = retryCount + 1 WHERE retryCount < 5 to prevent race conditions |
| Return the full current state on retry | Don't return stale data — query the latest payment state |
Forward merchantTransactionId to Stripe as the Stripe idempotency key | Ensures Stripe-level dedup matches our dedup |
| Never re-process a terminal payment | If status is COMPLETED, FAILED, or CANCELLED, return as-is |
| Log retries distinctly | Include 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:
| Layer | Key | Protection |
|---|---|---|
| Service layer | merchantTransactionId + merchantId | Database unique constraint |
| Stripe layer | merchantTransactionId as Idempotency-Key | Stripe 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.