Feed stock levels from your warehouse

Updated September 9, 20269 min read

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_key is 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[].result in the import response: processing or rejected.
  • Batch status, status in the request-id read: processing or completed.
  • Event status, status on each event in the event history: APPLIED, REJECTED or UNCHANGED.
  • A processing row ends as one event with one of those statuses, or as no event at all when skip_unchanged=true skipped 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 rejected with a reason. Fix it and resend it in a new batch.
  • At apply. The row came back processing but could not be applied, for example a snapshot older than one already applied. It appears in the request-id read with status: REJECTED and an error_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 typeWhat it doesFields
SNAPSHOTSets on_hand and unavailableBoth required
SNAPSHOT_ONHANDSets on_handon_hand only; unavailable refused
SNAPSHOT_UNAVAILABLESets unavailableunavailable only; on_hand refused
INVENTORY_ADJUSTMENT_ONHANDDelta on on_handon_hand only; unavailable refused
INVENTORY_ADJUSTMENT_UNAVAILABLEDelta on unavailableunavailable only; on_hand refused
RETURNDelta on unavailable, for returned itemsunavailable only; on_hand refused
TRANSFER_IN, TRANSFER_OUT, SALE, PO_RECEIPTDelta on on_hand, unavailable or bothAt 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_key named the key; every event addressed that key at one location by location_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 -2 to on_hand, and available followed.
  • 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-id every batch. A reused id refuses the whole request with 400, valid rows included, after the rows were validated. Send an Idempotency-Key too, so a retry after a timeout replays instead of being refused.
  • Rows fail individually. The batch answers 200 even when every row is rejected. Read results[].result and reason; nothing else tells you.
  • Movements need a sign. SALE with on_hand: 2 adds 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_location is 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_key has 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_hand and unavailable in one row.