Skip to content
iQBus HomeDocumentationBlogContact
Updated September 17, 2026
Tutorials/Getting started/Inventory export to 3PL

Inventory export to 3PL

Acme uses an outside warehouse, a third-party logistics provider, to pack and ship its orders. That warehouse plans tomorrow’s work from a stock file Acme sends over tonight, so it has to know exactly how many of each product is available to pick. Every night Acme reads its own stock figures, writes them into a file shaped the way the warehouse wants, and puts that file on the warehouse’s server before the cut-off. The warehouse collects it in the morning and plans the day. If the file is missing, the warehouse has nothing to plan from, so a run that does not finish has to raise an alarm rather than fail quietly. And a file that says there is no stock is worse than no file at all, because the warehouse would believe it.

So the job is plain, and it runs itself once a night. Take a fresh count of what is available, turn it into the file the warehouse expects, and put that file on the warehouse’s server before the deadline. One dated file per night, never missing without someone being told, and never a file that quietly reports nothing.

A nightly stock file for the warehouse Every night before the cut-off Count the stock what is available Shape the file warehouse format Warehouse server dated stock file lands Warehouse collects it plans the morning’s picking

Here is how iQBus builds that. This is the mirror image of the supplier price list import: the same clock-driven shape, running outwards instead of inwards. Everything inside the product rides a shared message backbone called the bus: parts of the flow publish messages onto it, and other parts subscribe to the ones they care about, so the pieces never call each other directly.

The way in is a receive port (an inbound endpoint that puts messages onto the bus) on the Scheduler (an adapter, the connector to one kind of outside system, here a clock). It fires once a night and publishes a tick, typed StockExportTick. A send port (an outbound endpoint that picks messages up and acts on them) bound to the SQL Caller in two-way mode subscribes to that tick (a subscription is the filter a port uses to pick up only the messages it wants) and calls the extract procedure:

EXPRESSION
{{Message.MessageType}} == "StockExportTick"

The procedure ends in FOR JSON PATH, so its result comes back as JSON on the send port’s own inbound leg, and a map (a named rule that turns one message shape into another) collapses it into one canonical StockSnapshot document on the bus. A second send port, bound to the SFTP Caller, subscribes to that snapshot and uploads it, and a second map on its way out shapes the canonical document into the warehouse’s own fixed layout, their column order and their date format. The partner’s format is an edge concern, not something the bus should carry: map at the edges. You write neither map by hand; the AI Accelerator generates both from a plain-language description, and the working code is on this page as a fallback.

EXPRESSION
{{Message.MessageType}} == "StockSnapshot"

The upload port’s remote path carries the date, so every night’s file is distinct and a re-run replaces exactly its own night. The date is not read out of the body by the adapter, which cannot do that; it is lifted onto the message as a promotion in Step 4 and bound as a token here:

TEMPLATE
/inbound/stock/stock_{{Promoted.StockSnapshot.SnapshotDate}}.csv
Scheduler once a night BUS tick SQL Caller (two-way) extract, then map ERP DB StockSnapshot SFTP Caller map, then Upload 3PL SFTP failed StockExportFailed routed to a failures table Activity Notification fallback, emails operations

When it fails. Two ports can fail, the extract and the upload, and both carry the same Exception Message Type, StockExportFailed. On an error the in-flight message is republished under that type with its payload untouched, so the payload tells you which leg broke: an empty body is a tick whose extract failed, a stock document is an upload that did not land. A third send port subscribes to it and writes each failure into a failures table operations works from, so the failure is handled, and the run does not suspend:

EXPRESSION
{{Message.MessageType}} == "StockExportFailed"

Underneath that sits an Activity Notification, a per-Application email subscription that fires on any failed run. It is the safety net for what the exception route cannot foresee, including the route itself failing: it only tells a person, and the message it reports on stays suspended in tracking for replay. Step 6 builds both and explains why you want both. The upload side also deserves generous retries before either of them gets involved: partner servers have maintenance windows, and a file that lands at 23:40 instead of 23:05 is a non-event, while a file that never lands is tomorrow’s empty picking schedule.

An empty snapshot is a failure, not an empty file. The one outcome worse than no file is a file saying nothing is in stock, because the warehouse would plan a day around it. So the extract procedure in Step 2 refuses to return a snapshot when the stock table is empty, and refusing raises, which puts the run on the failure path. Validate at the edge cuts both ways: an export checks what it is about to send, not only what it receives.
Dated filenames double as an audit trail. stock_2026-09-09.csv means a dispute about what was sent on the ninth is settled by opening the ninth’s file, and a replayed run overwrites its own date, never a different night’s.
The adapter writes straight to the remote path. There is no temporary name and rename, so a connection that drops halfway through can leave a short file on the partner’s server. What this flow gives you instead is that a dropped upload is always a failed run: it is in tracking, it is in the failures table, and the fix is to replay it, which rewrites the same dated file from the start. If the partner insists on a file that cannot be read until it is whole, the shape is an Upload to a temporary path followed by the Caller’s Move operation, which needs a second port and a message to trigger it, and that is not what this tutorial builds.
Two maps and no pipeline component. A map’s source must be XML or JSON; its target is free to be anything, including flat text. Here both legs start structured, JSON out of the database and the canonical XML on the bus, so both are legitimate maps and this tutorial has no component and no pipeline at all. The price list import is the opposite case: its source was a CSV file, which can never be a map source, so a pipeline component had to do the job.
A snapshot needs no high-water mark. The shipment status push reads only what is new, so it keeps a mark of how far it has read and it splits the result into one message per row. A snapshot is the opposite: it reports everything as it stands, so there is nothing to remember between runs, nothing to split, and a doubled tick simply rewrites the same file. Idempotent by design comes free with a full refresh.

