Skip to content
Art2link ESB HomeDocumentationBlogContact
Updated May 5, 2026
Tutorials/Healthcare & EDI/EDI 810 invoices outbound

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.

One EDI file a night, every number unique Nightly clock starts the job Gather approved invoices since last run Next sequence number never repeats Package as one EDI file Customer file server file delivered to mailbox

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:

EXPRESSION
{{Message.MessageType}} == "InvoiceBatch"
Scheduler nightly 21:00 BUS SQL Caller (two-way) approved invoices InvoiceBatch 810 assembler segments + envelopes + ctrl # Customer SFTP ISA*…GS*IN… ST*810*0001…SE GE…IEA~

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.

EXPRESSION
{{Message.MessageType}} == "EdiInvoiceFailed"
Control numbers are a contract. The counter lives in its own table and advances by one atomic increment per run. The per-flow Variable only carries the drawn number from the SQL Caller to the component property; it never holds the count itself, so nothing restarts with the batch. Gaps are acceptable to most partners; duplicates are not.

Build it, step by step. The steps run in dependency order: every object is created before the object that selects it.

Before you start. Create the Application AcmeEdi with its namespace under Applications and select it, so every artifact below lands in it. Then define the Variable ControlNumber on the Application; Variables are defined on the Application and instantiated per flow, and Step 7 assigns it the freshly drawn interchange number.
1
Step One
Create the message types
The two types

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:

NameFormatPurpose
InvoiceBatchJSONthe canonical batch everything downstream consumes
EdiInvoiceFailedJSONthe shared exception type the failure path publishes under (Step 11)
The canonical InvoiceBatch

The shape the extract returns to the bus and the assembler reads:

JSON
{
  "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
    }
  ]
}
Promotion on InvoiceBatch

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.

PromotionPath
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.


2
Step Two
Create the Authentications

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.

Invoice database, for the SQL Caller
SettingValue
NameInvoiceDbSql
AdapterSQL Caller
DefinitionSQL Server Connection
Connection String (Database Config)the invoice database’s connection string, credentials included
Customer mailbox, for the SFTP Caller
SettingValue
NameRetailcoSftp
AdapterSFTP Caller
DefinitionSFTP Password Authentication
Username / Passwordthe credentials for the customer’s SFTP server
Host Key Fingerprint (optional)the customer’s host-key fingerprint
Alert mailbox, for O365 Mail
SettingValue
NameO365Ops
AdapterO365 Mail Sender
DefinitionMicrosoft Graph
Tenant Id / Client Id / Client Secret (Graph AuthConfig)an app registration with Mail.Send granted

3
Step Three
Create the database objects

Both procedures live in the invoice database the InvoiceDbSql Authentication reaches; the ports in Steps 7 and 8 call them.

The control-number counter

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.

SQL
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 watermark and invoice tables

The high-water mark, the approved invoices, their lines, and a couple of seed invoices to test with:

SQL
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);
The approved-invoice extract

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:

SQL
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;

4
Step Four
Build the assembler component
Create the component

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.)

The interchange it renders
EDI
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~
The component code
C#
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
            });
        }
    }
}

5
Step Five
Build the inbound map
Create the map

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 settingValue
NameResultToInvoiceBatch
Source message typeSqlCallerResult, 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 typeInvoiceBatch, from Step 1
The envelope it reads

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:

XML
<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>
The SQL Caller returns what the SELECT asks for. This query ends in FOR XML, so the result is XML, and the map consumes XML to match. A plain SELECT would have returned JSON, which would match nothing here. If the formats disagree, the map matches nothing and the message republishes empty.
Author the XSLT

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:

XSLT 3.0
<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>

6
Step Six
Create the Scheduler clock
General

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.)


7
Step Seven
Create the control-number draw port
General

Draw the control number first. Create a two-way SQL Caller send port ControlNumberDraw subscribing on {{Config.PortName}} == "InvoiceExportClock":

SettingValue
AdapterSQL Caller
WayTwo
Auth ConfigInvoiceDbSql, from Step 2
Command TypeStoredProcedure
Command Textdbo.GetNextControlNumber
Input Parameters

Its Input Parameters (key/value):

ParameterValue
PartnerRETAILCO
Assign the Variable

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:

AssignmentValue
Variable.ControlNumber{{Message.Body}}.XPath("/Result/Row/ControlNumber")

8
Step Eight
Create the invoice extract port
General

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.

Select the map

Its inbound flow maps SqlCallerResult → InvoiceBatch with the map from Step 5.


9
Step Nine
Create the delivery port
General

Create the SFTP Caller send port EdiInvoiceOut, subscribing on {{Message.MessageType}} == "InvoiceBatch":

SettingValue
AdapterSFTP Caller
WayOne
Hostsftp.retailco.example
AuthenticationRetailcoSftp, from Step 2
Remote Directory/inbound/invoices, the customer’s inbound mailbox
Filenameinv_{{Promoted.InvoiceBatch.BatchDate}}.edi
Overwrite PolicyFail if exists

The filename templates against the message before the outbound pipeline renders segments, binding the BatchDate Promotion from Step 1.

Bind the assembler properties

In that pipeline, reference Edi810Assembler, from Step 4, and bind its properties, this is where the Variable meets the component:

PropertyValue
SenderYOURCO
ReceiverRETAILCO
ControlNumber{{Variable.ControlNumber}}

10
Step Ten
Create the archive port
General

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.


11
Step Eleven
Wire the failure path
Set the Exception Message Type

On both delivery ports, EdiInvoiceOut and EdiInvoiceArchive, set Exception Message Type to EdiInvoiceFailed, from Step 1.

Create the OpsAlert send port

Create an O365 Mail send port OpsAlert subscribing on {{Message.MessageType}} == "EdiInvoiceFailed":

SettingValue
AuthenticationO365Ops, from Step 2
Fromnoreply@acme.example
Toops@acme.example
SubjectOperations alert, {{Message.MessageType}}

12
Step Twelve
Start the ports and test
Start in dependency order

Everything you configure is live the moment you save it, there is nothing to deploy. Start the send ports first, then the receive ports.

Turn on tracking

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.

Trigger a run

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 interchange

Inspect the file on the customer server: envelopes balanced (IEA count matches, SE segment counts right), control number advanced from the previous run.

Run it twice

Run twice and confirm the second interchange carries a new control number and only newly-approved invoices.

Break it on purpose

Make delivery fail and confirm the alert, and that the replayed run draws a fresh control number.

Turn bodies off in production. Enabled + Body is the most expensive tracking level, extra processing and database space, and it records every payload including successful runs. Once the flow is proven, set the ports to Only on Error instead: failures still capture the full body for diagnosis, while healthy runs are not recorded.