In web development, network connections can fail in the middle of a request. If a client submits a payment request, the database write might succeed but the client connection drops before they receive the response. When the client resubmits the request, you must ensure they are not charged twice.
The Concept of Idempotency
An API endpoint is idempotent if executing it multiple times produces the same database state and returns the same response payload. Achieving this requires implementing an idempotency verification layer on your backend.
We implement these secure design patterns across all our client payment integrations under our custom software development services.
The Idempotency Key Pipeline
To secure mutations, the client must generate and send a unique identifier (e.g., a UUID v4) in a custom request header, such as Idempotency-Key. The server processes the request as follows:
Idempotency Key Pipeline:
[Client Request]
│ (includes Idempotency-Key)
▼
[Check Redis Cache] ────► (Key Exists?) ───► [Return Saved Response]
│ (No)
▼
[Acquire Distributed Lock]
│
[Execute DB Transaction]
│
[Save Response in Redis Cache]
│
[Release Lock & Return Response]
Implementing Redis-Backed Locks
Here is how we implement distributed locking using Redis inside a Node.js API controller. This setup ensures that if a user double-clicks a purchase button, the second request is rejected or queued until the first transaction completes:
async function processPayment(req, res) {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) {
return res.status(400).json({ error: 'Missing Idempotency-Key header' });
}
const lockKey = `lock:payment:${idempotencyKey}`;
const cacheKey = `response:payment:${idempotencyKey}`;
// Check if we have already processed this key
const cachedResponse = await redis.get(cacheKey);
if (cachedResponse) {
return res.json(JSON.parse(cachedResponse));
}
// Acquire lock with a 10-second TTL to prevent concurrent duplicate submissions
const lockAcquired = await redis.set(lockKey, 'locked', 'NX', 'PX', 10000);
if (!lockAcquired) {
return res.status(409).json({
error: 'Conflict: Request is already being processed'
});
}
try {
// Execute the database write and billing action in a transaction
const transactionResult = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: req.body.userId } });
// Deduct balance
const billingResponse = await stripe.charges.create({
amount: req.body.amount,
currency: 'usd',
source: req.body.token,
idempotencyKey: idempotencyKey // Forward key to Stripe
});
return await tx.transaction.create({
data: {
userId: user.id,
amount: req.body.amount,
stripeId: billingResponse.id,
status: 'SUCCESS'
}
});
});
// Cache the response payload for 24 hours
await redis.set(cacheKey, JSON.stringify(transactionResult), 'EX', 86400);
return res.json(transactionResult);
} catch (error) {
console.error('Payment processing failed:', error);
return res.status(500).json({ error: 'Payment processing failed' });
} finally {
// Always release the lock
await redis.del(lockKey);
}
}
Database Concurrency Guardrails
If you are not using an external locking system like Redis, you can implement basic database-level idempotency using unique constraints. By creating a database index on the idempotency_key column, you can catch duplicate write attempts directly inside your SQL layer:
// Prisma Schema representation
model PaymentTransaction {
id String @id @default(uuid())
idempotencyKey String @unique
amount Int
status String
}
When executing the insert query, wrap the call in a try/catch block. If the database engine returns a unique constraint violation error (error code P2002 in Prisma), intercept the error and query the database for the existing record to return it to the client.
Production Considerations
- Distributed Lock Expired: Set the lock TTL longer than your longest external API timeout to prevent locks from expiring before the database transaction completes.
- Idempotent Keys for Sub-Requests: Always forward the client's idempotency key to downstream payment services (like Stripe or Adyen) to ensure safety across the entire request path.