Automating AS2 MDN Acknowledgments for EDI: Production-Grade Non-Repudiation
Problem: you push an X12 837 interchange to a payer or clearinghouse over AS2, the HTTP POST returns 200 OK, and your pipeline marks the transfer “delivered” — but the trading partner never sent back a Message Disposition Notification, or sent one whose Message Integrity Check (MIC) does not match the payload you signed. Without a verified MDN you have no non-repudiation of receipt, and a silently dropped interchange surfaces days later as a timely-filing denial. This guide shows the exact Python needed to request a signed MDN, verify its MIC against the bytes you actually transmitted, correlate an asynchronous MDN back to its interchange via Message-ID, and alert when a receipt never arrives. It targets the Python automation engineers and healthcare IT teams who own the AS2 edge that sits in front of Secure File Transfer Protocols for EDI.
Prerequisites
Spec Reference: AS2 MDN Fields to Assert
An MDN is an RFC 4130 acknowledgment of transport receipt, not of EDI content validity. Assert exactly these fields; treat anything else as advisory. The Disposition and Received-Content-MIC headers are the two that gate non-repudiation.
| Field | Location | Requirement | Valid / expected value |
|---|---|---|---|
Message-ID |
Outbound HTTP header | Mandatory, globally unique | <uuid@your-domain> — the correlation key |
Original-Message-ID |
MDN body | Mandatory | Must echo the outbound Message-ID exactly |
Disposition |
MDN body | Mandatory | automatic-action/MDN-sent-automatically; processed = success |
Received-Content-MIC |
MDN body | Required for signed MDN | <base64-digest>, sha-256 — compare to sent MIC |
Disposition-Type modifier |
Disposition header |
Present on failure | processed/error: ... or failed/failure: ... |
| Signature | MDN MIME part | Required when signed-receipt-protocol requested |
Verifies against partner’s public cert |
The MIC is computed over the MIME-canonicalized payload before transport encoding, using the agreed digest. The receiver recomputes it over what it received and returns the result; if their MIC equals the MIC you computed at send time, the bytes are provably intact end to end. Request a signed MDN by setting the Disposition-Notification-Options header to signed-receipt-protocol=optional, pkcs7-signature; signed-receipt-micalg=optional, sha-256.
Step-by-Step Implementation
Step 1 — Set up PHI-safe logging and typed AS2 models
An 837 interchange carries member IDs, diagnoses, and provider NPIs. Log only control-plane identifiers — the Message-ID, the ISA13 interchange control number, and the MDN disposition — never the payload. Model the send result as a typed record so the reconciliation step has a stable contract.
import base64
import hashlib
import logging
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("as2.mdn")
def mask(value: str) -> str:
"""Deterministic masking so the same interchange correlates across nodes."""
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:12]
@dataclass(slots=True)
class OutboundInterchange:
isa13: str # interchange control number, ISA element 13
message_id: str # AS2 Message-ID, the MDN correlation key
sent_mic: str # base64 digest we computed at send time
micalg: str # e.g. "sha-256"
sent_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@dataclass(slots=True)
class MdnResult:
original_message_id: str
disposition: str
received_mic: str | None
micalg: str | None
signature_valid: bool
Step 2 — Compute the MIC over the canonical payload and send
Compute the MIC over the exact signed MIME entity, capture the Message-ID, and request a signed synchronous MDN. The Disposition-Notification-To header tells the partner to acknowledge; Disposition-Notification-Options pins the digest so both sides agree on the algorithm.
def compute_mic(canonical_mime: bytes, micalg: str = "sha-256") -> str:
"""MIC = base64(digest(canonical MIME entity)). Must match the partner's."""
digest = {"sha-256": hashlib.sha256, "sha1": hashlib.sha1}[micalg]
return base64.b64encode(digest(canonical_mime).digest()).decode("ascii")
def build_as2_headers(as2_from: str, as2_to: str, micalg: str) -> tuple[dict, str]:
message_id = f"<{uuid.uuid4()}@{as2_from}>"
headers = {
"AS2-Version": "1.2",
"AS2-From": as2_from,
"AS2-To": as2_to,
"Message-ID": message_id,
"Disposition-Notification-To": f"mailto:as2@{as2_from}",
"Disposition-Notification-Options": (
f"signed-receipt-protocol=optional, pkcs7-signature; "
f"signed-receipt-micalg=optional, {micalg}"
),
"Content-Type": "application/pkcs7-mime; smime-type=enveloped-data",
}
return headers, message_id
def send_interchange(
canonical_mime: bytes, isa13: str, as2_from: str, as2_to: str, micalg: str = "sha-256"
) -> OutboundInterchange:
headers, message_id = build_as2_headers(as2_from, as2_to, micalg)
sent_mic = compute_mic(canonical_mime, micalg)
# transport = requests.post(partner_url, data=signed_body, headers=headers)
logger.info(
"AS2 sent | isa13=%s | msg=%s | micalg=%s",
mask(isa13), mask(message_id), micalg,
)
return OutboundInterchange(isa13=isa13, message_id=message_id,
sent_mic=sent_mic, micalg=micalg)
Step 3 — Verify the returned MIC against the sent payload
When the MDN arrives (synchronously in the same HTTP response, or asynchronously on a later connection), verify the partner’s signature first, then compare the Received-Content-MIC to the MIC you computed at send time. A constant-time comparison avoids leaking timing information about the digest.
import hmac
def verify_mdn(mdn: MdnResult, outbound: OutboundInterchange) -> bool:
if not mdn.signature_valid:
logger.error("MDN signature invalid | msg=%s", mask(outbound.message_id))
return False
if "processed" not in mdn.disposition or "error" in mdn.disposition:
logger.error(
"MDN negative disposition | msg=%s | disposition=%s",
mask(outbound.message_id), mdn.disposition,
)
return False
if mdn.micalg and mdn.micalg.lower() != outbound.micalg.lower():
# Algorithm mismatch: the digests cannot be compared meaningfully.
logger.error(
"MIC algorithm mismatch | sent=%s | received=%s | msg=%s",
outbound.micalg, mdn.micalg, mask(outbound.message_id),
)
return False
if mdn.received_mic is None or not hmac.compare_digest(
mdn.received_mic, outbound.sent_mic
):
logger.error("MIC mismatch — payload integrity unproven | msg=%s",
mask(outbound.message_id))
return False
logger.info("MDN verified — non-repudiation established | msg=%s | isa13=%s",
mask(outbound.message_id), mask(outbound.isa13))
return True
Step 4 — Reconcile the MDN to its interchange
Look up the outbound record by Original-Message-ID. For a synchronous MDN this is trivial, but an asynchronous MDN arrives on an entirely separate inbound POST minutes later, so the correlation must go through the durable store — never assume the MDN rides the send response.
_PENDING: dict[str, OutboundInterchange] = {} # message_id -> interchange
def register_pending(outbound: OutboundInterchange) -> None:
_PENDING[outbound.message_id] = outbound
def reconcile_mdn(mdn: MdnResult) -> None:
outbound = _PENDING.get(mdn.original_message_id)
if outbound is None:
# Unknown Message-ID: duplicate, spoofed, or expired correlation window.
logger.error("Unmatched MDN | original=%s", mask(mdn.original_message_id))
return
if verify_mdn(mdn, outbound):
_PENDING.pop(mdn.original_message_id, None)
# mark interchange DELIVERED_CONFIRMED, keyed on ISA13
else:
# leave pending; the sweep in Step 5 will alert/retry
logger.warning("MDN failed verification | isa13=%s", mask(outbound.isa13))
Step 5 — Alert or retry on a missing MDN
A missing MDN is indistinguishable from a lost interchange, so a timeout must trigger a resend of the same interchange (with the same control number, to stay idempotent) or a human alert. Sweep the pending store on an interval.
from datetime import timedelta
def sweep_pending(mdn_timeout: timedelta = timedelta(minutes=15)) -> None:
now = datetime.now(timezone.utc)
for message_id, outbound in list(_PENDING.items()):
if now - outbound.sent_at > mdn_timeout:
logger.error(
"MDN overdue | isa13=%s | msg=%s | age=%ss — alerting",
mask(outbound.isa13), mask(message_id),
int((now - outbound.sent_at).total_seconds()),
)
# resend same ISA13 idempotently, or page on-call after N sweeps
Verification
Drive the happy path and the two failure branches before enabling automatic delivery confirmation. A matching MIC confirms non-repudiation; a mismatched or wrong-algorithm MIC must never confirm.
outbound = send_interchange(b"<canonical 837 mime>", isa13="000000123",
as2_from="Myco", as2_to="Payerco")
register_pending(outbound)
good = MdnResult(original_message_id=outbound.message_id,
disposition="automatic-action/MDN-sent-automatically; processed",
received_mic=outbound.sent_mic, micalg="sha-256", signature_valid=True)
assert verify_mdn(good, outbound) is True
bad_mic = MdnResult(original_message_id=outbound.message_id,
disposition="automatic-action/MDN-sent-automatically; processed",
received_mic="ZZZZ", micalg="sha-256", signature_valid=True)
assert verify_mdn(bad_mic, outbound) is False
Expected log output (identifiers masked, no payload PHI):
2026-07-16 09:12:01 | INFO | as2.mdn | AS2 sent | isa13=8d969eef6ec | msg=1f2e3d4c5b6a | micalg=sha-256
2026-07-16 09:12:03 | INFO | as2.mdn | MDN verified — non-repudiation established | msg=1f2e3d4c5b6a | isa13=8d969eef6ec
2026-07-16 09:12:03 | ERROR | as2.mdn | MIC mismatch — payload integrity unproven | msg=1f2e3d4c5b6a
Under HIPAA §164.312(e)(1), transmission security requires guarding against unauthorized modification of ePHI in transit; the signed MDN with a matching MIC is the integrity control that satisfies §164.312(e)(2)(i) for the AS2 hop, and the verification log is your audit evidence of it.
Common Gotchas
- MIC algorithm mismatch. If you request
sha-256but the partner’s profile still computessha1, the returned MIC will never equal yours and every transfer looks corrupt. Pinsigned-receipt-micalgexplicitly and reject an MDN whosemicalgdiffers rather than comparing incomparable digests. - Assuming the MDN is synchronous. With asynchronous MDNs the acknowledgment arrives on a fresh inbound connection, so a
200 OKon the sendPOSTproves nothing. Persist every outboundMessage-IDand reconcile through the durable store, or you will confirm deliveries that were never acknowledged. - Treating the MDN as a 997/999. An MDN acknowledges transport receipt, not EDI syntax. A partner can return
processedon an interchange whose contents later fail functional acknowledgment. Wait for the X12 999/TA1 rejection handling before declaring a claim accepted. - Computing the MIC over the wrong bytes. The MIC is over the canonical MIME entity, not the raw file or the transport-encoded body. Canonicalize line endings and header ordering exactly once, hash that, and reuse it — recomputing over a re-serialized message produces a different digest.
Related
- Parent guide: Secure File Transfer Protocols for EDI — where AS2, SFTP, and their transmission-security controls fit in the ingestion edge.
- Configuring SFTP for HIPAA-Compliant EDI Transfers — the alternate transport when a partner has no AS2 endpoint, and how its host-key model compares to AS2 non-repudiation.
- Logging & Categorizing X12 Syntax Errors — the 999/TA1 layer that runs after a successful MDN and actually validates the interchange contents.