Build it, step by step. The steps follow the integration itself, from the clock to the warehouse’s server, not the order that is quickest to click through. Every Authentication, message type and map is created from the dialog that first asks for it, so you never have to leave the port you are configuring to go and make something it needs. The ERP database objects get a step of their own just before the port that calls them.

Before you start. Everything in this walkthrough lives inside one Application. Under Applications, create it and select it, so every port, map and message type below is created in its scope. The Application is also where tracking is set, and one choice covers every port in it: while you are building, use Enabled + Body, which records each run step by step with the message body, so you can open any night’s run in tracking and see exactly what was extracted, shaped and uploaded. Once the flow is proven and moves to production, drop it to Only on Error if you wish.
Application
SettingValue
NameAcmeStock
NamespaceAcmeStock
TrackingEnabled + Body
DescriptionNightly stock snapshot to the 3PL warehouse
1
Step One
Create the clock

The integration begins with the thing that sets it in motion, the clock. Under the application’s Ports, create a receive port bound to the Scheduler. The Scheduler talks to nothing outside the bus, so it is the one adapter with no Authentication to create; its whole configuration is the cadence. If you have to step away mid-setup, Save Draft keeps the port under just its name; a draft does not exist to the runtime until the required fields are filled in and the port is saved properly.

Receive port
SettingValue
NameNightlyStockClock
ApplicationAcmeStock (preset)
Port TypeReceive
AdapterScheduler
Adapter Message TypeStockExportTick, created below, at this field
Exception Message Typeempty; a Scheduler tick has nothing to fail on the way in, the failures this flow handles are on the send ports
WayOne (the Scheduler offers nothing else; there is no caller waiting for a reply)
Configure the adapter

Open the adapter configuration (the button beside Adapter). This is the one screen in the tutorial where the obvious setting is the wrong one, so it is worth slowing down. There is no "fire at 23:00" field. The cadence is an interval counted from the moment the port is enabled, so Repeat Every 1 with Repeat Unit Days does fire once a day, but at whatever time of day you happened to start the port, and it moves again every time you restart it. The Daily Window schedules nothing either; it only silences the ticks that fall outside it.

A nightly job at a chosen time comes from combining the two. Run a short interval around the clock, and open the window for exactly one interval’s width:

EXPRESSION
Daily End Window = Daily Start Window + Repeat Every, one second less

A window as wide as the interval cannot hold two ticks that are one interval apart, and it cannot hold none, because the ticks never stop. So exactly one tick lands inside it each night, whatever time the port was enabled and however often it is restarted. Outside the window the port only wakes, checks the clock, and goes back to sleep. Five minutes is a comfortable width for a stock file: the warehouse reads it in the morning, so landing somewhere in the first five minutes of the hour is exactly what was asked for.

Adapter configuration
SettingValue
Time Zoneyours; this is what makes the two clock times below mean local time, and it matters more here than on most flows because the warehouse’s cut-off is a local time. The cadence itself is interval-based and time-zone-agnostic
Repeat Every5
Repeat UnitMinutes
Start Date Windowoff; the export is open-ended, there is no date to begin on
End Date Windowoff; the export is open-ended, there is no date to stop on
Daily WindowOn; this is what turns a five-minute interval into one nightly run
Daily Start Window11:00:00 PM
Daily End Window11:04:59 PM, the start plus the five-minute interval, one second less
Payloadempty; the tick is only a wake-up call, and the message still carries the standard envelope, which is all the extract port needs
Both ways of mismatching the window and the interval have a cost. A window wider than the interval holds more than one tick, so the job runs twice in the same night, and you extract and upload twice. That one is survivable here, because the second upload rewrites the same dated file with the same figures, but it is two logins and two extracts for nothing. A window narrower than the interval can fall between two ticks and hold none, so a night passes with no file collected in the morning and nobody told, because no run failed. Match the two and neither can happen.
Type the tick at the adapter

Back on the port’s General tab, one row of the table above is still open, the required Adapter Message Type. The runtime applies it the instant the Scheduler releases the tick, so even an empty message is typed before anything else runs, and that type is what the extract port subscribes to in Step 3. Nothing exists yet, so create the message type here at the field. A message type is a name plus a Format, no schema required; the body is empty, so plain text describes it best and there is nothing to promote.

Message type, the tick
SettingValue
NameStockExportTick
FormatTXT
Sample dataempty; the tick carries no body
Promotionsnone; the subscription matches on the type alone

Saving the type returns you to the port with Adapter Message Type now reading StockExportTick, which completes the General table above. Save the port. NightlyStockClock is complete, stopped, and waiting; it is the last port you start, in Step 7.


2
Step Two
Create the ERP database objects

The ERP database is the system you are integrating with, not part of iQBus, so it is set up by hand rather than asked of the AI Accelerator. Create the stock table and the extract procedure the send port calls in Step 3. Three things about the procedure are deliberate and the rest of the flow leans on all three.

It puts the snapshot date on every row, so the filename and the contents of the file can never disagree about which night they describe. It ends in FOR JSON PATH, so the rows come back as a single JSON array rather than a row set, which is what lets one map collapse the whole extract into one document with no splitting. And it refuses to return an empty snapshot: if the stock table has no rows at all, something is wrong upstream, and a file reporting nothing available would be read by the warehouse as fact. Raising instead puts the run on the failure path built in Step 6, and last night’s file stays the most recent thing the warehouse has.

The stock table
SQL
CREATE TABLE dbo.StockOnHand (
    Sku      VARCHAR(40) NOT NULL PRIMARY KEY,
    OnHand   INT         NOT NULL,
    Reserved INT         NOT NULL CONSTRAINT DF_Stock_Res DEFAULT 0
);
The extract procedure
SQL
CREATE OR ALTER PROCEDURE dbo.GetStockSnapshot
AS
BEGIN
    SET NOCOUNT ON;

    -- An empty stock table is an upstream problem, not a stock level.
    -- Raise rather than hand the warehouse a file that says nothing is available.
    IF NOT EXISTS (SELECT 1 FROM dbo.StockOnHand)
        THROW 51000, 'GetStockSnapshot: dbo.StockOnHand is empty, refusing to export an empty snapshot.', 1;

    SELECT
        CONVERT(CHAR(10), SYSUTCDATETIME(), 23) AS SnapshotDate,
        Sku,
        OnHand,
        Reserved
    FROM dbo.StockOnHand
    ORDER BY Sku
    FOR JSON PATH;
