Rate limits
Pdirect applies sliding-window rate limits per session, with tighter
buckets on security-sensitive endpoints. Hitting a limit returns
429 Too Many Requests with error_code: "1301" and a
Retry-After header.
Buckets
Section titled “Buckets”| Path prefix | Limit | Why |
|---|---|---|
/api/v1/payments/verify-otp | 5 / 5 min | Brute-force defence on OTP verification |
/api/v1/payments/resend-otp | 5 / 5 min | Prevents OTP-spam on a single payment |
/api/v1/payments/3ds/callback | 60 / 1 min | Tolerant of legitimate 3DS callback storms |
/api/v1/payments/* (all other) | 30 / 1 min | General payment endpoint limit |
/api/v1/internal/sessions/create | 30 / 1 min | Per merchant-key, not per session |
/api/v1/auth/* | 10 / 1 min | (Reserved — no auth endpoints today) |
| Default catch-all | 60 / 1 min | Anything not above |
Limits are evaluated on the first match — the order in the table is the order the gateway checks.
Bypass list
Section titled “Bypass list”These paths skip rate limiting entirely:
/health,/api/v1/health,/api/v1/payments/health/openapi.json,/docs,/redoc/.well-known/*,/static/*/favicon.ico
Identification
Section titled “Identification”The gateway identifies the caller via:
X-Forwarded-For(first value) — when behind a CDN or load balancerX-Real-IP- The direct connection IP
Plus the bound session for session-authenticated endpoints. So:
- A single client IP and a single session count as one subject.
- Two clients sharing an IP but different sessions count as two subjects.
What 429 looks like
Section titled “What 429 looks like”HTTP/1.1 429 Too Many RequestsContent-Type: application/jsonRetry-After: 60
{ "error": true, "error_code": "1301", "message": "Rate limit exceeded", "timestamp": "2026-05-05T10:15:00Z", "request_id": "req_8f3a4c2e9b1d7a6f", "retryable": true, "retry_after": 60}Backoff guidance
Section titled “Backoff guidance”- Honour
Retry-After. It’s seconds. Do not retry sooner. - Don’t tight-loop on
429. Use exponential backoff with jitter if you need to retry beyond the firstRetry-Afterwindow. - Idempotency keys still apply. A retry after a
429on a cached request will replay the cached response — no new charge, no new rate-limit cost. See Idempotency.
async function withRetry<T>(fn: () => Promise<T>, maxAttempts = 3): Promise<T> { let attempt = 0; while (true) { try { return await fn(); } catch (err) { if (err instanceof RateLimited && attempt < maxAttempts) { const delayMs = (err.retryAfter + Math.random()) * 1000; await new Promise((r) => setTimeout(r, delayMs)); attempt++; continue; } throw err; } }}What does NOT cost
Section titled “What does NOT cost”- Cache-hit idempotent replays. The IdempotencyMiddleware runs after the rate limiter, but cache hits short-circuit before the handler — the rate-limit window is not debited again.
- Health probes.
/healthand/payments/healthare bypassed. - OpenAPI / docs assets. Bypassed.
Tight buckets explained
Section titled “Tight buckets explained”The 5 / 5 min ceiling on
/payments/verify-otp
and
/payments/resend-otp
is intentional. OTP brute-forcing is a real attack and the small
budget gives an attacker statistically zero chance of guessing a
six-digit code before the bucket pushes them out. Legitimate
customers hit a 5/5min limit only if they have miskeyed five times
or have a flaky SMS pipeline.
If your customers genuinely hit OTP rate limits, the right fix is usually a UX improvement (clearer entry, longer expiry) rather than a bigger bucket.
See also
Section titled “See also”- Errors — the full envelope shape for
1301 - Idempotency — retry-safety
- Authentication — sessions are the rate-limit subject