EDI 810 invoices outbound
This is the same relationship as inbound orders, but running the other way. You bill a large retail customer, and that customer wants its invoices delivered as EDI files, the same long-standing business format, dropped into a mailbox on a secure file server once a night. Each night a clock starts the job. It gathers every invoice that has been approved since the previous run, turns the batch into one properly formatted EDI file, and drops it in the customer’s mailbox. There is one rule that matters most. Every file the customer receives must carry a sequence number that never repeats, because their software rejects a file whose number it has seen before. So even when a delivery has to be re-sent, the re-send must go out with a brand-new number.
Two parties are involved: your own invoice records, which hold the amounts owed, and the customer’s file server, where the finished EDI file has to land. Every night the flow wakes up, collects the newly approved invoices, packages them into a single EDI file stamped with the next sequence number in line, and delivers it. The picture below is the whole story in plain terms.
Here is how Art2link ESB builds that. Inside the product, work moves as small messages across a shared message backbone called the bus, and a flow is wired from a few simple parts: a receive port brings data in, a send port hands data out, and an adapter is the connector a port uses to reach a particular kind of system. This is the nightly polling-consumer shape. A Scheduler (a clock that wakes the flow on a timer) ticks once a night and starts a two-way SQL Caller, a port that runs a query against a database and brings the answer back. The first call draws the next sequence number from a small counter the database keeps; a second call runs the extract that returns the invoices approved since the previous run. The result comes back as structured data and is reshaped into one tidy batch, an InvoiceBatch, that the rest of the flow can carry.
The delivery port is where the EDI file is actually made. EDI cannot just be written out in one step, so the assembling is done by a pipeline component, a small piece of custom code that runs on a port to reshape a message as it passes through. Inbound, such a component takes EDI apart. Here it works in reverse and assembles: it turns the batch of invoices into the lines and envelopes an EDI file needs, stamped with that night’s sequence number. The number reaches the component through a Variable (a small named value the flow carries from one step to the next), filled earlier from the database counter. Because the number is drawn fresh every run rather than stored in the message, a re-send always gets a new one, which is exactly what the customer’s software demands. The component shapes the file, and the SFTP Caller send port delivers it: as the rule of thumb puts it, the component transforms, the port delivers. The work the component does on the way out is the port’s outbound pipeline. The send port watches the bus and picks up the finished batch through a standing instruction called a subscription:
{{Message.MessageType}} == "InvoiceBatch"
When it fails. One shared exception type, EdiInvoiceFailed, on both send ports, with the usual O365 Mail operations subscription. The case to respect is a failure after assembly: the control number may already be consumed, and a replayed run must not reuse it; because the number is drawn from the counter on every run and bound into the component property, never cached in the message, a replay assembles with a fresh one. An invoice the customer’s EDI software rejects comes back to you out-of-band (or as a 997 if you add acknowledgements later); the dated archive of what you sent is your half of that conversation.
{{Message.MessageType}} == "EdiInvoiceFailed"
Build it, step by step. The steps run in dependency order: every object is created before the object that selects it.
Under the application’s Message types, create the two types this flow routes on. A message type is a name plus a format, no schema required:
| Name | Format | Purpose |
|---|---|---|
| InvoiceBatch | JSON | the canonical batch everything downstream consumes |
| EdiInvoiceFailed | JSON | the shared exception type the failure path publishes under (Step 11) |
The shape the extract returns to the bus and the assembler reads:
{
"batchDate": "2026-06-05",
"invoices": [
{
"invoiceNumber": "INV-20455",
"poNumber": "PO-7714",
"customer": { "id": "C-2201", "name": "Brams Outdoor BV" },
"lines": [
{ "sku": "TP-4501", "qty": 40, "unitPrice": 61.20 },
{ "sku": "HX-0090", "qty": 60, "unitPrice": 18.75 }
],
"total": 3573.00
}
]
}Promotions live on the message type, so add the one this flow binds while you are here. The delivery filename (Step 9) and the archive folder (Step 10) template against it, and adapter parameters are plain strings: they bind {{…}} tokens but never evaluate a body path.
| Promotion | Path |
|---|---|
| BatchDate | $.batchDate |
A failed run re-publishes its payload intact under EdiInvoiceFailed, and promotions are type-qualified, but the alert subject (Step 11) binds {{Message.MessageType}}, so the exception type needs none of its own.
Three external parties, three credentials. Under the application’s Authentications, the dialog asks for a Name, the Adapter it pairs with, and a Definition, the credential shape that adapter offers, with the Application preset; the Definition decides the config section that follows.
| Setting | Value |
|---|---|
| Name | InvoiceDbSql |
| Adapter | SQL Caller |
| Definition | SQL Server Connection |
| Connection String (Database Config) | the invoice database’s connection string, credentials included |
| Setting | Value |
|---|---|
| Name | RetailcoSftp |
| Adapter | SFTP Caller |
| Definition | SFTP Password Authentication |
| Username / Password | the credentials for the customer’s SFTP server |
| Host Key Fingerprint (optional) | the customer’s host-key fingerprint |
| Setting | Value |
|---|---|
| Name | O365Ops |
| Adapter | O365 Mail Sender |
| Definition | Microsoft Graph |
| Tenant Id / Client Id / Client Secret (Graph AuthConfig) | an app registration with Mail.Send granted |
Both procedures live in the invoice database the InvoiceDbSql Authentication reaches; the ports in Steps 7 and 8 call them.
An atomic increment in its own table, gaps are fine, duplicates are not, so the increment and the read are one UPDATE with an OUTPUT clause.
CREATE TABLE dbo.ControlNumbers ( Partner VARCHAR(30) NOT NULL PRIMARY KEY, LastControlNo BIGINT NOT NULL CONSTRAINT DF_CtlNo DEFAULT 0 ); INSERT INTO dbo.ControlNumbers (Partner, LastControlNo) VALUES ('RETAILCO', 0); CREATE OR ALTER PROCEDURE dbo.GetNextControlNumber @Partner VARCHAR(30) AS BEGIN SET NOCOUNT ON; DECLARE @drawn TABLE (ControlNumber BIGINT); UPDATE dbo.ControlNumbers SET LastControlNo = LastControlNo + 1 OUTPUT inserted.LastControlNo INTO @drawn(ControlNumber) WHERE Partner = @Partner; SELECT ControlNumber FROM @drawn FOR XML PATH('Row'), ROOT('Result');
The high-water mark, the approved invoices, their lines, and a couple of seed invoices to test with:
CREATE TABLE dbo.ExportWatermark ( FeedName VARCHAR(40) NOT NULL PRIMARY KEY, LastExportedId BIGINT NOT NULL CONSTRAINT DF_Wm DEFAULT 0 ); INSERT INTO dbo.ExportWatermark (FeedName, LastExportedId) VALUES ('InvoiceOut', 0); CREATE TABLE dbo.ApprovedInvoices ( InvoiceSeq BIGINT IDENTITY(1,1) NOT NULL PRIMARY KEY, InvoiceNumber VARCHAR(40) NOT NULL, PoNumber VARCHAR(40) NOT NULL, CustomerId VARCHAR(40) NOT NULL, CustomerName NVARCHAR(200) NOT NULL, Total DECIMAL(18,2) NOT NULL ); CREATE TABLE dbo.ApprovedInvoiceLines ( InvoiceNumber VARCHAR(40) NOT NULL, LineNo INT NOT NULL, Sku VARCHAR(40) NOT NULL, Qty INT NOT NULL, UnitPrice DECIMAL(18,2) NOT NULL, CONSTRAINT PK_InvLines PRIMARY KEY (InvoiceNumber, LineNo) ); INSERT INTO dbo.ApprovedInvoices (InvoiceNumber, PoNumber, CustomerId, CustomerName, Total) VALUES ('INV-20455', 'PO-7714', 'C-2201', N'Brams Outdoor BV', 3573.00), ('INV-20456', 'PO-7720', 'C-2201', N'Brams Outdoor BV', 612.00); INSERT INTO dbo.ApprovedInvoiceLines (InvoiceNumber, LineNo, Sku, Qty, UnitPrice) VALUES ('INV-20455', 1, 'TP-4501', 40, 61.20), ('INV-20455', 2, 'HX-0090', 60, 18.75), ('INV-20456', 1, 'TP-4501', 10, 61.20);
Returns one row per invoice line past the high-water mark, with the batch date and the invoice header riding every row, and advances the mark in the same transaction:
CREATE OR ALTER PROCEDURE dbo.GetApprovedInvoices AS BEGIN SET NOCOUNT ON; DECLARE @from BIGINT, @to BIGINT; BEGIN TRANSACTION; SELECT @from = LastExportedId FROM dbo.ExportWatermark WITH (UPDLOCK, HOLDLOCK) WHERE FeedName = 'InvoiceOut'; SELECT @to = MAX(InvoiceSeq) FROM dbo.ApprovedInvoices; SELECT CONVERT(CHAR(10), SYSUTCDATETIME(), 23) AS BatchDate, i.InvoiceNumber, i.PoNumber, i.CustomerId, i.CustomerName, i.Total, l.LineNo, l.Sku, l.Qty, l.UnitPrice FROM dbo.ApprovedInvoices i JOIN dbo.ApprovedInvoiceLines l ON l.InvoiceNumber = i.InvoiceNumber WHERE i.InvoiceSeq > @from ORDER BY i.InvoiceSeq, l.LineNo FOR XML PATH('Row'), ROOT('Result'); UPDATE dbo.ExportWatermark SET LastExportedId = ISNULL(@to, @from) WHERE FeedName = 'InvoiceOut'; COMMIT; END;
In Pipeline components, create Edi810Assembler: it reads the InvoiceBatch body, renders one ST*810…SE per invoice, and wraps the set in GS/ISA envelopes, with balanced segment counts. It does not fetch the interchange control number itself: the number arrives through its ControlNumber property, bound from {{Variable.ControlNumber}} in Step 9, so the component stays free of database access. Expose the sender and receiver IDs and the control number as component properties. (The AI Accelerator’s guided mode is disassembler-centred today, use free-form mode for an assembler, with this article’s samples as the facts.)
ISA*00* *00* *ZZ*YOURCO *ZZ*RETAILCO *260605*2105*U*00401*000001207*0*P*>~ GS*IN*YOURCO*RETAILCO*20260605*2105*1207*X*004010~ ST*810*0001~ BIG*20260605*INV-20455**PO-7714~ IT1*1*40*EA*61.20**VN*TP-4501~ IT1*2*60*EA*18.75**VN*HX-0090~ TDS*357300~ CTT*2~ SE*7*0001~ GE*1*1207~ IEA*1*000001207~
using System.Text; using System.Text.Json; using System.ComponentModel.DataAnnotations; using CC.Art2link.Pipelines.Domain.Models.PipelineComponents; public sealed class Edi810Config { [Required] public string Sender { get; set; } = ""; [Required] public string Receiver { get; set; } = ""; [Required] public string ControlNumber { get; set; } = ""; // bound from {{Variable.ControlNumber}} } public sealed class Edi810Assembler : PipelineComponentBase<Edi810Config> { public override string Name => "Edi810Assembler"; protected override Task<PipelineComponentOutput> ExecuteAsync( PipelineComponentInput input, Edi810Config config, CancellationToken cancellationToken) { try { using var doc = JsonDocument.Parse(input.Body); var batch = doc.RootElement; var ctl = config.ControlNumber.PadLeft(9, '0'); var stamp = DateTime.UtcNow; var sb = new StringBuilder(); sb.Append($"ISA*00* *00* *ZZ*{config.Sender,-15}*ZZ*{config.Receiver,-15}*") .Append($"{stamp:yyMMdd}*{stamp:HHmm}*U*00401*{ctl}*0*P*>~\n"); sb.Append($"GS*IN*{config.Sender}*{config.Receiver}*{stamp:yyyyMMdd}*{stamp:HHmm}*{long.Parse(config.ControlNumber)}*X*004010~\n"); int setNo = 0; foreach (var inv in batch.GetProperty("invoices").EnumerateArray()) { setNo++; var lines = inv.GetProperty("lines"); var st = new StringBuilder(); st.Append($"ST*810*{setNo:0000}~\n"); st.Append($"BIG*{stamp:yyyyMMdd}*{inv.GetProperty("invoiceNumber").GetString()}**{inv.GetProperty("poNumber").GetString()}~\n"); int i = 0; foreach (var ln in lines.EnumerateArray()) { i++; st.Append($"IT1*{i}*{ln.GetProperty("qty").GetInt32()}*EA*{ln.GetProperty("unitPrice").GetDecimal()}**VN*{ln.GetProperty("sku").GetString()}~\n"); } var total = inv.GetProperty("total").GetDecimal(); st.Append($"TDS*{(int)(total * 100)}~\n"); st.Append($"CTT*{i}~\n"); st.Append($"SE*{i + 5}*{setNo:0000}~\n"); sb.Append(st); } sb.Append($"GE*{setNo}*{long.Parse(config.ControlNumber)}~\n"); sb.Append($"IEA*1*{ctl}~\n"); return Task.FromResult(new PipelineComponentOutput { Success = true, Messages = [new PipelineMessage { Body = sb.ToString() }] }); } catch (Exception ex) { return Task.FromResult(new PipelineComponentOutput { Success = false, ErrorMessage = $"Edi810Assembler: {ex.Message}", Exception = ex }); } } }
Under the application’s Maps, create the map the extract port’s inbound flow selects in Step 8. A map is not a file, it is a named resource stored in the platform, defined by its name and the two message types it sits between:
| Map setting | Value |
|---|---|
| Name | ResultToInvoiceBatch |
| Source message type | SqlCallerResult, the SQL Caller’s built-in result envelope (you do not create it; the adapter classifies its two-way response under this type) |
| Target message type | InvoiceBatch, from Step 1 |
Because the extract’s result SELECT ends in FOR XML PATH('Row'), ROOT('Result'), the SqlCallerResult body is XML, one <Row> per invoice line, each column an element. The map matches exactly this shape:
<Result> <Row> <BatchDate>2026-06-05</BatchDate> <InvoiceNumber>INV-20455</InvoiceNumber> <PoNumber>PO-7714</PoNumber> <CustomerId>C-2201</CustomerId> <CustomerName>Brams Outdoor BV</CustomerName> <Total>3573.00</Total> <LineNo>1</LineNo> <Sku>TP-4501</Sku> <Qty>40</Qty> <UnitPrice>61.20</UnitPrice> </Row> </Result>
Maps are authored in XSLT 3.0 and run on Saxon HE 12.9. This one groups the line rows by invoice number and rebuilds the canonical nested shape, lifting the batch date off the first row:
<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> <xsl:output method="text"/> <xsl:template match="/Result"> <xsl:variable name="invoices" as="map(*)*"> <xsl:for-each-group select="Row" group-by="InvoiceNumber"> <xsl:sequence select="map { 'invoiceNumber': string(InvoiceNumber), 'poNumber': string(PoNumber), 'customer': map { 'id': string(CustomerId), 'name': string(CustomerName) }, 'lines': array { for $l in current-group() return map { 'sku': string($l/Sku), 'qty': number($l/Qty), 'unitPrice': number($l/UnitPrice) } }, 'total': number(Total) }"/> </xsl:for-each-group> </xsl:variable> <xsl:value-of select="serialize( map { 'batchDate': string(Row[1]/BatchDate), 'invoices': array { $invoices } }, map { 'method': 'json', 'indent': true() })"/> </xsl:template> </xsl:stylesheet>
Create the Scheduler receive port InvoiceExportClock: Repeat Every 1 / Days, Daily Window opening 09:00:00 PM. Everything the ports from here on reference already exists, so each field is a pure selection. (The dropdowns can also create types and maps inline; building the dependencies first keeps each dialog a selection.)
Draw the control number first. Create a two-way SQL Caller send port ControlNumberDraw subscribing on {{Config.PortName}} == "InvoiceExportClock":
| Setting | Value |
|---|---|
| Adapter | SQL Caller |
| Way | Two |
| Auth Config | InvoiceDbSql, from Step 2 |
| Command Type | StoredProcedure |
| Command Text | dbo.GetNextControlNumber |
Its Input Parameters (key/value):
| Parameter | Value |
|---|---|
| Partner | RETAILCO |
In the port’s inbound flow, assign the drawn number to the Variable the assembler’s property reads in Step 9. dbo.GetNextControlNumber ends in FOR XML PATH('Row'), ROOT('Result'), so the two-way response is XML and the XPath below resolves against /Result/Row/ControlNumber; a plain SELECT would have returned JSON and the XPath would match nothing:
| Assignment | Value |
|---|---|
| Variable.ControlNumber | {{Message.Body}}.XPath("/Result/Row/ControlNumber") |
Then extract the batch. Create the two-way SQL Caller port InvoiceExtract subscribing on {{Config.PortName}} == "ControlNumberDraw", so the extract runs after the number is drawn and the Variable is set. Same Auth Config InvoiceDbSql, from Step 2, Command Type StoredProcedure, Command Text dbo.GetApprovedInvoices, no Input Parameters.
Its inbound flow maps SqlCallerResult → InvoiceBatch with the map from Step 5.
Create the SFTP Caller send port EdiInvoiceOut, subscribing on {{Message.MessageType}} == "InvoiceBatch":
| Setting | Value |
|---|---|
| Adapter | SFTP Caller |
| Way | One |
| Host | sftp.retailco.example |
| Authentication | RetailcoSftp, from Step 2 |
| Remote Directory | /inbound/invoices, the customer’s inbound mailbox |
| Filename | inv_{{Promoted.InvoiceBatch.BatchDate}}.edi |
| Overwrite Policy | Fail if exists |
The filename templates against the message before the outbound pipeline renders segments, binding the BatchDate Promotion from Step 1.
In that pipeline, reference Edi810Assembler, from Step 4, and bind its properties, this is where the Variable meets the component:
| Property | Value |
|---|---|
| Sender | YOURCO |
| Receiver | RETAILCO |
| ControlNumber | {{Variable.ControlNumber}} |
Add a second SFTP send port EdiInvoiceArchive with the same subscription and the same assembler, writing to /archive/edi-out/{{Promoted.InvoiceBatch.BatchDate}}/, the BatchDate Promotion from Step 1 dates the archive folder.
On both delivery ports, EdiInvoiceOut and EdiInvoiceArchive, set Exception Message Type to EdiInvoiceFailed, from Step 1.
Create an O365 Mail send port OpsAlert subscribing on {{Message.MessageType}} == "EdiInvoiceFailed":
| Setting | Value |
|---|---|
| Authentication | O365Ops, from Step 2 |
| From | noreply@acme.example |
| To | ops@acme.example |
| Subject | Operations alert, {{Message.MessageType}} |
Everything you configure is live the moment you save it, there is nothing to deploy. Start the send ports first, then the receive ports.
Set Tracking to Enabled + Body on every port the flow touches, so you can walk each run step by step, with its message body, in tracking.
Approve a test invoice and trigger the tick; to test without waiting for the 21:00 window, temporarily set InvoiceExportClock to repeat every 1 Minutes, then restore the schedule.
Inspect the file on the customer server: envelopes balanced (IEA count matches, SE segment counts right), control number advanced from the previous run.
Run twice and confirm the second interchange carries a new control number and only newly-approved invoices.
Make delivery fail and confirm the alert, and that the replayed run draws a fresh control number.