END;
The SQL Caller returns what the SELECT asks for. This query ends in FOR JSON, so the result is JSON, and the map you build in Step 4 has a JSON source to match. A plain SELECT would also have returned JSON; only a FOR XML clause makes it XML, which is what the shipment status push uses because it wants one message per row. Decide the format in the SELECT, then give everything downstream a source that agrees with it.
Quantity available is computed in the map, not here. The procedure returns OnHand and Reserved separately and the canonical document keeps both, because they are two facts about Acme’s stock and the bus should carry facts. Subtracting one from the other to get the single number this particular warehouse wants is the warehouse’s convention, so it happens on the way out, in the map in Step 5. A second warehouse with a different convention then costs one more map and nothing else.

3
Step Three
Create the extract port

Under the application’s Ports, create a send port bound to the SQL Caller in two-way mode. Rather than being told what to deliver, a send port states which messages it wants; this one wants every tick the clock publishes. Two-way means the procedure’s result comes back into the port and is published to the bus again, which is where Step 4 does its work.

Send port
SettingValue
NameStockExtract
ApplicationAcmeStock (preset)
Port TypeSend
AdapterSQL Caller
Adapter Message TypeSqlCallerResult, the type of the result coming back; a send port carries one only in two-way mode, and Step 4 sets it
Exception Message TypeStockExportFailed, created next, at this field
WayTwo
Subscription{{Message.MessageType}} == "StockExportTick"
Name the failure while you are here

Every port has an Exception Message Type. When a run fails, the in-flight message has its type swapped to this one and is republished to the bus, payload untouched, so that another port can pick the failure up and do something with it; without it, the failed run simply suspends. The type does not exist yet, so create it at the field. Its Format describes what will most often be inside it, and across this flow that is the canonical stock document from the upload port, so XML is the honest answer even though a failed extract republishes an empty tick under the same type. Step 6 gives the type its route.

Message type, the failure
SettingValue
NameStockExportFailed
FormatXML
Sample dataempty; the body is whatever was in flight when the run failed, an empty tick from this port or a canonical snapshot from the upload port
Promotionsnone; the route in Step 6 reads the whole body and nothing else subscribes on a value inside it
Solicit side, the request leg

The solicit side is the port’s outbound leg, bus to adapter, and it runs in this order. There is nothing to do on any of it: the message going out is an empty tick, so there is no shape to change and no value to lift out of it. All of the work is in the adapter, and then on the response side in Step 4.

SettingValue
Open Variablesempty; nothing outside the message needs carrying into this call
MapNone; the tick has no body to transform, and the procedure takes no parameters
Pipelineempty; there is nothing to pre-process on the way out
Close Variablesempty; the adapter fields below are all fixed values, so there is no last-mile value to compute
Configure the adapter

Open the adapter configuration. At the Auth Config field, create the ERP database credential in place; the dialog opens on top of the port and returns you to it when you save:

Authentication, ERP database
SettingValue
NameErpDb
AdapterSQL Caller (preset)
DefinitionSQL Server Connection
ApplicationAcmeStock (preset)
Connection String (Database Config)one of the two below
Which connection string

The SQL Caller connects from where iQBus runs, so two things decide the string: how iQBus reaches the server and how the server identifies iQBus. Reachability first: an Azure SQL database on a public endpoint needs iQBus’s outbound address allowed through the server firewall; one behind a private endpoint has to sit in, or be peered with, the iQBus network; an on-premises SQL Server, which is where an ERP database often lives, is reached over a VPN or ExpressRoute link, and its hostname has to resolve from the iQBus side. Then the identity, which is what the two strings differ in. iQBus supports SQL authentication and managed identity; it does not support Entra ID interactive sign-in, and putting an Entra account’s username and password into a connection string is strongly discouraged, so neither appears here. Replace the placeholders in angle brackets with your own values:

MethodConnection StringUse it when
SQL authenticationServer=tcp:<server>.database.windows.net,1433;Database=<database>;User ID=<login>;Password=<password>;Encrypt=True;TrustServerCertificate=False;the simplest to start with, and the only option for a SQL Server that is not Entra-integrated; the password lives in this Authentication, encrypted at rest
Managed identityServer=tcp:<server>.database.windows.net,1433;Database=<database>;Authentication=Active Directory Managed Identity;Encrypt=True;no secret at all: the iQBus deployment’s own Azure identity is granted access on the database (CREATE USER [<identity>] FROM EXTERNAL PROVIDER, then the roles it needs); the strongest choice for production

For an on-premises server, swap the Server= value for its hostname or IP and port and use SQL authentication. For this tutorial, SQL authentication against a database whose firewall lets iQBus in is enough. Whichever you pick, the login needs EXECUTE on dbo.GetStockSnapshot, and in Step 6 on dbo.RecordStockExportFailure as well, because the same Authentication serves both ports.

Back in the adapter configuration, set the command. The procedure takes no parameters, so there are no Input Parameters rows:

Adapter configuration
SettingValue
Auth Config TypeStatic
Auth ConfigErpDb, just created
Command TypeStored Procedure
Command Textdbo.GetStockSnapshot, from Step 2
Input Parametersnone; the procedure takes no arguments, the snapshot is always "everything, as of now"

Leave the port open; the next step continues on its Response side.


4
Step Four
Shape the result into a canonical stock snapshot

