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 component does that work inside a pipeline, a named, ordered list of components. The port selects the pipeline, never the component itself, and the selector is labelled the outbound pipeline because of where it is attached, not because a pipeline has a direction of its own. 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 three types this flow uses. A message type is a name plus a format, no schema required:
| Name | Format | Purpose |
|---|---|---|
| InvoiceBatch | JSON | the canonical batch everything downstream consumes |
| Edi810Interchange | EDI | the finished X12 810 the assembler renders (Step 4) and the mailbox receives; flat text, never a map source |
| EdiInvoiceFailed | JSON | the shared exception type the failure path publishes under (Step 11) |
Every message a pipeline component emits carries a Message Type, and that holds wherever the pipeline is attached, so the assembler needs a type to stamp on the interchange it renders even though that message is on its way out rather than in. The type is what tells the platform the payload is an X12 interchange and not JSON, so nothing tries to read it as JSON on the way to the mailbox. See Components classify the Message Type.
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}} on the pipeline below, so the component stays free of database access. Expose the sender and receiver IDs and the control number as component properties. It stamps its one output message Edi810Interchange, from Step 1: the body it returns is flat X12, not the JSON it was handed, and the type has to say so. (The AI Accelerator will write it: describe the assembler in the chat and state the facts this article gives you, the InvoiceBatch body it reads, the envelope layout, the properties, and the Message Type it stamps.)
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(), MessageType = "Edi810Interchange" }] }); } catch (Exception ex) { return Task.FromResult(new PipelineComponentOutput { Success = false, ErrorMessage = $"Edi810Assembler: {ex.Message}", Exception = ex }); } } }
The delivery ports never reference this component directly; they reference a pipeline, a named, ordered list of components that also carries the default values for each component’s configuration properties. Every property Edi810Config exposes is [Required] with no default in the code, so all three are set here, and this is where the drawn control number meets the assembler:
| Setting | Value |
|---|---|
| Name | Edi810DeliveryPipeline |
| Components | Edi810Assembler (this step) |
| Sender | YOURCO |
| Receiver | RETAILCO |
| ControlNumber | {{Variable.ControlNumber}}, the number Step 7 draws |
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; it is the Adapter Message Type the two-way response is given at the adapter handoff) |
| 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 |
The stored procedure’s answer comes back on the send port’s response section, which is an inbound position, so the port carries a required Adapter Message Type, applied the instant the SQL Caller hands the response on:
| Setting | Value |
|---|---|
| Adapter Message Type (required) | SqlCallerResult, the SQL Caller’s built-in result envelope |
On the same response section, assign the drawn number to the Variable the assembler’s ControlNumber property reads, bound as a pipeline default in Step 4. 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.
The extract comes back on this send port’s response section, the same inbound position as the draw port, so the same required setting applies and the map then reads what the adapter typed:
| Setting | Value |
|---|---|
| Adapter Message Type (required) | SqlCallerResult, the SQL Caller’s built-in result envelope |
| Map | ResultToInvoiceBatch, from Step 5, mapping SqlCallerResult → InvoiceBatch |
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.
The port selects the pipeline, not the component inside it. The assembler’s sender, receiver and control number are already set as that pipeline’s defaults in Step 4, so there is one thing to choose here:
| Setting | Value |
|---|---|
| Outbound pipeline | Edi810DeliveryPipeline, from Step 4 |
The message the port hands to the adapter leaves this pipeline typed Edi810Interchange, the type the component stamped in Step 4. The subscription above still matches InvoiceBatch, because subscriptions are evaluated on the message coming off the bus, before the pipeline runs.
Add a second SFTP send port EdiInvoiceArchive with the same subscription. It selects the same pipeline as the delivery port, so the archive keeps the interchange the customer receives, control number and all:
| Setting | Value |
|---|---|
| Outbound pipeline | Edi810DeliveryPipeline, from Step 4, the one EdiInvoiceOut uses |
| Remote Directory | /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 the Application’s tracking level to Enabled + Body so you can walk each run 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.
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.