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 Art2link bridges the two with a polling consumer: a clock, a query, and a call. Here is how it builds it.
Everything in Art2link 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. 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), matching on the originating port’s name, and runs the pick-up procedure. That procedure ends in FOR XML, so its result is 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 row per status, and a map (a named rule that turns one message shape into another) shapes each row into a canonical ShipmentStatus message on the bus:
{{Config.PortName}} == "ShipmentStatusClock"
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:
{{Message.MessageType}} == "ShipmentStatus"
When it fails. Two ports can fail, and both get the same treatment: one shared Exception Message Type, ShipmentSyncFailed. The re-published payload tells you which leg broke, a tick means the pick-up failed, a status means the push failed. An O365 Mail send port subscribes to it and alerts operations; the failed runs wait in tracking for replay:
{{Message.MessageType}} == "ShipmentSyncFailed"
Build it, step by step. The steps run in dependency order: every object is created before the object that selects it.
Under the application’s Message types, create the three types this flow routes on. A message type is a name plus a format, no schema required:
| Name | Format | Purpose |
|---|---|---|
| ShipmentRow | XML | the split result row, one per status |
| ShipmentStatus | JSON | the canonical status the storefront consumes |
| ShipmentSyncFailed | JSON | the exception type the failure path publishes under (Step 9) |
Three external parties, three credentials. Under the application’s Authentications, the dialog asks for a Name, the Adapter it pairs with, and a Definition, the credential shape that adapter offers, with the Application preset; the Definition decides the config section that follows.
| Setting | Value |
|---|---|
| Name | WarehouseDbSql |
| Adapter | SQL Caller |
| Definition | SQL Server Connection |
| Connection String (Database Config) | the warehouse database’s connection string, credentials included |
| Setting | Value |
|---|---|
| Name | StorefrontApi |
| Adapter | API Caller |
| Definition | API Basic Authentication |
| Username / Password | the credentials the storefront issued |
| Setting | Value |
|---|---|
| Name | O365Ops |
| Adapter | O365 Mail Sender |
| Definition | Microsoft Graph |
| Tenant Id / Client Id / Client Secret (Graph AuthConfig) | an app registration with Mail.Send granted |
In the warehouse database, 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 (Step 7) 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 (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 Pipeline components, create SqlCallerResultSplitter, a reusable disassembler: it parses the <Result><Row> envelope and returns one message per row, classified by the Message Type you set in the component’s configuration. An empty result set returns no messages, nothing to do this tick. The same component is reused by the FHIR tutorial. SqlCallerResult is the SQL Caller’s built-in result envelope (you do not create it as a message type; the adapter classifies its two-way response under that name).
Because the pick-up procedure’s result SELECT ends in FOR XML PATH('Row'), ROOT('Result'), the SqlCallerResult body is XML, one <Row> per status row, each column an element:
<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>
using System.Xml.Linq; using System.ComponentModel.DataAnnotations; using CC.Art2link.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 }); } } }
Under the application’s Maps, create the map that shapes each split <Row> into the canonical JSON the storefront’s API expects:
| Map setting | Value |
|---|---|
| Name | ShipmentRowToStatus |
| Source message type | ShipmentRow, from Step 1 |
| Target message type | ShipmentStatus, from Step 1 |
One canonical ShipmentStatus as the map publishes it:
{
"orderNumber": "SO-10472",
"status": "Shipped",
"statusAt": "2026-06-04T16:12:00"
}Maps are authored in XSLT 3.0 and run on Saxon HE 12.9:
<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) }, map { 'method': 'json', 'indent': true() })"/> </xsl:template> </xsl:stylesheet>
Create a receive port ShipmentStatusClock on the Scheduler; its tick is what wakes the pick-up port (Step 7). Leave the Payload empty, the tick is just a wake-up.
| Setting | Value |
|---|---|
| Adapter | Scheduler |
| Repeat Every | 5 |
| Repeat Unit | Minutes |
| Time Zone | yours |
Create a send port ShipmentPickup on the SQL Caller, two-way mode, calling the procedure from Step 3. Subscription: {{Config.PortName}} == "ShipmentStatusClock". (The dropdowns can also create types and maps inline; building the dependencies first keeps each dialog a selection.)
| Setting | Value |
|---|---|
| Adapter | SQL Caller |
| Way | Two |
| Command Type | StoredProcedure |
| Command Text | dbo.GetNewShipmentStatuses |
Set Authentication to WarehouseDbSql, from Step 2.
The result set re-enters the bus through the port’s Response stages. Select the SqlCallerResultSplitter, from Step 4, and set its RowMessageType to ShipmentRow, from Step 1; then set Map Assignment to Typed and add one row, selecting the map from Step 5:
| Source Message Type | Map | Target Message Type |
|---|---|---|
| ShipmentRow | ShipmentRowToStatus | ShipmentStatus |
Create a send port StatusToStorefront on the API Caller, one-way mode, subscribing on {{Message.MessageType}} == "ShipmentStatus".
| Setting | Value |
|---|---|
| Adapter | API Caller |
| Way | One |
| Method | POST |
| URL | the storefront’s status endpoint, e.g. https://shop.acme.example/api/shipment-status |
| Body | {{Message.Body}} |
Set Authentication to StorefrontApi, from Step 2.
On both send ports, ShipmentPickup and StatusToStorefront, set Exception Message Type to ShipmentSyncFailed, from Step 1.
Create an O365 Mail send port OpsAlert subscribing on {{Message.MessageType}} == "ShipmentSyncFailed":
| Setting | Value |
|---|---|
| Authentication | O365Ops, from Step 2 |
| From | noreply@acme.example |
| To | ops@acme.example |
| Subject | Shipment status sync failed |
Everything you configure is live the moment you save it, there is nothing to deploy. Start the send ports first, StatusToStorefront, OpsAlert and ShipmentPickup, then the receive port ShipmentStatusClock.
Set Tracking to Enabled + Body on every port the flow touches, so you can walk each run step by step, with its message body, in tracking.
Insert a test status row, and watch the next tick in tracking: tick → pick-up → N statuses → N pushes. If five minutes is too long a wait, temporarily set ShipmentStatusClock to Repeat Every 1 / Minutes, then restore it once the test passes. Insert two rows and confirm two independent pushes.
Point the API port at a dead URL and confirm the alert email and the suspended runs waiting for replay.