Setting Up AS2 Transmission for EDI Freight Bills

This page fixes the way AS2 freight-bill deliveries fail silently: an unreturned MDN receipt makes the carrier retransmit and you double-ingest, or a signature/decryption failure drops the payload with no trail anyone can follow.

The failure you are hitting

You stood up an AS2 endpoint, exchanged certificates with the carrier’s EDI team, and the first test interchange came through clean. In production the failures are quiet in a way SFTP’s never are, because AS2 layers cryptography and a receipt protocol on top of HTTP:

  • The carrier POSTs a signed EDI 810, your endpoint verifies and stores it, but the MDN — the Message Disposition Notification, AS2’s signed receipt — never goes back. As far as the carrier’s VAN is concerned the message was never delivered, so their retry policy retransmits it. Now the same freight bill is ingested twice, minutes apart, under the same AS2 message-id.
  • A payload arrives, but signature verification or decryption throws. Your handler catches the exception, logs it at DEBUG, and returns a bare 200. The carrier believes it succeeded (or gets a malformed MDN and gives up), and the invoice simply vanishes — no ledger row, no quarantine record, no REJECT_QUEUE entry. Weeks later a reconciliation shows a bill that was tendered but never audited.
  • A certificate rotates. The carrier switches to a new signing key on their published cutover date, your endpoint still trusts only the old one, and every message from that carrier fails verification at once. The symptom is a carrier-specific cliff: 100% of ABCD’s traffic fails verification starting at midnight, with nothing wrong on the wire.

This is the AS2 terminal inside Ingestion Transport Protocols. Unlike a passive SFTP drop, AS2 owes the sender a signed answer, and every failure here is a failure to answer correctly.

Root cause analysis

These are not TLS or networking bugs. They come from mishandling the three things AS2 adds on top of a plain POST — the receipt, the crypto, and the key lifecycle:

  1. The MDN is treated as optional. AS2’s whole reliability model is: the sender keeps retransmitting until it receives a valid signed MDN. If your endpoint persists the payload but returns the receipt late, unsigned, or not at all, the sender is correct to resend. The duplicate is the protocol working as designed against a broken receiver.
  2. No dedup on message-id. AS2 gives every message a globally unique Message-ID header expressly so a receiver can recognize a retransmit. A receiver that does not persist and check it cannot distinguish a genuine new bill from a resend of one it already stored.
  3. Crypto failures are swallowed instead of routed. A signature that does not verify or a payload that will not decrypt is a security event, not a parse error. Catching it and returning 200 hides a dropped invoice and, worse, tells the sender it was accepted.
  4. Certificates rotate and nobody guarded the overlap. Partners rotate signing and encryption certs on a schedule. A receiver that trusts exactly one cert at a time has a hard cutover with zero overlap window, so any clock skew or early switch drops a batch.
AS2 receive path: decrypt, verify, dedup, then signed MDN A carrier AS2 client POSTs an encrypted, signed EDI payload to the receiver. The receiver decrypts it with its private key and verifies the signature against the partner's trusted signing certificates; a verification or decryption failure branches to a quarantine store and returns a negative signed MDN rather than dropping silently. A verified payload is persisted as raw bytes and its AS2 message-id is checked against a dedup store: a message-id already seen means a retransmit, so the receiver returns the same signed MDN again without re-emitting, while a new message-id is recorded, emitted as an intake event to the broker, and answered with a positive signed MDN back to the carrier. Carrier AS2 encrypted + signed EDI 210/810 POST Decrypt + verify private key · trusted signer certs Quarantine + negative MDN verify fails verified Persist + dedup store raw bytes message-id seen? Retransmit: re-send same MDN id already seen new id IntakeEvent → broker record message-id Positive signed MDN back to carrier signed MDN receipt path
Every AS2 message is decrypted and signature-verified before anything else; a failure quarantines and returns a negative MDN, a retransmit replays the stored MDN, and only a first-seen message-id is emitted and answered with a positive signed receipt.

Reproducible diagnostic

Confirm which failure you have. This snippet parses a captured AS2 message, attempts verification and decryption, and reports whether its message-id is already known — the three axes that separate the failure modes:

import structlog
from cryptography.hazmat.primitives.serialization import pkcs7
from cryptography.exceptions import InvalidSignature

logger = structlog.get_logger()


def diagnose_as2(headers: dict, body: bytes, decrypt_key, trusted_certs, seen_ids: set[str]):
    message_id = headers.get("Message-ID", "").strip("<>")
    result = {"message_id": message_id, "decrypted": False,
              "verified": False, "duplicate": message_id in seen_ids}

    try:
        # In production these are two distinct S/MIME layers; shown compactly here.
        cleartext = decrypt_key.decrypt_smime(body)
        result["decrypted"] = True
    except Exception as exc:
        result["error"] = f"decrypt_failed: {exc}"
        logger.warning("as2_decrypt_failed", message_id=message_id)
        return result

    try:
        verify_signature(cleartext, trusted_certs)   # raises InvalidSignature on mismatch
        result["verified"] = True
    except InvalidSignature:
        result["error"] = "signature_invalid"
        logger.warning("as2_signature_invalid", message_id=message_id)

    return result

