DEV Community

rinat kozin
rinat kozin

Posted on • Originally published at redbase.app

AS2 in .NET without a separate Java gateway: EDI with trading partners, right inside the route

redb.Route.AS2
AS2 — signed, encrypted S/MIME document exchange with trading partners — is now a native redb.Route connector, not an external gateway.

If you ship product to a big-box retailer, move freight for a 3PL, send payment advices to a bank, or exchange X12 healthcare transactions, you almost certainly move those documents over AS2. The purchase order (EDI 850), the invoice (810), the ship notice (856), the payment order (820) don't go out by email or REST — they go as a signed, encrypted S/MIME envelope over HTTP, with a signed receipt coming back. That's how regulated B2B document exchange has worked in retail, logistics, finance, manufacturing and healthcare for twenty years: Walmart, Amazon and their supplier networks, banks with a host-to-host channel, automotive, distributors — all require AS2.

In .NET, there have been two ways to do this. Either a commercial AS2 gateway — Cleo, Seeburger, BizTalk — a separate box, a separate license, a separate team to run it. Or an open-source Java server — OpenAS2, Mendelson Community — a separate JVM process next to your .NET backend, with its own inbox directory you still have to poll a document out of. Either way, AS2 lives beside your integration, not inside it.

redb.Route.As2 closes that gap: AS2 becomes an ordinary step of a route in your own .NET process. Receive an envelope from a partner, decrypt it, verify the signature, hand the document to the pipeline — validate it, transform it, drop it into Kafka or SQL — and return a signed receipt to the partner. One process, one deployment, one observability plane. Let's walk through what that looks like in code, where it's used, and why a native connector inside the ESB beats a standalone gateway.

AS2 in one minute

AS2 (Applicability Statement 2, RFC 4130) is a protocol for guaranteed delivery of business documents between two parties over the internet. Mechanically, it's an envelope:

  1. The payload (usually EDI: X12 or EDIFACT, but it can be XML, JSON, anything) is compressed (optional), signed with your private key and encrypted with the partner's public certificate.
  2. The finished S/MIME envelope goes to the partner as a plain HTTP POST.
  3. The partner decrypts it with their key, verifies your signature with your certificate, and answers with an MDN (Message Disposition Notification) — a receipt. A signed MDN carries a Received-Content-MIC: a cryptographic hash of what the partner actually received.

The point of the MDN is legally meaningful non-repudiation. You sent the order, the partner returned a signed receipt whose MIC matches what you sent: you now have proof that this exact document was delivered, not some other one. That's why AS2 became the de-facto standard wherever a document carries money and obligation.

"Why not just HTTPS"

Fair question: if the channel is already TLS-protected, why sign and encrypt the document on top? Because TLS protects the channel, and AS2 protects the document. TLS lives from your socket to the partner's socket and vanishes the moment the bytes hit disk — in a proxy log, in an inbox directory, on a load balancer the document is already in the clear. The AS2 S/MIME envelope stays signed and encrypted the whole way and at rest — only the private-key holder can decrypt it, and the signature proves the author. And crucially, TLS gives you no receipt: HTTPS has no MDN with a MIC, and that's what makes delivery non-repudiable. AS2 usually runs over HTTPS (as2s) anyway — the two don't compete: TLS encrypts the channel, S/MIME provides the document and the non-repudiation.

A readable route: the endpoint is a string

redb.Route is Apache Camel for .NET: a route is described as From → … → To, and an endpoint is a URI string. The AS2 connector adds two schemes, as2 (HTTP) and as2s (HTTPS), and they read like a sentence:

as2s://partner.example.com/as2?connectionFactory=walmart          # who we send to
as2:/inbound/orders?host=0.0.0.0&port=4080&connectionFactory=walmart   # where we receive
as2:/as2/mdn?host=0.0.0.0&port=4081&mode=mdn&connectionFactory=walmart # where the partner posts an async MDN
Enter fullscreen mode Exit fullscreen mode

The URI states the intent up front: where we're going, what port we listen on, which partner. Certificates and algorithms don't live in the string — they don't belong there. There's a dedicated object for that.

A partner is one object, not a scatter of parameters

An AS2 exchange is always an agreement between two sides: whose certificates, which AS2 identifiers, what to sign and encrypt with, which MDN mode. All of that is an As2ConnectionFactory, registered once by name:

