Run a nightly price and stock sync from a supplier file

Pick up a supplier's price and stock file on a schedule, diff it against the last one with Compare Datasets, write prices with a bulk upsert and stock as signed adjustments, and make the run safe when two firings overlap.

Every night a supplier drops one file with a price and a quantity per SKU. This guide builds the Integration Studio workflow that picks it up, works out which lines changed since the last file, writes those prices to a price list and those quantities to stock, and archives the file. When the file arrives over SFTP, a second firing that starts while the first run is still going ends without writing. Step 7 says exactly where that guarantee stops.

This is for integrators who feed prices and stock from a supplier or an ERP export. You need a tenant with the Pricing and Inventories core apps and Integration Studio enabled, access to Cockpit, and an SFTP account the supplier writes to. The BMECAT guide ends on the official import template; this one builds a workflow from nodes, because no shipped template is built for prices and stock. Recovering a failed night is Keep an Integration Studio workflow running, and this guide links to it instead of repeating it.

Integration Studio is experimental and its schemas are drafts, so expect a workflow built today to need a migration later. Statements about what a node or the platform does not do were checked in November 2026 and can change (as of 2026-11). Where a node's exact output field is not documented, the guide says to read it off a test run.

Before you start

Create one key in Cockpit under API keys (see create an API key) with these scopes. They name single operations, not whole apps:

ScopeFor
prices.lists.listFinding the price list's id, from your machine
inventories.locations.listFinding the location's id, from your machine
prices.entries.bulkWriting prices
inventories.stock.listReading a SKU's current on_hand
inventories.adjustWriting stock
prices.entries.list, inventories.movements.listChecking the writes in step 8, from your machine

The workflow itself needs two credentials under Integration Studio → Credentials: a Revenexx API credential holding that key and your tenant slug, and an SFTP credential for the supplier's server. See Reaching Revenue Cloud and File transfer. The SFTP host has to be reachable from the public internet.

REVENEXX_TENANT is your tenant slug:

Environment
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."

How one night runs

run startsschedule or manualclaim the filemove to processing/diffCompare Datasetswriteprices, stockarchivemove to archive/overlapping run stopsfile stays in processing/file already takena call failsthe clock or your handthe workflow you buildwhere a run stops

Two facts shape the design, and both come from the platform, not from this guide:

  • The Inventories API has no absolute stock write. on_hand is the running total of the movements ledger. A PUT on a stock row drops on_hand, and a body carrying nothing else answers 422. The only way to move it is a signed correction, POST /v1/inventories/adjust, so the workflow has to work out file quantity − current on_hand for each SKU.
  • Nothing stops two runs overlapping. The scheduler starts a run for every firing and does not check for one still going (as of 2026-11). Step 7 puts the guard in the workflow.

1. Fix the file, the list and the location

Agree four things with the supplier before you build, because each one is a node setting later:

  • A key. One column that names the SKU, the same value your products and stock rows carry. Prices and stock both match on it.
  • Columns. This guide uses sku, price and qty, one row per SKU, one tier per row.
  • A file name that sorts by date, such as stock-2026-11-03.csv. Step 4 finds the last file by sorting names.
  • A finished file. Ask for a .ready marker or an atomic move into the inbox, as File transfer explains. A half-written file diffs as a catalog that lost half its lines.

Then look up the price list and the location the feed writes to:

Find the price list
curl -s https://api.revenexx.com/v1/prices/lists \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  | jq '[.items[] | {id, code, currency}]'

Entries carry no currency and no tax basis: the amount is in the list's own currency and on its tax basis, so use a list whose currency is the supplier's. Set up B2B pricing shows how to create one. For stock, every tenant starts with a location coded main. You need both its id and its code, because reading stock filters by location_id and writing it names the location_code:

Find the location
curl -s https://api.revenexx.com/v1/inventories/locations \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  | jq '[.items[] | {id, code, name}]'

In the workflow editor, open Variables on the canvas toolbar and declare these, so no node carries a folder or an id of its own. An empty variable resolves to nothing without a warning, so give every one a value:

