Skip to content
Art2link ESB HomeDocumentationBlogContact
Updated July 15, 2026
Tutorials/API scenarios/EDI 850 orders over a synchronous API

EDI 850 orders over a synchronous API

One of your cafe customers has stopped emailing spreadsheets and started sending real purchase orders, written in the long-standing business format called EDI, straight to a web address you gave them. The twist is that they do not drop the order and walk away. They post it and wait on the line for a one-word answer: did you take it or not? Your job is to read the order the instant it lands, decide whether it is a well-formed order you can act on, and reply on that same open call with a standard acknowledgment saying yes or no. Only after you have said yes does the order go on to be booked for fulfillment. A malformed order gets an immediate, polite no, and nothing is booked.

So two things are happening on one phone call, and it helps to keep them apart. First, the answer the caller is waiting for: is this a valid order? That answer is the acknowledgment, and it goes back down the same line while the caller holds. Second, and entirely separate, the booking of an accepted order into your records, which happens on its own time after the call has already ended. The picture below is the whole story in plain terms.

Answer on the spot, book the good ones afterwards Cafe posts a PO, waits the order yes or no Check the order against EDI rules valid malformed Acknowledge: yes then book the order Fulfillment Acknowledge: no nothing is booked

Here is how Art2link ESB builds it. Everything inside the product moves as small messages across a shared message backbone called the bus, which flows publish to and subscribe from. The order arrives on a receive port (the entry point that brings work onto the bus) bound to a two-way API Listener (an adapter is the connector a port uses for one kind of system, here an inbound web call). Two-way means the port keeps the caller’s connection open and waits to send a reply back, exactly the shape the stock availability lookup uses. This is the synchronous cousin of the file-triggered EDI 850 purchase orders tutorial: same document, same booking idea, but the acknowledgment is now a live answer on an open call rather than a file dropped back in a mailbox hours later.

The order lands as flat X12 text, which nothing else on the bus understands, so it is translated the moment it arrives by a pipeline component (a small piece of custom code that runs on a port to reshape a message as it passes through). This one is a disassembler: it reads the X12 850 and, in a single pass, does two jobs. It rewrites the order as clean JSON stamped with the message type Edi850Order (a message type is a name plus a format, the routing label the rest of the flow selects on), and it judges the order against X12 rules to build a second message, the acknowledgment, as JSON stamped Edi997Ack. Both are published to the bus. The component also stamps each with a promotion (a named value lifted out of the body so ports can route on it) and writes the request’s correlation token into a Variable, a named slot that holds a value for this one call.

The port’s Response Subscription Expression is the rule that picks the matching acknowledgment off the bus and hands it back to the waiting caller, keyed on that promoted token so ten cafes posting at once each get their own answer:

RESPONSE SUBSCRIPTION
{{Promoted.Edi997Ack.Correlation}} == {{Variable.AckCorrelation}}

On the way back out the acknowledgment is still JSON, so the response side of the same port assembles it into a real X12 997, and in that same step it calls the database for the next 997 control number for this partner. The accepted Edi850Order, meanwhile, is picked up off the bus by a send port (which delivers a message out) whose standing instruction, a subscription, selects it by type. That port maps the JSON into the XML shape a booking procedure expects and calls it.

EXPRESSION
{{Message.MessageType}} == "Edi850Order"
Cafe POST 850 X12 997 API Listener (two-way) disassemble + validate publish 850 + 997 assemble 997 on reply response subscription: Edi997Ack by correlation BUS 850 Booking mapJSON → XML SQL Callerbooking sproc Clients / OrdersOrderLines+ control-no counter assembler draws the next 997 control number for this partner
Why the 850 goes through a component, not a map. A map’s source format must be XML or JSON; flat text like X12 can never be a map source. So the raw 850 is disassembled to Edi850Order JSON by a pipeline component first. Every map in this flow is allowed precisely because its source is JSON. The reverse direction, JSON back to X12 997, is text output, which a component (or a map with method="text") is free to produce; only the source side is constrained.
A 997 is a functional acknowledgment, not a booking confirmation. The X12 997 answers one question: was the received order structurally valid EDI? A positive 997 means “we accepted your order for processing”, not “it is booked”. Booking happens later, on the bus’s time, and can still fail on its own (Step 7) long after the caller hung up with a yes in hand. If a partner needs confirmation that an order was actually recorded, that is a separate business document (an 855 purchase order acknowledgment), not the 997.

What makes an 850 good or bad. The 997 does not judge business sense; it judges EDI syntax and structure. A functional group is accepted when its envelope is consistent (the group control number in GS06 matches GE02, the count in GE01 matches the transaction sets present) and each transaction set is well formed (ST02 matches SE02, the segment count in SE01 is right, mandatory segments such as BEG are present, and each element is the right type, length and code value). When something is off, the 997 reports exactly where: AK3 names the segment in error and AK4 the element within it, AK5 accepts or rejects the transaction set, and AK9 the whole group. The disassembler in Step 1 enforces this full sweep of checks, and because each is only a few lines of C#, extending the rulebook is just adding another check. It catches, and reports, at least these:

Fault the 850 carriesHow the 997 reports it
Group control numbers in GS06 and GE02 disagreeAK9 reject, AK905 = 4
GE01 count does not match the sets receivedAK9 reject, AK905 = 5
ST02 and SE02 control numbers disagreeAK5 reject, AK502 = 3
SE01 segment count is wrongAK5 reject, AK502 = 4
A mandatory segment (such as BEG) is missingAK3 code 3, AK5 reject, AK502 = 5
A bad element (such as an invalid BEG05 date)AK3 code 8 + AK4 code 8, AK5 reject, AK502 = 5

