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.
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:
{{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.
{{Message.MessageType}} == "Edi850Order"
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 carries | How the 997 reports it |
|---|---|
| Group control numbers in GS06 and GE02 disagree | AK9 reject, AK905 = 4 |
| GE01 count does not match the sets received | AK9 reject, AK905 = 5 |
| ST02 and SE02 control numbers disagree | AK5 reject, AK502 = 3 |
| SE01 segment count is wrong | AK5 reject, AK502 = 4 |
| A mandatory segment (such as BEG) is missing | AK3 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).
| Setting | Value |
|---|---|
| Name | BlueHarborEdi |
| Namespace | BlueHarborEdi, a code-safe namespace |
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.
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.
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:
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
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; }
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:
{
"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 positive acknowledgment (AK9 code A), still JSON at this point, carrying the correlation token that routes it back:
{
"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:
{
"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"
}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:
| Setting | Value |
|---|---|
| Name | Edi850InboundPipeline |
| Components | Edi850ApiDisassembler (this step) |
| SegmentTerminator | ~ |
| ElementSeparator | * |
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.
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 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:
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:
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
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 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:
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;
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.
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:
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
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 positive acknowledgment, stamped with our own control number (say the counter’s next value is 4021), not the buyer’s:
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:
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~
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:
| Setting | Value |
|---|---|
| Name | Edi997ResponsePipeline |
| Components | Edi997Assembler (this step) |
| ConnectionString | {{Constant.EdiDbConn}} |
The ConnectionString binds a Constant, deployment configuration rather than code; create it from that field:
| Setting | Value |
|---|---|
| Name | EdiDbConn |
| Value | Server=prod-sql-01;Database=Orders;Integrated Security=true |
Now the port that ties it together, a two-way receive port on the API Listener.
| Setting | Value |
|---|---|
| Name | EdiOrderApi |
| Adapter | API Listener |
| Way | Two |
| Authentication | PartnerApiToken, the credential below |
Create that credential from the Authentication field:
| Setting | Value |
|---|---|
| Name | PartnerApiToken |
| Adapter | API Listener |
| Definition | API Listener Token |
| Token (Partner Config) | the secret the cafe presents on every call |
| Header | Value | Why |
|---|---|---|
| App | BlueHarborEdi | the Application’s namespace, narrows the call to this Application |
| Purpose | EdiOrderApi | keeps this Listener unique within the Application |
| Setting | Value |
|---|---|
| Adapter Message Type (required) | EdiInterchange, the type the posted X12 is given the instant the API Listener hands it on |
| Inbound pipeline | Edi850InboundPipeline, from Step 1 |
| Map | None |
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 type | Format | Purpose |
|---|---|---|
| EdiInterchange | EDI | the raw X12 body as posted; the port’s Adapter Message Type and what the disassembler reads; never a map source |
| Edi850Order | JSON | one accepted PO the component emits; the booking port (Step 6) subscribes to it; the schema (Step 5) validates it |
| Edi997Ack | JSON | the acknowledgment the disassembler emits; the response side subscribes to it by correlation and the assembler consumes it |
| Edi997Interchange | EDI | the 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 type | Promotion | Path |
|---|---|---|
| Edi997Ack | Correlation | $.correlation |
| Edi850Order | PoNumber | $.purchaseOrder.poNumber |
In the Variables stage, create the Variable the disassembler writes per call:
| Setting | Value |
|---|---|
| Name | AckCorrelation |
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.
| Setting | Value |
|---|---|
| Response Subscription Expression | {{Promoted.Edi997Ack.Correlation}} == {{Variable.AckCorrelation}} |
| Map | None |
| Response pipeline | Edi997ResponsePipeline, from Step 3 |
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:
| Field | Value |
|---|---|
| Name | Edi850OrderSchema |
| Application | BlueHarborEdi (preset) |
| Type | JSON (the disassembler emits JSON; an XML payload would be XSD) |
| Body | the 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.
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:
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
{
"$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" }
}
}
}
}
}
}
}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.
| Setting | Value |
|---|---|
| Name | OrderBooking |
| Adapter | SQL Caller |
| Way | One |
| Subscription | {{Message.MessageType}} == "Edi850Order" |
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 setting | Value |
|---|---|
| Name | Edi850OrderToBookingXml |
| Source message type | Edi850Order (JSON), from Step 4 |
| Target message type | Edi850BookingXml (XML), created from the target picker |
| 850 / JSON field | Table column (XML element) |
|---|---|
| interchange.senderId | PartnerCode |
| buyer.id | AccountRef |
| buyer.name | DisplayName |
| poNumber | CustomerReference |
| type | OrderKind |
| poDate | PlacedOn |
| requestedDelivery | NeededBy |
| group.controlNumber | SourceGroupControl |
| vendorPart / description | CatalogCode / LineText |
| quantity / uom / unitPrice | Cases / 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:
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:
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
<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>
With the map in place, configure the adapter that runs the procedure. At the Auth Config field, select OrdersDbSql, or create it there:
| Setting | Value |
|---|---|
| Name | OrdersDbSql |
| Adapter | SQL Caller |
| Definition | SQL Server Connection |
| Connection String | the 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:
| Setting | Value |
|---|---|
| Command Type | StoredProcedure |
| Command Text | dbo.usp_BookPurchaseOrder, from Step 2 |
| Input Parameter doc | {{Message.Body}}, the mapped XML |
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.
| Setting | Value |
|---|---|
| Name | ESB errors, ops |
| Application | BlueHarborEdi (preset) |
| On Error Only | On |
| Subscription | All |
| Mode | On Event |
| Recipients | the ops list, e.g. ops@blueharbor.example |
| Is Enabled | Enabled |
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.
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.
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 part | Value |
|---|---|
| Method | POST |
| URL | the shared ingress address shown read-only on the port’s adapter (the Url field) |
| Auth | the PartnerApiToken token from Step 4, presented as your ingress expects it |
| Header App | BlueHarborEdi |
| Header Purpose | EdiOrderApi |
| Content-Type | application/edi-x12 (any text type; the body is raw X12) |
| Body | raw, the X12 850 from Step 1 pasted verbatim (in Postman, Body → raw → Text) |
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.
Open Tracking, find the run for the call, and step through it in order, opening each message body as you go:
| Step in Tracking | What to confirm |
|---|---|
| API Listener receives | the 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 port | the 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.
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.
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.
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.
Post several orders at once with distinct interchange control numbers and verify each caller receives its own acknowledgment, never another’s.