Skip to content
Art2link ESB HomeDocumentationBlogContact
Updated August 2, 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, is caught by an Activity Notification (On Error Only) that emails the team, no dead-letter port required (Step 7).

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 Accelerator can build it for you from a plain-language prompt (see below), or you can write it by hand. 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.

Build it with the AI Accelerator

You do not have to write this component by hand. Open the AI Accelerator, describe the component in plain language, and let it generate the C# for you to review and save. It already knows X12 and how Art2link ESB pipeline components work, so the prompt only needs what is specific to your integration, the message types, the correlation token, and the properties to expose, not a segment-by-segment EDI rulebook. A prompt like this is enough:

AI ACCELERATOR PROMPT
Build a disassembler pipeline component that reads an inbound X12 850 and, in a single pass, produces two JSON messages: the purchase order and its 997 functional acknowledgment.

Validate the 850 to the usual X12 envelope and transaction rules, and reflect the outcome in the acknowledgment, accept or reject, naming the segment and element a fault sits on. If the body has no readable ISA/GS envelope, fail it as a poison message.

Always publish the acknowledgment as message type Edi997Ack; publish the order as message type Edi850Order only when the 850 is accepted. Build a correlation token as sender:interchangeControlNumber:groupControlNumber and write it to a Variable named AckCorrelation so the response port can match on it.

Expose the segment terminator and element separator as component properties, defaulting to ~ and *.

For reference, an 850 the partner posts looks like this (yours will carry different values but the same segment structure):

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 AI Accelerator writes a first draft, not a finished component: run the sample 850 below through it and confirm the JSON and acknowledgment it emits before you rely on it. 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
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;
}
Generated output varies, so treat every sample from here on as an example. The AI Accelerator does not return the exact same result twice, so the JSON and X12 shown below are one workable shape, not a guarantee your component will match them character for character. What matters is that the artifacts agree with each other. When the AI generates the disassembler it shows you the JSON it settled on; that shape is your contract. Reuse it when you prompt for the pieces that consume it, the schema (Step 5), the booking map (Step 6), and the 997 assembler (Step 3), and once the ports are running (Step 8) confirm the real bodies in Tracking with Enabled + Body and reconcile any differences.
The canonical order it emits (example shape)

An accepted PO comes out as Edi850Order. The field names below are the 850’s own vocabulary; whatever names your generated component actually uses become the ones the map in Step 6 translates from, so keep the two aligned:

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 (example shape)

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:

Build it with the AI Accelerator

You can have the AI Accelerator write this procedure too, which matters because its input shape follows the booking XML the map produces, and that XML is itself generated. Describe what the procedure must do, and base it on the booking XML your map actually emits so the two agree:

AI ACCELERATOR PROMPT
Build a stored procedure that books an accepted order from the booking XML the map produces, passed as a single XML parameter.

Resolve or create the client from its partner code without touching the outbound control-number counter. Upsert the order header idempotently on the client plus PO number, so a re-sent order updates rather than duplicates, and replace its line rows on each run. Write into the Clients, Orders and OrderLines tables.

The booking XML the map produces looks like this (one line shown; there can be many):

<BookOrder>
  <Client>
    <PartnerCode>CAFECRESCENT</PartnerCode>
    <AccountRef>CAFE-014</AccountRef>
    <DisplayName>Crescent Cafe</DisplayName>
  </Client>
  <Order>
    <CustomerReference>CRPO-48815</CustomerReference>
    <OrderKind>NE</OrderKind>
    <PlacedOn>2026-07-15</PlacedOn>
    <NeededBy>2026-07-22</NeededBy>
    <SourceGroupControl>3187</SourceGroupControl>
    <Lines>
      <Line>
        <LinePosition>1</LinePosition>
        <CatalogCode>BH-ETH-12</CatalogCode>
        <LineText>Ethiopia Yirgacheffe 12oz</LineText>
        <Cases>6</Cases>
        <UnitOfMeasure>CA</UnitOfMeasure>
        <PriceEach>38.50</PriceEach>
      </Line>
    </Lines>
  </Order>
</BookOrder>

To start from the finished procedure instead, expand it here to read or copy.

Show the full booking procedure (SQL), expand to read or copy
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.

Build it with the AI Accelerator

As with the disassembler, the AI Accelerator can generate this component from a description. It knows the X12 997 layout, so the prompt only has to say what it reads (the acknowledgment your disassembler emits), where the control number comes from, and how the reply is typed. A prompt like this is enough:

AI ACCELERATOR PROMPT
Build a pipeline component that turns an Edi997Ack JSON message into a real X12 997 and returns it as text.

Draw a fresh outbound control number by calling the stored procedure dbo.usp_NextOutboundControlNumber with the partner code, and take the database connection from a component property named ConnectionString, bound to a Constant rather than hardcoded. Read our own EDI id and the partner id from the message itself, so one component serves every partner.