The worked reject example carried through this tutorial is the last row, an invalid date in BEG05, but the same machinery turns away any of the others just as cleanly.

When it fails. The caller is waiting, so failure must be fast and explicit. A body that is not a readable interchange at all, no parseable ISA envelope, cannot be acknowledged (there are no control numbers to answer with): the disassembler rejects it as a poison message, the call returns an error status, and Activity Notifications (On Error Only) mails the team. A body whose ISA reads but whose transaction is malformed is answered normally with a negative 997, a deterministic, terminal rejection, not a retry. And a booking failure downstream, after the 997 has already gone back, re-publishes under a shared exception type with the usual O365 Mail alert (Step 7):

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

Build it, following the flow. Rather than making every artifact of one kind at once, these steps follow the order the order itself travels, and wherever a dialog can create what it references we make the dependency right there: an adapter dialog creates its own Authentication, a Message type dropdown creates the type, a Map field creates the map, a binding field creates a Constant or Variable. The one artifact with no inline entry point is the Schema, so it gets its own step (Step 5).

Before you start. Create the Application below under Applications and select it, so every artifact you create lands in it.
Application
SettingValue
NameBlueHarborEdi
NamespaceBlueHarborEdi, a code-safe namespace
1
Step One
Disassemble and judge the order

The first thing that happens to an order is translation. The posted X12 is flat text nothing on the bus understands, so a pipeline component rewrites it as JSON and, in the same pass, judges it against the X12 rules above. In Pipeline components, create Edi850ApiDisassembler; the AI assistant’s guided mode fits. On acceptance it publishes an Edi850Order and a positive Edi997Ack; on rejection only the negative Edi997Ack, since there is nothing valid to book. It also writes the call’s correlation token into the AckCorrelation Variable.

The message types it classifies to, Edi850Order and Edi997Ack, and the AckCorrelation Variable do not need to exist while you write the code. You create them inline from the port that publishes them in Step 4, and nothing runs until you start the ports in Step 8.

The 850 a cafe posts
EDI
ISA*00*          *00*          *ZZ*CAFECRESCENT   *ZZ*BLUEHARBORHQ   *260715*1032*U*00501*000012345*0*P*>~
GS*PO*CAFECRESCENT*BLUEHARBORHQ*20260715*1032*3187*X*005010~
ST*850*0001~
BEG*00*NE*CRPO-48815**20260715~
DTM*002*20260722~
N1*BY*Crescent Cafe*92*CAFE-014~
PO1*1*6*CA*38.50**VP*BH-ETH-12~
PID*F****Ethiopia Yirgacheffe 12oz~
PO1*2*4*CA*42.00**VP*BH-COL-12~
PID*F****Colombia Huila 12oz~
PO1*3*10*CA*29.75**VP*BH-HOUSE-2~
PID*F****House Blend 2lb~
CTT*3~
SE*12*0001~
GE*1*3187~
IEA*1*000012345~

The rejected variant is identical but for one element, an impossible date in BEG05: BEG*00*NE*CRPO-48815**20261332~ (month 13, day 32). That single fault drives the negative branch below, and the same code path catches the other faults from the coverage table.

The component code
C#
using System.Globalization;
using System.Text.Json;
using CC.Art2link.Pipelines.Domain.Models.PipelineComponents;

public sealed class EdiConfig
{
    public string SegmentTerminator { get; set; } = "~";
    public string ElementSeparator  { get; set; } = "*";
}

public sealed class Edi850ApiDisassembler : PipelineComponentBase<EdiConfig>
{
    public override string Name => "Edi850ApiDisassembler";

