Rate limits
Two layers of limits apply to every /v1 request:
- Per-venue budgets (plan-based) — the limits you plan an integration around. Counted per venue (the venue your API key belongs to), so they don't change when your traffic comes from several hosts or a shared egress IP.
- Per-IP burst protection — coarse anti-abuse tiers shared by the whole API. A well-behaved integration under its venue budget rarely sees these.
Per-venue budgets#
Each budget is a rolling 60-second window. Free and paid plans get different budgets:
| Bucket | Applies to | Free (Egg) | Paid plans |
|---|---|---|---|
| General | every /v1 request | 30/min | 120/min |
| Media | image ingest (POST .../image, POST /v1/logo) | 5/min | 30/min |
| Media (fast lane) | pre-optimized product/banner image with ?preoptimized=true | 20/min | 100/min |
| Bulk writes | sync, publish, cleanup, PUT translations | 10/min | 60/min |
All applicable buckets are checked on every request — a media upload consumes one General slot and one Media slot.
-
Pre-optimized media get a softer lane: a product/banner image that is already a small WebP at target size (see Media → Fast lane) can be sent with
?preoptimized=trueto draw from the roomier 20/100 Media fast-lane bucket instead of Media. The server rejects the flag with400 PREOPTIMIZED_INVALIDif the bytes don't qualify, so it can't be used to dodge the normal Media limit. -
Dry-run syncs are cheap:
POST /v1/sync?dryRun=truevalidates without writing, so it skips the Bulk-writes bucket (it still counts toward General). Prefer it while iterating on a payload. -
Your venue's budgets are also served by
GET /v1/reference(requestLimits.planRateLimitsplus your ownplanLimits.rateLimitTier), so an agent can read its headroom instead of hard-coding numbers.
Counters are tracked in-process on the API instance (single-instance deployment today). If the API ever scales to multiple instances, the counters move to a shared store — the limits you see here stay the same.
Per-IP burst protection#
Independent of plan, every client IP is limited to 10 requests per second, 50 per 10 seconds and 300 per minute. These exist to absorb abuse and scanning — size your integration against the per-venue budgets above.
The 429 response#
A request over any limit is rejected with 429 Too Many Requests, the
machine code RATE_LIMITED and a Retry-After header (seconds):
HTTP/1.1 429 Too Many Requests
Retry-After: 42{
"statusCode": 429,
"message": "ThrottlerException: Too Many Requests",
"code": "RATE_LIMITED",
"requestId": "8f14e45f-ceea-4671-a2d5-6d5c9a3f1b2e",
"timestamp": "2026-07-05T12:00:00.000Z",
"path": "/v1/sync"
}Responses also carry X-RateLimit-Limit-* / X-RateLimit-Remaining-* /
X-RateLimit-Reset-* headers per bucket, so you can watch your headroom
before hitting a limit.
Handling 429s#
Honour Retry-After when present; otherwise retry with exponential
backoff and jitter — wait ~1s after the first 429, doubling up to
~30s, with random jitter so parallel workers don't retry in lockstep.
Don't retry in a tight loop
Retrying immediately after a 429 keeps the counters saturated and delays recovery. Every retry must wait.
# curl retries with growing delays on 429 (--retry honours Retry-After
# when present, otherwise backs off exponentially)
curl --retry 5 --retry-delay 1 \
https://api.duck-hub.com/v1/orders \
-H "Authorization: Bearer dk_live_your_api_key"async function fetchWithBackoff(url, options, maxRetries = 5) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, options)
if (response.status !== 429) return response
const retryAfter = Number(response.headers.get('retry-after'))
const delay = retryAfter
? retryAfter * 1000
: Math.min(1000 * 2 ** attempt, 30000)
const jitter = Math.random() * 500
await new Promise((r) => setTimeout(r, delay + jitter))
}
throw new Error('Rate limited after all retries')
}Staying under the limits#
- Batch your syncs — one
POST /v1/synccan carry up to 200 categories, 200 ingredients and 500 products; don't sync items one-by-one. - Validate with dry runs —
?dryRun=truewhile developing keeps the Bulk-writes budget untouched. - Poll orders with
updatedSince— one list request per polling interval instead of fetching orders individually. See Polling. - Space out media uploads — on the free plan especially (5/min), upload images as products are created rather than in one burst at the end.