Stamp the result with message type Edi997Interchange, an EDI type, so the reply is never re-classified as JSON on the way back to the caller.

The Edi997Ack it reads looks like this (a reject sets ak5 to R and lists the faults under segmentErrors):

{
  "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"
}

Review the output the same way: a control number is consumed on every run, so test against a throwaway counter first. To start from the working code instead, expand it here to read or copy.

Show the full component code (C#), expand to read or copy
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 = "Edi997Interchange" }]
        };
    }

    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));
    }
}
The X12 reply carries an EDI message type, not the JSON one. The assembler consumes the JSON Edi997Ack but emits flat X12 text, so it stamps the result Edi997Interchange, an EDI type (Step 4). Stamp text with a JSON type and the platform tries to classify and validate the reply as JSON on the way back to the caller; it is not JSON, so the response never completes and the open call hangs until it times out. The Response Subscription still matches the JSON Edi997Ack by correlation; only the assembled reply changes type.
This is the general typing rule, shown doing real work. Every message a component emits carries a Message Type, and the type matters wherever the pipeline is attached, not only where the message enters the bus. Nothing here is special-cased for a reply. The assembler is an ordinary pipeline component: it has no direction of its own, it is simply attached at a position where the message is leaving, and the type it leaves on the message is what decides how that message is treated on the way to the wire. Get the type wrong at this position and the consequence is not a cosmetic label, it is a call that never returns. See Components classify the Message Type for the rule and the four positions it applies to.
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 (example shape)

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, the receive port’s inbound leg (Caller → Bus)
SettingValue
Adapter Message Type (required)EdiInterchange, the type the posted X12 is given the instant the API Listener hands it on
Inbound pipelineEdi850InboundPipeline, from Step 1
MapNone

The Adapter Message Type is the first thing that happens to the message, before any component sees it, so the disassembler is handed a message that is already typed. It is required, not a default and not a last resort. These are the flow’s message types; create each from the type picker when its field asks for it, except the assembler’s reply type, which has no picker field and is created standalone:

Message typeFormatPurpose
EdiInterchangeEDIthe raw X12 body as posted; the port’s Adapter Message Type and what 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 disassembler emits; the response side subscribes to it by correlation and the assembler consumes it
Edi997InterchangeEDIthe assembled X12 997 returned to the caller; EDI so the text reply is never classified or validated as JSON

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, the receive port’s outbound leg (Bus → Caller): 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.

Build it with the AI Accelerator

This is a standard JSON Schema, so the AI Accelerator can write it from a short description of what must be present in the order your disassembler emits, then you paste the result into the Body field. A prompt like this is enough:

AI ACCELERATOR PROMPT
Build a JSON schema for the Edi850Order message the disassembler emits. Require the interchange envelope and the purchase order; within the order require a PO number, a buyer with an id, and at least one line, each line needing a vendor part and a quantity of one or more. Keep it a canonical-shape guard, not a full EDI rulebook.

The Edi850Order it validates looks like this (one line shown; there can be many):

{
  "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" }
    ],
    "totalLineItems": 3
  }
}

Check the generated schema against the sample order above before you attach it. To use the finished schema directly, expand it here to read or copy.

Show the full schema (JSON), expand to read or copy
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, maps it to the booking XML, and calls the procedure. Only accepted orders ever carry that type, so nothing the 997 rejected reaches the database, the subscribe, do not hardcode discipline.

A send port runs its stages in a fixed order, subscription first, then the outbound map, then the adapter, so configure it in that order.

Send port
SettingValue
NameOrderBooking
AdapterSQL Caller
WayOne
Subscription{{Message.MessageType}} == "Edi850Order"
Outbound map

On the port’s outbound flow, set Map to Typed and create the map from that 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:

Build it with the AI Accelerator

The AI Accelerator builds maps too, and it is good at lining fields up by meaning, so the prompt can stay high level: name the target type and let it match the order’s fields to the booking columns. It works from whatever your disassembler actually emits (the example order above, or the body you see in Tracking once it runs), so you do not have to spell out every pair:

AI ACCELERATOR PROMPT
Build a map from Edi850Order (JSON) to a new XML target message type named Edi850BookingXml, the shape the booking procedure reads.

Match each order field to the booking column it corresponds to by meaning: the trading-partner code, the buyer account and name, the PO number, the order kind, the order and requested-delivery dates, the source group control number, and per line the position, catalog code, description, quantity, unit of measure and price.

Omit the XML declaration, because the SQL Caller passes the result to an XML-typed stored-procedure parameter that rejects a document carrying an encoding declaration.

The Edi850Order source looks like this (one line shown; there can be many):

{
  "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" }
    ],
    "totalLineItems": 3
  }
}

