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-256 but the partner’s profile still computes sha1, the returned MIC will never equal yours and every transfer looks corrupt. Pin signed-receipt-micalg explicitly and reject an MDN whose micalg differs rather than comparing incomparable digests.
  • Assuming the MDN is synchronous. With asynchronous MDNs the acknowledgment arrives on a fresh inbound connection, so a 200 OK on the send POST proves nothing. Persist every outbound Message-ID and 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 processed on 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.

Up: Secure File Transfer Protocols for EDI