Still inside StockExtract, move to the Response side, the send port’s inbound leg, where the procedure’s result re-enters the bus. Because a message is entering here, the response side carries a required Adapter Message Type, applied the instant the SQL Caller releases the result to the next step inside the port. The response side runs Adapter Message Type, then Pipeline, then Map: transport-level work comes before any reshaping, the inverse of the way out.

The result the SQL Caller returns

Because the procedure ends in FOR JSON PATH, the rows arrive as one JSON array with one object per product, and the snapshot date repeats on every object. Three products are shown; a real snapshot runs to thousands and the shape is the same:

JSON
[
  { "SnapshotDate": "2026-09-09", "Sku": "TP-4501", "OnHand": 118, "Reserved": 12 },
  { "SnapshotDate": "2026-09-09", "Sku": "HX-0090", "OnHand": 64,  "Reserved": 0 },
  { "SnapshotDate": "2026-09-09", "Sku": "GL-1240", "OnHand": 25,  "Reserved": 5 }
]

The SQL Caller hands its two-way result over under the standard name SqlCallerResult; select it at the Adapter Message Type field. If the picker does not offer it yet, create it there with the values below. Paste the array above into Sample data: it has no runtime effect, but it documents the shape for whoever opens the type later, and it is what the map tester further down this step reads from.

Message type, the result
SettingValue
NameSqlCallerResult
FormatJSON, because the procedure ends in FOR JSON
Sample datathe result array above
Promotionsnone; nothing routes on the raw result, and the one value this flow binds is promoted on the canonical type below

Saving the type fills the Adapter Message Type row that Step 3 left open on this port.

Response pipeline
SettingValue
Pipelineempty; the whole snapshot is meant to travel as one message, so there is nothing to split and no code to run before the map
Why there is no splitter here. The shipment status push puts a pipeline component on this exact step, because it wants one message per row so each status becomes its own call. An export wants the opposite: one file, so one message. The array arrives as a single body and the map below turns it into a single document, which is why this response side needs no pipeline at all. If you ever find yourself splitting a result and then reassembling it, the split was the mistake.
Response map

Go to the Map step and set Map Assignment to Typed. Add one row and, at its Map field, create the map. A map is not a file, it is a named resource defined by its name and the two message types it sits between; the source exists already, and the target is created from the map dialog’s own Target field:

Map
SettingValue
NameResultToStockSnapshot
Source message typeSqlCallerResult, from the Adapter Message Type above
Target message typeStockSnapshot, created next, at the Target field
The canonical StockSnapshot

Now the target, from the Target field. This is the shape the rest of the business reads, and the one value the upload port needs outside the body, the snapshot date that goes into the remote path in Step 5, is lifted out of it by a promotion. A promotion is a named path into the payload (XPath for an XML type), so before naming it, look at the payload the map will produce. Note that the date appears once, at the top, rather than on every item: repeating it was the database’s convenience, not a fact worth carrying:

XML
<Stock>
  <SnapshotDate>2026-09-09</SnapshotDate>
  <Item><Sku>TP-4501</Sku><OnHand>118</OnHand><Reserved>12</Reserved></Item>
  <Item><Sku>HX-0090</Sku><OnHand>64</OnHand><Reserved>0</Reserved></Item>
  <Item><Sku>GL-1240</Sku><OnHand>25</OnHand><Reserved>5</Reserved></Item>
</Stock>

Paste that XML into the type’s Sample data first. Then add the promotion row, open its ellipsis menu and choose Generate Path: it shows the sample, you click the node you want, and the path is written for you. Click SnapshotDate and you get /Stock/SnapshotDate. You can also type the path by hand, but the tool removes the guesswork on deeper shapes.

Message type, the canonical snapshot
SettingValue
NameStockSnapshot
FormatXML
Sample datathe Stock XML above
Promotions on StockSnapshot
PromotionPathRead by
SnapshotDate/Stock/SnapshotDatethe Path of the SFTP send port, Step 5

Adapter fields are plain strings: they bind {{…}} tokens such as {{Promoted.StockSnapshot.SnapshotDate}} but never evaluate a body path themselves, which is why the promotion has to exist on the type. That is the whole reason the date is promoted at all; nothing subscribes on it. Save the type; you are back in the map with both types selected.

Build it with the AI Accelerator

You do not have to write this map by hand. Open the AI Accelerator, describe the translation in plain language, and let it generate the XSLT for you to review and paste into the map. It knows how iQBus maps read JSON and emit XML, but it sees nothing of your Application: not the two types you just created, not the promotion, nothing but the text you give it. So the prompt carries everything the map needs: the result sample, the canonical sample, and the one path the promotion reads. A prompt like this is enough:

AI ACCELERATOR PROMPT
Build a map from a JSON message type named SqlCallerResult to an XML message type named StockSnapshot.

The source is the result of a SQL query that ends in FOR JSON PATH, so it is a JSON array with one object per product, and every object repeats the same snapshot date. Collapse that array into one XML document. The root element is Stock. It holds a single SnapshotDate element taken from the first object in the array, then one Item element per object, each Item carrying the product code, the quantity on hand and the reserved quantity as child elements named Sku, OnHand and Reserved. Match the source fields to them by meaning, and keep the array's order.

The element path /Stock/SnapshotDate must be exactly that, because a promotion reads it and a send port's remote path binds the promotion. Do not compute anything: OnHand and Reserved are carried through as they are. An empty array is not something the map has to handle, because the procedure refuses to return one.

The SqlCallerResult source looks like this (yours will carry different products and a different date, but the same shape):

[
  { "SnapshotDate": "2026-09-09", "Sku": "TP-4501", "OnHand": 118, "Reserved": 12 },
  { "SnapshotDate": "2026-09-09", "Sku": "HX-0090", "OnHand": 64, "Reserved": 0 },
  { "SnapshotDate": "2026-09-09", "Sku": "GL-1240", "OnHand": 25, "Reserved": 5 }
]

