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 bare200. The carrier believes it succeeded (or gets a malformed MDN and gives up), and the invoice simply vanishes — no ledger row, no quarantine record, noREJECT_QUEUEentry. 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:
- 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.
- No dedup on
message-id. AS2 gives every message a globally uniqueMessage-IDheader 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. - 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
200hides a dropped invoice and, worse, tells the sender it was accepted. - 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.
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_receivedwithUNIQUE(message_id)andON CONFLICT DO NOTHINGso 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_idhanded to the gateway is the samededup_keyIngestion 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.
Related
- Ingestion Transport Protocols — the parent transport edge this AS2 receiver plugs into.
- Configuring SFTP Drop Ingestion for Carrier Invoices — the unacknowledged-transport sibling that dedups on content hash instead of a message-id.
- EDI 210/810 Processing — the X12 parser that consumes the verified interchanges this receiver emits.
- Async Batch Processing Workflows — how the emitted intake events fan out once the MDN is safely returned.
Up one level: Ingestion Transport Protocols · Section: Automated Invoice Parsing & EDI/XML Ingestion