    protected override Task<PipelineComponentOutput> ExecuteAsync(
        PipelineComponentInput input, EdiConfig config, CancellationToken ct)
    {
        var seg = input.Body
            .Split(config.SegmentTerminator, StringSplitOptions.RemoveEmptyEntries)
            .Select(s => s.Trim().Split(config.ElementSeparator))
            .ToList();

        var isa = seg.FirstOrDefault(s => s[0] == "ISA");
        var gs  = seg.FirstOrDefault(s => s[0] == "GS");
        var ge  = seg.FirstOrDefault(s => s[0] == "GE");
        if (isa is null || gs is null)                 // no readable envelope: cannot acknowledge
            return Task.FromResult(new PipelineComponentOutput
                { Success = false, ErrorMessage = "No ISA/GS envelope" });

        var sender = isa[6].Trim();          // ISA06, the partner (the 997 receiver)
        var self   = isa[8].Trim();          // ISA08, our own id (the 997 sender)
        var icn    = isa[13].Trim();          // ISA13 interchange control number
        var funcId = gs[1].Trim();            // GS01, "PO" for an 850
        var gcn    = gs[6].Trim();            // GS06 group control number
        var correlation = $"{sender}:{icn}:{gcn}";

        // ---- Group-level checks -> AK9 / AK905..909 ----
        var groupErrors = new List<object>();
        if (ge is not null && ge.Length > 2 && ge[2].Trim() != gcn)
            groupErrors.Add(new { code = "4", meaning = "Group control numbers in GS and GE disagree" });
        var stCount = seg.Count(s => s[0] == "ST");
        if (ge is not null && ge.Length > 1 && int.TryParse(ge[1], out var declaredSets) && declaredSets != stCount)
            groupErrors.Add(new { code = "5", meaning = "Number of included transaction sets does not match count" });

        // ---- Transaction-set checks -> AK5 (502..506) and AK3/AK4 ----
        var st  = seg.First(s => s[0] == "ST");
        var se  = seg.FirstOrDefault(s => s[0] == "SE");
        var beg = seg.FirstOrDefault(s => s[0] == "BEG");
        var segErrors  = new List<object>();   // AK3/AK4
        var setReasons = new List<object>();   // AK5 502..506

        if (se is not null && se.Length > 2 && se[2].Trim() != st[2].Trim())
            setReasons.Add(new { code = "3", meaning = "Control numbers in ST and SE do not match" });
        if (se is not null && se.Length > 1 && int.TryParse(se[1], out var declaredSeg) && declaredSeg != CountSetSegments(seg))
            setReasons.Add(new { code = "4", meaning = "Number of included segments does not match count" });
        if (beg is null)
            segErrors.Add(SegErr("BEG", 2, "3", "Mandatory segment missing", null));
        else if (beg.Length > 5 && !IsDate(beg[5]))
            segErrors.Add(SegErr("BEG", 2, "8", "Segment has data element errors",
                new { position = 5, code = "8", meaning = "Invalid date", badValue = beg[5] }));
        // ...add more segment/element rules here as the trading relationship needs them...
        if (segErrors.Count > 0)
            setReasons.Add(new { code = "5", meaning = "One or more segments in error" });

        var setAccepted = setReasons.Count == 0;
        var accepted    = setAccepted && groupErrors.Count == 0;
        var ack = new
        {
            correlation, partner = sender, self,
            ak1 = new { functionalIdCode = funcId, groupControlNumber = gcn },
            transactionSets = new[] { new {
                setId = st[1], controlNumber = st[2],
                ak5 = setAccepted ? "A" : "R",
                ak5Reasons = setReasons,
                segmentErrors = segErrors
            } },
            ak9 = new { code = accepted ? "A" : "R", setsIncluded = stCount, setsReceived = stCount,
                          setsAccepted = setAccepted ? 1 : 0, errors = groupErrors },
            result = accepted ? "ACCEPTED" : "REJECTED"
        };

        var messages = new List<PipelineMessage>
        {
            new() { Body = JsonSerializer.Serialize(ack), MessageType = "Edi997Ack" }
        };
        if (accepted)
            messages.Add(new() {
                Body = JsonSerializer.Serialize(BuildOrder(seg, sender, isa, gs, st, beg, correlation)),
                MessageType = "Edi850Order" });

        return Task.FromResult(new PipelineComponentOutput
        {
            Success   = true,
            Messages  = messages,
            Variables = { ["AckCorrelation"] = correlation }   // the response subscription matches this
        });
    }

    private static int CountSetSegments(List<string[]> seg)
    {
        int st = seg.FindIndex(s => s[0] == "ST"), se = seg.FindIndex(s => s[0] == "SE");
        return (st >= 0 && se >= st) ? se - st + 1 : 0;   // ST..SE inclusive
    }

    private static bool IsDate(string s) =>
        DateTime.TryParseExact(s, "yyyyMMdd", CultureInfo.InvariantCulture,
            DateTimeStyles.None, out _);

    private static object SegErr(string id, int pos, string code, string meaning, object? el) =>
        new { segmentId = id, position = pos, code, meaning,
              elementErrors = el is null ? Array.Empty<object>() : new[] { el } };

    private static object BuildOrder(List<string[]> seg, string sender,
        string[] isa, string[] gs, string[] st, string[] beg, string correlation)
    {
        var n1  = seg.First(s => s[0] == "N1" && s[1] == "BY");
        var dtm = seg.FirstOrDefault(s => s[0] == "DTM" && s[1] == "002");

        var lines = new List<object>();
        for (var i = 0; i < seg.Count; i++)
        {
            if (seg[i][0] != "PO1") continue;
            var p = seg[i];
            var pid = (i + 1 < seg.Count && seg[i + 1][0] == "PID") ? seg[i + 1] : null;
            lines.Add(new {
                lineNumber  = int.Parse(p[1]),
                quantity    = int.Parse(p[2]),
                uom         = p[3],
                unitPrice   = decimal.Parse(p[4], CultureInfo.InvariantCulture),
                vendorPart  = p[7],
                description = pid is not null && pid.Length > 5 ? pid[5] : null
            });
        }

        return new
        {
            interchange = new { senderId = sender, receiverId = isa[8].Trim(), controlNumber = isa[13].Trim() },
            group       = new { functionalId = gs[1].Trim(), controlNumber = gs[6].Trim(), version = gs[8].Trim() },
            transaction = new { setId = st[1], controlNumber = st[2] },
            correlation,
            purchaseOrder = new
            {
                purpose = beg[1], type = beg[2], poNumber = beg[3],
                poDate = Iso(beg[5]), requestedDelivery = dtm is not null ? Iso(dtm[2]) : null,
                buyer = new { name = n1[2], idQualifier = n1[3], id = n1[4] },
                lines, totalLineItems = lines.Count
            }
        };
    }

    private static string Iso(string d) =>
        d.Length == 8 ? $"{d[..4]}-{d.Substring(4, 2)}-{d.Substring(6, 2)}" : d;
}
The canonical order it emits

An accepted PO comes out as Edi850Order. Note the field names, they are the 850’s vocabulary, which the map (Step 6) will translate to the table’s:

JSON
{
  "interchange": { "senderId": "CAFECRESCENT", "receiverId": "BLUEHARBORHQ", "controlNumber": "000012345" },
  "group": { "functionalId": "PO", "controlNumber": "3187", "version": "005010" },
  "transaction": { "setId": "850", "controlNumber": "0001" },
  "correlation": "CAFECRESCENT:000012345:3187",
  "purchaseOrder": {
    "purpose": "00", "type": "NE", "poNumber": "CRPO-48815",
    "poDate": "2026-07-15", "requestedDelivery": "2026-07-22",
    "buyer": { "name": "Crescent Cafe", "idQualifier": "92", "id": "CAFE-014" },
    "lines": [
      { "lineNumber": 1, "quantity": 6,  "uom": "CA", "unitPrice": 38.50, "vendorPart": "BH-ETH-12",  "description": "Ethiopia Yirgacheffe 12oz" },
      { "lineNumber": 2, "quantity": 4,  "uom": "CA", "unitPrice": 42.00, "vendorPart": "BH-COL-12",  "description": "Colombia Huila 12oz" },
      { "lineNumber": 3, "quantity": 10, "uom": "CA", "unitPrice": 29.75, "vendorPart": "BH-HOUSE-2", "description": "House Blend 2lb" }
    ],
    "totalLineItems": 3
  }
}
The acknowledgment it emits

The positive acknowledgment (AK9 code A), still JSON at this point, carrying the correlation token that routes it back:

JSON
{
  "correlation": "CAFECRESCENT:000012345:3187",
  "partner": "CAFECRESCENT",
  "self": "BLUEHARBORHQ",
  "ak1": { "functionalIdCode": "PO", "groupControlNumber": "3187" },
  "transactionSets": [ { "setId": "850", "controlNumber": "0001", "ak5": "A", "ak5Reasons": [], "segmentErrors": [] } ],
  "ak9": { "code": "A", "setsIncluded": 1, "setsReceived": 1, "setsAccepted": 1, "errors": [] },
  "result": "ACCEPTED"
}

The negative acknowledgment for the bad-date variant names the fault down to the element: segment BEG (position 2) has an element error at position 5, an invalid date, so AK5 and AK9 both reject:

JSON
{
  "correlation": "CAFECRESCENT:000012345:3187",
  "partner": "CAFECRESCENT",
  "self": "BLUEHARBORHQ",
  "ak1": { "functionalIdCode": "PO", "groupControlNumber": "3187" },
  "transactionSets": [ {
    "setId": "850", "controlNumber": "0001", "ak5": "R",
    "ak5Reasons": [ { "code": "5", "meaning": "One or more segments in error" } ],
    "segmentErrors": [ {
      "segmentId": "BEG", "position": 2, "code": "8", "meaning": "Segment has data element errors",
      "elementErrors": [ { "position": 5, "code": "8", "meaning": "Invalid date", "badValue": "20261332" } ]
    } ]
  } ],
  "ak9": { "code": "R", "setsIncluded": 1, "setsReceived": 1, "setsAccepted": 0, "errors": [] },
  "result": "REJECTED"
}
Wrap the component in a pipeline

A port never references a component directly; it references a pipeline, a named, ordered list of components that also carries the default values for each component’s configuration properties. Create one that holds this disassembler, setting the two properties EdiConfig exposes:

SettingValue
NameEdi850InboundPipeline
ComponentsEdi850ApiDisassembler (this step)
SegmentTerminator~
ElementSeparator*

2
Step Two
Set up the database it all leans on

Two things downstream lean on a database: numbering each 997 reply (Step 3) and booking accepted orders (Step 6). Set both up now. These are plain SQL objects in your order database, not ESB artifacts. Column names deliberately do not echo the 850’s field names; the map in Step 6 is where the two vocabularies meet. The partner’s outbound control-number counter lives on Clients as NextControlNumber, and partners are provisioned in advance so the row exists before any order arrives.

SQL
CREATE TABLE dbo.Clients (
    ClientKey         INT IDENTITY(1,1) PRIMARY KEY,
    PartnerCode       VARCHAR(30)  NOT NULL UNIQUE,   -- the X12 sender id (ISA06)
    AccountRef        VARCHAR(40)  NULL,             -- the buyer id in the N1 loop
    DisplayName       NVARCHAR(120) NULL,
    NextControlNumber BIGINT       NOT NULL CONSTRAINT DF_Clients_Ctl DEFAULT 4000,
    Active            BIT          NOT NULL CONSTRAINT DF_Clients_Act DEFAULT 1
);

CREATE TABLE dbo.Orders (
    OrderKey           INT IDENTITY(1,1) PRIMARY KEY,
    ClientKey          INT         NOT NULL REFERENCES dbo.Clients(ClientKey),
    CustomerReference  VARCHAR(40) NOT NULL,   -- the PO number (BEG03)
    OrderKind          VARCHAR(4)  NULL,       -- BEG02
    PlacedOn           DATE        NULL,       -- BEG05
    NeededBy           DATE        NULL,       -- DTM 002
    SourceGroupControl VARCHAR(20) NULL,       -- GS06, for traceability
    ReceivedAtUtc      DATETIME2   NOT NULL CONSTRAINT DF_Orders_Rcv DEFAULT SYSUTCDATETIME(),
    CONSTRAINT UQ_Orders_ClientRef UNIQUE (ClientKey, CustomerReference)
);