The StockSnapshot target must look like this:

<Stock>
  <SnapshotDate>2026-09-09</SnapshotDate>
  <Item><Sku>TP-4501</Sku><OnHand>118</OnHand><Reserved>12</Reserved></Item>
  <Item><Sku>HX-0090</Sku><OnHand>64</OnHand><Reserved>0</Reserved></Item>
  <Item><Sku>GL-1240</Sku><OnHand>25</OnHand><Reserved>5</Reserved></Item>
</Stock>

The AI Accelerator writes a first draft, not a finished map, so test it before you rely on it. The map editor has a tester built in: paste the result array as the input, run the map, and compare the result with the canonical XML above. What has to match exactly is the promoted path, /Stock/SnapshotDate, and the element names Item, Sku, OnHand and Reserved, because the second map in Step 5 reads them; indentation and attribute style may differ. If you would rather start from working code, expand it here to read or copy.

Show the full map code (XSLT 3.0), expand to read or copy

Maps are authored in XSLT 3.0 and run on Saxon HE 12.9. The source is JSON, which arrives as text, so parse-json(string(.)) turns it into an array; ?1?SnapshotDate lifts the date off the first entry and ?* iterates the rest.

XSLT 3.0
<xsl:stylesheet version="3.0"
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:output method="xml" indent="yes"/>
  <xsl:template match="/">
    <xsl:variable name="rows" select="parse-json(string(.))"/>
    <Stock>
      <SnapshotDate><xsl:value-of select="$rows?1?SnapshotDate"/></SnapshotDate>
      <xsl:for-each select="$rows?*">
        <Item>
          <Sku><xsl:value-of select="?Sku"/></Sku>
          <OnHand><xsl:value-of select="?OnHand"/></OnHand>
          <Reserved><xsl:value-of select="?Reserved"/></Reserved>
        </Item>
      </xsl:for-each>
    </Stock>
  </xsl:template>
</xsl:stylesheet>
Generated output varies, so treat the samples on this page as examples. The Accelerator does not return the exact same code twice, and neither map draft will match its collapsed version line for line. What has to hold regardless of the draft is the contract the rest of the flow depends on: the root is Stock, its date sits at /Stock/SnapshotDate where the promotion reads it, each item is an Item with Sku, OnHand and Reserved, and the file the second map writes carries the header and column order the warehouse agreed to. With tracking on Enabled + Body, open any run in Step 7 to see the real bodies.

Save the map, and the row on the Response side’s Map step reads:

Map assignment
Source Message TypeMapTarget Message Type
SqlCallerResultResultToStockSnapshotStockSnapshot

Save the port. StockExtract is complete: every tick it acts on becomes one StockSnapshot message on the bus, or a failure.


5
Step Five
Create the upload port

Under the application’s Ports, create a send port bound to the SFTP Caller, one-way. The SFTP adapter exists on the send side only, as a Caller, so putting a file on someone else’s server is a send port that reaches out and does it, and there is no SFTP receive port anywhere in iQBus. It subscribes to every snapshot the extract port publishes. The Exception Message Type already exists from Step 3, so this time it is a selection:

Send port
SettingValue
NameStockToWarehouse
ApplicationAcmeStock (preset)
Port TypeSend
AdapterSFTP Caller
Adapter Message Typenone; one-way, so nothing comes back to be typed
Exception Message TypeStockExportFailed, from Step 3
WayOne (the upload either succeeds or raises, and there is no result the flow needs)
Subscription{{Message.MessageType}} == "StockSnapshot"
Solicit side, the request leg

The solicit side is the port’s outbound leg, bus to adapter, and unlike the extract port there is real work on it: this is where the canonical document becomes the warehouse’s file. Present in editor order, the Map row is the only one with anything in it:

SettingValue
Open Variablesempty; the only value the adapter needs from outside the body is the snapshot date, and that is a promotion, not a Variable
MapStockSnapshotTo3plFile, created below, at this field, with Map Assignment set to Typed and keyed on StockSnapshot
Pipelineempty; the map produces the finished file, so there is nothing left to frame, compress or wrap on the way to the server
Close Variablesempty; nothing to compute at the last moment
The map

At the Map field, create the second and last map. Its source is the canonical type from Step 4; its target is the warehouse’s file, which needs a type of its own so the map has something to sit between, created from the map dialog’s Target field:

Map
SettingValue
NameStockSnapshotTo3plFile
Source message typeStockSnapshot, from Step 4
Target message typeWarehouse3plStockFile, created next, at the Target field
The file the warehouse expects

Their format, not ours: semicolon-separated, a fixed header line, one line per product, the date compacted to yyyymmdd, and a single quantity column holding what is actually pickable, on hand less reserved. Three products, matching the snapshot in Step 4:

CSV
SKU;QTY_AVAILABLE;SNAPSHOT_DATE
TP-4501;106;20260909
HX-0090;64;20260909
GL-1240;20;20260909

Create the target type from the Target field and paste that file into Sample data. A flat target is perfectly legal: it is a map’s source that has to be XML or JSON, never its target, which is why the same rule that forced a component on the price list import costs nothing here.

Message type, the warehouse’s file
SettingValue
NameWarehouse3plStockFile
FormatCSV
Sample datathe semicolon file above
Promotionsnone; only XML and JSON types carry promotions, and by this point nothing routes on the payload anyway, the port that produced it is the one delivering it
Build it with the AI Accelerator

The second map, and again you do not write it by hand. Open the AI Accelerator, describe the file in plain language, and let it generate the XSLT. It knows how iQBus maps emit flat text, but it sees nothing of your Application, so the prompt carries the canonical sample, the exact file it has to produce, and the one piece of arithmetic the warehouse expects. A prompt like this is enough:

