Errors
The API uses conventional HTTP status codes. Every error response has the same JSON envelope:
{
"statusCode": 401,
"message": "Invalid API key",
"requestId": "8f14e45f-ceea-4671-a2d5-6d5c9a3f1b2e",
"timestamp": "2026-07-05T12:00:00.000Z",
"path": "/v1/menu"
}| Field | Always | Meaning |
|---|---|---|
statusCode | Yes | HTTP status, repeated in the body |
message | Yes | Human-readable summary |
code | No | Machine-readable error code (present on errors that define one, e.g. PAID_PLAN_REQUIRED) — branch on this, not on message text |
details | No | Structured context for coded errors (e.g. plan-limit rejections carry { limit, available, requested }) |
errors | No | Per-rule details on validation failures |
requestId | Yes | Correlation id — also sent as the x-request-id response header on every response (echoes your inbound x-request-id if you send one); include it in support requests |
timestamp / path | Yes | When and where |
Validation errors additionally carry an errors array with one entry per
violated rule:
{
"statusCode": 400,
"message": "Validation failed",
"errors": [
"products.0.priceMinor must not be less than 0",
"property unknownField should not exist"
],
"requestId": "8f14e45f-ceea-4671-a2d5-6d5c9a3f1b2e",
"timestamp": "2026-07-05T12:00:00.000Z",
"path": "/v1/sync"
}Status codes#
| Status | Meaning |
|---|---|
200 | Success (GET, PATCH) |
201 | Success (POST — sync, publish, cleanup) |
400 | Bad request — validation failed, plan limit exceeded, array too large, or an invalid/naive timestamp |
401 | Unauthorized — missing, malformed or invalid API key |
403 | Forbidden — inactive (suspended/canceled) subscription on any endpoint, Orders API on a free plan, or a write with a read-only key (READ_ONLY_KEY) |
404 | Not found — an id/ref that does not exist for your venue (*_NOT_FOUND codes) |
409 | Conflict — creating a resource with an externalId that already exists (EXTERNAL_ID_ALREADY_EXISTS) or adding a product that is already in a promotion (DUPLICATE_PROMOTION_ITEM) |
412 | Precondition failed — your If-Match ETag is stale (STALE_RESOURCE): re-read the resource and retry (see Settings) |
413 | Payload too large — request body over the route's cap (PAYLOAD_TOO_LARGE), a media source over the fetch cap (SOURCE_TOO_LARGE), or a sync that timed out mid-write (SYNC_TOO_LARGE — split into smaller batches) |
422 | Unprocessable — invalid order status transition |
429 | Too many requests (RATE_LIMITED) — see Rate limits |
500 | Internal server error — safe to retry with backoff |
503 | Media processing at capacity (MEDIA_BUSY) — honour Retry-After and resend |
401 — Unauthorized#
message | Cause |
|---|---|
Missing API key | No Authorization: Bearer ... header |
Invalid API key format | Token does not start with dk_live_ / dk_test_ |
Invalid API key | Unknown, revoked or regenerated key |
{
"statusCode": 401,
"message": "Missing API key",
"requestId": "8f14e45f-ceea-4671-a2d5-6d5c9a3f1b2e",
"timestamp": "2026-07-05T12:00:00.000Z",
"path": "/v1/orders"
}400 — Bad request#
Validation — the body or query violates the schema (wrong type, missing required field, unknown extra field, array over its per-request cap of 200 categories / 200 ingredients / 500 products):
{
"statusCode": 400,
"message": "Validation failed",
"errors": ["categories must contain no more than 200 elements"],
"requestId": "8f14e45f-ceea-4671-a2d5-6d5c9a3f1b2e",
"timestamp": "2026-07-05T12:00:00.000Z",
"path": "/v1/sync"
}Timestamps without a timezone — date filters on
GET /v1/orders must be ISO 8601 with an explicit
timezone (Z or +hh:mm offset):
{
"statusCode": 400,
"message": "Parameter 'since' must include timezone (Z or +/-offset). Received: '2026-07-05T12:00:00'",
"requestId": "8f14e45f-ceea-4671-a2d5-6d5c9a3f1b2e",
"timestamp": "2026-07-05T12:00:00.000Z",
"path": "/v1/orders"
}Cleanup safety check — passing an empty array to
POST /v1/cleanup without force: true:
{
"statusCode": 400,
"message": "Passing an empty array would affect ALL items. Set force: true to confirm this action.",
"requestId": "8f14e45f-ceea-4671-a2d5-6d5c9a3f1b2e",
"timestamp": "2026-07-05T12:00:00.000Z",
"path": "/v1/cleanup"
}Plan limits — a sync that would create more items than your plan allows
is rejected with 400 before anything is written, carrying a
machine-readable code (CATEGORY_BULK_LIMIT_EXCEEDED,
INGREDIENT_BULK_LIMIT_EXCEEDED or PRODUCT_BULK_LIMIT_EXCEEDED) plus a
details object with the exact headroom. See
Plan limits for the caps per plan:
{
"statusCode": 400,
"message": "Bad Request Exception",
"code": "PRODUCT_BULK_LIMIT_EXCEEDED",
"details": { "limit": 70, "available": 12, "requested": 50 },
"requestId": "8f14e45f-ceea-4671-a2d5-6d5c9a3f1b2e",
"timestamp": "2026-07-05T12:00:00.000Z",
"path": "/v1/sync"
}403 — Forbidden#
Two distinct cases, distinguishable by code:
Inactive subscription — while the venue's subscription is suspended or canceled, every API endpoint is rejected (the same lockout as in the DuckHub app). Access resumes when the subscription is active again:
{
"statusCode": 403,
"message": "This account's subscription is not active",
"code": "SUBSCRIPTION_INACTIVE",
"requestId": "8f14e45f-ceea-4671-a2d5-6d5c9a3f1b2e",
"timestamp": "2026-07-05T12:00:00.000Z",
"path": "/v1/menu"
}Free plan on the Orders API — the Orders API
requires a paid plan. On the free Egg plan, GET /v1/orders,
GET /v1/orders/:id and PATCH /v1/orders/:id/status are rejected
(API keys and the Menu API work on every plan):
{
"statusCode": 403,
"message": "The Orders API requires a paid plan",
"code": "PAID_PLAN_REQUIRED",
"requestId": "8f14e45f-ceea-4671-a2d5-6d5c9a3f1b2e",
"timestamp": "2026-07-05T12:00:00.000Z",
"path": "/v1/orders"
}Endpoint-specific codes#
Beyond the cases below, feature endpoints return their own machine codes —
documented on their pages: settings
(DELIVERY_NOT_AVAILABLE), media (INVALID_IMAGE_SOURCE,
INVALID_SOURCE_URL, BLOCKED_SOURCE_URL, SOURCE_TOO_LARGE,
INVALID_IMAGE, PREOPTIMIZED_INVALID, plus processing codes like
FILE_TOO_LARGE, IMAGE_TOO_SMALL, IMAGE_NOT_SQUARE,
IMAGE_WRONG_ASPECT_RATIO),
merchandising (EXTERNAL_ID_ALREADY_EXISTS,
DUPLICATE_PROMOTION_ITEM, BANNER_LIMIT_REACHED, TABLE_LIMIT_REACHED)
and translations (INVALID_LOCALE,
LOCALE_NOT_ENABLED, EMPTY_TRANSLATION). Branch on code, never on
message text.
404 — Not found#
Returned when an id or ref does not exist for your venue — the code
names the entity (PRODUCT_NOT_FOUND, CATEGORY_NOT_FOUND,
INGREDIENT_NOT_FOUND, PROMOTION_NOT_FOUND, BANNER_NOT_FOUND,
TABLE_NOT_FOUND, PROMOTION_ITEM_NOT_FOUND,
MODIFIER_GROUP_NOT_FOUND; order lookups keep their message-only form):
{
"statusCode": 404,
"message": "Order not found",
"requestId": "8f14e45f-ceea-4671-a2d5-6d5c9a3f1b2e",
"timestamp": "2026-07-05T12:00:00.000Z",
"path": "/v1/orders/ord_123"
}422 — Invalid status transition#
PATCH /v1/orders/:id/status enforces the
order state machine. Repeating the current status is idempotent (200);
an illegal jump is rejected:
{
"statusCode": 422,
"message": "Invalid status transition: 'completed' -> 'preparing'. Allowed transitions from 'completed': none (terminal state)",
"requestId": "8f14e45f-ceea-4671-a2d5-6d5c9a3f1b2e",
"timestamp": "2026-07-05T12:00:00.000Z",
"path": "/v1/orders/ord_123/status"
}429 — Too many requests#
Carries the machine code RATE_LIMITED and a Retry-After header
(seconds until the window resets). Limits are per venue and per plan,
with stricter sub-limits on media and bulk-write routes:
{
"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/orders"
}Honour Retry-After, back off and retry — see
Rate limits for the budgets and a retry recipe.
500 — Internal server error#
Something failed on our side. These are safe to retry with exponential
backoff; if a 500 persists, contact support with the requestId,
timestamp and path from the response.
Request size#
Request bodies are limited to 5 MB — except the
media ingest routes, which accept up to
21 MB so a base64-encoded image can reach the
15 MB binary cap. A body over the limit is rejected with 413 and the
code PAYLOAD_TOO_LARGE. Larger syncs should be split into multiple
requests (the per-request array caps of
200/200/500
usually keep you far below this).
Handling errors#
# -f makes curl exit non-zero on HTTP errors; -w exposes the status code
curl -sf https://api.duck-hub.com/v1/orders \
-H "Authorization: Bearer dk_live_your_api_key" \
-w "\nHTTP %{http_code}\n"const response = await fetch('https://api.duck-hub.com/v1/orders', {
headers: { Authorization: 'Bearer dk_live_your_api_key' },
})
if (!response.ok) {
const error = await response.json()
if (response.status === 429) {
// back off and retry
} else if (response.status === 401) {
// check the API key
}
throw new Error(`DuckHub API ${error.statusCode}: ${error.message}`)
}
const orders = await response.json()