Inventory Operations
The Inventory guide covers products, variants, locations, purchase order basics, transfers, adjustments, standard costs and ledger settings. This guide covers the operational endpoints around them: counting stock, forecasting and reordering, allocating freight to received goods, production runs, lot and expiry tracking, receiving, the supply chain map and reports. In the app they sit behind the Inventory tabs Counts, Demand Forecast, Landed COGS, Production & BOMs, Supply Chain and Reports.
Access and conventions
- Commerce add-on. Every
/api/v1/inventory/...route and/api/v1/purchase-ordersrequires the Commerce add-on. Without it the API answers403with codeBILLING_003. It is unlocked during an active trial, on demo workspaces, when the add-on is enabled on the business, or through a firm plan that bundles it. - Permissions.
GETneeds read,POSTandPUTneed write,DELETEneeds delete. That includes the side-effect-freePOSTcalls (preview-post,cash-impact, runcalculate), so aread_onlytoken can only callGETroutes (see Authentication). - Envelope and status codes. Responses are
{"success": true, "data": ...}. EveryPOSTreturns201, including action routes such as/post,/cancel,/completeand/recompute;GET,PUTandDELETEreturn200. Money is integer cents. Quantities on counts, forecasts and reorder suggestions are decimals. Schema validation failures are400GEN_002. - Inventory mode. A business is
finished_goods(default) orcpg, set with theinventory_modefield ofPUT /api/v1/businesses/{business_id}. The app hides Supply Chain, Production & BOMs and Landed COGS infinished_goods. The API does not check the mode: every route in this guide responds in either mode. - Retries. Send an
Idempotency-KeyonPOSTandPUTrequests you may retry (Error Handling). Countpost, runcompleteandreorder/draftcreate records, so do not repeat them blindly after a timeout.
Cycle counts
A count is a walk through one location: you record what you physically see, the API stores the variance against system on-hand, and posting turns each variance into a manual inventory adjustment. Statuses: draft, in_progress, posted, cancelled. Adding a line or recording a quantity moves a draft count to in_progress.
curl -X POST "https://api.ondayzero.com/api/v1/inventory/counts" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"location_id": "LOCATION_UUID", "notes": "Q3 warehouse count"}'location_id is required and must be an active (not archived) location of the business; notes is optional (up to 2,000 characters). The count is numbered CC-YYYYMMDD-NNN (per business, per day) and returned with id, count_number, status (draft), location_id, notes, started_at, posted_at, counted_by_user_id, posted_by_user_id, variance_line_count and lines.
GET /api/v1/inventory/counts?status=in_progress&limit=50 lists recent counts with their lines as {"items": [...]} (status is one of the four values, anything else is 400; limit 1-200, default 50; no cursor). GET /api/v1/inventory/counts/{count_id} returns one count.
Add or scan a line
curl -X POST "https://api.ondayzero.com/api/v1/inventory/counts/COUNT_UUID/lines" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"barcode": "0123456789012", "counted_quantity": 94, "reason": "count_variance"}'{
"success": true,
"data": {
"id": "019ab37c-c057-7000-8000-0000000000a1",
"count_id": "019ab37c-c057-7000-8000-000000000001",
"variant_id": "019ab37c-c057-7000-8000-0000000000b1",
"variant_name": "Widget Pro - Large",
"sku": "WP-LG-001",
"barcode": "0123456789012",
"expected_quantity": 100.0,
"counted_quantity": 94.0,
"variance": -6.0,
"reason": "count_variance",
"notes": null,
"adjustment_id": null
}
}| Field | Rules |
|---|---|
variant_id, barcode, sku |
Send at least one (400 VALIDATION_002 if none; an unknown item is 404 NOT_FOUND_011). variant_id wins, then barcode, then sku |
counted_quantity |
Optional. Without it the line is added with only expected_quantity, the variant's on-hand at the count's location at that moment |
reason |
Optional, up to 64 characters |
notes |
Optional, up to 500 characters |
Scanning a variant that is already on the count updates that line: it sets the quantity, it does not add to it. variance is counted_quantity - expected_quantity.
PUT /api/v1/inventory/counts/COUNT_UUID/lines/LINE_UUID records or corrects a line: {"counted_quantity": 94, "reason": "damage", "notes": "Two cases crushed"} (counted_quantity is required and >= 0). Lines can only change while the count is draft or in_progress; otherwise 409.
Preview and post
POST /api/v1/inventory/counts/COUNT_UUID/preview-post (no body) changes nothing. It returns valuation (unit_cost), categories, je_lines and a plan_hash:
categories: one row per inventory category (raw_material,packaging,finished_goodoruncategorized) withbook_cents,counted_cents,delta_cents(counted minus book),ledger_idandline_count. Book and counted values use each variant's catalog unit cost.je_lines: the journal lines posting would write (ledger_id,debit_credit,amountin cents). Found stock is valued at unit cost; a shortfall is valued from the oldest cost lots first, the rest at unit cost. Lines go to the category ledgers when your ledger settings map them, otherwise to the system Inventory and Inventory Adjustments ledgers.
curl -X POST "https://api.ondayzero.com/api/v1/inventory/counts/COUNT_UUID/post" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"plan_hash": "PLAN_HASH_FROM_PREVIEW"}'The body is optional. With plan_hash, posting is refused with 400 VALIDATION_640 if the lines or their values changed since the preview; preview again and send the new hash. posted_by_user_id optionally stamps the count record. Posting creates one manual adjustment per line that has a counted_quantity and a non-zero variance, at the count's location, using the same valuation and journal posting as POST /inventory/adjustments (Inventory); each line's adjustment_id is filled in and the count becomes posted. Lines without a counted quantity are skipped. Adjustments take whole units, so a fractional variance is truncated toward zero. A line reason that matches a canonical adjustment reason (shrinkage, damage, expired, count_variance, theft, transfer, return, receipt_correction, found, other) is bucketed under it in reports; the default is count_variance.
Posting a count that is already posted or cancelled answers 409. POST /api/v1/inventory/counts/COUNT_UUID/cancel abandons a count without posting (409 if already posted).
Demand forecast
A background job recomputes a weekly demand forecast per SKU every night, and derives a service-level safety stock, a reorder point and an order-up-to level. The reads return the stored result; POST /api/v1/inventory/forecast/recompute forces a refresh.
The forecast uses up to 104 weeks (Sunday-aligned) of Shopify order lines, excluding refunded and voided orders (see Shopify), so a variant that never sold through a connected store has no forecast. Horizon: 13 weeks. Service level: 95%. Lead time: the default vendor's effective lead time, or 14 days without a default vendor.
curl "https://api.ondayzero.com/api/v1/inventory/forecast/skus" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"Returns {"items": [...], "total": N} sorted by avg_weekly_demand descending (no pagination). Each item:
| Field | Meaning |
|---|---|
variant_id, sku, variant_name |
The SKU (location_id is always null: forecasts are per business, not per location) |
method |
holt_winters, croston, ewma or naive (no demand in the window). The method is chosen by a backtest on the most recent weeks |
demand_class |
smooth, erratic, intermittent, lumpy or new |
horizon_weeks, weekly_forecast |
Forecast length and one value per week |
avg_weekly_demand, sigma_weekly |
Historical weekly mean and the error spread used for safety stock |
service_level, lead_time_days |
Inputs to the safety stock |
safety_stock, forecast_reorder_point, order_up_to |
Derived levels, in units |
accuracy_mape, accuracy_bias |
Backtest error (percent) and bias; null without enough history |
generated_at |
When it was computed |
GET /api/v1/inventory/forecast/skus/{variant_id} returns {variant_id, sku, variant_name, forecast, history}: history is the last 26 weeks of actual demand as {week_start, units} and forecast is null when none exists yet. A variant of another business is 404.
POST /api/v1/inventory/forecast/recompute (no body) replaces all stored forecasts for the business and answers {"status": "ok", "forecasted_skus": 42, "by_method": {"ewma": 30, "holt_winters": 8, "croston": 4}}.
Reorder
curl "https://api.ondayzero.com/api/v1/inventory/reorder/suggestions" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"A line exists for each non-archived variant and location whose available quantity is at or below a positive reorder_point (set per location in Inventory). Lines are grouped by the variant's default_vendor_id; variants without one are returned in unassigned so you can set a vendor first. The result is cached for about a minute.
{
"success": true,
"data": {
"suggestions": [
{
"vendor_id": "019ab37c-c057-7000-8000-0000000000c1",
"vendor_name": "Acme Packaging",
"lead_time_days": 14,
"payment_term_days": 0,
"discount_percent": null,
"discount_days": null,
"estimated_total_cents": 120000,
"lines": [
{
"variant_id": "019ab37c-c057-7000-8000-0000000000b1",
"product_id": "019ab37c-c057-7000-8000-0000000000d1",
"product_name": "Widget Pro",
"variant_name": "Widget Pro - Large",
"sku": "WP-LG-001",
"location_id": "019ab37c-c057-7000-8000-0000000000e1",
"on_hand": 12.0,
"available": 10.0,
"reorder_point": 25.0,
"reorder_quantity": 100.0,
"daily_velocity": 1.4,
"lead_time_days": 14,
"days_of_cover": 8.6,
"suggested_quantity": 100,
"unit_cost_cents": 1200,
"forecast_method": "ewma",
"demand_class": "smooth",
"forecast_weekly_demand": 9.8,
"safety_stock": 6.2,
"forecast_reorder_point": 25.4,
"order_up_to": 98.0,
"forecast_accuracy_mape": 18.5
}
]
}
],
"unassigned": [],
"total_lines": 1
}
}daily_velocity is units sold per day over the last 30 days (Shopify order lines); days_of_cover is on_hand divided by it (null without sales). suggested_quantity is a whole number, at least 1, rounded up. With a forecast it is order_up_to - available; without one it is lead_time_days + 7 days of velocity; in both cases never below the deficit (reorder_point - available) or reorder_quantity. The forecast_*, demand_class, safety_stock and order_up_to fields are null until a forecast exists.
Draft purchase orders
curl -X POST "https://api.ondayzero.com/api/v1/inventory/reorder/draft" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"vendor_ids": ["019ab37c-c057-7000-8000-0000000000c1"]}'The body is optional; vendor_ids limits drafting to those vendors (omit it to draft every group). It creates one draft purchase order per vendor, named Auto-reorder YYYY-MM-DD with number PO-REORDER-YYYYMMDD-NNN, with one line per suggestion at the suggested quantity and the variant's unit cost. Response:
{
"success": true,
"data": {
"drafts": [
{
"order_id": "019ab37c-c057-7000-8000-0000000000f1",
"po_number": "PO-REORDER-20261014-001",
"vendor_id": "019ab37c-c057-7000-8000-0000000000c1",
"vendor_name": "Acme Packaging",
"line_count": 1,
"estimated_total_cents": 120000
}
],
"drafted_count": 1,
"skipped_unassigned": 0
}
}skipped_unassigned counts the suggestion lines without a default vendor. Continue with the purchase order lifecycle in Inventory.
Transfer suggestions
GET /api/v1/inventory/transfer-suggestions/suggestions?surplus_pct=0.25&limit=50 proposes moving stock of one variant from a location with a surplus to one that is short. Only locations with a positive reorder point take part. A receiver has available at or below its reorder point; a donor qualifies when its available exceeds its reorder point times 1 + surplus_pct (surplus_pct 0 to 2, default 0.25). limit is 1-200 (default 50) and is applied after pairing. Each suggestion has variant_id, variant_name, sku, from_location_id, to_location_id, from_location_name, to_location_name, donor_available, donor_reorder_point, receiver_on_hand (the receiver's available quantity), receiver_reorder_point and a whole-unit suggested_quantity, in {"suggestions": [...], "total": N}. Create the move with POST /api/v1/inventory/transfers (Inventory). Cached for about a minute.
Cash impact
Before placing orders, model what they do to the cash forecast. The API runs the live forecast with and without your proposed purchase orders and returns the difference. Nothing is created or saved.
curl -X POST "https://api.ondayzero.com/api/v1/inventory/reorder/cash-impact" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{
"weeks": 13,
"proposed_pos": [
{"amount_cents": 120000, "week_number": 3, "label": "Acme restock"},
{"amount_cents": 45000, "expected_pay_date": "2026-11-20"}
]
}'| Field | Rules |
|---|---|
weeks |
1-52, default 13 |
proposed_pos[].amount_cents |
Required, greater than 0 |
proposed_pos[].week_number |
1-52, the forecast week the cash leaves (default 1, clamped to weeks). Week 1 starts on the Sunday on or before today |
proposed_pos[].expected_pay_date |
ISO date; overrides week_number. A past date counts in week 1; a date outside the horizon or not in ISO format adds nothing |
proposed_pos[].variant_id, label |
Optional (label up to 200 characters) |
The response has proposed_total_cents, proposed_po_count, weeks, baseline_ending_cash_cents, scenario_ending_cash_cents, ending_cash_delta_cents, baseline_lowest_cash_cents, scenario_lowest_cash_cents, lowest_cash_delta_cents, baseline_lowest_cash_week, scenario_lowest_cash_week, week_deltas (per week: week, start_date, baseline_ending_cash_cents, scenario_ending_cash_cents, delta_cents) and a one-line headline, which warns when the reorder pushes projected cash negative.
Landed cost allocation
A standalone freight, duty or handling bill can be spread across the received purchase orders it relates to. Each allocation raises the matching cost layer (freight, duties or handling) on the latest cost snapshot of every variant on the purchase order. That feeds GET /inventory/cost-history and GET /inventory/reports/margin-by-sku. It does not post a journal entry or change cost lots, and a variant with no cost snapshot yet is skipped.
GET /api/v1/inventory/landed-cost-allocations/orders?limit=200 lists the purchase orders that can absorb cost, those in received or completed status (limit 1-500): inventory_order_id, po_number, name, status, vendor_name, total_cents, total_units, variant_count.
curl -X PUT "https://api.ondayzero.com/api/v1/inventory/landed-cost-allocations/BILL_UUID" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{
"targets": [
{"inventory_order_id": "PO_UUID_1", "cost_layer": "freight", "amount_cents": 40000, "allocation_basis": "quantity"},
{"inventory_order_id": "PO_UUID_2", "cost_layer": "freight", "amount_cents": 25000, "allocation_basis": "value", "note": "Second container"}
]
}'PUT sets the complete allocation list for the bill: existing allocations are reversed first, then targets are applied, so repeating a request is safe and an empty targets clears everything.
| Target field | Rules |
|---|---|
inventory_order_id |
Required; a received or completed purchase order of the business |
cost_layer |
Required: freight, duties or handling |
amount_cents |
Required, greater than 0. The targets may not add up to more than the bill amount |
allocation_basis |
quantity (default): the same amount per unit across the order's line quantities. value: pro rata by line value (quantity times unit price), falling back to quantity when the order has no line value |
note |
Up to 255 characters |
GET /api/v1/inventory/landed-cost-allocations/{bill_id} and the PUT response return:
{
"success": true,
"data": {
"bill_id": "019ab37c-c057-7000-8000-000000000b01",
"bill_amount_cents": 70000,
"allocated_cents": 65000,
"unallocated_cents": 5000,
"items": [
{
"id": "019ab37c-c057-7000-8000-000000000a01",
"bill_id": "019ab37c-c057-7000-8000-000000000b01",
"inventory_order_id": "019ab37c-c057-7000-8000-000000000c01",
"po_number": "PO-2026-001",
"cost_layer": "freight",
"allocation_basis": "quantity",
"amount_cents": 40000,
"note": null,
"variant_count": 3,
"created_at": "2026-10-14T15:02:11Z"
},
{
"id": "019ab37c-c057-7000-8000-000000000a02",
"bill_id": "019ab37c-c057-7000-8000-000000000b01",
"inventory_order_id": "019ab37c-c057-7000-8000-000000000c02",
"po_number": "PO-2026-002",
"cost_layer": "freight",
"allocation_basis": "value",
"amount_cents": 25000,
"note": "Second container",
"variant_count": 2,
"created_at": "2026-10-14T15:02:11Z"
}
]
}
}DELETE /api/v1/inventory/landed-cost-allocations/{bill_id}/{allocation_id} removes one allocation and reverses exactly the per-unit cost it added.
Production: recipes and runs
A recipe (bill of materials) describes how a finished product is made; a run applies it to a quantity and computes the cost; completing the run consumes material cost lots and creates cost lots for the finished goods. Recipes and runs are cursor-paginated lists (Pagination).
curl -X POST "https://api.ondayzero.com/api/v1/inventory/production/recipes" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{
"name": "Granola 12oz",
"output_unit_name": "bag",
"output_unit_weight": 12,
"output_unit_weight_unit": "oz",
"materials": [
{"category": "raw_material", "name": "Oats", "variant_id": "OATS_VARIANT_UUID",
"quantity_type": "fixed_per_unit", "quantity_value": 2, "quantity_unit": "units",
"cost_per_unit": 35, "cost_unit": "per_unit"},
{"category": "packaging", "name": "Pouch", "variant_id": "POUCH_VARIANT_UUID",
"quantity_type": "fixed_per_unit", "quantity_value": 1, "quantity_unit": "units",
"cost_per_unit": 22, "cost_unit": "per_unit"}
],
"output_variants": [
{"name": "Granola 12oz", "variant_id": "FINISHED_VARIANT_UUID", "allocation_pct": 100}
],
"processing_steps": [
{"name": "Co-packer", "step_type": "Co-Packing", "allocation_type": "all_units",
"cost_per_unit": 4, "cost_unit": "per_oz"}
]
}'Recipe fields: name (required, up to 255), description, output_unit_name (default unit), output_unit_weight (default 1, greater than 0), output_unit_weight_unit (default oz), conversion_params (optional object of custom unit conversions such as lbs_per_mt) and the three child lists.
| Child | Fields and values |
|---|---|
materials[] |
name, quantity_type (pct_of_weight, fixed_per_unit, per_n_units, fixed_per_run), quantity_value, quantity_unit (default units), yield_factor (0-1, default 1), cost_per_unit (cents), cost_unit (default per_unit; also per_lb, per_kg, per_mt, per_oz, per_gal), category (default raw_material; also inclusion, packaging, other), position, output_variant_id, variant_id, vendor_id |
output_variants[] |
name, allocation_pct (0-100), is_remainder (takes 100% minus the others), variant_id, position |
processing_steps[] |
name, step_type (label, default Processing), allocation_type (pct_of_total, by_output_variant, all_units), allocation_pct, output_variant_ids, cost_per_unit (cents), cost_unit, position, vendor_id |
Every material must reference an active catalog variant of the business through variant_id; a missing, archived or foreign variant, or the category custom, is a 400. The material name is replaced by the variant's name. Link output_variants[].variant_id to the finished-good variants that should receive cost lots when a run completes.
GET /api/v1/inventory/production/recipes and GET .../recipes/{recipe_id} read recipes. PUT .../recipes/{recipe_id} updates the scalar fields and replaces each child list you send (a list you omit is kept). DELETE soft-deletes. POST .../recipes/{recipe_id}/duplicate clones a recipe.
Runs
curl -X POST "https://api.ondayzero.com/api/v1/inventory/production/runs" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"recipe_id": "RECIPE_UUID", "name": "October batch", "total_units": 1000, "unit_sale_price": 499}'recipe_id and total_units (greater than 0) are required; unit_sale_price (cents, default 0) and name are optional. The run is created as draft with its cost_snapshot already computed (all money in cents):
{
"success": true,
"data": {
"id": "019ab37c-c057-7000-8000-000000000d01",
"recipe_id": "019ab37c-c057-7000-8000-000000000e01",
"name": "October batch",
"total_units": 1000,
"unit_sale_price": 499,
"status": "draft",
"completed_on": null,
"cost_snapshot": {
"materials_by_category": {"raw_material": 70000, "packaging": 22000},
"processing_steps_by_type": {"Co-Packing": 48000},
"total_cost": 140000,
"cost_per_unit": 140,
"sales_potential": 499000,
"gross_profit": 359000,
"gross_margin_pct": 71.94,
"total_weight": 750.0,
"total_weight_unit": "lb"
}
}
}The snapshot also carries material_line_items and step_line_items (per-line quantities and cost_cents). GET .../runs and GET .../runs/{run_id} read runs. PUT .../runs/{run_id} changes name, total_units or unit_sale_price and recalculates the snapshot. POST .../runs/{run_id}/calculate takes optional total_units and unit_sale_price overrides and returns the cost breakdown without saving anything.
Complete a run
curl -X POST "https://api.ondayzero.com/api/v1/inventory/production/runs/RUN_UUID/complete" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"completion_date": "2026-10-31", "tolling_cents": 40000, "tolling_bill_id": "BILL_UUID"}'The body and every field in it are optional. Completion consumes the material cost lots (oldest first, or earliest expiry first for products with tracks_expiration; any shortfall is costed from the recipe's catalog cost), creates a cost lot for each linked output variant at the blended cost per unit (materials, processing steps and tolling, split by allocation_pct) and marks the run completed with completed_on. Completion also takes stock-tracked components out of on-hand and adds the finished goods at location_id (the default location if omitted). A tracked component that is short refuses with 400 VALIDATION_890 before anything moves. A component with no stock history, or one whose recipe unit differs from its stock unit, keeps its on-hand and is listed in warnings. A run that is already completed answers 400.
| Field | Meaning |
|---|---|
completion_date |
Date of the production transfer entry (YYYY-MM-DD), default today |
location_id |
Location the components come out of and the finished goods go into; an archived one is 400 VALIDATION_891. Default: the business's default location |
tolling_cents |
Co-manufacturer conversion cost, in cents, to capitalize into finished goods |
tolling_bill_id, tolling_vendor_id |
The bill carrying the tolling charge and its vendor. Both must belong to the business and both require tolling_cents (otherwise 400 VALIDATION_716). The vendor defaults to the bill's vendor |
When the finished goods ledger is set in the inventory ledger settings, completion also posts one journal entry dated completion_date: debit finished goods for the total; credit the raw material and packaging ledgers for the material cost consumed; credit accrued inventory for processing steps; credit accrued inventory for tolling (or, with a posted tolling_bill_id, the ledger that bill debited most). A needed but unmapped ledger refuses the completion with 400 BUSINESS_167 before any lot moves. The response is the run plus journal_entry (id, entry_number, date, lines) and reason; without ledger settings journal_entry is null and reason says why. It also returns location_id (the location whose stock moved) and warnings (the items whose on-hand was left unchanged, and why).
Cancel a run
curl -X POST "https://api.ondayzero.com/api/v1/inventory/production/runs/RUN_UUID/cancel" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"reversal_date": "2026-11-02"}'The body and reversal_date are optional (default today). Cancelling a draft run only sets its status to cancelled. Cancelling a completed run undoes the completion exactly: the on-hand movements are reversed, the consumed units go back into the cost lots they came from, the output lots are removed, and the production transfer entry (if one was posted) is reversed on reversal_date. The run becomes cancelled, completed_on is cleared, and it can be completed again. Cancelling a run that is already cancelled changes nothing.
Cancelling is refused with 400 and nothing changed when:
- the goods the run made were already sold, used or moved (
VALIDATION_892; the message names each item). Record a stock adjustment instead. - the run was completed before completions were recorded (
VALIDATION_893). Record stock adjustments and reverse its journal entry instead.
The response is the run plus reversal_journal_entry_id (null if no entry was posted) and stock_rows_reversed.
Lots and expiry
Each receipt, production output and positive adjustment creates a cost lot with its remaining quantity and unit cost. A lot can carry lot_number, external_lot_id (an upstream id such as an ASN line) and expiration_date. Send them on the line items of a purchase order or shipment (all optional):
{"variant_id": "VARIANT_UUID", "quantity": 200, "unit_price": 450,
"lot_number": "L2026-1031", "external_lot_id": "ASN-88-1", "expiration_date": "2027-04-30"}lot_number and external_lot_id are up to 100 characters; expiration_date is YYYY-MM-DD (an invalid date is dropped without an error). A receipt creates one lot per variant, and a lot is only created when the line has a price or the variant has a unit cost; see Shipments and receiving for which lot data wins. For products with tracks_expiration: true (Inventory), sales, production and negative adjustments consume the earliest expiry first (FEFO), undated lots last; other products consume oldest first.
curl "https://api.ondayzero.com/api/v1/inventory/lots/expiring?within_days=30&include_expired=true&limit=200" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"Returns lots with remaining quantity whose expiration date is on or before today plus within_days (0-365, default 30; 0 returns lots that expired or expire today), earliest first. include_expired=false drops lots already past their date; limit is 1-1000 (default 200). It lists any lot with an expiration date, whether or not the product has tracks_expiration. Cached for about five minutes.
{
"success": true,
"data": {
"within_days": 30,
"total_remaining_value_cents": 90000,
"lots": [
{
"id": "019ab37c-c057-7000-8000-000000000f01",
"variant_id": "019ab37c-c057-7000-8000-0000000000b1",
"variant_name": "Widget Pro - Large",
"sku": "WP-LG-001",
"product_name": "Widget Pro",
"lot_number": "L2026-1031",
"external_lot_id": "ASN-88-1",
"expiration_date": "2026-10-31",
"days_until_expiry": 17,
"quantity_remaining": 200.0,
"unit_cost_cents": 450
}
]
}
}days_until_expiry is negative for lots already expired.
Shipments and receiving
Inventory introduces shipments. What matters when you integrate a warehouse or freight forwarder:
curl -X POST "https://api.ondayzero.com/api/v1/inventory/shipments" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{
"status": "received",
"location_id": "LOCATION_UUID",
"tracking_number": "1Z999AA10123456784",
"shipped_date": "2026-10-02T00:00:00Z",
"expected_arrival_date": "2026-10-09T00:00:00Z",
"line_items": [
{"variant_id": "VARIANT_UUID", "inventory_order_id": "PO_UUID", "quantity": 200,
"unit_price": 450, "lot_number": "L2026-1031", "expiration_date": "2027-04-30"}
],
"fees": [{"name": "Freight", "amount": 30000}]
}'- Status.
statusis a string, defaultdraft. The system acts onshippedandreceived; the other defined values arependingandcanceled. Dates are ISO 8601. - Lines and purchase orders. Put the purchase order id in each line's
inventory_order_id. A line quantity cannot exceed what is still unshipped on that PO line (400VALIDATION_010), and unknown or archived variants are404(VALIDATION_009). Only lines with aninventory_order_idthat matches a PO line are stored on the shipment. - PO status. Creating a shipment as
shippedmarks the linked POs shipped. Creating it asreceivedmarks them shipped, then received (a PO is marked received even when the shipment covers only part of it). Transitions that are not valid for a PO, for example fromdraft, are skipped silently. - Stock. Only a
receivedshipment changes stock: it adds each variant's quantity to on-hand atlocation_id(the default location if omitted) and creates one cost lot per variant. Moving a shipment out ofreceived, or deleting a received shipment, reverses the quantity (the cost lot is not reversed); moving one intoreceivedposts it. - Cost and lot data. The lot's unit cost is the line
unit_price, else the variant's unit cost (no lot is created if both are zero), plus the shipmentfeesspread evenly per unit across every unit in the shipment (landed cost). Several lines for one variant are merged into one lot using the first line'sunit_priceandexternal_lot_idand the firstlot_numberandexpiration_datethat are set, so send separate shipments for separate lots of one variant.unit_priceand the lot fields are read from the create request only: a shipment created asshippedand moved toreceivedlater withPUTposts stock at the variant's unit cost without lot data. To keep them, create the shipment alreadyreceived. Costs billed later on a separate bill belong in landed cost allocation. - Updating.
PUT /api/v1/inventory/shipments/{id}takes any create field (aline_itemslist replaces the existing lines) plusversion, the value from the last response, for optimistic locking (409CONFLICT_001on a mismatch). Omitted ornullfields are left unchanged.DELETEneeds delete permission.GET /api/v1/inventory/shipmentsis cursor-paginated (Pagination);GET .../shipments/{id}returns one shipment withstatus,fees,line_items,location_id,location_nameandversion.
Receiving a purchase order
POST /api/v1/purchase-orders/{id}/receive (no body) receives a PO without a shipment. It works on shipped, ready and partially_received orders (a PO already received or completed is returned unchanged; any other status is 400). It marks the PO received, marks a linked transfer received, flips the PO's forecasted bill to received, writes cost snapshots, re-evaluates the three-way match, and adds the line quantities to on-hand at ship_to_location_id (defaulted if empty) with a cost lot at the PO line price. Stock is not posted again when a received shipment is already linked to the PO. PO-level fees are not added to lot costs on this path.
POST /api/v1/purchase-orders/{id}/mark-partially-received only moves a shipped, ready or in_production PO to partially_received; it posts no stock. Record the goods with a shipment.
Three-way match
GET /api/v1/inventory/accounts-payable?status=received,paid&limit=100 lists the vendor bills created from purchase orders (status is a comma-separated list of bill statuses, unknown values are ignored; limit 1-500, default 100; no cursor). Each bill carries match_status: matched, qty_variance, price_variance, qty_and_price_variance, unmatched (no receipt yet, or the PO has no lines) or no_po. Receiving and bill changes evaluate it automatically; to re-run it:
curl -X POST "https://api.ondayzero.com/api/v1/inventory/accounts-payable/BILL_UUID/three-way-match?qty_tolerance=0&price_tolerance_cents=500&price_tolerance_pct=0.01" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"The query parameters are optional: qty_tolerance (units, default 0), price_tolerance_cents (default 500) and price_tolerance_pct (0-1, default 0.01). A price variance is flagged when the bill amount differs from the PO total by more than the larger of price_tolerance_cents and price_tolerance_pct of the PO total. The result is saved on the bill and returned:
{
"success": true,
"data": {
"match_status": "qty_variance",
"variance_qty": -10,
"variance_amount_cents": 0,
"reason": "Received 10 units short vs PO.",
"lines": [
{"variant_id": "019ab37c-c057-7000-8000-0000000000b1", "ordered_qty": 100, "received_qty": 90, "variance": -10}
]
}
}variance_qty and variance are received minus ordered; variance_amount_cents is bill amount minus PO total.
Supply chain map
curl "https://api.ondayzero.com/api/v1/inventory/supply-chain" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"One payload with nodes, edges and generated_at:
- A node is a location (all non-archived locations, plus any location a vendor uses as its default):
id,name,code,location_type,is_default,city,state,on_hand_units,on_hand_value_cents(on-hand times variant unit cost),open_po_count,low_stock_count(variants at or below reorder point) andvendor_idwhen a vendor's default location is this one. - An edge is goods in flight, with
id,kind,from_location_id,to_location_id,label,status,total_centsandexpected_arrival_date.kind: "purchase_order"covers every PO that is notreceived,completedorcanceled: its endpoints are the PO's ship-from and ship-to locations, falling back to the vendor's default location and the business default location (so either can benull),labelis the PO number or name,total_centsis the PO total andexpected_arrival_dateis alwaysnull.kind: "transfer"covers onlyin_transittransfers:labelis the transfer number,total_centsisnullandexpected_arrival_dateis the transfer's.
Reports and activity
All are GET and read-only.
| Endpoint | Parameters | Returns |
|---|---|---|
/inventory/reports/valuation |
group_by: location (default), category or vendor |
{group_by, total_value_cents, total_on_hand_units, buckets}; each bucket has key, label, on_hand_units, total_value_cents, lot_count. Value comes from remaining cost lots. category is the product's category (Uncategorized if empty); vendor is the variant's default vendor (Unassigned if none); location values on-hand at each location at the variant's average remaining lot cost, and its lot_count counts variants |
/inventory/reports/aging |
detail (default false), detail_limit (1-500, default 100) |
{total_value_cents, total_on_hand_units, buckets, variants}; buckets 0-30, 31-60, 61-90, 91-180, 180+ days of lot age with on_hand_units, total_value_cents, lot_count; variants (only with detail=true, oldest first) has variant_id, variant_name, sku, product_name, oldest_received_at, days_oldest, on_hand_units, total_value_cents |
/inventory/reports/margin-by-sku |
lookback_days (1-365, default 30), limit (1-1000, default 100) |
{rows, lookback_days, totals}. Takes the limit non-archived variants with the highest sale price, then sorts them by trailing revenue. Each row has price_cents, landed_cost_cents (latest cost snapshot, else the variant unit cost), margin_cents, margin_pct (null without a price), layers (material, packaging, processing, freight, duties, handling per unit), units_sold, revenue_cents, cogs_cents, daily_velocity, snapshot_month, snapshot_source. Sales come from Shopify order lines; totals sum the returned rows |
/inventory/reports/adjustment-reasons |
since (inclusive), until (exclusive), ISO 8601; all-time when omitted |
{since, until, rows, total_adjustments, total_value_cents}; each row has reason, label, adjustment_count, total_units (signed), total_value_cents (absolute units at the variant unit cost), is_canonical. Counts every inventory movement that carries a reason, not only manual adjustments and count postings: system movements appear as non-canonical rows such as po_received:<po id> or transfer_shipped:<transfer id> |
/inventory/cost-history |
months (1-36, default 12) |
{window_months, variants} sorted by latest total cost. Each variant has points of monthly cost snapshots (snapshot_month, material_per_unit, packaging_per_unit, processing_per_unit, freight_per_unit, duties_per_unit, handling_per_unit, total_per_unit, source), latest_total_per_unit and trailing_delta_pct (change from the first point in the window; null with fewer than two points) |
/inventory/activity |
limit (1-100, default 20) |
{items} newest first; each item has event_type, title, description, related_type, related_id, color, created_at (purchase order status changes, count postings and similar) |
Reports are cached for about two minutes and activity for about one.
Errors
| Status | code |
Cause |
|---|---|---|
403 |
BILLING_003 |
The Commerce add-on is not unlocked |
400 |
GEN_002 |
Body or query failed validation (limit out of range, amount_cents of 0, bad quantity_type); also a recipe material without an active catalog variant or with category custom, a run that is already completed, a PO in the wrong status for receive |
400 |
VALIDATION_001 |
status filter on counts is not a count status |
400 |
VALIDATION_002 |
Count line with no variant_id, barcode or sku |
400 |
VALIDATION_640 |
Stale plan_hash on count post |
400 |
VALIDATION_005 |
Landed cost targets add up to more than the bill amount |
400 |
VALIDATION_008 |
Landed cost target PO is not received or completed |
400 |
VALIDATION_010 |
Shipment line quantity exceeds the unshipped quantity on the PO line |
400 |
VALIDATION_716 |
Invalid tolling on run completion |
400 |
BUSINESS_167 |
Run completion needs an inventory ledger that is not mapped |
400 |
VALIDATION_890 |
Run completion: a stock-tracked component has less available stock than the run needs at the location. Nothing moved |
400 |
VALIDATION_891 |
Run completion: location_id is archived |
400 |
VALIDATION_892 |
Run cancel: goods the run made were already sold, used or moved |
400 |
VALIDATION_893 |
Run cancel: the run was completed before completions were recorded |
404 |
NOT_FOUND_001 |
Unknown or archived location on a new count; unknown recipe or run |
404 |
NOT_FOUND_011 |
Variant not found on a count line |
404 |
NOT_FOUND_027 |
Unknown count or count line |
404 |
NOT_FOUND_007 / NOT_FOUND_068 |
Unknown bill / purchase order |
404 |
NOT_FOUND_005 |
Unknown shipment |
404 |
VALIDATION_009 |
Shipment line variant is archived or belongs to another business |
409 |
VALIDATION_001 |
Changing a count that is posted or cancelled, or posting one again |
409 |
CONFLICT_001 |
Stale shipment version |