●  LIVE

AI-native delivery OS

Read
primebytelabs
Back to Insights

Designing Idempotent API Endpoints: Preventing Duplicate Transactions

Prime Admin
January 18, 2026
3 min
#575 words
RedisTypeScript patternsDesigning Idempotent APIREST API designAPI best practicestype-safe APIs

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.

Share this Insight

Spread the word about engineering design and AI solutions.