Stock
Base path: https://api.revenexx.com/v1/inventories. Four entities: locations, stock_levels, stock_movements, reservations.
The domain rule
Every stock change is a movement. on_hand and reserved are never written by an edit — they are the running total of the movements ledger, and only the booking routes move them. A PUT on a stock row that sends on_hand has those keys dropped, and a body that carries nothing else answers 422.
That is why there is no editable quantity field anywhere in this app. A correction is another booking, so a number can never silently differ from yesterday's.
Availability — the call to know
curl -X POST https://api.revenexx.com/v1/inventories/availability \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"items":[{"sku":"ACME-4711-BLK","quantity":5},{"sku":"ACME-9001","quantity":2}]}'
A batch call. Each item comes back with on_hand, reserved, the derived available (their difference), a per-location breakdown, and orderable — whether that much can be promised at this moment.
An item this app has never seen is not an error: it answers tracked: false, and the storefront decides whether an untracked item sells freely. At most 200 items per call; naming none is a 400.
Locations
curl -X POST https://api.revenexx.com/v1/inventories/locations \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"code":"leipzig","name":"Leipzig warehouse","type":"warehouse","priority":20}'
type is warehouse, store (a shop floor a click-and-collect order draws on), dropship (a supplier whose stock this row only tracks) or virtual (pre-orders, consignment, a quarantine shelf).
code and name are required, and code is unique per tenant. A second insert, or an update that moves a row onto a sibling's code, answers 409. A location also carries an external_id for its source system; see Source fields.
Two columns are not decoration:
prioritydecides where a reservation is served from. Nothing branches ontype.enableddefaults totrue, so a location created for a warehouse that has not opened yet starts being offered by availability and reserve immediately.
A new location starts empty, and creating one moves nothing. Stock arrives through a receipt, or is transferred by two adjustments — one negative at the old location, one positive here.
POST /v1/inventories/locations/defaults seeds the main warehouse.
| Route | What it does |
|---|---|
GET /v1/inventories/locations/{id} | Reads one location by its row id. Stock rows, bookings and reservations carry only location_id, so this turns that id back into a place. |
PUT /v1/inventories/locations/{id} | Partial update: send the fields that change. After a change to code, the old value is refused in two places. Receive, adjust, restock and the reserve fallback answer 400 with the message unknown location '<code>'; the error's code is bad_request. POST /v1/inventories/availability answers 404 with the code unknown_location, and its message reads unknown location '<code>' — no location carries this code. The message wording is the same in both (unknown location, with a space); only the 404's machine-readable code uses the underscore. Reserve ignores an unknown ship_to.location_code. If the renamed location is the default_location_code setting, calls that send no location_code fail too. |
DELETE /v1/inventories/locations/{id} | Refuses with 409 location_in_use while stock is on hand at the location or a reservation there is still active. The counts sit under details: {location_code, stock_levels, reservations}. Ledger history never blocks it. |
A delete is not reversible. It also removes the location's zero-balance stock rows, with their reorder_point, availability_code and source fields. To get past the 409, release or commit the active reservations first, then adjust what is left to zero (an adjustment needs a reason while movement_reason_required is adjustments, the default). An adjustment that would take on_hand below reserved answers 422 unless allow_negative_stock is on. A location that is only out of service should be disabled with PUT /v1/inventories/locations/{id} and enabled: false instead: every stock row, booking and reservation stays, and the location comes back when it is enabled again. The ledger keeps the bookings of a deleted location, and they keep its id on purpose.
Stock levels
A stock row is an item at a location: location_id plus product_id and/or sku, its reorder_point and its metadata.
curl "https://api.revenexx.com/v1/inventories/stock?limit=50" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..."
available and below_reorder are list filters too (exact match). ?below_reorder=true lists the rows at or below their own reorder_point (above 0). It does not apply reorder_point_default: while reorder alerts are on, the reorder alerts list is the complete one.
You mostly do not need POST /v1/inventories/stock at all — every stock call creates the row it is missing. The row is born at zero and on_hand/reserved are not accepted, so an opening balance is a receipt, not a field.
A second row for an item a location already tracks answers 409: no unique index enforces one row per item per location, so that row would split the item's balance across two rows the write routes cannot tell apart.
| Route | What it does |
|---|---|
GET /v1/inventories/stock/{id} | Reads one item at one location, with on_hand, reserved, available and below_reorder. It does not show how the balance got there: list the movements for that location and item for that. |
PUT /v1/inventories/stock/{id} | Partial update of the row's metadata and never its balance. Writable: reorder_point, metadata, availability_code and expected_at. on_hand and reserved are dropped, and a body with nothing else answers 422. An unknown availability_code answers 400, and a retired one 409 availability_state_inactive. Changing an item's location, product or SKU through this route is not supported: the API accepts it today, but it carries the balance along without a booking and does not check for an existing row, so it can split one item's balance across two rows. To move stock, book a negative adjustment at the old row and a receipt or positive adjustment at the new one. |
DELETE /v1/inventories/stock/{id} | Stops tracking the item at that location. Refuses with 409 stock_row_in_use while the row holds on_hand or an active reservation for that item at that location. The counts sit under details: {stock_level_id, on_hand, reservations}. |
A delete ends tracking; it does not move stock. Release or commit the active reservations first, then adjust the balance to zero with POST /v1/inventories/stock/{id}/adjust and a reason, which books the movement, so the stock leaves through the ledger. The same 422 applies here if the adjustment would take on_hand below reserved. A later receipt for the same item creates a new row at zero, and the deleted balance is not rebuilt from the ledger. A row at zero does not block the delete, and the bookings for it stay.
Availability states
A stock row can carry an availability_code, the code for what the source system says about the article, and an expected_at for when it is due back. Each tenant keeps its own list of codes, and each code has an orderable flag that says whether an item in that state can still be ordered, so a storefront can show or hide the order button without a list of its own.
GET /v1/inventories/availability-stateslists the set. This list returns empty for a tenant that has no states yet. Reading the vocabulary (GET /v1/inventories/vocabularies/availability-states), a stock write that carries anavailability_code, orPOST /v1/inventories/locations/defaultsseeds the shipped set into it.POST /v1/inventories/availability-statesadds one.codeandtitleare required, and acodethe tenant already has answers409.
| Route | What it does |
|---|---|
GET /v1/inventories/availability-states/{id} | Reads one state by its row id. A stock row stores the code, not this id, so a caller holding a code filters the list with ?code= instead. |
PUT /v1/inventories/availability-states/{id} | Partial update: send the fields that change. The readable fields (title, labels, description, descriptions, tone, position) reach every screen at once. |
DELETE /v1/inventories/availability-states/{id} | Removes the state. See below. |
DELETE does not check stock rows. A row keeps the code it carries, and the vocabulary no longer resolves it. A storefront that was hiding the order button for that state stops knowing to, so move the rows first with PUT /v1/inventories/stock/{id}, or keep the state and give it a title that says it is retired. Deleting every state is not final: the next read of GET /v1/inventories/vocabularies/availability-states seeds the shipped set back into the empty table. That is a known exception to the rule that a GET only reads: this one writes rows when the table is empty, and so does a stock write that carries an availability_code. The stock call that answers availability reads the states without seeding.
On PUT, orderable is the consequential field. Setting it to false stops a stock row in that state from serving an order on the next read, without touching any stock row or booking a movement. An article that another location can still supply without that state stays orderable. active: false retires a state instead: rows that already carry it keep it and its title still resolves, but no stock row can be given it (409 availability_state_inactive), and the vocabulary lists it under inactive. The default state cannot be retired (409 default_must_stay_active). Leave code alone: stock rows store it, so a new value orphans them.
Source fields
A row that a connected system owns, such as an ERP's warehouse list, carries four nullable fields that say where it came from. The owning system is the source. Locations and stock rows have them; movements do not.
Every read returns them, and each is an exact-match list filter, for example GET /v1/inventories/locations?external_id=LAGER-01. The create and update routes do not list them (as of 2026-10), so read them and filter on them, and join on code and sku until a route writes them.
| Field | Meaning |
|---|---|
external_id | The key the row has in the source, stored as the source gave it. On a location it is unique per tenant, and a duplicate is a 409. On a stock row it is informational (as of 2026-10): a stock row is identified by its location and its item. |
external_refs | Every other system that knows the row, keyed by system name, such as {"business-central": "LAGER-01"}. |
source_synced_at | When the source last confirmed the row. An edit made here does not touch it. Null until a source has confirmed the row. |
source_data | What the source said about the row, kept as it said it. It carries the ETag a write-back must return in If-Match, and the source fields this app does not model, which would otherwise be lost on the first edit here. |
The booking routes
| Route | Books | Note |
|---|---|---|
POST /v1/inventories/receive | +on_hand, inbound | Goods in. Answers 201. A reason is optional unless movement_reason_required is all. |
POST /v1/inventories/adjust | ±on_hand, adjustment | Batch correction — stocktake, breakage, shrinkage. Quantities are signed. A reason is mandatory unless movement_reason_required is none. |
POST /v1/inventories/stock/{id}/adjust | ±on_hand, adjustment | The row-scoped twin: the row already knows its location and item, so you owe a signed delta and a reason. |
POST /v1/inventories/restock | +on_hand | Returns back to stock. Answers 200, because it may legitimately create nothing. |
curl -X POST https://api.revenexx.com/v1/inventories/receive \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"sku":"ACME-4711-BLK","quantity":120,"location_code":"leipzig","reason":"DN 88213"}'
The receiving location is the caller's location_code, else the default_location_code setting.
Whether a return rejoins sellable stock follows restock_on_return_default, overridable per call with restock. When the answer is no, the response says restocked: false and nothing moves — there is no movement to book, because no stock moved.
Reservations: reserve → commit | release
curl -X POST https://api.revenexx.com/v1/inventories/reserve \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"order_ref":"ORD-000123","items":[{"sku":"ACME-4711-BLK","quantity":6}]}'
A hold against an order_ref. The whole call is planned before anything is written, so a reservation that cannot be satisfied changes nothing.
Which location serves an item is not yours to choose. The tenant's allocation_strategy decides:
| Strategy | Picks |
|---|---|
priority | Walks the enabled locations by their priority. |
nearest | Matches ship_to against a location's country. |
single_location | One location for the whole order. |
backorder_policy decides what happens when none can serve: refuse with 422, or reserve anyway and let availability go negative. expires_at defaults from reservation_ttl_minutes.
Reserving raises reserved and does not move on_hand — the stock is still on the shelf.
curl -X POST https://api.revenexx.com/v1/inventories/commit \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"order_ref":"ORD-000123"}'
Commit when the goods leave the building, and not before. It lowers both numbers on each row and writes one shipment booking per hold, with a signed negative quantity.
order_ref and every hold still active on it — so a part shipment means reserving the parts separately in the first place.committed is final. Goods coming back are a restock (a new receipt), never an undo of a commit.POST /v1/inventories/release is the cancellation end: it ends every hold still active on an order_ref, gives the stock back and writes a release booking for each.
The two answer an empty order differently, on purpose. release is idempotent and answers 200 with released: 0 — cancelling twice is harmless. commit answers 422 — it means the hold was already released or already shipped, and shipping twice is not harmless.
POST /v1/inventories/reservations/sweep is the expiry sweeper, also run on a schedule every 15 minutes. It releases reservations past their expires_at and — once reservation_ttl_minutes is above 0 — reservations older than that lifetime which never carried a deadline. Idempotent: a second run finds nothing.
GET /v1/inventories/reservations/{id} reads one hold: the location it was allocated to, its expires_at, and in metadata.backordered how much of it no stock covered. The id is for reading only, and no route takes it. Reserve, release and commit act on the whole order_ref, so no route releases one line of an order on its own. The expiry sweep is the exception to the order-wide rule: it expires each hold by its own expires_at.
The movements ledger
curl "https://api.revenexx.com/v1/inventories/movements?order=created_at.desc&limit=50" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..."
Every stock change this app has ever made is a booking row: a receipt, a correction, a hold, a release, a shipment, a return. That is what lets one list be an audit trail and an event feed at the same time — these are the rows the stock_movement.created event carries, so a consumer that missed an event catches up by paging here.
Append-only. No update, no delete: a correction is another booking. order=created_at.desc is the feed order.
GET /v1/inventories/movements/{id} reads one booking. quantity is signed, and the sign follows the type. A booking carries no running total, so to see how a balance got to its value, list the ledger for that location and item.
Reorder alerts
curl https://api.revenexx.com/v1/inventories/reorder-alerts \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..."
The replenishment worklist, computed on read so it is never stale. A row alerts when available (on_hand − reserved) has fallen to or below its own reorder_point, or the reorder_point_default setting when it carries none. A point of 0 never alerts.
It answers enabled: false with an empty list when reorder_alert_enabled is off — a tenant replenishing from an ERP should not be told twice.
Vocabularies
Four vocabularies name the values this app accepts: availability-states, location-types, movement-types and reservation-statuses. A UI reads them instead of keeping its own table.
| Route | What it does |
|---|---|
GET /v1/inventories/vocabularies | Lists the names and titles, without the values. |
GET /v1/inventories/vocabularies/{name} | Returns one vocabulary with every value, each with its title, description and badge tone. An unknown name answers 404. |
curl https://api.revenexx.com/v1/inventories/vocabularies/movement-types \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..."
source says who owns the set. For location-types, movement-types and reservation-statuses it is schema: the values are the ones the app enforces, so the list served is the list enforced. For availability-states it is table: the tenant's own rows, so a merchant can add a code without a release. Only those table values carry orderable. Every vocabulary reports closed: true, but for availability-states that means the list is exhaustive when it is read, not fixed: codes a tenant adds appear in it, and a retired code stays in it under inactive. A client therefore re-reads the vocabulary when it meets a code it does not know, and does not treat that code as stale data.
Where it fits in an order
POST /v1/carts/{id}/order ─ may reserve, per convert_reserves_stock
POST /v1/inventories/reserve ─ hold the stock
POST /v1/orders/{id}/ship ─ book the goods out on the order
POST /v1/inventories/commit ─ consume the hold: −on_hand −reserved
POST /v1/inventories/release ─ instead, if the order is cancelled
POST /v1/inventories/restock ─ after a completed return
None of those calls each other. The orchestration is yours.