context.AddToRegistry("walmart", new As2ConnectionFactory
{
    OurCertificate     = ourPfx,     // our cert + PRIVATE key — signs outgoing, decrypts incoming
    PartnerCertificate = theirCer,   // partner's PUBLIC cert — encrypts outgoing, verifies their signature
    As2From = "OUR-AS2-ID",
    As2To   = "WALMART-AS2-ID",
    PartnerUrl = "https://partner.example.com/as2",

    // Profile — what both sides agreed on
    Sign = true, Encrypt = true, Compress = false,
    SignAlg = "sha-256", EncryptAlg = "aes-128-cbc",
    SignedMdn = true, MdnMode = As2MdnMode.Sync,
});
Enter fullscreen mode Exit fullscreen mode

Routes reference the partner by name — ?connectionFactory=walmart in the URI, or .ConnectionFactory("walmart") in the fluent DSL. Add a second partner and you register another object; the route URI doesn't change. Certificates are versioned with your application, not sitting in a gateway's keystore that one person on the team knows about.

One route, three environments

The endpoint URI is a string, and {{key}} placeholders work inside it — redb.Route resolves them from IConfiguration when the route is built. The partner's address differs across dev, staging and prod, but the route stays the same:

From("direct://outbound")
    .To(As2.Send("{{walmart.as2.url}}").ConnectionFactory("walmart"));
Enter fullscreen mode Exit fullscreen mode
# appsettings.Development.json → "walmart.as2.url": "https://sandbox.partner/as2"
# appsettings.Production.json  → "walmart.as2.url": "https://edi.partner.com/as2"
Enter fullscreen mode Exit fullscreen mode

Host, receiver port, partner name — all externalized to configuration instead of hard-coded into the route. Secrets — the PFX password — come from the same layer (environment variables, user-secrets) and never settle into source. One built image ships from dev to prod; only the config changes.

Sending (producer)

From("direct://outbound")
    .To(As2.Send("https://partner.example.com/as2").ConnectionFactory("walmart"));
Enter fullscreen mode Exit fullscreen mode

Behind that single line: compression (if enabled), signing with your key, encryption with the partner's certificate, the POST, and parsing the MDN that comes back. The connector puts the outcome on exchange.Out so the route can act on it:

Header on exchange.Out Meaning
redbAs2.mdnDisposition the partner's verdict, e.g. automatic-action/MDN-sent-automatically; processed
redbAs2.signatureValid bool — the MDN's signature verified
redbAs2.mdnMicMatch bool — the partner received exactly what we sent, intact

And right there, in the same route, you branch on the result:

From("direct://outbound")
    .To(As2.Send("https://partner/as2").ConnectionFactory("walmart"))
    .Choice()
        .When(e => e.Out!.GetHeader<bool>(As2Headers.MdnMicMatch))
            .Log("delivered & verified")
        .Otherwise()
            .To("direct://delivery-alert");   // MIC mismatch or negative MDN — escalate
Enter fullscreen mode Exit fullscreen mode

The connector does not throw on a negative MDN or a MIC mismatch — it surfaces the fact and lets the route decide. For one partner a MIC mismatch is cause to raise an alert immediately; for another, log it and move on. That's your process's policy, not behavior wired into a gateway.

Receiving (consumer / AS2 server)

From(As2.Receive("/inbound/orders").Host("0.0.0.0").Port(4080).ConnectionFactory("walmart"))
    .Unmarshal(...)                 // your EDI parsing
    .To("direct://process-order");
Enter fullscreen mode Exit fullscreen mode

From(As2.Receive(...)) stands up an AS2 server. The incoming envelope is decrypted with your private key, its signature verified with the partner's certificate, unpacked — and the route receives the clean business document. Its real content type is on Message.ContentType (e.g. application/edi-x12), and the exchange metadata sits under redbAs2.*:

Header Meaning
redbAs2.mic / redbAs2.micalg the computed Message Integrity Check and its algorithm
redbAs2.signatureValid the inbound signature verified against the partner cert
redbAs2.remoteAddress the sender's IP
redbAs2.partner the resolved connection-factory name

The connector builds and returns a synchronous MDN itself. The AS2 wire headers (AS2-From, AS2-To, Message-ID, Subject) are copied onto the message as-is. The S/MIME wrapper Content-Type is deliberately not leaked into the headers — the route sees the type of the real document, not the transport envelope.

Asynchronous MDN

Large partners often require an asynchronous receipt: accept the message, answer 200, and post the signed MDN separately later. The sender registers the outgoing Message-ID, and the inbound MDN is correlated by Original-Message-ID:

// In the partner profile
MdnMode = As2MdnMode.Async,
AsyncMdnUrl = "https://our-host:4081/as2/mdn",

// Routes
From(As2.Receive("/inbound").Host("0.0.0.0").Port(4080).ConnectionFactory("walmart"))
    .To("direct://process");

