Error Catalog — Inventory and Locations
/inventory/* and /locations/* are two separate services behind the same API Gateway. They don't share a response envelope or an error-code convention — each returns, literally, whatever its service layer produced. This page documents both exactly as they behave in production today, including their inconsistencies.
There is no {data, meta} envelope
Each response is the body returned by the service layer, serialized as-is. Shapes vary by resource: a flat document, { items, pagination }, { transfers, pagination }, { data, total, page, limit, totalPages, hasMore } (lots), { locations, totalCount, page, limit } (locations — note totalCount, not total). Don't assume a shared response schema across endpoints in this domain.
The two generic 500 error codes
| Base path | Code in the generic 500 |
|---|---|
/inventory/* | internal-error |
/locations/* | internal-server-error |
They are literally different. This is not a typo in this guide — they are two separate lambdas with independent implementations of the generic catch-all.
Missing tenant (401)
Both services return 401 with the same code, despite the code suggesting a 400:
{ "code": "bad-request/missing-tenant", "message": "..." }Permissions and suspended tenant (403)
A 403 can be due to your API key lacking the permission required by the endpoint, or to the tenant being suspended (for example, due to billing). This audit's source did not confirm a single literal error code for these cases — check the response message to distinguish the exact cause.
Inventory domain errors (/inventory/*)
All require the permission indicated on each endpoint (inventory:read, inventory:update, inventory:adjust, inventory:transfer, inventory:count, or inventory:manage depending on the resource — see Inventory API).
Stock (/inventory, /inventory/items, /inventory/location, /inventory/bulk-update, /inventory/stats)
| Code | HTTP | Cause | How to resolve |
|---|---|---|---|
bad-request/missing-sku | 400 | The SKU is missing from the route or the body. | Include sku. |
bad-request/missing-body | 400 | The request body is empty. | Send a valid JSON body. |
bad-request/invalid-json | 400 | The body is not valid JSON. | Check the Content-Type and the JSON sent. |
bad-request/invalid-stock | 400 | stock is not of type number. | Send stock as a number, not a string. |
bad-request/invalid-items | 400 | POST /inventory/bulk-update — items is invalid or empty. | Send a non-empty items array with {sku, stock}. |
not-found | 404 | GET /inventory/items/{sku} or GET /inventory/location/{id}/product/{sku} — no inventory exists for that SKU/location. | Check the SKU and the locationId. |
not-found/location | 404 | PUT /inventory/location/{id}/product/{sku} — the location does not exist. | Check the locationId. |
forbidden/inventory-handler | 403 | PUT /inventory/location/{id}/product/{sku} — the SKU/location is under integration/channel management and does not allow manual editing. | Use the corresponding handler's sync flow instead of manual editing. |
Negative stock returns 500, not 400
Sending a negative stock value in PUT /inventory/location/{id}/product/{sku} or within POST /inventory/bulk-update currently produces a 500 with no structured business-error shape:
{ "code": "internal-error", "message": "Stock cannot be negative" }It is not a validation 400 — it is a generic JavaScript exception (Error('Stock cannot be negative')) that falls into the lambda's catch-all. Validate yourself that stock is ≥ 0 before sending it; don't rely on the API returning a structured 400 for this case today.
Reports (/inventory/reports)
| Code | HTTP | Cause |
|---|---|---|
bad-request/missing-type | 400 | reportType is missing. |
bad-request/invalid-report | 400 | reportType is not one of the five valid values. |
Transfers (/inventory/transfers)
Require inventory:transfer.
| Code | HTTP | Cause |
|---|---|---|
bad-request/missing-origin | 400 | originId is missing on create. |
bad-request/missing-destination | 400 | destinationId is missing on create. |
bad-request/missing-products | 400 | productsList is missing or empty on create. |
not-found | 404 | The transfer does not exist, on any action (approve, receive, cancel). |
No state machine — and no idempotency
approve, receive, and cancel do not validate the transfer's current state before applying the change: a transfer can be received twice (applying the stock movement twice) or cancelled after being received. Also, receive is not idempotent — resending the same request reapplies the stock movement. Don't blindly retry a POST .../receive after a timeout; check the transfer's state first.
Adjustments (/inventory/adjustments)
Require inventory:adjust.
| Code | HTTP | Cause |
|---|---|---|
bad-request/missing-location | 400 | locationId is missing on create. |
bad-request/missing-sku | 400 | sku is missing on create. |
bad-request/missing-quantity | 400 | newQuantity is missing on create. |
bad-request/missing-reason | 400 | reason is missing on create, or the reason is missing on reject. |
bad-request/invalid-reason | 400 | reason is not among the 11 values accepted by this endpoint (see below). |
not-found | 404 | The adjustment does not exist. |
'sale' is not a valid reason here, even though the model supports it
POST /inventory/adjustments accepts exactly these 11 reason values: count_correction, damage, theft, expired, found, return, supplier_error, restock, shrinkage, sample, other. The canonical InventoryAdjustment schema defines a twelfth value, sale ("sale — automatic decrement from an order"), but this public endpoint rejects it with bad-request/invalid-reason. sale is reserved for the internal automatic decrement triggered by an order — don't use it when creating a manual adjustment.
Counts (/inventory/counts)
Require inventory:count.
| Code | HTTP | Cause |
|---|---|---|
bad-request/missing-name | 400 | name is missing on create. |
bad-request/missing-location | 400 | locationId is missing on create. |
bad-request/missing-type | 400 | countType is missing on create. |
bad-request/invalid-items | 400 | PUT /inventory/counts/{id}/items — countItems is invalid. |
not-found | 404 | The count does not exist. |
Lots (/inventory/lots)
Require inventory:read. Read-only surface.
| Code | HTTP | Cause |
|---|---|---|
not-found | 404 | The lot does not exist. |
| — | 400 | Missing locationId on GET /inventory/lots/sku/{sku}/valuation (required; the literal code was not confirmed in this audit). |
External inventory handlers (/inventory/handlers)
Require inventory:manage.
Multi-tenant isolation gap in production
GET /inventory/handlers/{id} and POST /inventory/handlers/{id}/sync do not filter by tenantId in the version currently deployed to production. If another tenant's handlerId is guessed or leaked, it is possible to read or write their handler/sync record. The fix (both methods gain a required tenantId parameter) already exists merged into the development branch, but it is not yet deployed to production. If you expose handlerId in a client, treat it as a secret until the fix is live.
Locations domain errors (/locations/*)
Require the permission indicated on each endpoint (locations:read, locations:create, locations:update, or locations:delete — see Locations and Zones).
| Code | HTTP | Cause |
|---|---|---|
not-found | 404 | GET /locations/{id} — the location does not exist. The only endpoint in this domain with this literal code on a 404. |
bad-request/not-found | 404 | The rest of the ID lookups (places, set-default, delete) when the resource doesn't exist. |
bad-request/not-found | 400 | GET /locations/count when the service cannot resolve the count. |
bad-request/missing-name | 400 | name is missing when creating a location. |
bad-request/missing-type | 400 | type is missing when creating a location. |
bad-request/invalid-type | 400 | type is not in the valid enum (location or zone). |
bad-request/can't-create | 400 | The service could not create the location. |
bad-request/can't-update | 400 | The service could not update the location. |
bad-request/can't-delete | 400 | The service could not delete the location. |
bad-request/system-location | 400 | An attempt was made to delete a system location, or to remove its isDefault flag. |
bad-request/missing-id | 400 | PUT /locations/{id} called without the id route parameter. |
bad-request/missing-code, bad-request/missing-name, bad-request/missing-type | 400 | Required fields missing when creating a zone (code, name, type). |
bad-request/duplicate-code | 400 | A zone with that code already exists in the location (checked in memory, not via a database unique index). |
internal-server-error | 500 | Generic error from the locations lambda. |
'not-found' inconsistent within the same service
bad-request/not-found combines a bad-request/-prefixed code with a 404 status — contradictory by name, but that's how it behaves today. Branch your logic on the full (code, status) pair, don't assume bad-request/* always implies 400.
When to retry and when not to
| Status | Retry? | Notes |
|---|---|---|
400 validation | No | Fix the request per the code. |
401 missing tenant/auth | No | Check the Authorization header and your API key. |
403 permission/suspended tenant | No | Request the correct scope or resolve the tenant's billing status. |
404 not found | No | The resource does not exist in your tenant. |
500 internal (internal-error / internal-server-error) | Yes, with backoff | Often transient — but remember that negative stock also produces a 500 (see above), so a 500 is not always transient in this domain. |
Don't blindly retry writes with no state machine
POST /inventory/transfers/{id}/receive and the count/transfer/adjustment transitions have no state guard and are not idempotent. Before retrying a write after a timeout or a 500, check the resource's current state first (GET) to confirm whether the operation already applied — retrying blindly can duplicate the stock movement.
Log the code, not the message
Messages can change; codes are the stable surface to branch your integration logic on.
// Correct
if (error.code === "forbidden/inventory-handler") {
await notifyMerchant("This SKU is managed by an integration, not manually");
}
// Fragile
if (error.message.includes("handler")) { ... }