VariableExample
inboxPath, processingPath, archivePath/in, /in/processing, /in/archive
filePatternstock-*.csv
priceListIdthe list's id
locationId, locationCodethe location's id and main

2. Try the two writes by hand

Run each write once from your machine before you wire it into a node, so a refusal is read in a terminal and not in a run. Prices first. entries/bulk upserts: a row that matches a tier the list already has updates it, and any other row creates one, so sending a line twice is an update, not a duplicate:

Write prices
curl -s -X POST https://api.revenexx.com/v1/prices/lists/<PRICE_LIST_ID>/entries/bulk \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"entries":[{"sku":"ACME-4711-BLK","quantity_min":1,"unit_price":19.90,"unit":"pcs"}]}'
Response
{ "created": 1, "updated": 0, "mode": "upsert" }

Send the same body again and the answer is "created": 0, "updated": 1. Six limits matter for a nightly file:

  • At most 5000 entries per call. A longer payload is refused with 400 and nothing is written.
  • One tier once per payload. The same SKU and quantity_min twice is 400 duplicate_tier.
  • A tier matches on product id, SKU and quantity_min together. An entry stored with both a product id and a SKU is not matched by a row that sends only the SKU, and the call inserts a second tier beside it. Send the same identity the list was loaded with, and for a list shared with other loads, read a few entries first.
  • An update replaces the whole tier. The row you send becomes the stored tier, so any optional field it leaves out (unit, price_quantity, price_quantity_unit, discount_percent, description, valid_from, valid_until, metadata) is reset to empty. A tier loaded with price_quantity: 100 and then updated by a sync that sends only the price silently reads as per 1. Map every field the feed owns, or give the feed its own price list.
  • The upsert reads at most 10,000 existing entries of the list. Past that, rows are not matched and are inserted again as duplicates. Keep a list under 10,000 entries, as Set up B2B pricing says.
  • An unchanged row still counts as updated and still announces a price change. One bulk call publishes one event for the list however many rows it carries, and counts an unchanged row as updated. So send only changed lines, which is what step 4 is for, and your price caches are not told about prices that did not move.

Now stock. Read the row, then book the difference. The current balance:

Read the current balance
curl -s "https://api.revenexx.com/v1/inventories/stock?sku=ACME-4711-BLK&location_id=<LOCATION_ID>" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  | jq '.items[] | {on_hand, reserved}'

If the file says 120 and on_hand is 100, the correction is +20:

Book the difference
curl -s -X POST https://api.revenexx.com/v1/inventories/adjust \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"location_code":"main","reason":"Supplier file 2026-11-03","items":[{"sku":"ACME-4711-BLK","quantity":20}]}'

The answer's stock array holds the row at its new balance. A SKU with no row yet gets one, born at zero, so a new article in the file needs only a positive correction. Know the refusals now:

  • reason is owed by default (the movement_reason_required setting asks for one on corrections). A call without it is 400.
  • A quantity of 0 is 400. The workflow must not send a line whose difference is zero.
  • A correction below zero is 422, and so is one below what is reserved unless the tenant turned on allow_negative_stock (off by default). Nothing is booked. A SKU the supplier set to 5 while 8 are promised to buyers fails here.
  • A call carries at most 200 items, and the same SKU twice is 400 duplicate_item.
  • A correction is not idempotent. Sending +20 twice books +40. The safe unit is read, subtract, write, never "write the same body again".

POST /v1/inventories/receive is the same shape for a delivery and has the same property: it adds, every time. Use it for goods that arrive, not for a balance you copy.

3. Build the fetch

Open Integration Studio → Workflows → New and add the triggers and the first nodes:

NodeSetting
Schedule trigger30 4 * * * in the timezone the business runs in
Manual triggernone. It feeds the same node as the schedule
SFTP: List Directory${{ vars.inboxPath }}, glob ${{ vars.filePattern }}
Array Toolscount the listed files
If condition, twicezero files: the night ends quietly on that branch. More than one: Stop and Error with a message naming the inbox. Exactly one: go on