AI ACCELERATOR PROMPT
Build a map from an XML message type named StockSnapshot to a flat text message type named Warehouse3plStockFile. The output is plain text, not XML and not JSON.

Write one header line reading exactly SKU;QTY_AVAILABLE;SNAPSHOT_DATE, then one line per Item element in the source, in the order they appear. Each line has three semicolon-separated fields and no trailing semicolon, and every line including the last ends with a newline.

The three fields per line, in order: the product code exactly as it stands in Sku; then the quantity available, which is OnHand minus Reserved, written as a whole number with no decimal part and no thousands separator; then the snapshot date with its hyphens removed, so 2026-09-09 becomes 20260909. The date is the same on every line and comes from the document's single SnapshotDate element, not from the items, so read it once.

The StockSnapshot source looks like this (yours will carry different products and a different date, but the same shape):

<Stock>
  <SnapshotDate>2026-09-09</SnapshotDate>
  <Item><Sku>TP-4501</Sku><OnHand>118</OnHand><Reserved>12</Reserved></Item>
  <Item><Sku>HX-0090</Sku><OnHand>64</OnHand><Reserved>0</Reserved></Item>
  <Item><Sku>GL-1240</Sku><OnHand>25</OnHand><Reserved>5</Reserved></Item>
</Stock>

The file it must produce looks like this:

SKU;QTY_AVAILABLE;SNAPSHOT_DATE
TP-4501;106;20260909
HX-0090;64;20260909
GL-1240;20;20260909

Test this one in the map editor’s built-in tester before you rely on it: paste the canonical Stock XML as the input, run the map, and compare the output with the file above, character for character. Two things are worth checking rather than assuming, because a partner’s loader is unforgiving about both: that the header line is spelled and cased exactly as agreed, and that QTY_AVAILABLE reads 106 and not 106.0. If you would rather start from working code, expand it here to read or copy.

Show the full map code (XSLT 3.0), expand to read or copy

Text output is declared with method="text", which is what makes a map able to write a flat file at all. The date is computed once into a variable, and &#10; is the newline; wrapping the subtraction in format-number is what keeps a whole number from being written as a decimal.

XSLT 3.0
<xsl:stylesheet version="3.0"
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:output method="text"/>
  <xsl:template match="/Stock">
    <xsl:variable name="d" select="translate(SnapshotDate, '-', '')"/>
    <xsl:text>SKU;QTY_AVAILABLE;SNAPSHOT_DATE&#10;</xsl:text>
    <xsl:for-each select="Item">
      <xsl:value-of select="Sku"/>
      <xsl:text>;</xsl:text>
      <xsl:value-of select="format-number(number(OnHand) - number(Reserved), '0')"/>
      <xsl:text>;</xsl:text>
      <xsl:value-of select="$d"/>
      <xsl:text>&#10;</xsl:text>
    </xsl:for-each>
  </xsl:template>
</xsl:stylesheet>

Save the map, and the Map row in the solicit-side table above is now filled:

Map assignment
Source Message TypeMapTarget Message Type
StockSnapshotStockSnapshotTo3plFileWarehouse3plStockFile
Configure the adapter

Open the adapter configuration. At the Auth Config field, create the warehouse’s SFTP credential in place; the dialog opens on top of the port and returns you to it when you save:

Authentication, warehouse server
SettingValue
NameWarehouse3plSftp
AdapterSFTP Caller (preset)
DefinitionSFTP Password Authentication
ApplicationAcmeStock (preset)
Username (SFTP Password Settings, Credentials)acme-stockfeed
Password (SFTP Password Settings, Credentials)W3!2026-stockfile-6c1f9a
Host Key Fingerprint (SFTP Password Settings, Server Identity)SHA256:4Tz8mVqL2wR6yE1aB9cD5fG7hJ0kN3pQ8sU2xZ4bC6d

The username and password are whatever the warehouse issued for your account; the values above are fine for the tutorial, and for a real feed use theirs and get them out of band rather than by email. The Host Key Fingerprint is optional in the dialog and belongs in every production configuration: when it is set, the adapter refuses to connect to a server whose host key does not match, which is the SFTP equivalent of certificate pinning. It matters more on an upload than on a download, because a download from a stranger gives you a bad file while an upload to a stranger gives them your stock positions. Get the fingerprint from the warehouse over a channel that is not the SFTP connection itself, and expect to update it the day they tell you the server moved.

Back in the adapter configuration, describe the write. There is no operation to choose: the SFTP Caller uploads, and that is the whole of it. Path is the full destination, filename included, and it is where the promotion from Step 4 is bound:

Adapter configuration
SettingValue
Auth Config TypeStatic
Auth ConfigWarehouse3plSftp, just created
Hostsftp.3pl.example
Port22
Path/inbound/stock/stock_{{Promoted.StockSnapshot.SnapshotDate}}.csv, the directory the warehouse gave you plus a filename carrying the night’s date
OverwriteOn; a replayed or repeated run for the same night has to be able to rewrite its own dated file, and it can never touch another night’s because the date is in the name
Create Directory If Not ExistsOff; /inbound/stock is the warehouse’s own directory and it exists. Leaving this Off is deliberate: if the path is ever wrong you want the upload to fail loudly rather than quietly create a directory nobody reads
Request Payload ModeText; the body being uploaded is the plain-text file the map produced
Request Payload Mode is not a guess. It decides how the message body is handed to the server, so it has to agree with what the body actually is: Text for a CSV or any other character file, Base64 for anything binary, such as the PDF in the PDF over SFTP tutorial, Json when the file is JSON being written as JSON. Pick it from the file, not from habit.

Save the port. StockToWarehouse is complete: every snapshot on the bus becomes one dated file on the warehouse’s server, or a failure.


6
Step Six
Handle failures, twice

