Skip to content
Art2link ESB HomeDocumentationBlogContact
Updated August 2, 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 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:

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 three types

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:

NameFormatPurpose
InvoiceBatchJSONthe canonical batch everything downstream consumes
Edi810InterchangeEDIthe finished X12 810 the assembler renders (Step 4) and the mailbox receives; flat text, never a map source
EdiInvoiceFailedJSONthe 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 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}} 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.)

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(),
                    MessageType = "Edi810Interchange"
                }]
            });
        }
        catch (Exception ex)
        {
            return Task.FromResult(new PipelineComponentOutput
            {
                Success      = false,
                ErrorMessage = $"Edi810Assembler: {ex.Message}",
                Exception    = ex
            });
        }
    }
}
Wrap the component in a pipeline

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:

SettingValue
NameEdi810DeliveryPipeline
ComponentsEdi810Assembler (this step)
SenderYOURCO
ReceiverRETAILCO
ControlNumber{{Variable.ControlNumber}}, the number Step 7 draws

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; it is the Adapter Message Type the two-way response is given at the adapter handoff)
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
Response section, the send port’s inbound leg

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:

SettingValue
Adapter Message Type (required)SqlCallerResult, the SQL Caller’s built-in result envelope
Assign the Variable

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:

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.

Response section, the send port’s inbound leg

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:

SettingValue
Adapter Message Type (required)SqlCallerResult, the SQL Caller’s built-in result envelope
MapResultToInvoiceBatch, from Step 5, mapping SqlCallerResult → InvoiceBatch

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.

Outbound pipeline

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:

SettingValue
Outbound pipelineEdi810DeliveryPipeline, 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.


10
Step Ten
Create the archive port
General

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:

SettingValue
Outbound pipelineEdi810DeliveryPipeline, 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
One pipeline for both ports is a choice, so make it deliberately. Both delivery ports run the assembler with identical settings, and the point of the archive is that it holds the exact bytes the customer received, so a single Edi810DeliveryPipeline is the right call here: one place to change the sender, the receiver or the segment layout, and both ports move together, which is precisely the guarantee the archive needs. Two pipelines would let them diverge. That is worth having when the ports are genuinely meant to differ, a partner who wants a different envelope for instance, and a liability when they are not, because a change made in one place quietly stops applying in the other. Decide on that basis: shared when the ports must stay identical, separate when they are allowed to drift apart.

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

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.