All posts

Designing Idempotent Payment Systems

Why idempotency keys are non-negotiable in financial APIs and how to implement them correctly in Spring Boot and NestJS.

3 min read
PaymentsAPI DesignSpring BootNestJS

Share this post

LinkedIn

Substack button copies a ready-to-paste draft snippet and opens the editor.

The Cost of Processing a Payment Twice

In payment systems, the difference between "at least once" and "exactly once" processing is the difference between a minor inconvenience and a financial disaster. When a webhook from Paystack or Stripe retries due to a network timeout, your system needs to recognize it has already processed this event and return the cached result.

This isn't a nice-to-have. It's table stakes.

What Is Idempotency?

An operation is idempotent if performing it multiple times produces the same result as performing it once. For payment APIs, this means:

  • POST /payments with the same idempotency key always returns the same response
  • A webhook event processed twice never double-credits a wallet
  • A network retry never creates a duplicate transaction

The Idempotency Key Pattern

Every mutating API call should accept an Idempotency-Key header. The flow:

Implementation in NestJS

@Injectable()
export class IdempotencyGuard implements CanActivate {
  constructor(private readonly redis: RedisService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const req = context.switchToHttp().getRequest();
    const key = req.headers['idempotency-key'];
    if (!key) return true; // No key = no idempotency

    const cached = await this.redis.get(`idempotency:${key}`);
    if (cached) {
      const res = context.switchToHttp().getResponse();
      const parsed = JSON.parse(cached);
      res.status(parsed.status).json(parsed.body);
      return false;
    }
    return true;
  }
}

Implementation in Spring Boot

@Component
public class IdempotencyFilter extends OncePerRequestFilter {
    private final RedisTemplate<String, String> redis;

    @Override
    protected void doFilterInternal(
        HttpServletRequest req,
        HttpServletResponse res,
        FilterChain chain
    ) throws ServletException, IOException {
        String key = req.getHeader("Idempotency-Key");
        if (key != null) {
            String cached = redis.opsForValue()
                .get("idempotency:" + key);
            if (cached != null) {
                res.setContentType("application/json");
                res.getWriter().write(cached);
                return;
            }
        }
        chain.doFilter(req, res);
    }
}

Choosing Your TTL

The idempotency key TTL depends on your retry window:

| Provider | Retry Window | Recommended TTL | |----------|-------------|-----------------| | Stripe | 72 hours | 72–96 hours | | Paystack | 24 hours | 48–72 hours | | Flutterwave | 24 hours | 48–72 hours |

Always set your TTL longer than the provider's retry window.

Database-Level Idempotency

For critical financial operations, Redis alone isn't enough. Use a database unique constraint as the final safety net:

CREATE TABLE payment_events (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  provider      VARCHAR(50) NOT NULL,
  provider_ref  VARCHAR(255) NOT NULL,
  status        VARCHAR(20) NOT NULL,
  processed_at  TIMESTAMPTZ DEFAULT now(),
  UNIQUE(provider, provider_ref)
);

If Redis is down, the UNIQUE constraint prevents duplicate inserts.

Key Takeaways

  1. Idempotency is a requirement, not a feature — Every payment API must support it
  2. Layer your defenses — Redis for speed, database constraints for safety
  3. Use distributed locks — Prevent race conditions during concurrent retries
  4. Log everything — When money is involved, observability isn't optional
  5. Test with chaos — Simulate Redis failures, database timeouts, and concurrent requests in your test suite

Enjoyed this post?

Get a new backend engineering deep-dive every week — payment systems, distributed architecture, core banking.

No spam. Unsubscribe anytime.