From(As2.ReceiveMdn("/as2/mdn").Host("0.0.0.0").Port(4081).ConnectionFactory("walmart"))
    .Process(e =>
    {
        var original = e.In.GetHeader<string>(As2Headers.MessageId);   // which of our documents is acknowledged
        var ok = e.In.GetHeader<bool>(As2Headers.MdnMicMatch);         // and acknowledged intact
    });
Enter fullscreen mode Exit fullscreen mode

A dedicated endpoint for inbound MDNs is just another From in a route, and the arriving receipt becomes a message you can act on: update the order status, clear a retry, close a saga.

Algorithm matrix

Partners agree on specific algorithms, and the connector supports the standard set:

Knob Values
SignAlg sha-1, sha-256 (default), sha-384, sha-512
EncryptAlg aes-128-cbc (default), aes-192-cbc, aes-256-cbc, 3des
Compress true / false (RFC 3274)

An unsupported algorithm fails fast when the route is built, not at run time on the first message to a partner. A configuration error is visible at startup, not at 3 a.m. in the dead-letter logs.

The crypto underneath is MimeKit (on Bouncy Castle) — the same cryptographic foundation the entire AS2 world interoperates on: Apache camel-as2, OpenAS2 and Mendelson all sit on Bouncy Castle. We land on the shared foundation the parties actually interoperate over, rather than inventing our own S/MIME.

How it differs from a gateway

.NET projects have covered AS2 three ways. The difference isn't "can or can't" — everyone can, it's one protocol. The difference is where AS2 lives relative to your logic.

Commercial gateway Java server (OpenAS2/Mendelson) redb.Route.As2
Process separate box separate JVM next door your .NET process
Received document file in an inbox dir file in an inbox dir a message in the route
How to pick it up a poller job a poller job straight into the pipeline
Deployment its own install its own install with your app
Observability its own panel JVM logs shared traces and stats
Further processing outside the gateway, by hand outside the server, by hand the same EIPs in the same route
License paid open open, no key

A standalone gateway is justified when the AS2 boundary is deliberately isolated — in a DMZ run by a different team, say. But when the document goes to your .NET backend for processing anyway, the intermediate box is an extra hop, an extra directory, and an extra component in the audit diagram.

Where it's used, and why a native connector

AS2 is needed wherever a document carries an obligation and the partner dictates the channel. A few typical situations.

Supplier to a big-box retailer. To ship to Walmart, Target or Amazon Vendor, a supplier must accept orders (850) and send invoices (810) and ASNs (856) over AS2 with a signed MDN. This used to mean a standalone gateway; now From(As2.Receive(...)) accepts the order straight into the route that validates it and lands it in your ERP.

3PL and logistics. A warehouse and a carrier exchange shipment statuses, receipt confirmations, inventory. The volume is streaming, and keeping a separate Java box with an inbox directory that a cron job scoops documents out of is an extra link. The document should flow, not sit in a folder.

Healthcare. X12 HIPAA transactions (claims, remittance) between payers and providers go over AS2 with strict signing and encryption requirements. Same profile — sign, encrypt, signed MDN.

Finance and payments. EDI payment and settlement documents — the payment order and remittance advice (X12 820/824) — travel between corporate clients and their banks over AS2 where the bank offers a host-to-host channel. AS2 doesn't replace SWIFT or EBICS on the interbank rails here; it covers the EDI layer: corporate-to-bank document flow and payment advices in the supply chain, where payment is just another EDI document alongside the order and the invoice. What matters to fintech is exactly AS2's non-repudiation: a signed MDN with a MIC is proof the bank received this exact payment order, not another.

Manufacturing and supply chain. Automotive and industrial supply-chain networks push orders, delivery schedules and ship notices (EDIFACT/X12) between OEMs and their tiered suppliers. The exchange is mandatory: without electronic document exchange, a supplier simply isn't onboarded.

Insurance. Enrollment, claims, remittance (X12) between payers, brokers and providers — the same signing-and-encryption profile, the same receipt requirements.

Consolidating a gateway. You already have a commercial AS2 gateway, but it's a separate box with its own license, its own patch cycle and its own monitoring, running alongside the .NET backend that does all the business logic. The AS2 connector folds that boundary into the application.