Confirm the generated XSLT keeps omit-xml-declaration="yes", the one detail the SQL Caller depends on (see the note below). To start from the working map instead, expand it here to read or copy.

Show the full map (XSLT 3.0), expand to read or copy
XSLT 3.0
<xsl:stylesheet version="3.0"
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:output method="xml" indent="yes" omit-xml-declaration="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>
Suppress the XML prolog, it is not cosmetic. Without omit-xml-declaration="yes" Saxon emits <?xml version="1.0" encoding="UTF-8"?> at the top of the output. When the SQL Caller then hands that string to the procedure’s XML-typed @doc parameter as Unicode, SQL Server refuses it with unable to switch the encoding and nothing books. Dropping the declaration is what lets the procedure shred the document.
SQL Caller configuration

With the map in place, configure the adapter that runs the procedure. At the Auth Config field, select OrdersDbSql, or create it there:

SettingValue
NameOrdersDbSql
AdapterSQL Caller
DefinitionSQL Server Connection
Connection Stringthe order database’s connection string, credentials included

Then set the command and its one input parameter together. The map has already run, so {{Message.Body}} is the BookOrder XML the procedure shreds:

SettingValue
Command TypeStoredProcedure
Command Textdbo.usp_BookPurchaseOrder, from Step 2
Input Parameter doc{{Message.Body}}, the mapped XML

7
Step Seven
Alert on 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. Alerting is not a job for a port; it is an Activity Notification, a per-Application email subscription that fires on failed runs. One notification scoped to the whole Application, on errors only, catches both a booking failure and an unparseable interchange, and reaches the team without any dead-letter routing.

Activity Notification
SettingValue
NameESB errors, ops
ApplicationBlueHarborEdi (preset)
On Error OnlyOn
SubscriptionAll
ModeOn Event
Recipientsthe ops list, e.g. ops@blueharbor.example
Is EnabledEnabled
One-time prerequisite. Activity Notifications send through the platform’s shared email channel, so the tenant’s Email notifications setup (an Entra app registration granted Mail.Send) must be completed once under Install & deploy; until then a notification saves and enables but nothing is delivered.

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 port OrderBooking first, then the receive port EdiOrderApi. The Activity Notification needs no starting; it is live once enabled.

Turn on tracking

Set the Application’s tracking level to Enabled + Body so you can walk each call step by step, with its message body. Tracking is an Application-level setting, so this one choice covers every port the flow touches, and you can drop it back to Only on Error once the flow is proven.

Build the request

Use any HTTP client to post the order, Postman, Insomnia, or curl, whatever you reach for. There is one shared ingress for every API Listener, so the request finds this port by its headers, not its path: the App header carries the Application namespace and the Purpose header distinguishes this Listener, the pair you declared in Step 4. Build the call like this:

Request partValue
MethodPOST
URLthe shared ingress address shown read-only on the port’s adapter (the Url field)
Auththe PartnerApiToken token from Step 4, presented as your ingress expects it
Header AppBlueHarborEdi
Header PurposeEdiOrderApi
Content-Typeapplication/edi-x12 (any text type; the body is raw X12)
Bodyraw, the X12 850 from Step 1 pasted verbatim (in Postman, Body → raw → Text)
Post a good order

Send the well-formed 850 from Step 1. The call holds briefly, then returns 200 with a positive X12 997 (AK9*A) in the response body, the acknowledgment assembled on the way out and stamped with your own control number. Keep the response open in the client to compare against the tracking view next.

Walk it in Tracking

Open Tracking, find the run for the call, and step through it in order, opening each message body as you go:

Step in TrackingWhat to confirm
API Listener receivesthe raw X12 arrives and is typed EdiInterchange at the adapter handoff, before the pipeline runs
Inbound pipeline (disassembler)two messages publish, an Edi850Order and an Edi997Ack (result: ACCEPTED), and Variable.AckCorrelation is set
Response side of the receive port (assembler)the Edi997Ack is matched back by correlation and assembled to the X12 997 returned to the caller, its control number drawn from the counter
OrderBooking send portthe Edi850Order is mapped to the BookOrder XML and the SQL Caller runs dbo.usp_BookPurchaseOrder

Then confirm the order landed across Clients, Orders and OrderLines.

Post the bad-date order

Send the variant with BEG05 = 20261332. The response is 200 with a negative 997 (AK9*R) naming BEG in AK3. In Tracking, confirm the disassembler published only the Edi997Ack (no Edi850Order), so nothing reached the booking port and no row was written. Try another fault 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.

Break the booking on purpose

Stop the database, then post a good order. The caller still gets a positive 997, acceptance is decided at receive and does not wait on booking, but the OrderBooking run fails. In Tracking that step shows an error, and the Activity Notification from Step 7 emails ops. That is the functional-ack-is-not-a-booking-confirmation point made concrete.

Fire concurrent calls

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