Two choices in the trigger deserve a minute:

  • Keep the firing out of the clock-change hour. A daily schedule fires twice, an hour apart, on the night the clocks go back, in the hour that repeats: 02:00 to 02:59 in the EU, 01:00 to 01:59 in the US. A time that does not exist on the night the clocks go forward still fires once. Pick a time outside that hour, such as 04:30, and the overlap you design for in step 7 stays a rare event instead of an annual one. See Schedule triggers.
  • The manual trigger is the test button. A schedule does not fire when you save it; the first run is the next matching time. Press Run in the editor header and you get a real run now, which writes real data, so point the first one at a test price list and a test location.

The workflow insists on one file. Two waiting, for example after a night the platform missed (a missed firing is not replayed, and the recovery guide covers catching one up), is a situation for a person: only the newest state matters for a price and stock file, but which one to drop is your call. Step 7 explains why the run stops instead of picking one.

Everything the file nodes put on their output ports is shown on the node's Output tab after a test run. Wire the next node's input from there, since the exact field names, such as the file name in the listing, are not listed in the node catalog.

Fetching over HTTP instead. If the supplier publishes the file at a URL, replace the list and the claim in step 7 with an HTTP Request or a Download node on that URL, with a bearer credential if the supplier wants one. There is then no inbox to claim, so step 7's guard does not exist. Do not read the read-subtract-write of step 6 as a replacement: it is not atomic, so two overlapping runs can read the same balance and each book the same difference. Keep the interval longer than a run, and accept that an overlap double-books. You also have to store each fetched file yourself, with SFTP: Upload File to a folder of your own, because step 4 diffs against the last one. This variant was not run for this guide.

4. Diff the file against the last one

A nightly file repeats most of yesterday's lines. Diffing finds the ones that moved, so the price write is small and the stock loop touches only SKUs that changed on the supplier's side.

  1. Parse today's file. SFTP: Download File returns base64; Extract From File parses the CSV into an array of rows.
  2. Parse the last archived file. List ${{ vars.archivePath }} with no glob, so the seed file below is seen, sort by name descending, limit to 1, download and extract it the same way.
  3. Add a Compare Datasets node per question. Set itemsA to the last file's rows and itemsB to today's. For prices set keyFields to sku and compareFields to price. For stock use a second node with keyFields set to sku and compareFields set to qty.

The node splits the rows into added, removed, changed and unchanged items. You write added and changed. Three things to know:

  • Removed lines are not deleted anywhere. A SKU that left the supplier's file keeps its last price and its balance. Whether it should disappear is a business decision, not something a diff can tell from one missing line.
  • keyFields is required. A compare without a key ends the run.
  • Read the item shape off a test run. The catalog names the four groups but not the exact output field names or what a changed item carries, so open the node's Output tab and check that changed holds today's row before you map it.

On the first night there is no archived file, so everything would count as added. Put an empty CSV with only the header row into the archive folder under a name that sorts first, such as 0000-seed.csv, and the first real file is all added. If the supplier's book is longer than 5000 prices, load it once with the chunked calls from Set up B2B pricing and copy that same file into the archive instead, so the first night diffs against it.

This diff is against the last file, not against the platform. A price a merchant changed by hand in Cockpit stays until the supplier changes that line again. If the feed must win every night, skip the diff and send every line, and accept the event noise from step 2.

5. Write the prices

After the price compare, map each added or changed row to the shape of one entry. A JSON Transform or Set Fields node does it: sku from sku, unit_price from price, quantity_min fixed at 1, unit as your list uses it, and every other field the tier already carries (step 2 says why). The call converts a text amount such as 19.90 to a number, so the type is not the hazard. Two other things are. An empty price cell is written as a price of 0, so put an If condition before the write that drops rows with an empty price. A decimal comma such as 19,90 is refused with 400, so agree a decimal point with the supplier. A night with no changed prices sends an empty entries array, which the platform accepts: it writes nothing and announces nothing.

