Skip to content

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.

Path prefixLimitWhy
/api/v1/payments/verify-otp5 / 5 minBrute-force defence on OTP verification
/api/v1/payments/resend-otp5 / 5 minPrevents OTP-spam on a single payment
/api/v1/payments/3ds/callback60 / 1 minTolerant of legitimate 3DS callback storms
/api/v1/payments/* (all other)30 / 1 minGeneral payment endpoint limit
/api/v1/internal/sessions/create30 / 1 minPer merchant-key, not per session
/api/v1/auth/*10 / 1 min(Reserved — no auth endpoints today)
Default catch-all60 / 1 minAnything not above

Limits are evaluated on the first match — the order in the table is the order the gateway checks.

These paths skip rate limiting entirely:

  • /health, /api/v1/health, /api/v1/payments/health
  • /openapi.json, /docs, /redoc
  • /.well-known/*, /static/*
  • /favicon.ico

The gateway identifies the caller via:

  1. X-Forwarded-For (first value) — when behind a CDN or load balancer
  2. X-Real-IP
  3. 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.
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-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
}
  • 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 first Retry-After window.
  • Idempotency keys still apply. A retry after a 429 on 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;
}
}
}
  • 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. /health and /payments/health are bypassed.
  • OpenAPI / docs assets. Bypassed.

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.