Two things can go wrong in this flow, and you already named them both StockExportFailed: the extract fails or refuses, so the empty tick is republished under that type, or the upload fails, so the canonical snapshot is republished under it. Right now nothing subscribes to that type, so a failure would go nowhere. This step configures failure handling twice, because the two mechanisms do different jobs.

Two mechanisms, two jobs. The Exception Message Type and its route are part of the integration: they handle the failures you can foresee, by giving the failed message a real destination, here a failures table operations works from. Because the failure is handled, the run does not suspend. The Activity Notification is monitoring, not integration: it reacts to any failed run in the Application, tells a person, and does nothing else; it routes nothing and handles nothing, so the message it reports on does suspend and waits in tracking for replay. It is the safety net for what you did not foresee, and the exception route itself failing is the clearest example, since an ERP database that is down takes the failures table down with it. Build the route for what you know can break; build the notification for everything else.
The failures table and procedure

The route needs somewhere to put the failure, and on a nightly export that somewhere had better hold the snapshot itself: the morning question is always "what did we try to send, and was it right?". Operations already lives in the ERP database, so the table goes there for this tutorial; in production you might prefer a database of your own, precisely so an ERP outage does not take the failure log with it. Like Step 2, this is scaffolding for an external system, so it is supplied as SQL rather than asked of the AI Accelerator. The procedure classifies the failure from its payload: a failed extract republishes the empty tick and a failed upload republishes a whole snapshot, so an empty body tells the two legs apart without the flow having to label them.

SQL
CREATE TABLE dbo.StockExportFailures (
    FailureId  BIGINT        IDENTITY(1,1) NOT NULL PRIMARY KEY,
    Leg        VARCHAR(10)   NOT NULL,          -- Extract or Upload
    Payload    NVARCHAR(MAX) NULL,              -- the snapshot that did not land, empty for a failed extract
    FailedAt   DATETIME2     NOT NULL CONSTRAINT DF_StockFail_At DEFAULT SYSUTCDATETIME(),
    ResolvedAt DATETIME2     NULL               -- operations sets this once the export is re-run
);
SQL
CREATE OR ALTER PROCEDURE dbo.RecordStockExportFailure
    @Payload NVARCHAR(MAX)
AS
BEGIN
    SET NOCOUNT ON;
    INSERT INTO dbo.StockExportFailures (Leg, Payload)
    VALUES (CASE WHEN NULLIF(LTRIM(RTRIM(@Payload)), '') IS NULL THEN 'Extract' ELSE 'Upload' END,
            @Payload);
END;
The exception route

Under the application’s Ports, create one more send port on the SQL Caller, one-way: it subscribes to the failure type and calls the procedure above. It reuses the ErpDb Authentication from Step 3, so at the Auth Config field this is a selection, not a creation. Leave its own Exception Message Type empty: a route that republished its own failures as StockExportFailed would pick them straight back up, and that loop is exactly the case the Activity Notification below exists for.

Send port
SettingValue
NameFailedExportToDeadLetter
ApplicationAcmeStock (preset)
Port TypeSend
AdapterSQL Caller
Adapter Message Typenone; one-way
Exception Message Typeempty, on purpose; see the paragraph above
WayOne
Subscription{{Message.MessageType}} == "StockExportFailed"
Solicit side, the request leg
SettingValue
Open Variablesempty; the parameter reads the failed message itself
MapNone; the payload is stored exactly as it failed, which is the point of keeping it
Pipelineempty; nothing to pre-process
Close Variablesempty; nothing to compute
Adapter configuration
SettingValue
Auth Config TypeStatic
Auth ConfigErpDb, from Step 3
Command TypeStored Procedure
Command Textdbo.RecordStockExportFailure
Input Parameters
ParameterValue
Payload{{Message.Body}}

Adapter parameters are plain strings that bind {{…}} tokens but never evaluate a body path themselves. {{Message.Body}} is the whole message, which is exactly what is wanted here, and it is also why StockExportFailed needed no promotion: the route reads everything and the procedure does the classifying. Save the port. From now on a failed upload lands as an Upload row holding the exact snapshot that did not reach the warehouse, ready to be re-sent, and the run completes as handled.

The safety net

Under the application’s Activity Notifications, create the notification. With Subscription on All and On Error Only on, it fires for any failed run in the Application, whichever port failed and whether or not you foresaw it:

Activity Notification
SettingValue
NameStockExportErrors
ApplicationAcmeStock (preset)
On Error OnlyOn
SubscriptionAll
ModeOn Event
Recipientsone row: Name Operations, Email ops@acme.example
Is EnabledEnabled
One-time prerequisite. Notifications go out through the platform email channel, configured once for the whole installation under Email notification configuration (an Entra app registration with Mail.Send). Until that is done, the notification can be created and enabled but sends nothing, and the second half of the test in Step 7 will prove nothing. There is no per-Application mail credential.
Do not let the notification stand in for the route. An email that says "the stock export failed" leaves the snapshot nowhere and the warehouse none the wiser until someone reads the mail, finds the run and opens the payload. The Upload row is the snapshot, already where operations can look at it and re-send it. On a nightly job the difference is a morning: a routed failure is waiting to be read when the team arrives, a suspended one is waiting to be noticed, and the warehouse starts picking either way. Keep the notification for the cases the route cannot reach; when one arrives, the right response is usually to add a route for it.

7
Step Seven
Start the ports and test
What you need in place first

Nothing in this flow speaks HTTP, so there is no API client to set up and no mock server to stand anything in for. The two systems are a database and a file server, and you test it with the tools you would use on either one. Have these ready, because each one is something the test cannot prove without:

