Errors

The Distribu API uses HTTP status codes for success and failure signals, and every error response has the same JSON shape:

{
  "error": "A human-readable message."
}

A handful of errors add one additional field (like retryAfter on 429 responses), but the error field is always present.

Status codes

CodeMeaning
200Success
201Created (only POST /api/v1/orders)
400Bad request — malformed JSON or failed validation
401Authentication failed — missing, malformed, or invalid key
403Authenticated, but missing the required scope
404Resource not found, or belongs to another company
422Request understood, but the business rule rejected it
429Rate limit exceeded
500Something broke on our end

400 — Bad request

Returned when your request body is malformed or fails validation. Typical messages:

{ "error": "Invalid JSON body." }
{ "error": "customerId is required" }
{ "error": "productId is required" }
{ "error": "quantity must be a number" }
{ "error": "quantity must be a whole number" }
{ "error": "quantity must be at least 1" }
{ "error": "At least one item is required" }
{ "error": "Invalid order status." }
{ "error": "Validation failed." }

Check the error string first — it tells you exactly which field failed.

401 — Authentication

{ "error": "Missing or invalid Authorization header. Use: Bearer <api_key>" }
{ "error": "Invalid, expired, or revoked API key." }

Both indicate the request couldn't be associated with an API key. The server doesn't distinguish between "wrong key," "expired key," and "revoked key" in the response — intentionally, to avoid leaking which state a leaked key is in.

403 — Insufficient scope

{
  "error": "Insufficient permissions. This key lacks the \"orders:write\" scope."
}

The key is valid but doesn't have the scope this endpoint requires. See Scopes for the endpoint → scope mapping. Fix by revoking the key and creating a new one with a broader scope set.

404 — Not found

{ "error": "Product not found." }
{ "error": "Order not found." }
{ "error": "Customer not found." }

Three possibilities for any 404:

  1. The resource ID is wrong (typo, truncation).
  2. The resource belongs to another company — the API treats cross-company reads as 404, not 403, to avoid leaking existence.
  3. The resource was deleted.

Check your own records before assuming the API lost something.

422 — Business rule failure

Returned when the request is well-formed and authorized but violates a data-consistency rule.

{
  "error": "Cannot update a cancelled order."
}

The one 422 today is on PATCH /api/v1/orders/{id} for an order that's already in CANCELLED status — there's no way forward from there.

Note that POST /api/v1/orders returns 400 (not 422) for stock and active-product violations, which is a minor inconsistency in the API's status-code vocabulary — the messages are still specific enough to distinguish:

{ "error": "Product \"clxxABC...\" not found or is inactive." }
{ "error": "Insufficient stock for product \"Widget Blue\". Available: 3, requested: 10." }

429 — Rate limit

{
  "error": "Rate limit exceeded. Try again shortly.",
  "retryAfter": 42
}

The retryAfter field is the number of seconds until your rate-limit bucket resets. Sleep at least that long before retrying. The default budget is 60 requests per minute per API key — see the overview for details.

Implement a retry-with-backoff in your client for 429 responses, including the retryAfter hint.

500 — Server error

If something breaks server-side, you'll see:

Internal Server Error

…with HTTP status 500. These shouldn't happen during normal operation; when they do, they're logged and alerted on our side. Retry once; if it keeps failing, email support@distribu.app with the time, endpoint, and payload (minus secrets).

Error handling pattern

A reasonable client approach:

async function callDistribu(path, options = {}) {
  const res = await fetch(`https://distribu.app${path}`, {
    ...options,
    headers: {
      Authorization: `Bearer ${process.env.DISTRIBU_API_KEY}`,
      "Content-Type": "application/json",
      ...options.headers,
    },
  });

  if (res.status === 429) {
    const body = await res.json();
    await new Promise((r) => setTimeout(r, (body.retryAfter ?? 5) * 1000));
    return callDistribu(path, options); // one retry
  }

  if (!res.ok) {
    const body = await res.json().catch(() => ({ error: res.statusText }));
    throw new DistribuError(res.status, body.error);
  }

  return res.json();
}

class DistribuError extends Error {
  constructor(public status, message) {
    super(message);
  }
}

No machine-readable error codes

There's no error_code or error_type field today — just the free-form error string and the HTTP status. If you need to branch on specific errors, match on the HTTP status plus substring in the message. This is a known limitation; structured error codes are on the roadmap.


Next: Products endpoints.