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 /paymentswith 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
- Idempotency is a requirement, not a feature — Every payment API must support it
- Layer your defenses — Redis for speed, database constraints for safety
- Use distributed locks — Prevent race conditions during concurrent retries
- Log everything — When money is involved, observability isn't optional
- Test with chaos — Simulate Redis failures, database timeouts, and concurrent requests in your test suite