CREATE TABLE dbo.OrderLines (
    LineKey       INT IDENTITY(1,1) PRIMARY KEY,
    OrderKey      INT          NOT NULL REFERENCES dbo.Orders(OrderKey),
    LinePosition  INT          NOT NULL,   -- PO101
    CatalogCode   VARCHAR(40)  NULL,       -- the vendor part (PO107)
    LineText      NVARCHAR(200) NULL,      -- the PID description
    Cases         INT          NOT NULL,   -- PO102 quantity
    UnitOfMeasure VARCHAR(4)   NULL,       -- PO103
    PriceEach     DECIMAL(18,2) NOT NULL   -- PO104
);

-- Provision the trading partner up front, counter seeded.
INSERT dbo.Clients (PartnerCode, AccountRef, DisplayName, NextControlNumber)
VALUES ('CAFECRESCENT', 'CAFE-014', 'Crescent Cafe', 4021);
The booking procedure

The booking port (Step 6) passes the mapped XML to this procedure. It resolves the client without disturbing the counter, upserts the order header idempotently on (ClientKey, CustomerReference) so a re-sent PO never double-books, and reloads the lines, the idempotent receiver discipline:

SQL
CREATE OR ALTER PROCEDURE dbo.usp_BookPurchaseOrder
    @doc XML
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE @PartnerCode VARCHAR(30) = @doc.value('(/BookOrder/Client/PartnerCode)[1]', 'VARCHAR(30)');
    DECLARE @AccountRef  VARCHAR(40) = @doc.value('(/BookOrder/Client/AccountRef)[1]', 'VARCHAR(40)');
    DECLARE @DisplayName NVARCHAR(120) = @doc.value('(/BookOrder/Client/DisplayName)[1]', 'NVARCHAR(120)');
    DECLARE @CustRef VARCHAR(40) = @doc.value('(/BookOrder/Order/CustomerReference)[1]', 'VARCHAR(40)');

    -- Keep the client current, but never touch NextControlNumber here.
    UPDATE dbo.Clients SET AccountRef = @AccountRef, DisplayName = @DisplayName
    WHERE PartnerCode = @PartnerCode;
    IF @@ROWCOUNT = 0
        INSERT dbo.Clients (PartnerCode, AccountRef, DisplayName)
        VALUES (@PartnerCode, @AccountRef, @DisplayName);

    DECLARE @ClientKey INT = (SELECT ClientKey FROM dbo.Clients WHERE PartnerCode = @PartnerCode);

    -- Idempotent on (client, PO number): a re-sent order updates, never duplicates.
    DECLARE @OrderKey INT =
        (SELECT OrderKey FROM dbo.Orders WHERE ClientKey = @ClientKey AND CustomerReference = @CustRef);
    IF @OrderKey IS NULL
    BEGIN
        INSERT dbo.Orders (ClientKey, CustomerReference, OrderKind, PlacedOn, NeededBy, SourceGroupControl)
        VALUES (@ClientKey, @CustRef,
            @doc.value('(/BookOrder/Order/OrderKind)[1]', 'VARCHAR(4)'),
            @doc.value('(/BookOrder/Order/PlacedOn)[1]', 'DATE'),
            @doc.value('(/BookOrder/Order/NeededBy)[1]', 'DATE'),
            @doc.value('(/BookOrder/Order/SourceGroupControl)[1]', 'VARCHAR(20)'));
        SET @OrderKey = SCOPE_IDENTITY();
    END
    ELSE
        UPDATE dbo.Orders SET NeededBy = @doc.value('(/BookOrder/Order/NeededBy)[1]', 'DATE')
        WHERE OrderKey = @OrderKey;

    DELETE dbo.OrderLines WHERE OrderKey = @OrderKey;
    INSERT dbo.OrderLines (OrderKey, LinePosition, CatalogCode, LineText, Cases, UnitOfMeasure, PriceEach)
    SELECT @OrderKey,
        L.n.value('(LinePosition)[1]', 'INT'),
        L.n.value('(CatalogCode)[1]', 'VARCHAR(40)'),
        L.n.value('(LineText)[1]', 'NVARCHAR(200)'),
        L.n.value('(Cases)[1]', 'INT'),
        L.n.value('(UnitOfMeasure)[1]', 'VARCHAR(4)'),
        L.n.value('(PriceEach)[1]', 'DECIMAL(18,2)')
    FROM @doc.nodes('/BookOrder/Order/Lines/Line') AS L(n);
END;
The control-number draw

The response assembler (Step 3) calls this to stamp the outgoing 997. It is a single transactional increment under UPDLOCK, HOLDLOCK, so two 997s to the same partner at the same instant can never take the same number, the same discipline the 810 invoices tutorial applies to interchange numbers:

SQL
CREATE OR ALTER PROCEDURE dbo.usp_NextOutboundControlNumber
    @PartnerCode VARCHAR(30)
AS
BEGIN
    SET NOCOUNT ON;
    DECLARE @next BIGINT;
    UPDATE dbo.Clients WITH (UPDLOCK, HOLDLOCK)
    SET @next = NextControlNumber, NextControlNumber = NextControlNumber + 1
    WHERE PartnerCode = @PartnerCode;
    SELECT @next AS ControlNumber;
END;

3
Step Three
Assemble the X12 997 reply

Next in the flow is the reply. The acknowledgment is still JSON on the bus; a second pipeline component renders it as a real X12 997 and, in the same pass, stamps a fresh control number drawn from the counter in Step 2. In Pipeline components, create Edi997Assembler. Its connection string arrives through its config, bound to a Constant you create with the pipeline below; it takes our own EDI id from the message, not from a setting, so nothing about it is client-specific.

C#
using System.Text;
using System.Text.Json;
using Microsoft.Data.SqlClient;
using CC.Art2link.Pipelines.Domain.Models.PipelineComponents;

