Feed stock levels from your warehouse
Carriyo never lets you write a stock number directly. You submit inventory events: a
snapshot of what a location holds, or a signed movement against it. Carriyo applies them
to the inventory key the product carries. This recipe sends a nightly snapshot
for two warehouses and records a sale as a movement. It then resyncs without flooding the
event history, and reads the inventory key and its event history back. Every stock write in it goes through
POST /inventory-events/bulk/import.
Scenario
ACME sells a camera from two warehouses, NJ-WH-01 and CA-WH-01. Each night the warehouse
system sends the counted stock for every product as a snapshot. During the day the point of
sale reports each sale as a movement. Checkout then reads the inventory key to decide which delivery
options to offer.
Prerequisites
- API credentials: see Getting started for the one-time setup.
- Inventory management enabled for the tenant. Without it, Carriyo still stores events but no checkout or allocation reads them.
- Two inventory-enabled locations with the codes used below. A location that is not flagged for inventory rejects every event addressed to it.
- A catalog product whose
inventory_keyis set. A product with no key is not stock-managed. Carriyo stores its events under the key you send, but no product read, checkout check or allocation ever finds them.
Step 1, give the product an inventory key
The inventory_key is the key that stock is held under. Products that share a key share stock; a
simple product usually uses its own reference. Set it when you create the product, or patch
it onto an existing one. ACME in the path is the merchant id.
curl -X POST 'https://api.carriyo.com/products/ACME' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
-H 'tenant-id: YOUR_TENANT_ID' \
-H 'x-api-key: YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{
"product_ref": "YOUR_PRODUCT_REF",
"sku": "CAM-BLK-01",
"description": "Compact camera, black",
"inventory_key": "YOUR_PRODUCT_REF"
}'
{
"merchant": "ACME",
"sku": "CAM-BLK-01",
"product_id": "PDTNV20YJSGS0MQSM",
"product_type": "SIMPLE",
"product_ref": "YOUR_PRODUCT_REF",
"inventory_key": "YOUR_PRODUCT_REF",
"status": "ACTIVE",
"creation_date": "2026-09-09T18:20:16.375Z",
"update_date": "2026-09-09T18:20:16.375Z"
}
The response carries Carriyo's product_id alongside your product_ref and the
inventory_key. Keep the key; every event below names it.
How events apply
Every row in a batch is one inventory event against one inventory position: one inventory key at one location. A snapshot sets a counter to an absolute value; a movement adds a signed delta to it. Carriyo creates a position on its first event, with every counter at zero.
event_timestamp is when the count was taken or the movement happened, not when you send
it. It may be up to 14 days old, and it may not run ahead of the Carriyo clock by more than
two minutes. Events apply in timestamp order, not arrival order:
- A snapshot older than the last snapshot applied to the position is rejected.
- A snapshot newer than the last snapshot but older than movements applied since is re-based: the stored counter becomes the snapshot value plus the sum of those later-timestamped movements.
- Movements are never de-duplicated. Send the same sale twice and it is applied twice.
Send the time the count was taken and keep clocks in sync.
Step 2, send the nightly snapshot
A SNAPSHOT sets both counters of one inventory position to absolute values. Send one row
per location. Every row needs inventory_key, event_type, event_timestamp and a
location, as location_code or location_id; a row missing any of them is rejected. A batch
holds at most 500 rows. The request-id header names the batch and must be new every time;
reusing one refuses the whole request.
curl -X POST 'https://api.carriyo.com/inventory-events/bulk/import' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
-H 'tenant-id: YOUR_TENANT_ID' \
-H 'x-api-key: YOUR_API_KEY' \
-H 'request-id: YOUR_BATCH_REF' \
-H 'Content-Type: application/json' \
-d '[
{
"inventory_key": "YOUR_PRODUCT_REF",
"location_code": "NJ-WH-01",
"event_type": "SNAPSHOT",
"on_hand": 40,
"unavailable": 2,
"event_timestamp": "2026-09-09T18:20:16Z",
"reason": "Nightly stock sync"
},
{
"inventory_key": "YOUR_PRODUCT_REF",
"location_code": "CA-WH-01",
"event_type": "SNAPSHOT",
"on_hand": 12,
"unavailable": 0,
"event_timestamp": "2026-09-09T18:20:16Z",
"reason": "Nightly stock sync"
}
]'
on_hand is what the location physically holds; unavailable is the part of it that
cannot be sold (damaged, quarantined, display). Carriyo derives available as on_hand
minus reserved minus unavailable, where reserved is what open orders hold. The
reason you send is your own note: Carriyo stores it on the event as source.reason and
delivers it on the webhook as update_source.reason.
The response answers per row, immediately:
{
"request_id": "YOUR_BATCH_REF",
"results": [
{
"inventory_key": "YOUR_PRODUCT_REF",
"location_id": "ACCOUNT_f2b82334-96d6-43bb-987d-ca36c2305824",
"location_code": "NJ-WH-01",
"reason": null,
"result": "processing"
},
{
"inventory_key": "YOUR_PRODUCT_REF",
"location_id": "ACCOUNT_cda49552-ddc6-4223-961f-29e93d32c6f4",
"location_code": "CA-WH-01",
"reason": null,
"result": "processing"
}
]
}
result is exactly processing or rejected. processing means the row passed validation
and is queued; the stock changes a few seconds later. rejected means the row failed
validation, and the response reason says why; it is null on a processing row. The
other rows still go through. Check every row; a 200 on the batch does not mean every row
is processing.
Step 3, confirm the batch landed
Read the batch by its request-id. Poll it a few seconds after the import; a batch this
size applies within seconds. status is processing while any row is still queued and
completed once every row has been applied, rejected or found unchanged. An unknown
request-id returns 404.
curl 'https://api.carriyo.com/inventory-events/request-id/YOUR_BATCH_REF' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
-H 'tenant-id: YOUR_TENANT_ID' \
-H 'x-api-key: YOUR_API_KEY'
{
"status": "completed",
"inventory_events": [
{
"inventory_key": "YOUR_PRODUCT_REF",
"location_id": "ACCOUNT_f2b82334-96d6-43bb-987d-ca36c2305824",
"request_id": "YOUR_BATCH_REF",
"event_type": "SNAPSHOT",
"on_hand": 40,
"unavailable": 2,
"status": "APPLIED",
"event_timestamp": "2026-09-09T18:20:16.000Z",
"receive_timestamp": "2026-09-09T18:20:37.890Z"
},
{
"inventory_key": "YOUR_PRODUCT_REF",
"location_id": "ACCOUNT_cda49552-ddc6-4223-961f-29e93d32c6f4",
"request_id": "YOUR_BATCH_REF",
"event_type": "SNAPSHOT",
"on_hand": 12,
"unavailable": 0,
"status": "APPLIED",
"event_timestamp": "2026-09-09T18:20:16.000Z",
"receive_timestamp": "2026-09-09T18:20:37.893Z"
}
]
}
Each event carries a status of APPLIED, REJECTED or UNCHANGED, and an
error_message when rejected. Rows the import call itself rejected never reach the event
history; they were reported only in the import response.
Three statuses, three levels:
- Row result,
results[].resultin the import response:processingorrejected. - Batch status,
statusin therequest-idread:processingorcompleted. - Event status,
statuson each event in the event history:APPLIED,REJECTEDorUNCHANGED. - A
processingrow ends as one event with one of those statuses, or as no event at all whenskip_unchanged=trueskipped it (Step 6).
If a batch fails or times out
Send an Idempotency-Key header with every batch, alongside request-id. A retry that
repeats both headers returns the stored response instead of a refusal, so after a timeout
you resend the same call unchanged. Without an Idempotency-Key, repeating a request-id
is refused with 400: read the old batch by request-id, then send only what is missing
under a new request-id.
A row can be rejected at two points, and you find it in two places:
- At import. The row failed validation. It appears only in the import response, as
rejectedwith areason. Fix it and resend it in a new batch. - At apply. The row came back
processingbut could not be applied, for example a snapshot older than one already applied. It appears in therequest-idread withstatus: REJECTEDand anerror_message.
Resend only the rows that were rejected. Movements are never de-duplicated, so resending a row that was applied applies it again.
Step 4, read the inventory positions
Address the inventory key directly, or through the product that carries it:
GET /inventory/product-id/{product_id} and
GET /inventory/product-ref/{product_ref}?merchant=ACME return the same response as the
key read.
curl 'https://api.carriyo.com/inventory/inventory-key/YOUR_PRODUCT_REF' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
-H 'tenant-id: YOUR_TENANT_ID' \
-H 'x-api-key: YOUR_API_KEY'
{
"inventory_key": "YOUR_PRODUCT_REF",
"stock_by_location": {
"ACCOUNT_f2b82334-96d6-43bb-987d-ca36c2305824": {
"on_hand": 40,
"reserved": 0,
"unavailable": 2,
"available": 38,
"update_date": "2026-09-09T18:20:37.847Z"
},
"ACCOUNT_cda49552-ddc6-4223-961f-29e93d32c6f4": {
"on_hand": 12,
"reserved": 0,
"unavailable": 0,
"available": 12,
"update_date": "2026-09-09T18:20:37.847Z"
}
},
"creation_date": "2026-09-09T18:20:37.847Z",
"update_date": "2026-09-09T18:20:37.847Z"
}
stock_by_location holds one inventory position per location, keyed by Carriyo's location
id, the location_id the import response returned for each code. A key with no inventory
position at any location returns 404, as does a product read for a product with no
inventory_key.
Step 5, record a sale as a movement
A movement changes a position by a signed delta rather than setting it. Carriyo applies the value exactly as sent, so a sale that reduces stock must be negative. Each event type is strict about which counters it takes:
| Event type | What it does | Fields |
|---|---|---|
SNAPSHOT | Sets on_hand and unavailable | Both required |
SNAPSHOT_ONHAND | Sets on_hand | on_hand only; unavailable refused |
SNAPSHOT_UNAVAILABLE | Sets unavailable | unavailable only; on_hand refused |
INVENTORY_ADJUSTMENT_ONHAND | Delta on on_hand | on_hand only; unavailable refused |
INVENTORY_ADJUSTMENT_UNAVAILABLE | Delta on unavailable | unavailable only; on_hand refused |
RETURN | Delta on unavailable, for returned items | unavailable only; on_hand refused |
TRANSFER_IN, TRANSFER_OUT, SALE, PO_RECEIPT | Delta on on_hand, unavailable or both | At least one |
curl -X POST 'https://api.carriyo.com/inventory-events/bulk/import' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
-H 'tenant-id: YOUR_TENANT_ID' \
-H 'x-api-key: YOUR_API_KEY' \
-H 'request-id: YOUR_BATCH_REF-2' \
-H 'Content-Type: application/json' \
-d '[
{
"inventory_key": "YOUR_PRODUCT_REF",
"location_code": "NJ-WH-01",
"event_type": "SALE",
"on_hand": -2,
"event_timestamp": "2026-09-09T18:21:06Z",
"reason": "POS sale 4471"
}
]'
A few seconds later the New Jersey position reads on_hand: 38, available: 36, and
update_source names the sale that moved it:
{
"inventory_key": "YOUR_PRODUCT_REF",
"stock_by_location": {
"ACCOUNT_cda49552-ddc6-4223-961f-29e93d32c6f4": {
"on_hand": 12,
"reserved": 0,
"unavailable": 0,
"available": 12,
"update_date": "2026-09-09T18:20:37.847Z"
},
"ACCOUNT_f2b82334-96d6-43bb-987d-ca36c2305824": {
"on_hand": 38,
"reserved": 0,
"unavailable": 2,
"available": 36,
"update_date": "2026-09-09T18:21:07.279Z"
}
},
"creation_date": "2026-09-09T18:20:37.847Z",
"update_date": "2026-09-09T18:21:07.279Z",
"update_source": {
"source_type": "api",
"request_type": "INVENTORY_EVENTS_BULK_IMPORT",
"reason": "POS sale 4471",
"event_type": "SALE"
}
}
Each row that changes a counter fires one inventory webhook when it is applied, carrying the
same update_source; see Inventory webhook events.
Step 6, resync nightly without flooding the event history
The following night the warehouse sends its snapshot again, and it mostly repeats what is
already stored. By default a row that changes nothing is still recorded with status
UNCHANGED. Add skip_unchanged=true and those rows are counted but left out of the event
history.
curl -X POST 'https://api.carriyo.com/inventory-events/bulk/import?skip_unchanged=true' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
-H 'tenant-id: YOUR_TENANT_ID' \
-H 'x-api-key: YOUR_API_KEY' \
-H 'request-id: YOUR_BATCH_REF-3' \
-H 'Content-Type: application/json' \
-d '[
{
"inventory_key": "YOUR_PRODUCT_REF",
"location_code": "NJ-WH-01",
"event_type": "SNAPSHOT",
"on_hand": 38,
"unavailable": 2,
"event_timestamp": "2026-09-10T02:00:00Z",
"reason": "Nightly stock sync"
},
{
"inventory_key": "YOUR_PRODUCT_REF",
"location_code": "CA-WH-01",
"event_type": "SNAPSHOT",
"on_hand": 12,
"unavailable": 0,
"event_timestamp": "2026-09-10T02:00:00Z",
"reason": "Nightly stock sync"
}
]'
The import response cannot tell yet whether a row will change anything, so each row still
comes back processing:
{
"request_id": "YOUR_BATCH_REF-3",
"results": [
{
"inventory_key": "YOUR_PRODUCT_REF",
"location_id": "ACCOUNT_f2b82334-96d6-43bb-987d-ca36c2305824",
"location_code": "NJ-WH-01",
"reason": null,
"result": "processing"
},
{
"inventory_key": "YOUR_PRODUCT_REF",
"location_id": "ACCOUNT_cda49552-ddc6-4223-961f-29e93d32c6f4",
"location_code": "CA-WH-01",
"reason": null,
"result": "processing"
}
]
}
Both rows match the stored counts, so the batch completes with an empty inventory_events
list when read by request-id:
{ "status": "completed", "inventory_events": [] }
Once the status is completed, a processing row with no event was unchanged. Snapshots are
safe to resend: a repeated snapshot converges on the same counts. A nightly full sync is
the simplest way to keep Carriyo and your warehouse aligned.
Step 7, read the event history of an inventory key
The event history for a key lists every applied, rejected and unchanged event, newest first,
in pages of 10 by default. Page with page_num and rows_per_page; only the newest 500
events are reachable. The envelope differs from the request-id read, which returns a plain
inventory_events list; the items are the same event objects.
curl 'https://api.carriyo.com/inventory-events/inventory-key/YOUR_PRODUCT_REF' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
-H 'tenant-id: YOUR_TENANT_ID' \
-H 'x-api-key: YOUR_API_KEY'
{
"total": 3,
"page_num": 1,
"rows_per_page": 10,
"items": [
{
"inventory_key": "YOUR_PRODUCT_REF",
"location_id": "ACCOUNT_f2b82334-96d6-43bb-987d-ca36c2305824",
"request_id": "YOUR_BATCH_REF-2",
"event_type": "SALE",
"on_hand": -2,
"status": "APPLIED",
"event_timestamp": "2026-09-09T18:21:06.000Z"
},
{
"inventory_key": "YOUR_PRODUCT_REF",
"location_id": "ACCOUNT_cda49552-ddc6-4223-961f-29e93d32c6f4",
"request_id": "YOUR_BATCH_REF",
"event_type": "SNAPSHOT",
"on_hand": 12,
"unavailable": 0,
"status": "APPLIED",
"event_timestamp": "2026-09-09T18:20:16.000Z"
},
{
"inventory_key": "YOUR_PRODUCT_REF",
"location_id": "ACCOUNT_f2b82334-96d6-43bb-987d-ca36c2305824",
"request_id": "YOUR_BATCH_REF",
"event_type": "SNAPSHOT",
"on_hand": 40,
"unavailable": 2,
"status": "APPLIED",
"event_timestamp": "2026-09-09T18:20:16.000Z"
}
]
}
The resync rows are absent because they were skipped; the sale and the first snapshot are the inventory key's whole event history.
What just happened
- The product's
inventory_keynamed the key; every event addressed that key at one location bylocation_code. - The first snapshot created both inventory positions. Carriyo creates a position on its first event, with every counter at zero, so there is nothing to set up per location.
- The sale was applied as a delta of
-2toon_hand, andavailablefollowed. - The resync changed nothing and, with
skip_unchanged=true, left no trace in the event history while still completing the batch.
Pitfalls
- A new
request-idevery batch. A reused id refuses the whole request with400, valid rows included, after the rows were validated. Send anIdempotency-Keytoo, so a retry after a timeout replays instead of being refused. - Rows fail individually. The batch answers
200even when every row isrejected. Readresults[].resultandreason; nothing else tells you. - Movements need a sign.
SALEwithon_hand: 2adds two units. Send-2. - Timestamps order the event history, not arrival. An old snapshot is rejected or re-based, as set out under How events apply.
stock_by_locationis keyed by location id, not code. Map codes to ids from the import response or the Locations API before you read inventory.- A product without an
inventory_keyhas no stock, however many events you send under a key of your own. Set the key on the product first. - Event types are strict about fields. Check the table in Step 5 before you put both
on_handandunavailablein one row.