Read the result against this table:

decrypted verified duplicate Diagnosis Fix
False wrong/rotated encryption cert cert-rotation guard (Step 4)
True False wrong/rotated signing cert cert-rotation guard (Step 4)
True True True carrier retransmit (MDN not honored) message-id dedup + replay MDN (Steps 2, 3)
True True False genuine new bill emit and return positive MDN

A carrier whose traffic flips to verified=False all at once, at a round timestamp, is a cert rotation — not a data problem.

Resolution path

The fix makes the receipt mandatory, dedups on message-id, and trusts an overlapping set of certs during rotation. Pin the deps:

# requirements.txt
cryptography==43.0.1
structlog==24.4.0

Step 1 — Verify and decrypt before doing anything else

Treat verification and decryption as a gate, not a try/except afterthought. A message that fails either is quarantined with its raw bytes and answered with a negative MDN, so the carrier learns it was rejected rather than silently accepted.

from dataclasses import dataclass


class AS2SecurityError(Exception):
    """Raised when decryption or signature verification fails."""


@dataclass
class VerifiedMessage:
    message_id: str
    partner_scac: str
    payload: bytes


def verify_and_decrypt(headers, body, keyring, quarantine) -> VerifiedMessage:
    message_id = headers.get("Message-ID", "").strip("<>")
    partner = headers.get("AS2-From", "").strip()
    try:
        cleartext = keyring.decrypt(body)                 # our private key
        verified = keyring.verify(cleartext, partner)     # partner's trusted signer certs
    except Exception as exc:
        # Persist the raw, undecrypted bytes so the failure is investigable.
        quarantine.put(message_id or "unknown", body, reason=str(exc))
        raise AS2SecurityError(f"{partner} {message_id}: {exc}") from exc
    return VerifiedMessage(message_id=message_id, partner_scac=keyring.scac_for(partner),
                           payload=verified)

Common mistake: returning a positive 200/MDN in the exception handler “so the carrier stops retrying”. That converts a dropped invoice into a silently dropped invoice. A rejected message must get a negative MDN and a quarantine row; the carrier should retry or escalate, not go quiet.

Step 2 — Dedup on the AS2 message-id

Persist and check the message-id atomically. A first-seen id proceeds; a repeat is a retransmit and must not re-emit. Store enough to replay the exact MDN you sent the first time.

def claim_message_id(conn, message_id: str, partner_scac: str) -> bool:
    """Return True only if this message-id is being recorded for the first time."""
    with conn.cursor() as cur:
        cur.execute(
            """INSERT INTO as2_received (message_id, partner_scac, received_at)
               VALUES (%s, %s, now())
               ON CONFLICT (message_id) DO NOTHING""",
            (message_id, partner_scac),
        )
        first_seen = cur.rowcount == 1
    conn.commit()
    return first_seen

Common mistake: deduping on a hash of the payload instead of the message-id. AS2 already guarantees a unique id per message and reuses it verbatim on a retransmit, so it is the correct key. Two genuinely distinct bills could, in pathological cases, share payload bytes (an empty test file); the id keeps them distinct.

Step 3 — Return the signed MDN, and replay it on retransmit

The MDN is the acknowledgement the whole protocol hinges on. Build it, sign it with your private key, and return it in the same HTTP response. On a retransmit, return the same stored MDN so the carrier finally records the receipt and stops resending.

def handle_as2(headers, body, keyring, conn, quarantine, gateway, mdn_store):
    try:
        msg = verify_and_decrypt(headers, body, keyring, quarantine)
    except AS2SecurityError:
        return negative_mdn(headers, keyring, disposition="processed/error: authentication-failed")

    if not claim_message_id(conn, msg.message_id, msg.partner_scac):
        # Retransmit: we already ingested this. Replay the original signed MDN.
        stored = mdn_store.get(msg.message_id)
        if stored is not None:
            return stored
        # Edge: seen id but MDN not stored (crash before send) — regenerate a positive MDN.
        return positive_mdn(headers, keyring, msg, mdn_store)

    # First-seen, verified bill: persist bytes + publish, then acknowledge.
    gateway.emit(msg.payload, msg.message_id, msg.partner_scac, "as2",
                 content_type="application/edi-x12")
    return positive_mdn(headers, keyring, msg, mdn_store)


def positive_mdn(headers, keyring, msg, mdn_store):
    mdn = keyring.sign_mdn(
        original_message_id=msg.message_id,
        disposition="processed",
        recipient=headers.get("AS2-From", ""),
    )
    mdn_store.put(msg.message_id, mdn)      # store so a retransmit replays it
    return mdn

Common mistake: generating the MDN but returning it asynchronously (or on a separate connection the carrier is not listening on). A synchronous MDN in the POST response is the reliable path; an async MDN that gets lost leaves the carrier retransmitting exactly as if you had sent nothing.