public sealed class AssemblerConfig
{
    public string ConnectionString { get; set; } = "";   // bound to {{Constant.EdiDbConn}}
}

public sealed class Edi997Assembler : PipelineComponentBase<AssemblerConfig>
{
    public override string Name => "Edi997Assembler";

    protected override async Task<PipelineComponentOutput> ExecuteAsync(
        PipelineComponentInput input, AssemblerConfig config, CancellationToken ct)
    {
        using var doc = JsonDocument.Parse(input.Body);
        var a = doc.RootElement;
        var partner = a.GetProperty("partner").GetString()!;   // the 997 receiver
        var self    = a.GetProperty("self").GetString()!;      // our id, taken from the inbound envelope
        var ak1 = a.GetProperty("ak1");
        var ts  = a.GetProperty("transactionSets")[0];
        var ak9 = a.GetProperty("ak9");

        // One deliberate, transactional draw per outbound 997. Gaps are normal in EDI.
        long ctl = await NextControlNumberAsync(config.ConnectionString, partner, ct);
        var ctl9 = ctl.ToString("000000000");
        var now  = DateTime.UtcNow;
        var pad  = (string s) => (s + new string(' ', 15))[..15];

        var seg = new List<string>
        {
            $"ISA*00*          *00*          *ZZ*{pad(self)}*ZZ*{pad(partner)}*{now:yyMMdd}*{now:HHmm}*U*00501*{ctl9}*0*P*>",
            $"GS*FA*{self}*{partner}*{now:yyyyMMdd}*{now:HHmm}*{ctl}*X*005010",
            "ST*997*0001",
            $"AK1*{ak1.GetProperty("functionalIdCode").GetString()}*{ak1.GetProperty("groupControlNumber").GetString()}",
            $"AK2*{ts.GetProperty("setId").GetString()}*{ts.GetProperty("controlNumber").GetString()}"
        };

        // AK3/AK4 only when the set was rejected for segment/element faults.
        if (ts.GetProperty("ak5").GetString() == "R")
            foreach (var e in ts.GetProperty("segmentErrors").EnumerateArray())
            {
                seg.Add($"AK3*{e.GetProperty("segmentId").GetString()}*{e.GetProperty("position").GetInt32()}**{e.GetProperty("code").GetString()}");
                foreach (var el in e.GetProperty("elementErrors").EnumerateArray())
                    seg.Add($"AK4*{el.GetProperty("position").GetInt32()}**{el.GetProperty("code").GetString()}");
            }

        var ak5line = ts.GetProperty("ak5").GetString() == "A"
            ? "AK5*A" : "AK5*R*5";
        seg.Add(ak5line);
        seg.Add($"AK9*{ak9.GetProperty("code").GetString()}*{ak9.GetProperty("setsIncluded").GetInt32()}*{ak9.GetProperty("setsReceived").GetInt32()}*{ak9.GetProperty("setsAccepted").GetInt32()}");
        seg.Add($"SE*{seg.Count - 1}*0001");   // count ST..SE inclusive; two envelope lines precede ST
        seg.Add($"GE*1*{ctl}");
        seg.Add($"IEA*1*{ctl9}");

        var x12 = string.Join("", seg.Select(s => s + "~\n"));
        return new PipelineComponentOutput
        {
            Success  = true,
            Messages = [new PipelineMessage { Body = x12, MessageType = "Edi997Ack" }]
        };
    }

    private static async Task<long> NextControlNumberAsync(string conn, string partner, CancellationToken ct)
    {
        await using var c = new SqlConnection(conn);
        await c.OpenAsync(ct);
        await using var cmd = new SqlCommand("dbo.usp_NextOutboundControlNumber", c)
            { CommandType = System.Data.CommandType.StoredProcedure };
        cmd.Parameters.AddWithValue("@PartnerCode", partner);
        return Convert.ToInt64(await cmd.ExecuteScalarAsync(ct));
    }
}
Every reply draws a number. Each 997, positive or negative, consumes one control number. Gaps are normal in EDI and not a problem; a partner never sees a recycled number. The UPDLOCK, HOLDLOCK draw in Step 2 is what makes two simultaneous replies safe. An equally valid alternative, the route the file-based 850 tutorial takes, is a custom function called from a text map; here the assembler owns both the number and the framing.
The X12 997 the cafe receives

The positive acknowledgment, stamped with our own control number (say the counter’s next value is 4021), not the buyer’s:

EDI
ISA*00*          *00*          *ZZ*BLUEHARBORHQ   *ZZ*CAFECRESCENT   *260715*1032*U*00501*000004021*0*P*>~
GS*FA*BLUEHARBORHQ*CAFECRESCENT*20260715*1032*4021*X*005010~
ST*997*0001~
AK1*PO*3187~
AK2*850*0001~
AK5*A~
AK9*A*1*1*1~
SE*6*0001~
GE*1*4021~
IEA*1*000004021~

The negative acknowledgment for the bad-date order, drawing the next number (4022), with the fault located in AK3/AK4:

EDI
ISA*00*          *00*          *ZZ*BLUEHARBORHQ   *ZZ*CAFECRESCENT   *260715*1034*U*00501*000004022*0*P*>~
GS*FA*BLUEHARBORHQ*CAFECRESCENT*20260715*1034*4022*X*005010~
ST*997*0001~
AK1*PO*3187~
AK2*850*0001~
AK3*BEG*2**8~
AK4*5**8~
AK5*R*5~
AK9*R*1*1*0~
SE*8*0001~
GE*1*4022~
IEA*1*000004022~
Wrap the component in a pipeline

