Skip to main content

Overview

Three endpoints support atomic multi-item inventory operations. Each accepts an items[] array and guarantees all-or-nothing behavior: every item writes in a single database transaction. If item 5 of 10 fails, items 0-4 are automatically rolled back. Each endpoint also accepts single-item (top-level) payloads for backward compatibility. All three need an API key with the inventory:write scope; the read operations at the end of this guide need inventory:read.

POST /v1/inventory/receive

When to use

Use for non-PO standalone receipts — opening stock loads, vendor samples, or inventory counts that don’t correspond to a purchase order. Each item establishes its own FIFO cost layer and posts its own journal entry in the same transaction: debit to inventory, credit to the entity’s inventory adjustments suspense account. Pass offset_account_id at the top level of the body to credit a different account for the whole batch. For PO-linked receipts use POST /v1/purchase-orders/{id}/receive.

Multi-item atomic receive

Response shape

The journal_entry_id on each transaction links the receipt to its ledger entry.

Required fields per item

The single-item form puts product_id, location_id, quantity, unit_cost and notes at the top level of the body instead of inside items[].

Validation error shape (400)

If any item fails upfront validation, no items are written and the response includes exact param paths:

Products that cannot be received (422)

Receive, adjustments and transfers all refuse a product that does not keep stock: a kit, a service, a variant parent, a box, or any product with inventory tracking turned off. The call returns 422 product_not_receivable with one entry per refused item in errors[], each carrying the index, the product_id and a hint; an item whose product does not exist is reported with the code product_not_found. Nothing is written.

If the batch fails after validation

A failure inside the transaction rolls back every item. A receive failure returns receive_failed with items_completed_before_failure, an informational count only, since all items are reverted. The batch also fails and rolls back if the entity has no inventory adjustments suspense account and you did not pass offset_account_id, because the entry has nothing to balance against.

POST /v1/inventory/adjustments

When to use

Use for cycle-count corrections, shrinkage write-downs, and inventory revaluations. A GL journal entry fires per item inside the same database transaction. Positive deltas (adding stock) should carry a unit_cost to establish a new FIFO layer; if you leave it out, Arcus uses the product’s existing average cost and refuses the item with unit_cost_required only when the product has no cost basis anywhere. Negative deltas consume existing FIFO layers automatically and calculate COGS from the oldest layers. A negative delta cannot take a balance below zero: it is refused with negative_balance_not_allowed unless the item sets allow_negative, which needs the inventory.allow_negative permission.

Multi-item atomic adjustment

Response shape

Required fields per item

GL behavior

Each adjustment item creates its own journal entry (DR/CR balanced to the cent):
  • Positive delta (add stock): DR Inventory Asset, CR Inventory Adjustment/Suspense. Amount = quantity_delta * unit_cost.
  • Negative delta (remove stock): DR Inventory Adjustment/Shrinkage, CR Inventory Asset. Amount = FIFO COGS (oldest layers consumed first).
The side of the entry opposite inventory is chosen from the reason text: words like damage, theft, loss, shrinkage or spoilage post to shrinkage; cycle count or variance posts to count variance; obsolete, write-off or expired posts to inventory write-downs; revaluation or cost correction posts to revaluation; found stock, overage, recovered, data error or correction posts to overage income on an addition and to shrinkage on a removal; and a reason that matches none of these posts to the inventory adjustments suspense account. Underscores count as spaces, so cycle_count and found_stock match. The journal_entry_id on each item links the transaction to the GL record. gl_amount is positive for additions, negative for removals.

Serialized product adjustments

For serialized products, include matching serial arrays whose length equals abs(quantity_delta):

POST /v1/inventory/transfers

When to use

Use to move inventory between locations within the same entity (internal transfer) or between entities of the same organization (intercompany transfer, by adding entity_to_id). The call creates the transfer in draft status and moves no stock. Submit it with POST /v1/transfers/{id}/submit: an internal transfer ships immediately and posts its entry, and an intercompany transfer goes to pending approval first (approve, ship and receive follow on /v1/transfers/{id}/approve, /ship and /receive).

Multi-item atomic transfer

Response shape

availability_warnings lists every line whose quantity exceeds what the source location has available at the moment you create the draft (each entry has transfer_item_id, product_id, requested_qty, available_qty and shortfall). It does not block the draft, because you may be planning for stock that is still arriving. The block comes later: submitting or shipping a short line is refused with 400 insufficient_stock.

Required fields

A missing top-level field returns 400 with the code missing_param and the field in param; item problems return 400 multi_item_validation_failed with the same errors[] shape as the other two endpoints.

Field name aliases

Both naming conventions are accepted:

GL lifecycle


Atomicity guarantee

All three endpoints wrap their items[] loop in a single pool.connect() + BEGIN/COMMIT/ROLLBACK transaction:
  1. All items validate upfront (before BEGIN).
  2. BEGIN starts.
  3. Each item writes inside the transaction.
  4. Any item failure triggers ROLLBACK — every item reverts.
  5. COMMIT fires only after all items succeed.
When an adjustment item fails inside the transaction, the response includes failed_item_index and items_completed_before_failure so you know which item triggered the rollback. A receive failure carries items_completed_before_failure only. All items are reverted regardless of that count; it is informational only.

Idempotency

All three endpoints honor the Idempotency-Key header. Replaying the same key within 24 hours returns the original response without re-applying the operation, and reusing a key with a different body is refused with 409 idempotency_key_collision. Use a UUID or a deterministic key tied to your batch (e.g. receive-batch-${date}-${batchId}).
  • POST /v1/purchase-orders/{id}/receive — PO-linked receipts (triggers GRNI GL, matches against PO lines). Needs the purchasing:write scope and a receiving location, sent as the X-Location-Id header or location_id in the body. Partial receipts are supported; a receipt past the entity’s over-receipt tolerance is refused with 400 over_receipt_blocked, and an identical retry inside a short window returns the original receipt with idempotent: true.
  • GET /v1/inventory/balances — on-hand, allocated and available quantity per product and location, including products with zero on hand. Filter with product_id, location_id, variant_id or low_stock_only; paginate with page and per_page (default 25).
  • GET /v1/inventory/transactions — movement history, newest first, with the quantity change, the on-hand before and after, and the source document. Filter by product_id, location_id, type, date_after or date_before.
  • GET /v1/inventory/fifo-layers — cost layers for one product in FIFO order, oldest first. product_id is required; location_id narrows it.
  • POST /v1/inventory/serials — bulk enroll up to 500 serial numbers with status available (no on-hand change). A duplicate serial is skipped and reported in the per-row errors[] of a partial-success 200 response rather than failing the call. Enrolling serials never changes on-hand quantity, so receive the stock separately.