In short
qc_status is AVAILABLE, or RELEASED with a qc_release_expires_at that is null or still ahead.lot_number is null is the generic lot: real stock, not a data error.archived=false for live stock.base_qty; Update sets an absolute quantity_base. Both record the change as an ADJUSTMENT stock movement in traceability.lease back when you update it, above all quantity_base: if the lot changed since your read, the update is refused with STALE_LEASE instead of overwriting stock.updated_since is strictly greater than, follows the parent row only and never reports deletions. Overlap each poll by a minute.A lot is a quantity of one product, in one location, that arrived or was made together. Everything about stock on this API is expressed in lots.
To list one product's lots, filter GET /product-lots with product_id or with its product_code, an exact match that ignores case. Send both and they must name the same product, or the list is empty.
quantity_base is in the product's base unit, on every operation, in both directions. To show a lot in the product's inventory unit, divide by both factors on the lot: quantity_base / (uom_qty * uom_base_conversion). See Units and quantities.
qc_status has five values: AVAILABLE, RELEASED, ON_HOLD, QUARANTINE and REJECTED. Two of them are sellable: AVAILABLE and RELEASED.
ON_HOLD and moves to RELEASED, never back to AVAILABLE, so filtering on AVAILABLE alone misses every positively released finished good.| Status | Means |
|---|---|
AVAILABLE | In stock, and no release decision was needed. Sellable. |
RELEASED | Signed off by quality. Sellable until qc_release_expires_at, if set. |
ON_HOLD | Made or received, not yet released. Yours, not sellable. |
QUARANTINE | Held pending investigation. Yours, not sellable. |
REJECTED | Failed. Not sellable. |
A release can carry a retest date, published as qc_release_expires_at. Only RELEASED lots have one, and it is null when the release is open-ended. Once it has passed, NutraSoft will not ship the lot or use it in production until quality retests and releases it again. The sellable rule, in full: qc_status is AVAILABLE, or it is RELEASED and qc_release_expires_at is null or still in the future.
A lot whose lot_number is null is the generic lot: the unnumbered bucket created with a product to hold stock that was never lotted. It is a real row with a real quantity. Do not filter it out, and do not treat a null lot number as a data error.
A lot's location is location_id: a warehouse id or a location inside one. When you create a lot, the warehouse id you send is location_id. To list the stock of a whole warehouse, filter GET /product-lots with warehouse_id.
Once a production run is COMPLETED, its own location_id and lot_number are where the finished product went, including when the operator changed them at close. For the exact lot, its location's name and how much the run made, read output on GET /productions/{production_id}.
A lot that is used up, spoiled or written off is archived: archived becomes true and the row stays readable with its history. Without an archived filter, the list returns both, so pass archived=false for live stock and archived=true to audit what was retired. A lot can also be deleted outright, when someone removes it from a product in NutraSoft; a deleted lot simply stops appearing.
Use Adjustment for stock added, removed, spoiled or corrected when the change must appear in traceability. Pass the lot's id in the URL and a signed base_qty in the body. Use Update to set the balance to a total you counted, or to edit the expiry. Update records the difference from the current balance as the same ADJUSTMENT movement, so both appear in traceability. A lot's number and location are fixed once it exists.
| Adjustment: POST /product-lots/{lot_id}/adjustments | Update: PATCH /product-lots/{lot_id} | |
|---|---|---|
| Quantity field | Signed base_qty: a change | quantity_base: the new total |
| Starting at 100, send 25 | Balance becomes 125 | Balance becomes 25 |
| Starting at 100, remove 25 | Send base_qty = -25; balance becomes 75 | Send quantity_base = 75; negative totals are refused |
| Traceability | Creates an ADJUSTMENT movement with optional notes and a Created via API label | Creates an ADJUSTMENT movement for the difference with a Created via API label; sending the current balance creates none |
| Accounting | Posts applicable adjustment entries; enabled QuickBooks sync runs after commit | Same, for the difference |
| Lot details | Keeps the lot number, location and expiry | Can edit the expiry; lot_number and location_id are refused with 400 |
| Concurrency | Applies the delta while holding a lot lock; optional lease | Records the difference while holding a lot lock; send lease to protect a total worked out from a stale read |
curl -X POST 'https://public-api.nutrasoft.ca/v1/product-lots/123/adjustments' \
-H "Authorization: Bearer $NUTRASOFT_API_KEY" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: lot-123-damaged-2026-10-09-001' \
-d '{"base_qty": -5.5, "notes": "Damaged stock removed"}'Adjustment requires inventory:write and returns the updated lot in data. Quantities are rounded to six decimal places; a change that rounds to zero is refused with 400. Insufficient stock and archived lots return 409, while a missing lot returns 404. Unarchive a retired lot first. Send an optional lease to require that it still matches your last read; otherwise the delta applies to the current balance. A successful adjustment updates linked Shopify stock and runs enabled QuickBooks adjustment sync after commit; integration failures do not undo the stock change.
Every lot carries a lease: a token for the state you read. It changes whenever the lot number, location, quantity, expiration date or archived state does, whoever made the change. Send it back in the body of PATCH /product-lots/{lot_id} and the update is applied only if the lot is still as you read it. Otherwise nothing is written and you get 409 STALE_LEASE.
{
"quantity_base": 18250,
"lease": "3f9a1c0b7e2d4a6f8b1c9e0d"
}quantity_base. A quantity worked out from a read is wrong as soon as NutraSoft ships, receives or consumes from the lot, and written without a lease it undoes that movement with an adjustment of its own.STALE_LEASE, read the lot again.lease.The lease is optional: an update without one is applied as before. Send it back exactly as received. A change to the lot's quality status leaves it as it is.
Seven resources accept updated_since: products, product lots, sales orders, invoices, purchase orders, customers and suppliers. Each publishes created_at and updated_at and can be sorted by them. Four rules are not guessable, so read them before you build a sync:
updated_at equals the value you pass is not returned again.updated_at. If you care about lines, re-fetch the documents you hold on a slower cycle.updated_at is slightly earlier than rows you have already seen. Overlap each poll by a minute and de-duplicate by id.updated_at, so an archived lot shows up in the feed. A deleted row, a lot included, simply stops appearing, and polling will never tell you about it.sort=updated_at:asc.updated_at you saw.updated_since set to that value minus a minute, sorted the same way, and upsert by id.Offset paging stops at an offset of 10,000. For a large first run, sort by updated_at and move updated_since forward to the last updated_at of each page, minus the same overlap, instead of paging deeper.
updated_since cannot report. Most integrations run both: webhooks to react, a periodic sweep to reconcile.