Skip to content
Art2link ESB v2.02 LTS HomeDocumentationBlogContact
Tutorials/Getting started/Shipment status push

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.

Every new status reaches the store within minutes Clock every few minutes Check for new status changes Warehouse picked, packed, shipped new ones Online store shows the customer where the order is

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:

EXPRESSIONShipmentPickup subscription
{{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:

EXPRESSIONStatusToStorefront subscription
{{Message.MessageType}} == "ShipmentStatus"
Scheduler every 5 min BUS tick SQL Caller (two-way) rows past high-water mark WMS DB ShipmentStatus ×N API Caller StatusToStorefront Storefront API ops alert O365 Mail

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:

EXPRESSIONOpsAlert subscription
{{Message.MessageType}} == "ShipmentSyncFailed"
Advance the mark with the read. If the high-water mark moves in a separate step, a crash between the two either re-sends or skips a window of statuses. One procedure, one transaction: return the rows and advance LastExportedId together.

Build it, step by step. The steps run in dependency order: every object is created before the object that selects it.

Before you start. Create the Application this flow lives in, a Name plus a code-safe Namespace, under Applications, and select it, so every artifact you create below lands inside it.
1
Step One
Create the message types
The three types

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:

NameFormatPurpose
ShipmentRowXMLthe split result row, one per status
ShipmentStatusJSONthe canonical status the storefront consumes
ShipmentSyncFailedJSONthe exception type the failure path publishes under (Step 9)

2
Step Two
Create the Authentications

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.

Warehouse database, for the SQL Caller
SettingValue
NameWarehouseDbSql
AdapterSQL Caller
DefinitionSQL Server Connection
Connection String (Database Config)the warehouse database’s connection string, credentials included
Storefront API, for the API Caller
SettingValue
NameStorefrontApi
AdapterAPI Caller
DefinitionAPI Basic Authentication
Username / Passwordthe credentials the storefront issued
Alert mailbox, for O365 Mail
SettingValue
NameO365Ops
AdapterO365 Mail Sender
DefinitionMicrosoft Graph
Tenant Id / Client Id / Client Secret (Graph AuthConfig)an app registration with Mail.Send granted

3
Step Three
Create the database objects

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 source and watermark tables

The warehouse writes ShipmentStatus; ExportWatermark remembers how far this feed has read.

SQLSource and watermark tables
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);
The pick-up procedure
SQLGetNewShipmentStatuses
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;

4
Step Four
Create the pipeline component

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).

The envelope it parses

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:

XMLSqlCallerResult envelope
<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>
The SQL Caller returns what the SELECT asks for. This query ends in FOR XML, so the result is XML, and the splitter consumes XML to match. A plain SELECT would have returned JSON, which would match nothing here. If the formats disagree, the splitter parses nothing and the message republishes empty.
The component code
C#SqlCallerResultSplitter.cs
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
            });
        }
    }
}

5
Step Five
Build the row map
Create the map

Under the application’s Maps, create the map that shapes each split <Row> into the canonical JSON the storefront’s API expects:

Map settingValue
NameShipmentRowToStatus
Source message typeShipmentRow, from Step 1
Target message typeShipmentStatus, from Step 1
The output

One canonical ShipmentStatus as the map publishes it:

JSONShipmentStatus output
{
  "orderNumber": "SO-10472",
  "status": "Shipped",
  "statusAt": "2026-06-04T16:12:00"
}
Author the XSLT

Maps are authored in XSLT 3.0 and run on Saxon HE 12.9:

XSLT 3.0ShipmentRowToStatus.xslt
<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>

6
Step Six
Create the Scheduler receive port
General

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.

SettingValue
AdapterScheduler
Repeat Every5
Repeat UnitMinutes
Time Zoneyours

7
Step Seven
Create the SQL pick-up port
General

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.)

SettingValue
AdapterSQL Caller
WayTwo
Command TypeStoredProcedure
Command Textdbo.GetNewShipmentStatuses
Pair the credential

Set Authentication to WarehouseDbSql, from Step 2.

Response side

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 TypeMapTarget Message Type
ShipmentRowShipmentRowToStatusShipmentStatus

8
Step Eight
Create the API push port
General

Create a send port StatusToStorefront on the API Caller, one-way mode, subscribing on {{Message.MessageType}} == "ShipmentStatus".

SettingValue
AdapterAPI Caller
WayOne
MethodPOST
URLthe storefront’s status endpoint, e.g. https://shop.acme.example/api/shipment-status
Body{{Message.Body}}
Pair the credential

Set Authentication to StorefrontApi, from Step 2.


9
Step Nine
Wire the failure path
Set the Exception Message Type

On both send ports, ShipmentPickup and StatusToStorefront, set Exception Message Type to ShipmentSyncFailed, from Step 1.

Create the OpsAlert send port

Create an O365 Mail send port OpsAlert subscribing on {{Message.MessageType}} == "ShipmentSyncFailed":

SettingValue
AuthenticationO365Ops, from Step 2
Fromnoreply@acme.example
Toops@acme.example
SubjectShipment status sync failed

10
Step Ten
Start the ports and test
Start in dependency order

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.

Turn on tracking

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.

Trigger the tick

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.

Break it on purpose

Point the API port at a dead URL and confirm the alert email and the suspended runs waiting for replay.

Turn bodies off in production. Enabled + Body is the most expensive tracking level, extra processing and database space, and it records every payload including successful runs. Once the flow is proven, set the ports to Only on Error instead: failures still capture the full body for diagnosis, while healthy runs are not recorded.