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.
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:
{{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.
{{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:
/inbound/stock/stock_{{Promoted.StockSnapshot.SnapshotDate}}.csv
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:
{{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.
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.
| Setting | Value |
|---|---|
| Name | AcmeStock |
| Namespace | AcmeStock |
| Tracking | Enabled + Body |
| Description | Nightly stock snapshot to the 3PL warehouse |
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.
| Setting | Value |
|---|---|
| Name | NightlyStockClock |
| Application | AcmeStock (preset) |
| Port Type | Receive |
| Adapter | Scheduler |
| Adapter Message Type | StockExportTick, created below, at this field |
| Exception Message Type | empty; a Scheduler tick has nothing to fail on the way in, the failures this flow handles are on the send ports |
| Way | One (the Scheduler offers nothing else; there is no caller waiting for a reply) |
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:
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.
| Setting | Value |
|---|---|
| Time Zone | yours; 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 Every | 5 |
| Repeat Unit | Minutes |
| Start Date Window | off; the export is open-ended, there is no date to begin on |
| End Date Window | off; the export is open-ended, there is no date to stop on |
| Daily Window | On; this is what turns a five-minute interval into one nightly run |
| Daily Start Window | 11:00:00 PM |
| Daily End Window | 11:04:59 PM, the start plus the five-minute interval, one second less |
| Payload | empty; the tick is only a wake-up call, and the message still carries the standard envelope, which is all the extract port needs |
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.
| Setting | Value |
|---|---|
| Name | StockExportTick |
| Format | TXT |
| Sample data | empty; the tick carries no body |
| Promotions | none; 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.
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.
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 );
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;
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.
| Setting | Value |
|---|---|
| Name | StockExtract |
| Application | AcmeStock (preset) |
| Port Type | Send |
| Adapter | SQL Caller |
| Adapter Message Type | SqlCallerResult, the type of the result coming back; a send port carries one only in two-way mode, and Step 4 sets it |
| Exception Message Type | StockExportFailed, created next, at this field |
| Way | Two |
| Subscription | {{Message.MessageType}} == "StockExportTick" |
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.
| Setting | Value |
|---|---|
| Name | StockExportFailed |
| Format | XML |
| Sample data | empty; 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 |
| Promotions | none; the route in Step 6 reads the whole body and nothing else subscribes on a value inside it |
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.
| Setting | Value |
|---|---|
| Open Variables | empty; nothing outside the message needs carrying into this call |
| Map | None; the tick has no body to transform, and the procedure takes no parameters |
| Pipeline | empty; there is nothing to pre-process on the way out |
| Close Variables | empty; the adapter fields below are all fixed values, so there is no last-mile value to compute |
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:
| Setting | Value |
|---|---|
| Name | ErpDb |
| Adapter | SQL Caller (preset) |
| Definition | SQL Server Connection |
| Application | AcmeStock (preset) |
| Connection String (Database Config) | one of the two below |
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:
| Method | Connection String | Use it when |
|---|---|---|
| SQL authentication | Server=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 identity | Server=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:
| Setting | Value |
|---|---|
| Auth Config Type | Static |
| Auth Config | ErpDb, just created |
| Command Type | Stored Procedure |
| Command Text | dbo.GetStockSnapshot, from Step 2 |
| Input Parameters | none; 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.
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.
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:
[
{ "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.
| Setting | Value |
|---|---|
| Name | SqlCallerResult |
| Format | JSON, because the procedure ends in FOR JSON |
| Sample data | the result array above |
| Promotions | none; 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.
| Setting | Value |
|---|---|
| Pipeline | empty; the whole snapshot is meant to travel as one message, so there is nothing to split and no code to run before the 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:
| Setting | Value |
|---|---|
| Name | ResultToStockSnapshot |
| Source message type | SqlCallerResult, from the Adapter Message Type above |
| Target message type | StockSnapshot, created next, at the Target field |
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:
<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.
| Setting | Value |
|---|---|
| Name | StockSnapshot |
| Format | XML |
| Sample data | the Stock XML above |
| Promotion | Path | Read by |
|---|---|---|
| SnapshotDate | /Stock/SnapshotDate | the 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.
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:
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.
<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>
Save the map, and the row on the Response side’s Map step reads:
| Source Message Type | Map | Target Message Type |
|---|---|---|
| SqlCallerResult | ResultToStockSnapshot | StockSnapshot |
Save the port. StockExtract is complete: every tick it acts on becomes one StockSnapshot message on the bus, or a failure.
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:
| Setting | Value |
|---|---|
| Name | StockToWarehouse |
| Application | AcmeStock (preset) |
| Port Type | Send |
| Adapter | SFTP Caller |
| Adapter Message Type | none; one-way, so nothing comes back to be typed |
| Exception Message Type | StockExportFailed, from Step 3 |
| Way | One (the upload either succeeds or raises, and there is no result the flow needs) |
| Subscription | {{Message.MessageType}} == "StockSnapshot" |
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:
| Setting | Value |
|---|---|
| Open Variables | empty; the only value the adapter needs from outside the body is the snapshot date, and that is a promotion, not a Variable |
| Map | StockSnapshotTo3plFile, created below, at this field, with Map Assignment set to Typed and keyed on StockSnapshot |
| Pipeline | empty; the map produces the finished file, so there is nothing left to frame, compress or wrap on the way to the server |
| Close Variables | empty; nothing to compute at the last moment |
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:
| Setting | Value |
|---|---|
| Name | StockSnapshotTo3plFile |
| Source message type | StockSnapshot, from Step 4 |
| Target message type | Warehouse3plStockFile, created next, at the Target field |
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:
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.
| Setting | Value |
|---|---|
| Name | Warehouse3plStockFile |
| Format | CSV |
| Sample data | the semicolon file above |
| Promotions | none; 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 |
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:
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 is the newline; wrapping the subtraction in format-number is what keeps a whole number from being written as a decimal.
<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 </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> </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:
| Source Message Type | Map | Target Message Type |
|---|---|---|
| StockSnapshot | StockSnapshotTo3plFile | Warehouse3plStockFile |
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:
| Setting | Value |
|---|---|
| Name | Warehouse3plSftp |
| Adapter | SFTP Caller (preset) |
| Definition | SFTP Password Authentication |
| Application | AcmeStock (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:
| Setting | Value |
|---|---|
| Auth Config Type | Static |
| Auth Config | Warehouse3plSftp, just created |
| Host | sftp.3pl.example |
| Port | 22 |
| Path | /inbound/stock/stock_{{Promoted.StockSnapshot.SnapshotDate}}.csv, the directory the warehouse gave you plus a filename carrying the night’s date |
| Overwrite | On; 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 Exists | Off; /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 Mode | Text; the body being uploaded is the plain-text file the map produced |
Save the port. StockToWarehouse is complete: every snapshot on the bus becomes one dated file on the warehouse’s server, or a failure.
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.
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.
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 );
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;
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.
| Setting | Value |
|---|---|
| Name | FailedExportToDeadLetter |
| Application | AcmeStock (preset) |
| Port Type | Send |
| Adapter | SQL Caller |
| Adapter Message Type | none; one-way |
| Exception Message Type | empty, on purpose; see the paragraph above |
| Way | One |
| Subscription | {{Message.MessageType}} == "StockExportFailed" |
| Setting | Value |
|---|---|
| Open Variables | empty; the parameter reads the failed message itself |
| Map | None; the payload is stored exactly as it failed, which is the point of keeping it |
| Pipeline | empty; nothing to pre-process |
| Close Variables | empty; nothing to compute |
| Setting | Value |
|---|---|
| Auth Config Type | Static |
| Auth Config | ErpDb, from Step 3 |
| Command Type | Stored Procedure |
| Command Text | dbo.RecordStockExportFailure |
| Parameter | Value |
|---|---|
| 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.
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:
| Setting | Value |
|---|---|
| Name | StockExportErrors |
| Application | AcmeStock (preset) |
| On Error Only | On |
| Subscription | All |
| Mode | On Event |
| Recipients | one row: Name Operations, Email ops@acme.example |
| Is Enabled | Enabled |
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:
| What | Why the test needs it |
|---|---|
| A SQL client, SQL Server Management Studio or Azure Data Studio | to 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 way | to 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 done | the 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 |
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.
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:
| Setting | Test value | Restore to |
|---|---|---|
| Daily Window | Off | On |
| Repeat Every | 1 | 5 |
| Repeat Unit | Minutes | Minutes (unchanged) |
| Daily Start Window / Daily End Window | ignored while the window is off | 11:00:00 PM / 11:04:59 PM |
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:
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 Tracking | What to confirm |
|---|---|
| Scheduler fires | an empty message typed StockExportTick is published |
| StockExtract picks it up | the subscription matched and dbo.GetStockSnapshot was called |
| SQL Caller responds | the body is the JSON array, three objects, each carrying the same SnapshotDate, typed SqlCallerResult |
| Map ResultToStockSnapshot | the body is now one <Stock> document with a single SnapshotDate and three <Item> elements, and no pipeline ran between the two |
| Publish | one StockSnapshot is on the bus and StockToWarehouse picked it up |
| Map StockSnapshotTo3plFile | the 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 Caller | an 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.
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.
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.