Error Catalog
This page is the canonical reference for all error codes the Orders API can return. Codes are grouped by namespace (orders:, returns:, validation:, attachments:, channels:/service:, auth:, abandoned_carts:, sync:, internal:, route:), which corresponds to the domain that originated the error, not the endpoint that returned it — the same code can appear on several different endpoints.
Shape of the error response
Every error response in this domain has exactly this shape:
{
"error": {
"code": "orders:not_found",
"message": "Order not found",
"details": {
"field": "orderId",
"value": "65f3a1b2c4d5e6f7a8b9c0d1"
}
}
}| Field | Type | Description |
|---|---|---|
error.code | string | Stable identifier, namespace:snake_case format. Branch your logic on this field. |
error.message | string | Human-readable description meant for logs/debugging. Its text can change between versions — never parse it. |
error.details | object (optional) | Additional context. May carry field, value, allowedValues, or other keys depending on the error. |
The code is never SCREAMING_SNAKE_CASE
Every code follows the lowercase namespace:snake_case format (orders:not_found, returns:already_approved). If you see an uppercase, namespace-less code in some outdated example, it is not a real code from this API.
How to handle an error
const response = await fetch("https://api.fenicia.io/orders/65f3a1b2c4d5e6f7a8b9c0d1", {
headers: { "Authorization": `Bearer ${process.env.FENICIA_API_KEY}` },
});
const body = await response.json();
if (!response.ok) {
switch (body.error.code) {
case "orders:not_found":
// the order doesn't exist in your tenant
break;
case "auth:permission_denied":
// your API key doesn't have the required scope
break;
default:
// handle the rest by HTTP status + code
console.error(body.error.code, body.error.message);
}
}orders: — order lifecycle and persistence
| Code | Status | When it occurs |
|---|---|---|
orders:not_found | 404 | No order with that ID exists in your tenant. |
orders:duplicate | 409 | An order with the same tenant + externalId + channel combination already exists. |
orders:invalid_transition | 422 | The requested state transition is not valid from the current state. See State Transitions. |
orders:already_accepted | 409 | The order was already accepted previously. |
orders:already_fulfilled | 409 | The order was already marked as fulfilled. |
orders:already_cancelled | 409 | The order was already cancelled. |
orders:cannot_cancel | 422 | The order's current state doesn't allow cancelling it. |
orders:cannot_fulfill | 422 | The order's current state doesn't allow fulfilling it. |
orders:cannot_confirm_delivery | 422 | The order is in a non-deliverable state; its delivery cannot be confirmed. |
orders:channel_unsupported | 422 | The order's channel doesn't support the requested operation. |
orders:settlement_query_invalid | 400 | The date window of a settlement query (settlement-overdue) is invalid. |
orders:persistence_failed | 500 | An invariant failed while saving the order (model / data layer error). |
orders:shipping_info_required | 400 | Required shipping information is missing (carrier, trackingNumber, or shipmentId). |
orders:shipment_not_found | 404 | The order has no shipment with the indicated _id. |
orders:shipping_label_not_available | 422 | The carrier hasn't generated the shipping label yet. |
orders:invalid_state | 422 | Generic transport-layer code, emitted when the case is more specific than an orders:invalid_transition / orders:cannot_* but doesn't yet have its own dedicated code. |
orders:items_required | 400 | At least one item is required for the operation; validated before reaching business logic. |
returns: — returns and refunds
| Code | Status | When it occurs |
|---|---|---|
returns:not_found | 404 | No return with that ID exists. |
returns:already_approved | 409 | The return was already approved. |
returns:already_rejected | 409 | The return was already rejected. |
returns:invalid_state | 422 | The requested state transition is not valid from the return's current state. |
returns:items_required | 400 | At least one item is required to create the return. |
returns:refund_failed | 502 | The upstream refund gateway (payment provider) failed. |
returns:already_processed | 409 | The return was already processed. |
validation: — input validation
| Code | Status | When it occurs |
|---|---|---|
validation:invalid_input | 400 | Generic schema/field validation on the request body. |
validation:invalid_pagination | 400 | page or limit out of range or malformed. |
validation:invalid_date_range | 400 | The startDate/endDate range is invalid (for example, startDate later than endDate). |
validation:invalid_value | 400 | The value sent is not in the allowed set (invalid enum). |
validation:tenant_required | 400 | The tenant is missing from the request context. |
validation:missing_field | 400 | A required field is missing. |
validation:invalid_format | 400 | The field has an incorrect format (date, email, ID, etc.). |
validation:invalid_json | 400 | The request body is not valid JSON. |
validation:field_too_long | 400 | The field exceeds the maximum allowed length. |
validation:field_too_short | 400 | The field doesn't reach the minimum required length. |
attachments: — order attachments
| Code | Status | When it occurs |
|---|---|---|
attachments:invalid_content_type | 400 | The contentType declared when requesting the upload URL is not valid. |
attachments:invalid_attachment_type | 400 | The attachment's type is not in the allowed types catalog. |
attachments:validation_failed | 400 | Generic attachment validation. |
attachments:attachment_limit_exceeded | 400 | The allowed attachment limit for the order was exceeded. |
attachments:unauthorized | 403 | Not authorized to operate on that attachment. |
attachments:file_not_found | 400 | The referenced file doesn't exist in storage. |
attachments:order_not_found | 404 | The order the file is being attached to doesn't exist. |
attachments:attachment_not_found | 404 | No attachment with that ID exists. |
attachments:delete_failed | 500 | Deleting the attachment failed. |
attachments:upload_failed | 500 | Uploading the file failed. |
attachments:invalid_type | 400 | Invalid attachment type. |
attachments:file_too_large | 400 | The file exceeds the maximum allowed size. |
attachments:invalid_mime_type | 400 | The file's MIME type is not allowed. |
channels: / service: — infrastructure
| Code | Status | When it occurs |
|---|---|---|
channels:sync_failed | 502 | The channel's integration lambda returned an error while syncing. |
service:peer_unavailable | 503 | An optional peer service (not installed in this environment) is unavailable. |
service:config_missing | 500 | A required environment variable is missing on the server. |
auth: — authentication and authorization
| Code | Status | When it occurs |
|---|---|---|
auth:missing_tenant | 401 | The request doesn't carry a resolved tenant. |
auth:invalid_token | 401 | The API key is invalid, expired, or was revoked. |
auth:permission_denied | 403 | The API key doesn't have the permission (resource:action) required for this endpoint. |
auth:tenant_suspended | 403 | The tenant associated with the API key is suspended. |
auth:tenant_not_found | 404 | The tenant associated with the API key doesn't exist. |
abandoned_carts: — abandoned carts
| Code | Status | When it occurs |
|---|---|---|
abandoned_carts:not_found | 404 | No abandoned cart with that ID exists. |
abandoned_carts:already_recovered | 409 | The cart was already marked as recovered. |
abandoned_carts:already_closed | 409 | The cart was already closed. |
abandoned_carts:recovery_failed | 500 | The cart recovery attempt failed. |
Underscore in the error code, hyphen in the permission
This group's error namespace uses an underscore: abandoned_carts:*. The permission your API key needs for these endpoints uses a hyphen: abandoned-carts:read, abandoned-carts:recover, etc. They are not the same string — check which one you need in each context (error code vs. permission scope).
sync: — channel synchronization
| Code | Status | When it occurs |
|---|---|---|
sync:channel_not_found | 404 | The referenced channel doesn't exist. |
sync:channel_disabled | 409 | The channel is disabled. |
sync:integration_error | 502 | The integration failed while syncing. |
sync:rate_limited | 429 | A rate limit was reached during the channel sync. |
Tip
sync:integration_error and channels:sync_failed cover the same case (integration failure while syncing) but come from two different layers of the code: sync:* is an earlier wrapper and channels:sync_failed is the current code backed by the library. Handle both if your integration depends on this flow.
internal: / route: — transport layer
| Code | Status | When it occurs |
|---|---|---|
internal:server_error | 500 | Generic, unclassified server error. |
internal:database_error | 500 | Database error. |
internal:timeout | 504 | The operation exceeded the time limit. |
internal:unknown | 500 | Unidentified error — last-resort fallback. |
route:not_found | 404 | No route is registered for the requested path. |
route:method_not_allowed | 405 | The HTTP method is not supported for that route. |
Best practices
Log the code, not the message
Error messages can change text between versions. Codes are stable — branch your logic on code, never on the content of message.
// Correct
if (error.code === "orders:cannot_cancel") {
await notifyMerchant(order);
}
// Fragile — the text can change
if (error.message.includes("cannot be cancelled")) { /* ... */ }When to retry and when to fail fast
| Status | Retry? | Strategy |
|---|---|---|
400 validation | No | Fix the request before retrying. |
401 auth | No | Rotate the API key or review authentication. |
403 permissions | No | Request the missing permission scope. |
404 not found | No | The resource doesn't exist with that ID in your tenant. |
409 business conflict | Sometimes | Only if you can resolve the state conflict before retrying (e.g. orders:already_accepted). |
422 invalid state | No | The operation is not valid for the resource's current state; check _links before retrying. |
429 sync:rate_limited | Yes | Wait before retrying the sync. |
500/502 | Yes | Exponential backoff — usually transient. |
503 service:peer_unavailable | Yes | Longer backoff — the dependency is recovering. |
504 internal:timeout | Yes | Retry with backoff; consider reducing the request size if it's a large batch. |
Advertencia
A 409 is not always safely retryable: orders:duplicate means the resource already exists — retrying the creation as-is will repeat the same error. Check the specific code, not just the HTTP status.