Why a native connector in the ESB rather than a gateway beside it:

  • The document flows, it doesn't sit. With a standalone gateway, a received file lands in an inbox directory you then have to pick up. With the connector, a received document is a message in a route: validation, transformation, routing right away. No intermediate folder and no scoop-up job.
  • One process, one deployment. No second box to install, patch, monitor and explain to an auditor. The AS2 endpoint stands up on the process's shared Kestrel host along with the rest of its HTTP routes.
  • One observability plane. AS2 endpoints emit statistics and health (visible in the redb.Tsak dashboard) and distributed traces alongside every other connector: the producer opens a Client span, the consumer a Consumer span linked over the inbound W3C traceparent. The exchange with a partner shows up in the same Jaeger trace as the document's onward path.
  • Composition with EIPs. This is the main thing. AS2 isn't an island, it's a step. A received document can go through the full redb.Route pattern catalog: validate against a schema, transform with XSLT, split, enrich, tee off to an archive with WireTap, land in Kafka and SQL at once.

AS2 as one step of an end-to-end route

The value of the connector shows when AS2 stands not by itself but in a chain. Accept an order from a partner, transform it, split it and route it onward — in one route:

From(As2.Receive("/inbound/orders").Host("0.0.0.0").Port(4080).ConnectionFactory("walmart"))
    .Validate(...)                               // document matches the schema
    .Xslt("styles/x12-to-canonical.xsl")         // X12 → your canonical model
    .WireTap("sftp://archive/edi?...")           // a copy to the archive with retention
    .To("kafka://orders?brokers=...")            // onto the bus for processing
    .To("sql:INSERT INTO inbound_orders ...");   // and into the audit DB
Enter fullscreen mode Exit fullscreen mode

The same document that arrived in an encrypted AS2 envelope ends up — in one pass — validated, transformed, archived, in Kafka and in SQL, and all of it visible in a single trace. A standalone gateway would have handed you a file in a folder; here you get a full pipeline whose entry point happens to be AS2.

The outbound direction is symmetric: assemble a document from your system, send it to the partner, parse the MDN, update the status — one route again.

The full loop: order in, invoice out

Onboarding a supplier to a retailer is two AS2 directions, living as two routes in one process.

Inbound — the order (EDI 850) from the retailer:

From(As2.Receive("/inbound").Host("0.0.0.0").Port(4080).ConnectionFactory("walmart"))
    .Validate(typeof(X12OrderValidator))          // 850 structure is valid
    .Unmarshal(typeof(X12Format), typeof(Order))  // X12 → domain model
    .Process(async (e, ct) => await _orders.Accept((Order)e.In.Body!, ct))
    .To("kafka://orders-inbound?brokers={{kafka.brokers}}");
Enter fullscreen mode Exit fullscreen mode

The order is accepted, validated, parsed, stored and published to the bus in one pass, and a synchronous MDN went back to the retailer automatically. Your system then processes the order at its own pace.

Outbound — the invoice (EDI 810) back to the partner once the shipment is ready:

From("kafka://invoices-ready?brokers={{kafka.brokers}}")
    .Marshal(typeof(X12Format))                   // domain model → X12 810
    .To(As2.Send("{{walmart.as2.url}}").ConnectionFactory("walmart"))
    .Choice()
        .When(e => e.Out!.GetHeader<bool>(As2Headers.MdnMicMatch))
            .Process(e => _invoices.MarkDelivered(e))
        .Otherwise()
            .To("direct://edi-ops-alert");
Enter fullscreen mode Exit fullscreen mode

Both directions use one connectionFactory("walmart") — the same certificates, the same profile. The whole EDI relationship with a partner is two routes and one partner object, inside your application, under your observability.

Visible in one trace

AS2 in a standalone gateway is a black box: the partner says "it didn't arrive," and you go digging in someone else's logs on someone else's box. Here, AS2 endpoints are part of redb.Route's shared observability. The producer opens a Client span, the consumer a Consumer span linked to the inbound document over the W3C traceparent. In one Jaeger trace you see the whole thing: envelope arrived → decrypted and signature verified → validated → transformed → sent to Kafka. Endpoint statistics — how many received, how many sent, errors — sit in the redb.Tsak dashboard next to every other connector. When a partner disputes delivery, you have the trace for a specific Message-ID, not "somewhere in the gateway logs."

What to know about AS2 in practice

A few things you step on exactly once.

The MIC is computed over the canonical form, byte for byte. The Message Integrity Check is a hash of the signed part in CRLF-canonical form, computed exactly the way the sending side computed it. That's where all of AS2's interop pain lives: if canonicalization or the algorithm diverges by even a byte, the MIC in the MDN won't match and the partner decides it got the wrong thing. The connector computes the MIC per RFC 4130 and checks it on receive and in MDN parsing — and that's exactly what's verified against an external server, not against itself.

