Rate limiting
Handbid rate-limits every V3 endpoint. The intent is to protect the platform from runaway clients and abusive bots — not to nickel-and-dime legitimate integrations. If you’re hitting a limit in production traffic you didn’t expect, talk to us; budgets are tunable per integration.
Keying
Section titled “Keying”Each call is keyed on the endpoint and on who is making it:
- Authenticated calls key on
userId. Two users on the same network don’t collide. - Anonymous calls key on the caller’s IP. An unkeyable anonymous request (no resolvable IP) is treated as adversarial and gets a 429 immediately.
The window is a fixed-second counter — typically 60 seconds. After the window rolls over the count resets. There’s no leaky-bucket smoothing.
The 429 response
Section titled “The 429 response”When you exceed a budget:
HTTP/1.1 429 Too Many RequestsContent-Type: application/jsonRetry-After: 60Access-Control-Expose-Headers: Retry-After{ "error": "rate_limited", "message": "Rate limit exceeded. Retry after 60 seconds.", "retryAfter": 60}Retry-After is exposed via CORS so browser clients can read it.
retryAfter in the body is the same value — included for convenience on
clients that find header introspection awkward.
Honor the value. Don’t retry sooner. If you do, you’re just adding to the count for nothing.
Current budgets
Section titled “Current budgets”These are the V3 production defaults. They’re tuned per-environment via
params.v3RateLimits; QA may be more permissive.
Public discovery (opt-auth)
Section titled “Public discovery (opt-auth)”| Endpoint | Authed | Anonymous |
|---|---|---|
GET /v3/auctions | 60/min | 30/min |
GET /v3/auctions/{id} | 60/min | 30/min |
GET /v3/auction/{id}/items | 60/min | 30/min |
GET /v3/item/{id} | 120/min | 60/min |
GET /v3/item/{id}/bids | 60/min | 30/min |
Bidder home & write
Section titled “Bidder home & write”| Endpoint | Authed | Notes |
|---|---|---|
GET /v3/bidder/my-auctions | 30/min | |
GET /v3/bidder/invited-auctions | 20/min | |
GET /v3/bidder/my-activity | 60/min | |
GET /v3/bidder/my-cart | 30/min | |
GET /v3/bidder/favorites | 30/min | |
POST/DELETE /v3/bidder/favorite/{itemId} | 60/min | Shared budget across the two verbs |
GET /v3/bidder/notifications | 120/min | High to cover badge polling |
PUT /v3/bidder/notifications/read-all | 20/min | One per screen-open is expected |
POST /v3/item/{itemId}/bid | 60/min | |
POST /v3/item/{itemId}/auto-bid | 30/min | |
DELETE /v3/item/{itemId}/auto-bid | 30/min |
Auth flow
Section titled “Auth flow”| Endpoint | Anonymous | Notes |
|---|---|---|
POST /v3/auth/email-check | 15/min | Lower with captcha-missing fallback |
POST /v3/auth/register | 10/min | Lower with captcha-missing fallback |
Auth-flow strict throttle
Section titled “Auth-flow strict throttle”/v3/auth/email-check and /v3/auth/register have a second throttle
layered underneath, defending the SMS-cost attack surface:
- Per IP per endpoint
- 10 requests per 5 minutes when a captcha token is missing or invalid
Retry-After: 300on the over-budget response
If you’re integrating server-to-server (not browser), the strict throttle will hit you within a few requests. Either get a Handbid-scoped reCAPTCHA site key so you can send valid tokens, or have us add an integration-level exemption. Email engineering@handbid.com.
Why these numbers
Section titled “Why these numbers”A few principles:
- High-frequency reads get high budgets. The notifications endpoint’s
limit=0mode is a badge poll — clients can call it every few seconds on app foreground. 120/min is the ceiling. - One-per-screen-open writes get low budgets. Bulk read-all happens exactly once when the user opens the notifications screen; 20/min is more than a frantic user can produce.
- Discovery is moderate. Browsing isn’t a tight loop, but a misbehaving client could thrash it; 60/min authed covers normal pagination.
- Auth is the most expensive to scale. Each call may trigger SMS delivery (real dollars). Budgets are deliberately tight.
Anti-patterns
Section titled “Anti-patterns”Things that will land you in 429 jail:
- Polling without backoff. If you saw a 429, wait
retryAfter. Don’t burn through your budget faster after a rejection. - Re-fetching the full list when only the count changed. Use
limit=0count-only mode for badge updates; only request the rows when the user navigates to the screen. - Generating tokens for every request. Mint once, cache for
expiresInseconds, reuse. - Calling discovery endpoints with browser-style rapid pagination on a server. Servers should batch + cache, not act like a frantic UI.
Tuning
Section titled “Tuning”The budgets are configured per-environment in params.v3RateLimits. If
your integration legitimately needs more throughput on a specific endpoint:
- Tell us which endpoint and what your expected steady-state rate is.
- We can either raise the global budget (if the use case is broad) or add an integration-specific budget keyed on your token.
- Allow a few days for the rollout — the change ships in the next release.
Cache-friendly patterns generally work better than higher budgets. Most “need more headroom” requests turn into “you don’t actually need to refresh this every second” conversations.
Reference
Section titled “Reference”The throttle is implemented in V3RestController::enforceRateLimit()
(HAN-1867). The auth-flow strict throttle is in
V3RestController::enforceStrictRateLimit() (HAN-1576). Both are
exercised by testing/API_tests/tests/V3/han-1867-rate-limiting.spec.ts.