Rate limits

Two layers of limits apply to every /v1 request:

  1. 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.
  2. 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:

BucketApplies toFree (Egg)Paid plans
Generalevery /v1 request30/min120/min
Mediaimage ingest (POST .../image, POST /v1/logo)5/min30/min
Media (fast lane)pre-optimized product/banner image with ?preoptimized=true20/min100/min
Bulk writessync, publish, cleanup, PUT translations10/min60/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=true to draw from the roomier 20/100 Media fast-lane bucket instead of Media. The server rejects the flag with 400 PREOPTIMIZED_INVALID if 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=true validates 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.planRateLimits plus your own planLimits.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
HTTP/1.1 429 Too Many Requests
Retry-After: 42
json
{
  "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.

bash
# 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"

Staying under the limits#

  • Batch your syncs — one POST /v1/sync can carry up to 200 categories, 200 ingredients and 500 products; don't sync items one-by-one.
  • Validate with dry runs?dryRun=true while 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.