Sync or async is the partner's call, not yours. A synchronous MDN is simpler: the receipt is in the same response, the route knows the outcome immediately. But at volume a synchronous MDN holds the HTTP connection until processing finishes, and large partners require async: accept, answer 200, post the MDN separately. The connector supports both; the choice is a line in the partner profile, not a route rewrite.

A signed MDN is about non-repudiation, not a checkbox. SignedMdn = true means the receipt is signed with the receiving side's key and carries a MIC. For a document that carries an obligation, that's the proof: you hold a signed confirmation that the partner received this exact document, intact. An unsigned MDN is just "it arrived," which is worth little in a dispute.

Content-Transfer-Encoding: binary. EDI documents are binary in spirit, and partners typically exchange in binary rather than base64 — less overhead at volume. A profile detail, but exactly the one home-grown implementations trip on.

Certificates and secrets

AS2 rests on a key pair: your private key (signs outgoing, decrypts incoming) and the partner's public certificate (encrypts outgoing, verifies their signature). In the connector that's an X509Certificate2 on the As2ConnectionFactory — load it from a PKCS#12/PFX however suits you: a file, the Windows certificate store, a secrets manager.

Certificates are part of the partner's configuration in the registry, not strings in a URI. The PFX password, if it comes through an endpoint parameter, is marked [Sensitive] and redacted from logs and the dashboard — the secret doesn't leak into a trace. Rotating a partner certificate is swapping the object in the registry; the route isn't touched.

Interop proven, not claimed

It's easy to write "AS2 supported" and attach unit tests that run the connector against itself. That proves internal consistency, but not that your envelope is accepted by software you didn't write. The real test is exchanging with an independent implementation.

The connector is verified against a live OpenAS2 v4.9.0 (a mature, Bouncy-Castle-based server) in Docker, in both directions, with the profile "SHA-256 sign + AES-128-CBC encrypt + signed MDN":

  • redb → OpenAS2. Our producer sent a document; OpenAS2's own logs confirmed it decrypted our envelope, verified our signature, stored the document and returned a positive signed MDN. Our MDN parser then verified that MDN's signature and confirmed the Received-Content-MIC matched what we sent.
  • OpenAS2 → redb. OpenAS2 built a signed, encrypted document and sent it to our consumer. We decrypted it with our private key, verified OpenAS2's signature, handed the EDI to the route, and returned a signed MDN that OpenAS2 accepted and correlated with its pending message.

That closes AS2's "hard part" in both directions: MIC computation per RFC 4130, S/MIME structure and MDN handling — send and receive — are correct against a real, independent partner, not merely self-consistent.

The tests are open. How exactly it was verified — three layers (crypto round-trips and MIC, an end-to-end producer→consumer loop over a live Kestrel, interop against OpenAS2) — is laid out in the connector's TESTING.md on GitHub. The interop harness (a docker-compose with OpenAS2, its config and generated certificates) reproduces with one command; the interop test is gated, so an ordinary run is green without the container.

FAQ

Multiple partners on one port? Yes. Different partners' receive endpoints live on the shared Kestrel host, separated by path (/inbound/walmart, /inbound/target), each with its own connectionFactory. One server, several partnerships.

Large files? The message body is bytes, and it flows through the normal HTTP pipeline rather than being assembled into a string. For genuinely heavy transfers partners usually move to SFTP — which in redb.Route is also a connector, and sits in the same route.

mTLS / client certificates on the transport? AS2 itself authenticates the document by signature, but some partners additionally require TLS client-auth. The as2s scheme runs on the same Kestrel host as the HTTP connector, with its TLS settings.

What about restarts? An async MDN is correlated by Original-Message-ID; outgoing messages awaiting a receipt are your route's state, held where you keep saga or idempotency state, not in the connector's memory.

Install

services.AddRedbRoute(route =>
{
    route.Services.AddRedbRouteAs2();
    route.AddRouteBuilder<MyRoutes>();
});
Enter fullscreen mode Exit fullscreen mode

AddRedbRouteAs2() registers the as2 / as2s schemes and reuses the process's shared Kestrel host — an AS2 route and a plain HTTP route in the same worker share one server and never fight over a port.

The package is redb.Route.As2 on NuGet; the source and the full DSL reference are in the connector README. AS2 is one more transport in the redb.Route family, alongside Kafka, RabbitMQ, IBM MQ, SFTP and the rest: the same From → … → To, the same EIPs, the same observability. The only difference is that the input and output are a signed, encrypted envelope your trading partner is waiting for.


More of my writing: redbase.app/articles, and on dev.to.

If this was useful — a ⭐ on GitHub helps others find it.

Top comments (0)