Every payment platform I have helped build eventually runs into the same uncomfortable truth: the request you cannot afford to drop and the request you must drop look almost identical at the edge of your network. A double-charge retry from a flaky mobile client and a brute-force enumeration attack both arrive as a burst of POST requests to the same endpoint. Rate limiting is the discipline of telling those situations apart at scale, under pressure, without becoming the reason a legitimate merchant cannot settle their day's takings.
In this post I want to share how I think about rate limiting specifically for payment APIs, where the stakes are higher and the failure modes are nastier than they are for a typical content API. The goal is not to throttle for the sake of it; it is to protect downstream systems, defend against abuse, and keep the platform predictable for the integrators who depend on it to move money.
Why Payment APIs Are Different
A generic public API can shed load fairly casually. If a weather endpoint returns a 429 and a client waits two seconds, nobody is materially harmed. Payment APIs do not have that luxury, because the cost of a wrongly dropped request is asymmetric. Drop a balance inquiry and the merchant retries. Drop the second leg of a two-phase capture and you may have an authorization with no corresponding settlement, a customer charged with no order recorded, or a reconciliation discrepancy that someone in operations spends a Tuesday afternoon untangling.
The other difference is the regulatory and contractual envelope. We commit to service levels in writing, and we answer to acquirers and scheme operators who have their own expectations about how we behave during incidents. A rate limiter that protects our infrastructure by silently degrading a regulated flow is not a clever optimization; it is a compliance problem wearing an engineering hat.
Finally, payment traffic is bursty in ways that correlate with money. End-of-month invoicing, payroll runs, and retail peak days produce legitimate spikes that dwarf the daily baseline. A limiter tuned for the median will strangle the platform exactly when it matters most.
Choosing the Right Algorithm
There are four algorithms worth knowing well, and each makes a different trade-off. The fixed window counter is the simplest: count requests per client per minute, reset on the boundary. It is cheap and easy to reason about, but it allows a double burst at the window edge, where a client sends a full window's worth of traffic in the last second of one window and the first second of the next. For payments, that edge burst is exactly the pattern that overwhelms a downstream processor.
The sliding window log fixes the edge problem by storing a timestamp for every request and counting only those inside the trailing interval, but it is memory-hungry at scale. The token bucket and the leaky bucket are the two I reach for most often. Token bucket allows controlled bursting up to a configured capacity while enforcing a steady refill rate, which maps cleanly onto how real integrators behave. Leaky bucket smooths output to a constant rate, which is ideal when the thing you are protecting is a fixed-throughput downstream connection.
The algorithm is rarely the hard part. The hard part is deciding what counts as one client, where the counter lives, and what you do when the counter store itself is degraded.
Picking the Right Key
What you rate limit by matters far more than the algorithm you choose. Limiting purely by IP address is a trap for payment APIs, because a single corporate NAT or a payment service provider aggregating many merchants will present as one address generating enormous legitimate volume, while a distributed attacker spreads across thousands of cheap addresses. IP is a useful signal but a poor primary key.
I prefer a layered keying strategy that combines identity with intent. Authenticated API keys or client identifiers form the primary dimension, because they map to a contractual relationship and a known throughput tier. On top of that I add per-endpoint and per-operation limits, so that an expensive operation like a refund or a payout has a tighter budget than a cheap balance read. The combination lets me say something precise, such as "this merchant may issue fifty refunds per minute and a thousand reads per minute," reflecting both business risk and computational cost.
- Tenant or merchant identity — the contractual unit, tied to an agreed throughput tier.
- API credential — to isolate a single misbehaving integration from the rest of the tenant.
- Endpoint and method — so writes and money-moving calls get stricter budgets than reads.
- Source IP, as a secondary signal — useful for unauthenticated abuse, never the sole gate.
Distributed State and the Redis Question
A rate limiter is only as honest as its counter. If you run a fleet of API nodes and each keeps its own in-process count, your effective limit is the configured limit multiplied by the node count, and it drifts every time you autoscale. For anything that crosses an instance boundary you need shared state, and in practice that means a fast central store such as Redis with atomic operations.
The critical detail is atomicity. Reading a counter, deciding, and writing it back as three separate round trips opens a race where two nodes both see capacity and both admit a request that together breaches the limit. The fix is to perform the check and the decrement in a single atomic step, which a Lua script executed server-side gives you cleanly. Below is the shape of a token bucket I have used in production, expressed as the Redis-side logic the .NET client invokes.
-- KEYS[1] = bucket key, ARGV: capacity, refillRate, now, requested
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now
-- refill based on elapsed time, capped at capacity
local elapsed = math.max(0, now - ts)
tokens = math.min(capacity, tokens + elapsed * refillRate)
local allowed = tokens >= requested
if allowed then
tokens = tokens - requested
end
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], 60000)
return { allowed and 1 or 0, math.floor(tokens) }
The script returns both the decision and the remaining token count, which lets the calling service populate response headers without a second lookup. Notice the PEXPIRE: idle buckets evict themselves, so a long tail of one-time clients does not bloat memory.
Failing Open or Failing Closed
The question that separates a toy limiter from a production one is what happens when the counter store is unreachable. You have two choices and both are wrong in some scenario. Fail closed, rejecting traffic you cannot account for, and a Redis hiccup becomes a full payment outage. Fail open, admitting everything you cannot count, and an attacker who can degrade your Redis has also disabled your defenses.
My default for payment flows is to fail open on the rate limiter but never on the deeper safeguards. The limiter exists to protect against accidental overload and casual abuse, not to be the last line against fraud. So if the counter store is down, I admit the request and lean on the idempotency layer, the downstream processor's own limits, and circuit breakers to contain the blast radius. A short snippet of that policy in .NET looks like this.
public async Task<bool> ShouldAllowAsync(string key, int cost)
{
try
{
var result = await _bucket.TryConsumeAsync(key, cost);
_metrics.Record(key, result.Allowed, result.Remaining);
return result.Allowed;
}
catch (RedisConnectionException ex)
{
// Counter store degraded: fail open, but loudly.
_logger.LogWarning(ex, "Rate limiter store unavailable; admitting {Key}", key);
_metrics.RecordFailOpen(key);
return true;
}
}
The word "loudly" is doing real work there. Failing open silently means you discover your limiter has been off for a week only when the bill or the incident arrives. Every fail-open decision emits a metric and trips an alert if the rate climbs, so the degraded state is visible within minutes rather than days.
Idempotency as the Real Backstop
Rate limiting and idempotency are often discussed separately, but in payments they are two halves of the same safety story. A limiter decides whether a request runs; idempotency decides what happens when the same request runs twice. Because clients retry on 429s, on timeouts, and on network blips, retries are not an edge case but the normal operating condition. A payment API that is not idempotent will eventually double-charge someone, and a rate limiter that triggers retries makes that more likely, not less.
The pattern I insist on is a client-supplied idempotency key on every state-changing request, stored alongside the canonical result. When a request arrives carrying a key you have already seen, you return the original outcome rather than executing again. This means a client can retry a throttled payment aggressively and safely: at worst it gets the same authorization it already has. The limiter and the idempotency store work together so that being defensive about load never translates into being careless about money.
Communicating Limits to Clients
A limit your integrators cannot see is a limit they cannot respect. The most common cause of retry storms is not malice; it is a client that has no idea how close it is to the ceiling and so hammers the endpoint blindly until it gets through. Good rate limiting is a conversation, and the response headers are how you speak.
I standardize on returning the limit, the remaining budget, and a reset timestamp on every response, plus a Retry-After header on a 429 so a well-behaved client knows exactly how long to back off. I also document the limits per tier and publish them, because surprising a partner with an undocumented ceiling during their peak season is how you lose a partner. The aim is to make the correct client behavior the easy behavior.
- X-RateLimit-Limit — the ceiling for the current window or bucket.
- X-RateLimit-Remaining — how much budget is left, so clients can self-pace.
- X-RateLimit-Reset — when the budget replenishes, as an epoch timestamp.
- Retry-After — on a 429, the seconds to wait before retrying.
Tiering and Graceful Degradation
Not all traffic deserves equal treatment when capacity is scarce. During an incident or an unexpected surge, I want the platform to shed the least valuable load first and protect the flows that move money or carry regulatory weight. That requires the limiter to understand priority, not just volume. A read-only reporting query can be throttled hard; a settlement callback should be among the last things to suffer.
I implement this with tiered budgets and a notion of request class. Critical money-moving operations draw from a protected reserve that lower-priority traffic cannot touch. When the system is healthy, everyone shares the headroom. When it is stressed, the limiter tightens the discretionary tiers first, so degradation is gradual and predictable rather than a cliff that takes everyone down at once. The merchant running their daily settlement should never notice that we are simultaneously rate limiting a bulk export somewhere else.
This is also where business and engineering have to agree explicitly. The priority ordering encodes decisions about which customers and which operations matter most under duress, and those are not decisions an engineer should make alone in a config file. We review the tiering with the commercial and compliance teams, because when capacity is the constraint, the rate limiter is enforcing a policy about who waits.
Testing and Observability
A rate limiter is a piece of infrastructure that, by design, does most of its important work during your worst days. That makes it precisely the component you cannot afford to leave untested until the incident arrives. I treat load and chaos testing of the limiter as a standing requirement, not a one-off. We replay realistic burst patterns, simulate the counter store going dark mid-flight, and verify that fail-open behaves as designed and that the alerts actually fire.
On the observability side, the metrics I care about are the rate of 429 responses broken down by tenant and endpoint, the fail-open counter, and the distribution of remaining budget across active clients. A sudden cluster of 429s for a single integrator usually means a deployment on their side gone wrong, and catching it from our dashboards before they call support is the difference between a proactive note and an escalation.
Conclusion
Rate limiting for payment APIs is less about clever algorithms and more about honest engineering under pressure. Choose a bucket-based algorithm that allows legitimate bursts, key on identity and intent rather than raw IP, keep your counters atomic and shared, and decide deliberately whether you fail open or closed. Pair the limiter with strong idempotency so that the retries it inevitably provokes never turn into double charges, and communicate limits clearly so your integrators can cooperate instead of fight you. Do that, and the limiter stops being a blunt instrument and becomes what it should be: a quiet, predictable guardrail that keeps money moving safely even when everything else is on fire.
