Errors

The API uses conventional HTTP status codes. Every error response has the same JSON envelope:

json
{
  "statusCode": 401,
  "message": "Invalid API key",
  "requestId": "8f14e45f-ceea-4671-a2d5-6d5c9a3f1b2e",
  "timestamp": "2026-07-05T12:00:00.000Z",
  "path": "/v1/menu"
}
FieldAlwaysMeaning
statusCodeYesHTTP status, repeated in the body
messageYesHuman-readable summary
codeNoMachine-readable error code (present on errors that define one, e.g. PAID_PLAN_REQUIRED) — branch on this, not on message text
detailsNoStructured context for coded errors (e.g. plan-limit rejections carry { limit, available, requested })
errorsNoPer-rule details on validation failures
requestIdYesCorrelation 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 / pathYesWhen and where

Validation errors additionally carry an errors array with one entry per violated rule:

json
{
  "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#

StatusMeaning
200Success (GET, PATCH)
201Success (POST — sync, publish, cleanup)
400Bad request — validation failed, plan limit exceeded, array too large, or an invalid/naive timestamp
401Unauthorized — missing, malformed or invalid API key
403Forbidden — inactive (suspended/canceled) subscription on any endpoint, Orders API on a free plan, or a write with a read-only key (READ_ONLY_KEY)
404Not found — an id/ref that does not exist for your venue (*_NOT_FOUND codes)
409Conflict — 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)
412Precondition failed — your If-Match ETag is stale (STALE_RESOURCE): re-read the resource and retry (see Settings)
413Payload 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)
422Unprocessable — invalid order status transition
429Too many requests (RATE_LIMITED) — see Rate limits
500Internal server error — safe to retry with backoff
503Media processing at capacity (MEDIA_BUSY) — honour Retry-After and resend

401 — Unauthorized#

messageCause
Missing API keyNo Authorization: Bearer ... header
Invalid API key formatToken does not start with dk_live_ / dk_test_
Invalid API keyUnknown, revoked or regenerated key
json
{
  "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):

json
{
  "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):

json
{
  "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:

json
{
  "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:

json
{
  "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:

json
{
  "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):

json
{
  "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):

json
{
  "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:

json
{
  "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:

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/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#

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