Then add a Generic API Call node on the Pricing app's bulk-upsert operation, the same call as step 2, with the price list from ${{ vars.priceListId }} and the mapped array as entries. Read created and updated off its output: on a night with 40 price changes they add up to 40.

The call takes at most 5000 entries and refuses a longer one whole. A nightly delta that large means the supplier re-priced the book, and a refusal that writes nothing is the safe way for that to fail, because the file stays in processing/ for you to look at. Array Tools has no chunk operation, so a feed that regularly moves that many prices belongs in a script that sends the chunks itself, as in Script a bulk change.

6. Write the stock

Stock goes one SKU at a time, because the difference needs the current balance. Wire an Iterator to the added and changed stock rows and, inside the loop:

  1. Generic API Call, the stock list, with sku from the item and ${{ vars.locationId }} as location_id. A SKU with no row comes back with an empty items, which means a balance of 0.
  2. A node that subtracts. The difference is qty − on_hand, and expressions do not compute, Set Fields only moves values and the catalog lists no code node (as of 2026-11). Write one small custom node, here called Stock delta, that takes the file's qty and the balance and returns the signed quantity. A CSV delivers qty as text, so the node must convert it to a number first. Custom nodes and registration cover building and installing it. This is the one piece of this workflow that is not a shipped node.
  3. If condition, quantity not equal to 0. A SKU already at the supplier's figure goes no further, which is what makes a rerun a no-op.
  4. Generic API Call, the adjust, with location_code from ${{ vars.locationCode }}, a reason that names the file, such as Nightly supplier sync, run ${{ run.id }}, and one item with the sku and the quantity.

Open the adjust node's Settings tab, set Max attempts under Retry on failure to 1 and On error to Route to error output. A node that fails is tried again by default, and a request that timed out may already have been booked, so a retry would book the same difference twice. With one attempt, the failure lands on the error port, where step 7 and the recovery guide's step on knowing it failed deal with it. The rerun you start after fixing the cause reads each balance again, so the difference it books is the real one.

The loop writes one call per changed SKU, and the gateway allows 100 requests per second per tenant, shared with everything else the tenant does (rate limits), which is ample for a few thousand changed lines a night. The adjust route takes up to 200 items per call in its items array, but only with an array you have already split, which Array Tools cannot do; use it from your own code if a night regularly moves tens of thousands of balances. For the loop's body and where its steps end, see the node catalog.

A line can be refused with 422 when the new balance would fall under what buyers hold. With the error port wired as step 7 says, that line stops the night at that SKU: the SKUs before it are booked, the file stays in processing/, and the alert names the SKU. That is deliberate, because writing off promised goods should be a person's call.

7. Claim the file, archive it and survive an overlap

A scheduled workflow tolerates the same work twice only if you build that in. This is the guard, in four moves around the middle of the workflow:

  1. Take exactly one file. After the listing in step 3, the workflow ends quietly on zero files and stops with Stop and Error on more than one. The guard needs this: with an older file left in the inbox, an overlapping run would claim that older file next and write its old prices and quantities over the new ones. Two files waiting is a person's decision, so the run says so and writes nothing.
  2. Claim the file. Right after that, and before any parsing, add SFTP: Move File from ${{ vars.inboxPath }} to ${{ vars.processingPath }}. Open its Settings tab and set On error to Route to error output, then wire the error port to a No Operation node. A second run that starts after the first has moved the file lists an empty inbox and ends quietly at the zero-files branch of step 1. Only a second run that lists the file before the first has moved it reaches the claim, where its move fails and it ends at that No Operation node.
  3. Archive at the end. After the writes, move the file from processing/ to ${{ vars.archivePath }}, keeping the supplier's file name so step 4 can sort on it.
  4. Leave the file where it is on a failure. Route every other error port to the alert and Stop and Error, not to an archive move. The file stays in processing/ and the inbox stays empty, so nothing is picked up twice. It is not rolled back: a failure in step 6 leaves the prices of step 5 written and some stock lines booked, and the archive still holds the older file.
