Shipment status push
A warehouse packs and ships customer orders. As each one moves along, the warehouse system marks it picked, then packed, then shipped, but it only writes those updates into its own database. It never reaches out to tell anyone. Meanwhile the online store wants to show customers where their order is. So the store needs each of those status changes pushed to it as it happens. Since the warehouse cannot send anything itself, something has to check it regularly, notice what is new, and pass each update to the store. The end result is that every status change shows up in the store, once, a few minutes after it happens.
So the job is a steady loop. Every few minutes, look in the warehouse for status changes you have not sent yet, and push each new one to the store.
The warehouse system is the WMS, the store wants the updates on its REST API, and iQBus bridges the two with a polling consumer: a clock, a query, and a call. Here is how it builds it.
Everything in iQBus 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. A receive port (an inbound endpoint that puts messages onto the bus) on the Scheduler (an adapter, a connector to one kind of outside system, here a clock) fires every five minutes and publishes a tick, typed ShipmentPollTick. 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 runs the pick-up procedure:
{{Message.MessageType}} == "ShipmentPollTick"
That procedure ends in FOR XML, so its result comes back as the XML SqlCallerResult envelope; a splitter (a pipeline component, a small piece of custom code that reshapes a message in transit) turns it into one message per status row, and a map (a named rule that turns one message shape into another) shapes each row into a canonical ShipmentStatus message on the bus. You write neither the splitter nor the map by hand: the AI Accelerator generates both from a plain-language description, and the working code is in this page as a fallback.
The query reads everything past a high-water mark, a LastExportedId the procedure advances in the same transaction that returns the rows. The interval decides how often you look; the mark decides what you pick up, so a missed or doubled poll neither drops nor repeats work.
A second send port, bound to the API Caller, subscribes to the result and POSTs each status to the storefront, addressing the order by the order number the map promotes:
{{Message.MessageType}} == "ShipmentStatus"
When it fails. Two ports can fail, the pick-up and the push, and both carry the same Exception Message Type, ShipmentSyncFailed. 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 pick-up failed, a JSON status is a push that failed. A third send port subscribes to it and writes each failure into a dead-letter table operations works from, so the failure is handled, and the run does not suspend:
{{Message.MessageType}} == "ShipmentSyncFailed"
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 7 builds both and explains why you want both.
Build it, step by step. The steps follow the integration itself, from the clock to the storefront, not the order that is quickest to click through. Every Authentication, message type, pipeline 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 warehouse database objects get a step of their own just before the port that calls them.
| Setting | Value |
|---|---|
| Name | AcmeShipments |
| Namespace | AcmeShipments |
| Tracking | Enabled + Body |
| Description | Warehouse shipment statuses pushed to the storefront |
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 | ShipmentStatusClock |
| Application | AcmeShipments (preset) |
| Port Type | Receive |
| Adapter | Scheduler |
| Adapter Message Type | ShipmentPollTick, 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). Five minutes is a sensible cadence for a status feed: quick enough that the storefront feels live, slow enough that an empty poll costs nothing worth counting. Leave the Payload empty, the tick is only a wake-up call; the message still carries the standard envelope, and that is all the pick-up port needs.
| Setting | Value |
|---|---|
| Repeat Every | 5 |
| Repeat Unit | Minutes |
| Time Zone | yours; it only matters to the optional Date and Daily Windows, the cadence itself is interval-based |
| Date Window, Daily Window | off; the warehouse ships around the clock |
| Payload | empty |
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 pick-up 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 | ShipmentPollTick |
| 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 ShipmentPollTick, which completes the General table above. Save the port. ShipmentStatusClock is complete, stopped, and waiting; it is the last port you start, in Step 8.
The warehouse 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. In it, create a procedure that returns all status rows with an id above LastExportedId and advances the mark in the same transaction; the pick-up port in Step 3 calls it. This is the high-water mark that makes the poll safe to miss or repeat: a missed or repeated tick neither drops nor double-sends. The result SELECT ends in FOR XML PATH('Row'), ROOT('Result'), so the SQL Caller’s response is the XML envelope the splitter in Step 4 parses.
The warehouse writes ShipmentStatus; ExportWatermark remembers how far this feed has read.
CREATE TABLE dbo.ShipmentStatus ( ShipmentId BIGINT NOT NULL PRIMARY KEY, OrderNumber VARCHAR(40) NOT NULL, Status VARCHAR(20) NOT NULL, StatusAt DATETIME2 NOT NULL ); CREATE TABLE dbo.ExportWatermark ( FeedName VARCHAR(60) NOT NULL PRIMARY KEY, LastExportedId BIGINT NOT NULL CONSTRAINT DF_Wm DEFAULT 0 ); INSERT INTO dbo.ExportWatermark (FeedName, LastExportedId) VALUES ('ShipmentStatus', 0);
CREATE OR ALTER PROCEDURE dbo.GetNewShipmentStatuses AS BEGIN SET NOCOUNT ON; DECLARE @from BIGINT, @to BIGINT; BEGIN TRANSACTION; SELECT @from = LastExportedId FROM dbo.ExportWatermark WITH (UPDLOCK, HOLDLOCK) WHERE FeedName = 'ShipmentStatus'; SELECT @to = MAX(ShipmentId) FROM dbo.ShipmentStatus; SELECT ShipmentId, OrderNumber, Status, StatusAt FROM dbo.ShipmentStatus WHERE ShipmentId > @from ORDER BY ShipmentId FOR XML PATH('Row'), ROOT('Result'); UPDATE dbo.ExportWatermark SET LastExportedId = ISNULL(@to, @from) WHERE FeedName = 'ShipmentStatus'; COMMIT; 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 Steps 4 and 5 do their work.
| Setting | Value |
|---|---|
| Name | ShipmentPickup |
| Application | AcmeShipments (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 | ShipmentSyncFailed, created next, at this field |
| Way | Two |
| Subscription | {{Message.MessageType}} == "ShipmentPollTick" |
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: for the push port in Step 6 that is a JSON status, for this port it is the empty tick, and JSON reads both. Step 7 gives the type its route.
| Setting | Value |
|---|---|
| Name | ShipmentSyncFailed |
| Format | JSON |
| Sample data | none; the body is whatever was in flight when the run failed |
| Promotions | none |
Open the adapter configuration. At the Auth Config field, create the warehouse database credential in place; the dialog opens on top of the port and returns you to it when you save:
| Setting | Value |
|---|---|
| Name | WarehouseDbSql |
| Adapter | SQL Caller (preset) |
| Definition | SQL Server Connection |
| 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 a WMS database usually 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.GetNewShipmentStatuses, and in Step 7 on dbo.RecordShipmentSyncFailure as well.
Back in the adapter configuration, set the command. The procedure takes no parameters, so there are no Input Parameters rows:
| Setting | Value |
|---|---|
| Auth Config | WarehouseDbSql, just created |
| Command Type | Stored Procedure |
| Command Text | dbo.GetNewShipmentStatuses, from Step 2 |
| Input Parameters | none |
Leave the port open; the next two steps continue on its Response side.
Still inside ShipmentPickup, 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 SQL Caller hands its two-way result over under the standard name SqlCallerResult; select it here. If the picker does not offer it yet, create it at the field with the values below, its Format is XML because the procedure ends in FOR XML.
One <Row> per status row, each column an element. Two rows are shown; a tick may return none, one, or hundreds:
<Result> <Row><ShipmentId>88412</ShipmentId><OrderNumber>SO-10472</OrderNumber><Status>Shipped</Status><StatusAt>2026-06-04T16:12:00</StatusAt></Row> <Row><ShipmentId>88413</ShipmentId><OrderNumber>SO-10488</OrderNumber><Status>Packed</Status><StatusAt>2026-06-04T16:14:30</StatusAt></Row> </Result>
| Setting | Value |
|---|---|
| Name | SqlCallerResult |
| Format | XML |
| Sample data | the Result XML above |
| Promotions | none, the envelope is split before anything routes on it |
| Setting | Value |
|---|---|
| Adapter Message Type | SqlCallerResult |
Next comes the response Pipeline. One envelope holding many rows is no use to the storefront, which wants one call per status, so a splitter has to turn the envelope into one message per <Row>. A port never selects a component directly; it selects a pipeline, a named, ordered list of pipeline components that also holds the default value for each component’s configuration property. Create the pipeline at the Pipeline field, and the component from the pipeline’s Components list; the component is where the code lives, and it is the first of the two iQBus artifacts in this tutorial you do not have to write yourself.
You do not have to write this component by hand. Open the AI Accelerator, describe the splitter in plain language, and let it generate the C# for you to review and save. It knows how iQBus pipeline components work, the base class, how a component returns many messages and types each one, but it sees nothing of your Application: not the ports, not the message types you have created, nothing but the text you give it. So the prompt carries everything the component needs: the envelope it reads, what each emitted message must contain, the property that names the type, and what to do with an empty result. A prompt like this is enough:
Build a pipeline component named SqlCallerResultSplitter that splits an XML result envelope into one message per row. The inbound body is an XML document whose root is Result and whose children are Row elements, each Row holding one column per child element. Emit one message per Row, with the Row element itself, children included, as the outgoing body. Stamp every emitted message with the message type given by a required component property named RowMessageType, a string with no default, so the pipeline decides the type. An envelope with no Row elements is normal, not an error: return no messages so nothing is published for that run. If the body is not well-formed XML, fail the component with a clear error message. The envelope it reads looks like this (yours will carry different values and different column names, but the same Result and Row structure): <Result> <Row><ShipmentId>88412</ShipmentId><OrderNumber>SO-10472</OrderNumber><Status>Shipped</Status><StatusAt>2026-06-04T16:12:00</StatusAt></Row> <Row><ShipmentId>88413</ShipmentId><OrderNumber>SO-10488</OrderNumber><Status>Packed</Status><StatusAt>2026-06-04T16:14:30</StatusAt></Row> </Result> Each message it emits must be exactly one Row, like this: <Row><ShipmentId>88412</ShipmentId><OrderNumber>SO-10472</OrderNumber><Status>Shipped</Status><StatusAt>2026-06-04T16:12:00</StatusAt></Row>
The AI Accelerator writes a first draft, not a finished component: read what it produced, check that the property is named RowMessageType exactly, since the pipeline table below sets it by that name, and confirm in Step 8 that each split message in tracking is one <Row>. If you would rather start from working code, expand it here to read or copy.
Show the full component code (C#), expand to read or copy
The component parses the body with XDocument, takes every Row under the root and returns one PipelineMessage per row, each typed from the configuration. An empty list means nothing is published for that tick. The configuration class has one property, marked [Required] with no default, which is why the pipeline has to supply it. Because it knows nothing about shipments, the same component serves any FOR XML PATH('Row'), ROOT('Result') query, and the FHIR lab results tutorial reuses it in its own Application.
using System.Xml.Linq; using System.ComponentModel.DataAnnotations; using CC.iQBus.Pipelines.Domain.Models.PipelineComponents; public sealed class SqlSplitterConfig { // Message Type assigned to each row message before the map runs. [Required] public string RowMessageType { get; set; } = string.Empty; } public sealed class SqlCallerResultSplitter : PipelineComponentBase<SqlSplitterConfig> { public override string Name => "SqlCallerResultSplitter"; protected override Task<PipelineComponentOutput> ExecuteAsync( PipelineComponentInput input, SqlSplitterConfig config, CancellationToken cancellationToken) { try { var doc = XDocument.Parse(input.Body); var rows = doc.Root?.Elements("Row") ?? Enumerable.Empty<XElement>(); var messages = rows .Select(r => new PipelineMessage { Body = r.ToString(), MessageType = config.RowMessageType }) .ToList(); return Task.FromResult(new PipelineComponentOutput { Success = true, Messages = messages // empty list => nothing published }); } catch (Exception ex) { return Task.FromResult(new PipelineComponentOutput { Success = false, ErrorMessage = $"SqlCallerResultSplitter: {ex.Message}", Exception = ex }); } } }
| Setting | Value |
|---|---|
| Name | SqlCallerResultSplitter |
| Code | the C# the AI Accelerator produced, or the collapsed code above |
Save the component and you are back in the pipeline with it listed. Its one property has no default in code, so the pipeline is where you say what each split row is classified as. The value is plain text, the name a message type will carry; the type itself is created in the next step, from the map’s Source field, under exactly this name:
| Setting | Value |
|---|---|
| Name | ShipmentRowSplitPipeline |
| Components | SqlCallerResultSplitter, just created |
| RowMessageType (default for the component) | ShipmentRow |
| Setting | Value |
|---|---|
| Pipeline | ShipmentRowSplitPipeline, just created |
Leave the port open; the next step continues on the Response side’s Map step.
Still on the Response side of ShipmentPickup, 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, and neither type exists yet, so both are created from the map dialog’s own fields:
| Setting | Value |
|---|---|
| Name | ShipmentRowToStatus |
| Source message type | ShipmentRow, created next, at this field |
| Target message type | ShipmentStatus, created after it, at the Target field |
The source is what the splitter emits, one <Row>, and its name must be exactly the RowMessageType you typed into the pipeline in Step 4, or the split messages will carry a type that no map matches. Paste one row into Sample data: it has no runtime effect, but it documents the shape and gives the map tester something to run.
<Row> <ShipmentId>88412</ShipmentId> <OrderNumber>SO-10472</OrderNumber> <Status>Shipped</Status> <StatusAt>2026-06-04T16:12:00</StatusAt> </Row>
| Setting | Value |
|---|---|
| Name | ShipmentRow |
| Format | XML |
| Sample data | the Row XML above |
| Promotions | none, nothing routes on the raw row |
Now the target, from the Target field. This is the shape the storefront consumes, and the one value the API send port needs outside the body, the order number that goes into the URL in Step 6, is lifted out of it by a promotion. A promotion is a named path into the payload (JPath for a JSON type), so before naming it, look at the payload the map will produce:
{
"orderNumber": "SO-10472",
"status": "Shipped",
"statusAt": "2026-06-04T16:12:00",
"source": "wms"
}Paste that JSON 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 JPath is written for you. Click orderNumber and you get $.orderNumber. You can also type the path by hand, but the tool removes the guesswork on deeper shapes.
| Setting | Value |
|---|---|
| Name | ShipmentStatus |
| Format | JSON |
| Sample data | the ShipmentStatus JSON above |
| Promotion | Path | Read by |
|---|---|---|
| OrderNumber | $.orderNumber | the URL Template of the API send port, Step 6 |
Adapter fields are plain strings: they bind {{…}} tokens such as {{Promoted.ShipmentStatus.OrderNumber}} but never evaluate a body path themselves, which is why the promotion has to exist on the type. Save the type; you are back in the map with both types selected.
Now the map body, the second artifact you do not write 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 XML and emit JSON, 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 Row sample, the ShipmentStatus sample, and the path the promotion reads. A prompt like this is enough:
Build a map from an XML message type named ShipmentRow to a JSON message type named ShipmentStatus.
Match the row's columns to the status fields by meaning: the order number, the status word, and the timestamp of the status change, all as strings. Leave the shipment id out, the storefront has no use for it. Add one constant field, source set to wms. One path must be exactly $.orderNumber, because a promotion reads it. Output indented JSON.
The ShipmentRow source looks like this (yours will carry different values but the same shape):
<Row>
<ShipmentId>88412</ShipmentId>
<OrderNumber>SO-10472</OrderNumber>
<Status>Shipped</Status>
<StatusAt>2026-06-04T16:12:00</StatusAt>
</Row>
The ShipmentStatus target must look like this:
{
"orderNumber": "SO-10472",
"status": "Shipped",
"statusAt": "2026-06-04T16:12:00",
"source": "wms"
}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 sample Row as the input, run the map, and compare the result with the ShipmentStatus sample above. What has to match exactly is the promoted path, $.orderNumber; field order and formatting may differ, because the Accelerator does not return the exact same result twice. 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 XML, so the template matches the Row element directly and reads each column as a child element; the result is built as a map{} and serialized to JSON with method="text" output.
<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> <xsl:output method="text"/> <xsl:template match="/Row"> <xsl:value-of select="serialize( map { 'orderNumber': string(OrderNumber), 'status': string(Status), 'statusAt': string(StatusAt), 'source': 'wms' }, map { 'method': 'json', 'indent': true() })"/> </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 |
|---|---|---|
| ShipmentRow | ShipmentRowToStatus | ShipmentStatus |
Save the port. ShipmentPickup is complete: every tick it picks up becomes zero or more ShipmentStatus messages on the bus, one per new row.
Under the application’s Ports, create a send port bound to the API Caller. It subscribes to every canonical status the pick-up port publishes and POSTs it to the storefront, one call per status. The Exception Message Type already exists from Step 3, so this time it is a selection:
| Setting | Value |
|---|---|
| Name | StatusToStorefront |
| Application | AcmeShipments (preset) |
| Port Type | Send |
| Adapter | API Caller |
| Adapter Message Type | none; a one-way send port publishes nothing back |
| Exception Message Type | ShipmentSyncFailed, from Step 3 |
| Way | One |
| Subscription | {{Message.MessageType}} == "ShipmentStatus" |
The message is already in the storefront’s shape, so the port’s Map and Pipeline steps stay empty; everything happens in the adapter.
Open the adapter configuration. At the Auth Config field, create the storefront credential in place. The storefront issued a username and password for its API, so the Definition is Basic Authentication; the adapter builds the Authorization header from it on every call, you never type that header yourself:
| Setting | Value |
|---|---|
| Name | StorefrontApi |
| Adapter | API Caller (preset) |
| Definition | API Basic Authentication |
| Username | wms-sync |
| Password | Sf!2026-status-push-9c4e7a |
The values above are fine for the tutorial, and the Postman mock server in Step 8 accepts anything; for the real storefront use the credentials its team issued, and keep the password out of email. Back in the adapter configuration, fill in the call. The URL names the order the status belongs to, which is what the promotion from Step 5 is for; the body is the canonical status exactly as the map produced it:
| Setting | Value |
|---|---|
| Auth Config Type | Static |
| Auth Config | StorefrontApi, just created |
| HTTP Method | POST |
| URL Template | https://shop.acme.example/api/orders/{{Promoted.ShipmentStatus.OrderNumber}}/shipment-status |
| Content-Type | application/json |
| Request Headers | none; the Authorization header comes from the Authentication |
| Request Body | {{Message.Body}} |
Save the port. In Step 8 you will come back and swap the storefront host for the Postman mock server’s address, so the test has something real to hit.
Two things can go wrong in this flow, and you already named them: the pick-up fails, so the tick is republished as ShipmentSyncFailed with an empty body, or a push fails, so the status is republished as ShipmentSyncFailed with the JSON status as its body. 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. Operations already lives in the warehouse database, so the dead-letter table goes there for this tutorial; in production you might prefer a database of your own. 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: an empty body can only be a tick, so it is a pick-up failure; anything else is a push that did not land.
CREATE TABLE dbo.ShipmentSyncFailures ( FailureId BIGINT IDENTITY(1,1) NOT NULL PRIMARY KEY, Leg VARCHAR(10) NOT NULL, -- PickUp or Push Payload NVARCHAR(MAX) NULL, -- the status that was in flight, empty for a tick FailedAt DATETIME2 NOT NULL CONSTRAINT DF_SyncFail_At DEFAULT SYSUTCDATETIME(), ResolvedAt DATETIME2 NULL -- operations sets this once re-sent );
CREATE OR ALTER PROCEDURE dbo.RecordShipmentSyncFailure @Payload NVARCHAR(MAX) AS BEGIN SET NOCOUNT ON; INSERT INTO dbo.ShipmentSyncFailures (Leg, Payload) VALUES (CASE WHEN NULLIF(LTRIM(RTRIM(@Payload)), '') IS NULL THEN 'PickUp' ELSE 'Push' END, @Payload); END;
Under the application’s Ports, create one more send port on the SQL Caller, this time one-way: it subscribes to the failure type and calls the procedure above. It reuses the WarehouseDbSql 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 republishes its own failures as ShipmentSyncFailed would pick them up again, and this is exactly the case the Activity Notification below exists for.
| Setting | Value |
|---|---|
| Name | FailedSyncToDeadLetter |
| Application | AcmeShipments (preset) |
| Port Type | Send |
| Adapter | SQL Caller |
| Adapter Message Type | none; one-way |
| Exception Message Type | empty, on purpose |
| Way | One |
| Subscription | {{Message.MessageType}} == "ShipmentSyncFailed" |
| Setting | Value |
|---|---|
| Auth Config | WarehouseDbSql, from Step 3 |
| Command Type | Stored Procedure |
| Command Text | dbo.RecordShipmentSyncFailure |
| Parameter | Value |
|---|---|
| Payload | {{Message.Body}} |
Save the port. From now on a failed push lands as a Push row with the status JSON in Payload, ready for operations to re-send once the storefront is back, and the run itself 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 | ShipmentSyncErrors |
| Application | AcmeShipments (preset) |
| On Error Only | On |
| Subscription | All |
| Mode | On Event |
| Recipients | one row: Name Operations, Email ops@acme.example |
| Is Enabled | Enabled |
The storefront in this tutorial does not exist, so give the API Caller something real to call. We use a Postman mock server; any request-capturing tool (Beeceptor, webhook.site, a tiny local API) works the same way. Install Postman, sign in (mock servers need an account), and create the mock: New → Mock Server, add one request row, name the mock and create it. Postman shows the mock’s address, something like https://a1b2c3d4-....mock.pstmn.io; copy it.
| Setting | Value |
|---|---|
| Name | AcmeStorefrontStandIn |
| Method | POST |
| Request URL | /api/orders/:orderNumber/shipment-status (the colon makes the order number a path variable, so any order matches) |
| Response code | 200 |
| Response body | {"accepted": true} |
Open StatusToStorefront and, in its adapter configuration, replace https://shop.acme.example in the URL Template with the mock’s address, keeping the path and the promotion token exactly as they were. The mock ignores the Basic Authentication header, which is fine here. Save the port.
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 FailedSyncToDeadLetter, then StatusToStorefront, then ShipmentPickup, and last the clock, ShipmentStatusClock. Starting each subscriber before its producer means no tick fires before the pick-up is listening, and no status is published before the push 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. If five minutes is too long to wait between attempts, set Repeat Every on the clock to 1 for the duration of the test and restore it afterwards; Restart the port to reset its timer.
The trigger in this flow is not an HTTP call but a row appearing in the warehouse. Insert two statuses, then wait for the next tick:
INSERT INTO dbo.ShipmentStatus (ShipmentId, OrderNumber, Status, StatusAt) VALUES (88412, 'SO-10472', 'Shipped', '2026-06-04T16:12:00'), (88413, 'SO-10488', 'Packed', '2026-06-04T16:14:30');
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 ShipmentPollTick is published |
| ShipmentPickup picks it up | the subscription matched and dbo.GetNewShipmentStatuses was called |
| SQL Caller responds | the body is the <Result> envelope with two <Row> elements, typed SqlCallerResult |
| Pipeline ShipmentRowSplitPipeline | two messages come out, each one <Row>, typed ShipmentRow |
| Map ShipmentRowToStatus | each body is now the canonical JSON with orderNumber, status, statusAt, source |
| Publish | two ShipmentStatus messages are on the bus and StatusToStorefront picked up both |
| API Caller | two POSTs, to /api/orders/SO-10472/shipment-status and /api/orders/SO-10488/shipment-status, each answered 200 |
Confirm the other end too: in Postman, open the mock server and its call log shows the two requests with the status JSON as their bodies. Then check the mark moved: SELECT LastExportedId FROM dbo.ExportWatermark now reads 88413, and the next tick, with nothing new, runs the procedure, returns an empty <Result/>, and the splitter publishes nothing, which tracking shows as a run that ends at the pipeline with no messages out. Insert a third row and confirm exactly one more push.
Make the push fail: in the mock, change the example’s response code to 500 and save, then insert a new status row and wait for the tick. The pick-up succeeds, the status is published, and StatusToStorefront fails after its retries. Watch what happens next in tracking: the message is republished as ShipmentSyncFailed, FailedSyncToDeadLetter picks it up and calls the procedure, and the run finishes as handled, not suspended. In the database, SELECT * FROM dbo.ShipmentSyncFailures shows one Push row with the status JSON as its Payload. No email is sent, because nothing was left unhandled. Set the mock’s response back to 200.
Now take away something the route depends on: stop the warehouse database, or temporarily revoke EXECUTE on dbo.GetNewShipmentStatuses, and wait for the tick. The pick-up fails, the tick is republished as ShipmentSyncFailed, 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 ShipmentSyncErrors email from Step 7, and the failed run sits suspended in tracking with its payload intact, available for replay once the database is back. Restore the database or the permission, replay the suspended run, and confirm it completes.