WhatWhy the test needs it
A SQL client, SQL Server Management Studio or Azure Data Studioto seed dbo.StockOnHand so there is something to export, and to read dbo.StockExportFailures afterwards. Connect as the login in ErpDb and confirm it has EXECUTE on both procedures, or the run will fail for a reason that has nothing to do with the export
An SFTP client, WinSCP, FileZilla, or the sftp command line; any of them works the same wayto look in /inbound/stock and read the file that landed, which is the only way to prove the other end actually received what tracking says was sent. Sign in with the same username and password you put in Warehouse3plSftp: if your client cannot write to that directory, neither can the port
The operations mailbox you put in StockExportErrors, and the platform Email notification configuration already donethe second break-it test below is entirely about an email arriving. Without the platform configuration nothing sends, and a silent mailbox will look like a passing test
Start in dependency order

There is nothing to deploy, everything you configured is already saved and live, because iQBus applies changes immediately. To bring the flow online you start the send ports first, then the receive port, and among the send ports, the ones furthest downstream first: start FailedExportToDeadLetter, then StockToWarehouse, then StockExtract, and last the clock, NightlyStockClock. Starting each subscriber before its producer means no tick fires before the extract is listening, and no snapshot is published before the upload port is ready to take it. (To take the flow offline, stop the clock first, for the same reason.) Tracking is already Enabled + Body from the Application, so every run you make now is recorded step by step with its payload.

Do not wait until eleven at night

The clock is deliberately set to fire once a night, which is useless for testing. Change three settings on NightlyStockClock, Restart the port from its row in the ports list to reset its timer, and you get a tick a minute:

SettingTest valueRestore to
Daily WindowOffOn
Repeat Every15
Repeat UnitMinutesMinutes (unchanged)
Daily Start Window / Daily End Windowignored while the window is off11:00:00 PM / 11:04:59 PM
A tick a minute is an upload a minute. While the clock is in test mode the flow extracts the whole snapshot and logs in to the warehouse’s server every sixty seconds, rewriting the same dated file each time. That is harmless against a test server, and it is not harmless against a partner’s production server, where a minute-by-minute file replacement is exactly the kind of thing that gets an account suspended. Restore all three settings the moment the test passes, and restart the port so the window takes effect.
Seed the stock and walk the run

The trigger in this flow is the clock, not a message, so all the test needs is something in stock to report. Insert the three products from the samples, then wait for the next tick:

SQL
INSERT INTO dbo.StockOnHand (Sku, OnHand, Reserved) VALUES
    ('TP-4501', 118, 12),
    ('HX-0090',  64,  0),
    ('GL-1240',  25,  5);

Then open Tracking, find the run the tick started, and step through it, opening each message body as you go:

Step in TrackingWhat to confirm
Scheduler firesan empty message typed StockExportTick is published
StockExtract picks it upthe subscription matched and dbo.GetStockSnapshot was called
SQL Caller respondsthe body is the JSON array, three objects, each carrying the same SnapshotDate, typed SqlCallerResult
Map ResultToStockSnapshotthe body is now one <Stock> document with a single SnapshotDate and three <Item> elements, and no pipeline ran between the two
Publishone StockSnapshot is on the bus and StockToWarehouse picked it up
Map StockSnapshotTo3plFilethe body is now the semicolon file, header line plus three product lines, with QTY_AVAILABLE reading 106, 64 and 20, the on-hand figures less what is reserved
SFTP Calleran Upload to /inbound/stock/stock_2026-09-09.csv (your run’s own date) succeeded

Then confirm the other end, which is the half tracking cannot tell you: with the SFTP client, list /inbound/stock and check the filename carries today’s date, then open the file and compare it with the sample in Step 5, header included. Change one Reserved figure in the database, wait for the next tick, and confirm the quantity in the file moves by the same amount and the filename does not change, because it is still the same night.

Break it on purpose, first the route

Make the upload fail. The cleanest way is to point it somewhere that is not there: open StockToWarehouse and change Path to /inbound/nosuchplace/stock_{{Promoted.StockSnapshot.SnapshotDate}}.csv, leaving Create Directory If Not Exists Off, and save. Wait for the tick. The extract succeeds, the snapshot is published, the map runs, and the upload fails after its retries. Watch what happens next in tracking: the message is republished as StockExportFailed, FailedExportToDeadLetter picks it up and calls the procedure, and the run finishes as handled, not suspended. In the database, SELECT Leg, FailedAt, Payload FROM dbo.StockExportFailures shows one Upload row whose Payload is the whole canonical snapshot, so the export can be re-sent without going back to the ERP for the figures. No email is sent, because nothing was left unhandled. Put the real Path back.

Worth doing the other half of the route while you are here, because it is the failure the scenario was written around. Empty the stock table, DELETE FROM dbo.StockOnHand, and wait for a tick. The procedure refuses and raises, the empty tick is republished, and the failures table gains an Extract row with an empty Payload. The check that matters most is on the warehouse’s server: no new file was written, so the warehouse still has last night’s figures rather than a file telling it there is nothing to pick. Put the three products back and let a tick clear it.

Then the safety net

Now take away something the route itself depends on: stop the ERP database, or temporarily revoke EXECUTE on both dbo.GetStockSnapshot and dbo.RecordStockExportFailure, and wait for the tick. The extract fails, the tick is republished as StockExportFailed, and the route tries to write the failure to the same database, which fails too. This is the case no route can handle, and it is where the notification earns its keep: the operations mailbox gets the StockExportErrors email from Step 6, and the failed run sits suspended in tracking, available for replay once the database is back. Restore the database or the permission, replay the suspended run from tracking, and confirm it completes and the file lands.

Before production. Restore the clock to Repeat Every 5 Minutes with the Daily Window back on from 11:00:00 PM to 11:04:59 PM and restart the port, check the Time Zone is the one the warehouse’s cut-off is quoted in, point StockToWarehouse at the warehouse’s real host, and check the Host Key Fingerprint is the one they gave you rather than a value copied from this page. Once the flow is proven, open the Application and drop tracking to Only on Error if body retention is a concern; a full stock snapshot is a large body to keep every night, and failed runs keep their payload for replay either way.