As on the inbound side, the port references a pipeline, not the component, and the pipeline carries the assembler’s configuration defaults. The assembler needs only one, the database connection; it takes our own EDI id from the inbound envelope (the 850’s receiver) rather than a setting, so a single pipeline serves every partner. Create the pipeline:

SettingValue
NameEdi997ResponsePipeline
ComponentsEdi997Assembler (this step)
ConnectionString{{Constant.EdiDbConn}}

The ConnectionString binds a Constant, deployment configuration rather than code; create it from that field:

SettingValue
NameEdiDbConn
ValueServer=prod-sql-01;Database=Orders;Integrated Security=true

4
Step Four
Receive the call and reply

Now the port that ties it together, a two-way receive port on the API Listener.

Receive port
SettingValue
NameEdiOrderApi
AdapterAPI Listener
WayTwo
AuthenticationPartnerApiToken, the credential below

Create that credential from the Authentication field:

Authentication, for the API Listener
SettingValue
NamePartnerApiToken
AdapterAPI Listener
DefinitionAPI Listener Token
Token (Partner Config)the secret the cafe presents on every call
Declare the routing headers
HeaderValueWhy
AppBlueHarborEdithe Application’s namespace, narrows the call to this Application
PurposeEdiOrderApikeeps this Listener unique within the Application
Solicit side
SettingValue
Message Type FallbackEdiInterchange, so the posted X12 classifies as the raw interchange at the start of the inbound flow
Inbound pipelineEdi850InboundPipeline, from Step 1
MapNone

The fallback and the disassembler’s two classifications are message types; create each from the type picker when its field asks for it:

Message typeFormatPurpose
EdiInterchangeTXTthe raw X12 body as posted; the fallback the disassembler reads; never a map source
Edi850OrderJSONone accepted PO the component emits; the booking port (Step 6) subscribes to it; the schema (Step 5) validates it
Edi997AckJSONthe acknowledgment the component emits; the response side subscribes to it and assembles it to X12

Promotions live on the message type, and the picker lets you add them without leaving the port. The one that matters is the correlation token on the acknowledgment, what the Response Subscription matches; add a tracing promotion on the order too. Keep it to what you route on, the minimal-promotion discipline:

On message typePromotionPath
Edi997AckCorrelation$.correlation
Edi850OrderPoNumber$.purchaseOrder.poNumber

In the Variables stage, create the Variable the disassembler writes per call:

Variable
SettingValue
NameAckCorrelation
Response side: subscription, then assembler
Response side

The response runs map first, then pipeline; with no map the assembler is the only stage, turning the JSON 997 into X12 and stamping the control number just before the reply goes back to the caller. Its connection string is already set as a pipeline default in Step 3, so there is nothing to configure here beyond the three settings below.

SettingValue
Response Subscription Expression{{Promoted.Edi997Ack.Correlation}} == {{Variable.AckCorrelation}}
MapNone
Response pipelineEdi997ResponsePipeline, from Step 3
The correlation token is what keeps calls apart. The bus is shared: many cafes can post at the same instant, and many Edi997Ack messages will be in flight. The expression’s reference to this call’s own {{Variable.AckCorrelation}} is what guarantees each caller gets their acknowledgment and not the next one off the bus, the same guarantee the stock lookup relies on.

5
Step Five
Create and attach the schema

The schema is the one artifact with no inline shortcut: it has its own editor and must exist before you can attach it to a type. Open Schemas, create a new one, and fill the form:

FieldValue
NameEdi850OrderSchema
ApplicationBlueHarborEdi (preset)
TypeJSON (the disassembler emits JSON; an XML payload would be XSD)
Bodythe JSON Schema below

Save (not Save Draft), then open the Edi850Order message type from Step 4 and associate this schema with it. Validation then fires automatically the moment a message is classified as Edi850Order. The component only ever emits an Edi850Order for an order it already judged valid, so this schema is a canonical-shape guard on what reaches the booking path, not the basis for the 997; the 997 verdict is the X12 check in Step 1.

JSON
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Edi850Order",
  "type": "object",
  "required": ["interchange", "purchaseOrder"],
  "properties": {
    "purchaseOrder": {
      "type": "object",
      "required": ["poNumber", "buyer", "lines"],
      "properties": {
        "poNumber": { "type": "string" },
        "buyer": {
          "type": "object",
          "required": ["id"],
          "properties": { "id": { "type": "string" } }
        },
        "lines": {
          "type": "array",
          "minItems": 1,
          "items": {
            "type": "object",
            "required": ["vendorPart", "quantity"],
            "properties": {
              "vendorPart": { "type": "string" },
              "quantity":   { "type": "integer", "minimum": 1 },
              "unitPrice":  { "type": "number" }
            }
          }
        }
      }
    }
  }
}

6
Step Six
Book accepted orders

An accepted Edi850Order now needs to reach the database. Create a one-way send port on the SQL Caller that subscribes to the order by type and calls the booking procedure. Only accepted orders ever carry that type, so nothing the 997 rejected reaches the database, the subscribe, do not hardcode discipline.

Send port
SettingValue
NameOrderBooking
AdapterSQL Caller
WayOne
Subscription{{Message.MessageType}} == "Edi850Order"
Command TypeStoredProcedure
Command Textdbo.usp_BookPurchaseOrder, from Step 2
Outbound mapTyped, Edi850OrderToBookingXml, the map below