An error port with nothing behind it fails the run. Leaving the claim's error port unwired makes the losing run of an overlap red. Wire it to a step that ends the branch, as above. A branch that ends at If condition without a step is different: an unchosen decision branch ends quietly.

What a run does when the previous one is still going at the next firing, with the supplier's file arriving over SFTP:

The stateWhat the second run does
The first run is still writingThe inbox is empty or the claim fails, so the second run ends without writing
The first run failed and left the file in processing/The second run finds an empty inbox. The next night's file is taken as usual, and the failed one waits for you. The alert tells you
Both runs list the file at the same instantOne claim should win and the other should find no file. This relies on your SFTP server refusing a move whose source is gone. That is standard behaviour but was not verified for the move node, so test it in step 8

The guard is only as strong as the claim. Fetching over HTTP has no claim, and the platform does not serialise runs, so there the guard does not exist.

To recover a failed night, fix the cause and keep exactly one file in the inbox: move the failed file back from processing/, press Run, and put the next night's file back afterwards. Stock is re-read, so the rerun books only what is still missing. Prices are not: the archive still holds the older file, so the rerun sends every changed price line again, and the list announces the change a second time. That is harmless for the data and noisy for a price cache. The rest of recovery, retry against resume and replay, is in Keep an Integration Studio workflow running.

A schedule can also skip a night: a platform that was down at 04:30 does not fire it late, and the spec promises no catch-up. The morning check in that guide is the answer. Keep the failure alert there as well: route the error ports to a Send Email node as it describes.

8. Test it, then switch it on

Use a test price list, a test location and a file of three SKUs: one price change, one stock increase, one new SKU.

  1. Press Run. Open the run and read each step's Output, including the compare node's groups.
  2. Check the writes on the platform, not only in the run. This needs the read scopes from the table at the top. Read the entry with GET /v1/prices/lists/<PRICE_LIST_ID>/entries?sku=<SKU>, and the movements with GET /v1/inventories/movements?sku=<SKU>, which should show one adjustment per changed SKU with your reason.
  3. Put a copy of the same file back in the inbox under a new name and press Run again. The diff against the archive is empty, no movement is booked and the price call writes nothing. Using a new name keeps the test clear of one thing this guide did not verify, whether the archive move overwrites a file of the same name.
  4. With a fresh file in the inbox, press Run twice in quick succession. One run should write, and the other should write nothing and finish green, at the zero-files branch or at the claim's No Operation node. If both write, your SFTP server did not refuse the second move, and the guard does not hold on it.
  5. Point the variables at the real list and location, activate the workflow and check Integration Studio → Schedules, which names why a schedule is not firing: paused, workflow inactive, or workflow not built.

Troubleshooting

SymptomLikely cause
The adjust answers 400 about a reasonreason is missing, and the tenant setting requires it
The adjust answers 400 about a quantityThe difference was 0. Check the If condition in step 6
The adjust answers 422The new balance would fall below zero or, unless the tenant allows negative stock, below what is reserved. Nothing was booked
The price call answers 400 duplicate_tierToday's file names a SKU twice. Fix the feed, or dedupe with Array Tools before the compare
The price call answers 400 about too many entriesThe delta is over 5000 rows. Nothing was written
The price call answers 400 about unit_priceA price with a decimal comma such as 19,90. Ask the supplier for a decimal point
A price shows as 0The file had an empty price cell. Filter empty prices out before the write (step 5)
The same tier now exists twiceThe list holds entries stored with a product id, and the upsert sends only the SKU (step 2)
Every night everything is "changed"The key or compare fields name a column that is not in the file, or prices differ only in formatting, such as 12.5 against 12.50
The run is red with "more than one file"Two files wait in the inbox. Step 7 explains why that stops the night
The run is green and nothing was writtenA second run took the file, or the inbox was empty. Open Runs and check the file's location
The file sits in processing/A step failed after the claim. Fix it, then follow the recovery in step 7
A price a merchant set by hand was overwrittenThe supplier changed that line, so the diff wrote it. See step 4

Where to go next

Was this page helpful?