Stock sync and files over the API
Keeping stock current from your system: sync profiles, idempotent batch push, their status and the sync overview, and file drop over SFTP or S3.
On this page
There are three ways for StockAI's stock to follow your system's without anybody loading it by hand. All three go through the same engine: your column mapping, validation of every row, a safety brake that stops a suspicious change, and a log of every run.
| Way | Who moves the data | Set up |
|---|---|---|
| Reading your database | StockAI queries your database (SQL or MongoDB) on an interval. | Under Inventory sync or through the API. |
| Push | Your system sends the rows to StockAI. | Through the API (this page) or on the screen. |
| File drop | Your system leaves a file on an SFTP server or an S3 bucket and StockAI picks it up. | Only on the screen, by an administrator. |
Pushing stock from your system
If you cannot open your database to StockAI, your system sends the rows. First you create a push profile that says which column is which; then you send batches.
- Create the profile with
POST /inventory-sync/profilesandsource_kind: "push". Inmapping, tell each StockAI field which column of your rows it comes from.skuandcurrent_stockare required. It is created off (enabled: false). - Do a dry run: send a batch with
dry_run: true. It writes nothing and tells you what it would create, update and which rows it would reject. - Turn the profile on with
POST /inventory-sync/profiles/{id}/enable. It is refused withinventory_sync_dry_run_requiredif there was no dry run since the last configuration change. - Send the real batches, each with its own
batch_id. - Watch the state with
GET /inventory-sync/overvieworGET /inventory-sync/health.
curl -X POST https://app.stockai.es/api/v1/inventory-sync/profiles \
-H "Authorization: Bearer $STOCKAI_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: erp-push-profile-1" \
-d '{"name":"ERP push","source_kind":"push","default_warehouse":"principal",
"mapping":{"sku":{"column":"item_code"},
"current_stock":{"column":"on_hand"},
"warehouse":{"column":"site"}}}'
# 201 -> "data": { "id": "isp_d194eddb57d3", "source_kind": "push", "enabled": false,
# "mode": "full", "missing_policy": "ignore", "negative_policy": "reject",
# "duplicate_policy": "reject", "brake_max_vanished_pct": 20.0, ... }StockAI fields you can map: sku, warehouse, current_stock, min_stock, unit_cost, supplier, lead_time_days, category, brand, unit_of_measure, barcode, sale_price, display_name. Besides column, each accepts cast (auto, number, integer, text), trim, case (none, upper, lower), decimal (auto, dot, comma), multiplier, default and value_map. GET /inventory-sync/fields returns the full list of options and limits.
missing_policy- What happens to a SKU that stops coming in a full push:
ignore(nothing),mark_inactiveorzero. negative_policy- Negative stock:
reject(the row is rejected) orzero. duplicate_policy- The same SKU and warehouse twice:
rejectorsum. default_warehouse- The warehouse for rows that carry none.
allow_new_warehouses- Whether an unknown warehouse is created (by default no: the row is rejected).
brake_max_vanished_pct,brake_min_row_ratio,brake_max_rejected_pct,brake_max_jump_pct- The safety brake: by default it stops if more than 20% of SKUs vanish, if fewer than half the usual rows arrive, or if more than 50% are rejected.
stock_modelsnapshot(each row is the current stock),aggregateorledger(each row is a movement).
The dry run
curl -X POST https://app.stockai.es/api/v1/inventory-sync/profiles/$PROFILE/ingest \
-H "Authorization: Bearer $STOCKAI_KEY" -H "Content-Type: application/json" \
-d '{"batch_id":"night-2026-10-22","dry_run":true,
"rows":[{"item_code":"SKU-001","on_hand":118,"site":"principal"},
{"item_code":"SKU-002","on_hand":"42","site":"principal"},
{"item_code":"","on_hand":5}]}'"result": {
"rows_read": 3, "valid": 2, "rejected": 1,
"rejected_by_code": { "sku_blank": 1 },
"rejected_sample": [{ "code": "sku_blank", "params": {}, "sku": null, "row": 3 }],
"would_create": 0, "would_update": 2, "would_leave_unchanged": 0,
"changes_sample": [
{ "sku": "SKU-001", "warehouse": "principal", "action": "update",
"fields": { "current_stock": { "before": 100.0, "after": 118.0 } } },
{ "sku": "SKU-002", "warehouse": "principal", "action": "update",
"fields": { "current_stock": { "before": 570.0, "after": 42.0 } } }
],
"brake_warning": null, "stock_model": "snapshot"
}In the example, two rows would update stock (with before and after) and the third is rejected with sku_blank. Each rejection carries its code, its parameters and the row number.
The real batch
# Turn the profile on (refused with inventory_sync_dry_run_required until a dry run passed)
curl -X POST https://app.stockai.es/api/v1/inventory-sync/profiles/$PROFILE/enable \
-H "Authorization: Bearer $STOCKAI_KEY"
# Send the batch for real
curl -X POST https://app.stockai.es/api/v1/inventory-sync/profiles/$PROFILE/ingest \
-H "Authorization: Bearer $STOCKAI_KEY" -H "Content-Type: application/json" \
-d '{"batch_id":"night-2026-10-22",
"rows":[{"item_code":"SKU-001","on_hand":118,"site":"principal"},
{"item_code":"SKU-002","on_hand":"42","site":"principal"}]}'
# "data": { "batch_id": "night-2026-10-22", "replayed": false,
# "run": { "id": "isr_052d3da33a21", "status": "success", "run_kind": "push",
# "rows_read": 2, "rows_valid": 2, "created": 0, "updated": 2,
# "unchanged": 0, "rejected": 0, "duration_ms": 500 } }
# Same batch_id + same rows again: "replayed": true, same run, nothing applied twice.
# Same batch_id + different rows: 409 inventory_sync_batch_id_reused.batch_id: your batch id, 1 to 128 characters[A-Za-z0-9._:-]. Retrying with the same id and the same rows applies nothing twice.- Without
snapshot_idthe batch is partial: it updates the SKUs it names and never treats an absent one as gone. - Up to 50,000 rows per batch (more:
413 inventory_sync_ingest_too_large; send several). - If your rows carry nested objects, they are flattened to dotted paths (
stock.on_hand).
You can also send NDJSON (one JSON row per line), with the other fields as URL parameters:
printf '{"item_code":"SKU-003","on_hand":7}\n{"item_code":"SKU-005","on_hand":9}\n' |
curl -X POST "https://app.stockai.es/api/v1/inventory-sync/profiles/$PROFILE/ingest?batch_id=nd-1" \
-H "Authorization: Bearer $STOCKAI_KEY" -H "Content-Type: application/x-ndjson" --data-binary @-A full catalogue in several parts
# A full catalogue in 3 parts: the same snapshot_id, last=true only on the final part.
.../ingest {"batch_id":"snap-22-a","snapshot_id":"snap-22","last":false,"rows":[...]}
.../ingest {"batch_id":"snap-22-b","snapshot_id":"snap-22","last":false,"rows":[...]}
.../ingest {"batch_id":"snap-22-c","snapshot_id":"snap-22","last":true, "rows":[...]}
# Parts are staged; the last one applies the whole snapshot with every safety brake.With snapshot_id, the parts are staged and the one with last: true applies the whole catalogue at once, with every brake (vanishing SKUs, row drop). It is the right way to send "this is all my stock" when it does not fit in one batch.
The overview: is stock current?
curl https://app.stockai.es/api/v1/inventory-sync/overview -H "Authorization: Bearer $STOCKAI_KEY"
# "data": {
# "state": "fresh", // none | waiting | fresh | attention | blocked
# "attention": [], // what only a person can settle, worst first
# "sources": [{
# "id": "isp_d194eddb57d3", "name": "ERP push", "origin": "push",
# "state": "fresh", "health": "healthy", "enabled": true,
# "data_as_of": "2026-10-22T20:46:46-06:00", "running": false, "expected_minutes": 60,
# "last_run": { "id": "isr_17e09a684d26", "status": "success", "rows_read": 2,
# "created": 0, "updated": 2, "unchanged": 0, "rejected": 0,
# "error_code": null, "brake_overridden": false },
# "files_7d": null
# }],
# "data_as_of": "2026-10-22T20:46:46-06:00"
# }statenone(no sources),waiting(waiting for the first run),fresh(current),attention(something waits for a person) orblocked.attention[]- What only a person can settle, worst first: a stock file held for a decision, a sync the brake stopped, a source that refuses us, a late sync, rejected rows. Each item carries the ids the action needs.
sources[]- Every source — database, file drop or push — with its freshness and last run.
files_7dsummarises the last 7 days of files on file sources.
/overview never answers 503: it is for reading. For an uptime monitor use /health?strict=true, which answers 503 if any profile is not ok:
# For an uptime checker: answers 503 when any profile is not ok
curl -i "https://app.stockai.es/api/v1/inventory-sync/health?strict=true" -H "Authorization: Bearer $STOCKAI_KEY"Operating profiles
| Method and route | What it does |
|---|---|
GET /inventory-sync/profiles, GET .../profiles/{id} | Profiles with their status: on, last success, late, braked. |
PATCH /inventory-sync/profiles/{id} | Changes a profile. If what it writes changes, it turns off until a new dry run. |
POST .../profiles/{id}/dry-run | Dry run of a saved profile that reads a database. |
POST .../profiles/{id}/run | "Sync now": answers 202 with the run under way. {"override_brake": true} passes the brake once (audited). |
POST /inventory-sync/refresh | "Refresh now" (everything, one profile or some skus); waits up to wait_seconds and honours a minimum time between runs (status: cooldown, retry_after_s). |
GET .../profiles/{id}/runs, .../runs/{run_id}, .../runs/{run_id}/rejected.csv | Run history, one run's detail, and its rejected rows as CSV. |
POST .../profiles/{id}/disable, DELETE .../profiles/{id} | Turn the profile off or delete it. The stock it synced stays as it is. |
GET /inventory-sync/freshness, /ownership | How current stock is, and which profile owns each field. |
Profiles as code (CI)
curl -X POST "https://app.stockai.es/api/v1/inventory-sync/profiles/apply?dry_run=true" \
-H "Authorization: Bearer $STOCKAI_KEY" -H "Content-Type: application/json" \
--data-binary @inventory-sync.json
# The document format: GET /inventory-sync/schema/profile.json ("schema": "stockai.inventory-sync/v1").
# Export an existing one: GET /inventory-sync/profiles/{id}/export?format=yamlPOST /inventory-sync/profiles/apply takes a JSON or YAML document: it creates it, or updates it by its slug. Applying the same file again answers action: unchanged, so your CI can apply it on every deploy. POST /inventory-sync/profiles/validate only validates and returns each problem with its path.
File drop (SFTP or S3)
If your ERP already leaves an export in a folder, there is nothing to program: an administrator sets up an SFTP server or an S3 bucket under Inventory sync, and StockAI picks up every new file. A file can feed sales (it replaces a dataset) or stock.
- A file is read only once it has finished uploading (two looks that agree on size and date, and at least a minute old).
- The same content is never applied twice.
- A suspicious file — empty, with other columns, or with far fewer rows — is held for a person to decide, instead of erasing the history.
- Then the file is moved to
processed/orrejected/(with a.reason.txtnote). It is never deleted.
And sales
For sales, the usual way is to replace the source's file every night with POST /data-sources/{id}/file. See Forecasts and sessions.