Errors
The API uses conventional HTTP status codes. Every error response has the same JSON envelope:
{
"statusCode": 401,
"message": "Invalid API key",
"timestamp": "2026-07-05T12:00:00.000Z",
"path": "/v1/menu"
}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"
],
"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 — Orders API endpoints on a free plan |
404 | Not found — e.g. an order id that does not exist for your venue |
422 | Unprocessable — invalid order status transition |
429 | Too many requests — see Rate limits |
500 | Internal server error — safe to retry with backoff |
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",
"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"],
"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'",
"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.",
"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. See
Plan limits for the caps per plan.
Plan-limit responses are generic today
Plan-limit rejections currently return a 400 with a generic message
body. If a large sync fails with a 400 that is not
Validation failed, check your item counts against your plan
limits first.
403 — Forbidden#
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",
"timestamp": "2026-07-05T12:00:00.000Z",
"path": "/v1/orders"
}404 — Not found#
Returned by the order endpoints when the id does not exist (or belongs to
a different venue):
{
"statusCode": 404,
"message": "Order not found",
"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)",
"timestamp": "2026-07-05T12:00:00.000Z",
"path": "/v1/orders/ord_123/status"
}429 — Too many requests#
{
"statusCode": 429,
"message": "ThrottlerException: Too Many Requests",
"timestamp": "2026-07-05T12:00:00.000Z",
"path": "/v1/orders"
}Back off and retry — see Rate limits.
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 timestamp and
path from the response.
Request size#
Request bodies are limited to 10 MB. 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()