Step 4 — Guard certificate rotation with an overlap window

Trust a set of partner signing certs, not a single one, spanning the rotation window. Load the current and next cert during the published overlap, and try each in turn so the cutover is seamless.

class PartnerKeyring:
    def __init__(self, partner_certs: dict[str, list]):
        # partner_certs["ABCD"] = [current_cert, next_cert] during an overlap window
        self._certs = partner_certs

    def verify(self, cleartext: bytes, partner: str) -> bytes:
        for cert in self._certs.get(partner, []):
            try:
                return verify_with_cert(cleartext, cert)   # raises on mismatch
            except InvalidSignature:
                continue
        raise InvalidSignature(f"no trusted cert verified {partner}")

Common mistake: hot-swapping a single cert at midnight to match the carrier’s stated cutover. Clock skew and early switches guarantee dropped messages at the boundary. Overlapping the old and new cert for the announced window absorbs both sides moving at slightly different times.

Verification

Prove the failures are closed. These run against a keyring fixture and a fake carrier in CI:

def test_retransmit_does_not_double_emit(fake_gateway, conn, keyring, mdn_store):
    hdr, body = signed_message(keyring, message_id="MID-001", scac="ABCD")
    handle_as2(hdr, body, keyring, conn, quarantine, fake_gateway, mdn_store)
    handle_as2(hdr, body, keyring, conn, quarantine, fake_gateway, mdn_store)  # retransmit
    assert fake_gateway.publish_count("MID-001") == 1


def test_bad_signature_is_quarantined_not_dropped(quarantine, keyring):
    hdr, body = tampered_message(keyring, message_id="MID-002")
    resp = handle_as2(hdr, body, keyring, conn, quarantine, fake_gateway, mdn_store)
    assert quarantine.has("MID-002")
    assert resp.disposition.startswith("processed/error")   # negative MDN


def test_rotation_overlap_accepts_new_cert(keyring_with_overlap):
    hdr, body = signed_message(keyring_with_overlap, message_id="MID-003",
                               scac="ABCD", sign_with="next")
    msg = verify_and_decrypt(hdr, body, keyring_with_overlap, quarantine)
    assert msg.message_id == "MID-003"

The healthy production signal is one positive MDN per distinct message-id, a flat quarantine count, and no carrier whose verification rate drops off a cliff. A rising as2_signature_invalid for one partner is a rotation you missed — extend the overlap and reconcile the quarantined batch, do not disable verification.

Preventive configuration

Make the guarantees configuration, not convention:

as2_receiver:
  require_mdn: true              # never accept a message we cannot acknowledge
  mdn_mode: synchronous          # return the signed MDN in the POST response
  dedup_key: message_id          # AS2's own unique id, not a payload hash
  quarantine_on_security_error: true
  cert_rotation:
    overlap_days: 14             # trust current + next signing cert across the window
    fail_closed: true            # unknown/expired cert quarantines, never bypasses verify
  mdn_replay_retention_days: 30  # replay the original MDN for this long on retransmit
  • Atomic id claim. Back as2_received with UNIQUE(message_id) and ON CONFLICT DO NOTHING so concurrency and restarts cannot double-emit.
  • Fail closed on crypto. An unverifiable or undecryptable message quarantines and gets a negative MDN; it never falls through to a positive acknowledgement.
  • Shared canonical key. The message_id handed to the gateway is the same dedup_key Ingestion Transport Protocols keys the intake event on, so transport-level and event-level dedup agree.

FAQ

The carrier keeps retransmitting even though we stored the invoice. Why?

Because they never received a valid signed MDN. AS2’s reliability model retransmits until the sender gets its receipt, so a receiver that persists the payload but fails to return a synchronous signed MDN is telling the carrier the message was never delivered. Return the MDN in the same HTTP response, sign it with your key, and store it so a retransmit replays the identical receipt.

Should I dedup AS2 messages on the payload hash or the message-id?

On the AS2 message-id. The protocol assigns every message a globally unique Message-ID header and reuses it verbatim on a retransmit precisely so a receiver can recognize the resend. A payload hash can collide across genuinely distinct bills (an empty or identical test file) and is not what the sender keys its retry on.

A partner rotated certificates and all their traffic started failing verification at once. How do I prevent that?

Trust an overlapping set of signing certs across the rotation window rather than a single cert with a hard cutover. Load both the current and the next cert during the announced overlap and try each when verifying. A single hot-swap at the stated cutover time drops messages whenever the two sides switch even seconds apart due to clock skew.

A payload fails decryption. Should I just drop it so the carrier stops retrying?

No. Persist the raw undecryptable bytes to a quarantine store and return a negative MDN describing the error. Dropping it silently — or worse, returning a positive MDN from the exception handler — converts a recoverable security event into an invoice that vanished with no trail. A negative MDN lets the carrier retry or escalate, and the quarantine row lets you investigate the cert or encoding mismatch.

Up one level: Ingestion Transport Protocols · Section: Automated Invoice Parsing & EDI/XML Ingestion