At the Auth Config field, select OrdersDbSql, or create it there:

Authentication, for the SQL Caller
SettingValue
NameOrdersDbSql
AdapterSQL Caller
DefinitionSQL Server Connection
Connection Stringthe order database’s connection string, credentials included
Configure the outbound map

Create that map from the outbound Map field. Its whole job is to cross the vocabulary gap, since the table’s columns deliberately do not echo the 850’s field names:

Map settingValue
NameEdi850OrderToBookingXml
Source message typeEdi850Order (JSON), from Step 4
Target message typeEdi850BookingXml (XML), created from the target picker
850 / JSON fieldTable column (XML element)
interchange.senderIdPartnerCode
buyer.idAccountRef
buyer.nameDisplayName
poNumberCustomerReference
typeOrderKind
poDatePlacedOn
requestedDeliveryNeededBy
group.controlNumberSourceGroupControl
vendorPart / descriptionCatalogCode / LineText
quantity / uom / unitPriceCases / UnitOfMeasure / PriceEach

Maps are authored in XSLT 3.0 and run on Saxon HE 12.9. JSON in, XML out: parse-json(.) reads the order and the ?key operator pulls each field into its renamed element:

XSLT 3.0
<xsl:stylesheet version="3.0"
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:output method="xml" indent="yes"/>
  <xsl:template match="/">
    <xsl:variable name="o" select="parse-json(.)"/>
    <xsl:variable name="po" select="$o?purchaseOrder"/>
    <BookOrder>
      <Client>
        <PartnerCode><xsl:value-of select="$o?interchange?senderId"/></PartnerCode>
        <AccountRef><xsl:value-of select="$po?buyer?id"/></AccountRef>
        <DisplayName><xsl:value-of select="$po?buyer?name"/></DisplayName>
      </Client>
      <Order>
        <CustomerReference><xsl:value-of select="$po?poNumber"/></CustomerReference>
        <OrderKind><xsl:value-of select="$po?type"/></OrderKind>
        <PlacedOn><xsl:value-of select="$po?poDate"/></PlacedOn>
        <NeededBy><xsl:value-of select="$po?requestedDelivery"/></NeededBy>
        <SourceGroupControl><xsl:value-of select="$o?group?controlNumber"/></SourceGroupControl>
        <Lines>
          <xsl:for-each select="$po?lines?*">
            <Line>
              <LinePosition><xsl:value-of select="?lineNumber"/></LinePosition>
              <CatalogCode><xsl:value-of select="?vendorPart"/></CatalogCode>
              <LineText><xsl:value-of select="?description"/></LineText>
              <Cases><xsl:value-of select="?quantity"/></Cases>
              <UnitOfMeasure><xsl:value-of select="?uom"/></UnitOfMeasure>
              <PriceEach><xsl:value-of select="?unitPrice"/></PriceEach>
            </Line>
          </xsl:for-each>
        </Lines>
      </Order>
    </BookOrder>
  </xsl:template>
</xsl:stylesheet>
Pass the mapped XML to the procedure

The map runs first, so by the time the SQL Caller binds its parameter the body is already the BookOrder XML. Add one Input Parameter, passing that whole body to the procedure’s @doc:

ParameterValue
doc{{Message.Body}}, the mapped XML the procedure shreds
Set the exception type

On the port’s General step, set Exception Message Type to a new type, created from the field. If the database is down when an accepted order tries to book, the message re-publishes under it, payload intact, for the alert in Step 7 to pick up:

SettingValue
NameEdiOrderFailed
FormatJSON

7
Step Seven
Alert on booking failures

Booking runs after the call has ended, so a database failure then is an internal recovery task, not a broken promise, the caller already has a positive 997. Alert operations when an accepted order fails to book, on the EdiOrderFailed type from Step 6.

Send port
SettingValue
NameOpsAlert
AdapterO365 Mail Sender
Subscription{{Message.MessageType}} == "EdiOrderFailed"
Fromnoreply@blueharbor.example
Toops@blueharbor.example
SubjectOperations alert, {{Message.MessageType}}

At the Authentication field, select O365Ops, or create it there:

Authentication, for O365 Mail
SettingValue
NameO365Ops
AdapterO365 Mail Sender
DefinitionMicrosoft Graph
Tenant Id / Client Id / Client Secretan app registration with Mail.Send granted

Cover bodies the disassembler cannot parse at all (no readable ISA) with Activity Notifications (On Error Only); those cannot be acknowledged and return an error status to the caller before any 997 exists.


8
Step Eight
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 (OrderBooking, OpsAlert), then the receive port EdiOrderApi.

Turn on tracking

Set Tracking to Enabled + Body on every port the flow touches, so you can walk each call step by step, with its message body, in tracking.

Post a good order

Post the well-formed 850 from Step 1 and confirm a positive X12 997 (AK9*A) comes back on the same connection, then that the order appears across Clients, Orders and OrderLines.

Post the bad-date order

Post the variant with BEG05 = 20261332 and confirm a negative 997 (AK9*R) comes back naming BEG in AK3, and that no row was written. Try one of the other faults from the coverage table, a mismatched SE01 count for instance, and watch the 997 report it just as precisely.

Check the control numbers

Confirm the two 997s carried consecutive numbers (4021 then 4022) and that Clients.NextControlNumber advanced. Re-post the good order and confirm the counter advances again while Orders does not gain a duplicate row.

Fire concurrent calls

Post several orders at once with distinct interchange control numbers and verify each caller receives its own acknowledgment, never another’s.

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 calls are not recorded.