Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

as2d is a production AS2 server: it exchanges EDI documents (or any payload) with your trading partners over HTTP using the AS2 protocol (RFC 4130, AS2 version 1.1 including RFC 5402 compression), with encryption, digital signatures, and signed delivery receipts (MDNs).

It is a single small container image, written in Rust on the OpenSSL crypto library, that runs the same way everywhere: a laptop, a Docker host, any Kubernetes cluster, or Azure Container Apps at scale-to-zero.

Who this book is for

What AS2 is, in one paragraph

AS2 wraps a business document in S/MIME layers — optionally compressed, signed, and encrypted — and POSTs it to the partner’s HTTPS endpoint. The receiver unwraps it, verifies who sent it and that it was not altered, and returns a Message Disposition Notification (MDN): a signed receipt containing a cryptographic digest (the MIC) of what was received. When the sender’s own digest matches the receipt’s, both sides hold proof of delivery — the property that makes AS2 the standard for retail, logistics, pharma, and healthcare EDI (including Drummond-certified networks like Walmart’s and the DEA’s e-prescribing rules).

Why as2d

Verified against the software your partners actually run

Most AS2 products are tested against themselves. as2d’s test rig runs seven independent AS2 implementations in containers — spanning four crypto lineages (BouncyCastle/Java, Go, Python, Ruby/OpenSSL, JavaScript) — and exchanges live signed, encrypted, compressed messages with each of them in both directions on every release:

  • phase2 / as2-lib (Java, BouncyCastle)
  • OpenAS2 (Java, BouncyCastle)
  • mendelson AS2 Community Edition (Java, BouncyCastle)
  • pyas2lib / django-pyas2 (Python)
  • Waarp AS2 (Go)
  • the Ruby as2 gem
  • node-libas2 (JavaScript)

Where a counterpart deviates from the RFCs, that behavior is measured, documented, and — where safe — handled automatically or by a per-partner switch. See Interoperability and Working with specific products.

Built like infrastructure, not like a Java app from 2005

  • One static binary in a small container. No JVM, no app server, cold starts in milliseconds, tens of megabytes of memory at idle.
  • Durable by design. Outbound sends and asynchronous MDNs live in a persistent retry queue; kill the server mid-delivery and the exchange completes after restart. Replay protection deduplicates partner retries.
  • Streaming-friendly. Large payloads are spooled, not held in RAM.
  • Cloud-neutral core. Storage is pluggable: SQLite + filesystem for a zero-dependency single node, PostgreSQL for multi-replica, with optional Azure Blob payload storage and Azure Key Vault key storage. No cloud SDK ever touches the protocol engine.

Costs that match the workload

AS2 traffic is bursty. as2d’s smallest production profile is a single container with no external services at all; the Azure serverless profile scales to zero between messages and costs approximately nothing at idle. See Cost profiles.

Honest engineering

The protocol engine is built test-first with property-based and golden-vector tests, kept above 90 % line coverage, and every deviation a counterpart forced on us is recorded with the measurement that proved it. Security decisions (inbound security floors, receipt authentication, SSRF guards on receipt URLs, transient-only retries) are each pinned by regression tests.

Feature matrix

Protocol

CapabilitySupport
AS2 version1.1 (RFC 4130 + RFC 5402 compression)
TransportHTTP; TLS terminated at your ingress/load balancer
DirectionsFull send and receive
SigningRSA PKCS#1 v1.5 and RSASSA-PSS; MD5, SHA-1, SHA-224/256/384/512
Encryption3DES, RC2, AES-128/192/256-CBC, AES-128/192/256-GCM (CAST5/IDEA present but excluded from support — see Algorithms)
CompressionZLIB (RFC 3274), before or after signing; double-compression rejected
MDNsSync and async; signed and unsigned; requested per partner
MICFull RFC 4130 §7.3/§7.4 rules incl. micalg negotiation and 3851/5751 name normalization
IdentifiersAS2 quoted-name support both directions (§6.2)
AuthenticationHTTP Basic to partner endpoints; pinned-certificate verification of partners

Operations

CapabilitySupport
Replay protectionMessage-ID dedup store with TTL; duplicate deliveries re-answered idempotently
RetriesDurable queue, exponential backoff with jitter, restart-safe; transient failures only (connect errors, 408/429/5xx)
Message journalEvery business event persisted and emitted as structured logs
Large payloadsSpooled to disk, multi-GiB verified
HealthGET /healthz (reports degraded states in the body, always HTTP 200)
LoggingJSON (default) or text to stdout; level configurable; RUST_LOG override
MetricsOptional Prometheus endpoint on a dedicated port (never on the partner-facing listener)
ShutdownGraceful on SIGTERM/SIGINT — in-flight exchanges finish

Storage backends

BackendUse case
SQLite + filesystem (default)Single replica, zero external services
PostgreSQLMulti-replica, scale-out, scale-to-zero (any Postgres: in-cluster, managed, free tiers)
Azure Blob StorageOptional payload store override, composable with either
Azure Key VaultOptional private-key store override (managed identity supported)

Deployment targets

One image for all of them: Docker/compose, Kubernetes via the provided Helm chart (AKS, EKS, k3s, bare metal), Azure Container Apps (scale-to-zero bicep template provided), and any other container platform.

Interoperability

Interoperability is where AS2 projects succeed or die: two RFC-compliant implementations can still fail to reconcile receipts because the RFCs leave real choices open (what exactly the MIC digests, how AES-GCM is encoded, which algorithm-name spellings appear on the wire). as2d’s approach: measure against live implementations, never assume.

The verification matrix

Every release exchanges live traffic — signed, encrypted, compressed, with signed MDN reconciliation — with seven independent implementations, each running unmodified in its own container:

CounterpartLineageBoth directionsNotes
phase2 / as2-libJava / BouncyCastle✔ (168-case matrix inbound)incl. async MDN, PSS, GCM
OpenAS2Java / BouncyCastleincl. RC2, PSS, SHA-512
mendelson AS2 CEJava / BouncyCastleincl. its native GCM form
pyas2lib (django-pyas2)Pythonincl. compression
Waarp AS2Gothree CMS lineages agreeing
Ruby as2 gemRuby / OpenSSLsigned+encrypted
node-libas2JavaScriptCBC/3DES

Real-world compatibility built in

  • BouncyCastle AES-GCM: the Java AS2 world encodes AES-GCM in a non-conformant way (plain EnvelopedData with the auth tag appended) that standard crypto libraries refuse. as2d receives that form transparently — with the tag still enforced — and can send it per partner (bc_style_gcm) while defaulting to the RFC 5084 conformant encoding.
  • Algorithm-name spellings: RFC 5751 names by default, RFC 3851 (sha256 vs sha-256) per partner, and in-the-wild aliases accepted on input.
  • Peppol: signer certificate embedded in signatures (default on).
  • IBM Sterling: per-partner header-quoting and other switches.
  • Per-partner content-transfer-encoding: binary (the wire default), base64, or quoted-printable outer encoding — different counterparts require different forms, and both are expressible per partnership.

Where a counterpart’s behavior makes strict receipt reconciliation impossible (for example, one product digests receipts without MIME headers on unsigned compressed messages), the exchange still completes and the divergence is documented in Working with specific products so your operators are never surprised by it in production.

Performance and feature comparison

Every number and every cell on this page is measured, not quoted from documentation: the same benchmark harness drives the same live exchange against each product running unmodified in its own container, and the feature matrix comes from the live interop verification runs. Tested versions are listed; newer releases may differ.

Benchmark

Scenario: the exchange profile every tested product supports — payload signed (RSA / SHA-256) and encrypted (AES-256-CBC), synchronous signed MDN, MIC reconciliation. One uniform sender (the as2d client) drives each receiver; latency is the full exchange as a partner experiences it: packaging, POST, the receiver’s decrypt/verify/store, MDN generation, and receipt verification.

Method: 100 messages per payload size after warmup, 4 concurrent senders, containerized receivers with their default configurations, all on the same host (a 12th-gen Intel i7-12700H laptop, NVMe SSD, Linux). Receivers were benchmarked one at a time. Results from 2026-08-26; raw data and the harness ship in the as2d source tree, so the run is reproducible.

Receiver1 KiB msgs/s1 KiB p50 ms1 KiB p95 ms64 KiB msgs/s64 KiB p50 ms64 KiB p95 ms1 MiB msgs/s1 MiB p50 ms1 MiB p95 ms
as2d30612.716.627914.018.412531.635.0
phase223514.630.416623.529.727.8143153
openas230812.818.933811.414.773.753.064.7
mendelson37.310118133.21181905.557111109
pyas299.236.171.667.456.988.516.4239319
waarp-as26705.778.305207.779.8214826.631.1
ruby-as215824.539.815425.933.685.446.058.5
node-libas271.955.663.253.673.185.4

Reading notes:

  • What “durable” costs. as2d’s numbers include per-message durable state writes — replay-protection and journal entries are fsynced to disk before the receipt is acknowledged, because an acknowledged message must survive a crash. Products differ in what they persist before acknowledging, and this benchmark does not normalize that.
  • as2d is nearly size-insensitive: median latency rises only 13 ms → 32 ms from 1 KiB to 1 MiB. It is the fastest receiver measured at every payload size except the Go implementation — while being the only product in the table whose numbers include fsyncing the payload, replay-protection state, and the journal before acknowledging (at 1 MiB it is still 4× phase2 and 22× mendelson CE).
  • The crypto runtime matters as much as the product. as2d’s own container numbers doubled just by moving its base image from OpenSSL 3.0 to 3.5 (3.0’s per-operation algorithm-fetch locking is a known cost under concurrent CMS load that microbenchmarks like openssl speed do not show). If you run any AS2 product on an OpenSSL-3.0-era distribution, you are likely paying this too.
  • Waarp AS2 (Go) is the fastest receiver at small payloads; at 1 MiB it and as2d are effectively tied at the top.
  • node-libas2 failed every 1 MiB exchange under load (it returned MDNs with no disposition field), hence the dashes — consistent with the library’s frozen 2020-era dependency set.
  • Java products show their strength at small payloads once the JIT is warm (OpenAS2, phase2) but fall off steeply at 1 MiB.

Scaling under load

The same exchange at 16 concurrent senders: as2d’s durable writes batch across in-flight messages (group commit), so throughput scales near-linearly — 981 msg/s at 1 KiB, 800 msg/s at 64 KiB, and at 1 MiB it matches the fastest non-durable implementation measured (335 vs 342 msg/s) while fsyncing every payload before acknowledging it.

Feature matrix

As measured in the live interop rig (both directions, receipt reconciliation) — ✔ works as specified, ◐ works with caveats (see the per-product notes), ✘ not supported by that product.

Capabilityas2dphase2 (as2-lib)OpenAS2 4.10mendelson CE 1.1b69pyas2lib 1.4.4Waarp AS2Ruby as2 0.12node-libas2 0.8.2
Signing SHA-2 family
RSASSA-PSS signatures
AES-CBC / 3DES encryption
AES-GCM (RFC 5084 conformant)✘ rejects✘ rejects
AES-GCM (BC-compatible form)✔ receive + per-partner send✔ (its native form)✘ rejects
ZLIB compression (RFC 5402)
Sync signed MDN generation◐ broken upstream (sends unsigned)
Async MDN delivery
Honors requested receipt digest✘ always SHA-256✘ uses signature digest
Binary (raw) S/MIME bodies✘ base64 required
Enforces inbound security floor✔ (insufficient-message-security)✔ (requires signed+encrypted, always)

✘ is only used where the limitation was directly measured or is the product’s own documented constraint; — means the capability was not exercised against that product in the rig and is left unclaimed rather than assumed either way.

A note on fairness. These are open-source products measured under their own default configurations in containers; each of them may be tunable beyond what defaults deliver, and all of them interoperate correctly with as2d in the profiles above — that is the point of the interop rig. The benchmark answers one question honestly: what does the same exchange cost against each receiver, out of the box, on the same hardware.

Licensing

as2d is commercial software activated by a signed license file. The file is issued to you at purchase, is a few hundred bytes of text, and is installed alongside the server’s configuration — see Installing your license.

Two commercial models

Subscription — the license carries an expiry date. The server enforces it gently: after expiry there is a 14-day grace period during which everything keeps working while the logs, health endpoint, and metrics warn loudly; only after grace does the server stop accepting AS2 traffic (partners receive a clean, retryable HTTP 503 — messages are not lost, their retry mechanisms redeliver once you renew). Renewing is a file update; no restart is needed on Kubernetes.

Perpetual — the license never stops a running server. Instead it carries a maintenance-until date that gates which releases it activates: any build released on or before that date runs forever; a newer build asks you to renew maintenance first. You keep what you bought, renewals buy upgrades.

What a license covers

Licenses encode entitlements, so pricing can match usage:

  • send and/or receive
  • asynchronous MDNs
  • the set of encryption algorithms
  • messages per day (per running instance)
  • maximum number of trading partnerships

If your configuration ever exceeds the license (say, an eleventh partnership on a ten-partnership license), the server tells you precisely what and why at startup — never by failing a partner’s message at 2 am.

What enforcement never does

  • It never blocks receipts for messages already sent — in-flight exchanges always complete.
  • It never drops queued deliveries — an expired license pauses outbound work, it does not discard it.
  • It never phones home. Validation is a local cryptographic signature check against a key built into the software; as2d makes no network calls for licensing.

Security posture

Crypto core

All CMS/S-MIME cryptography is performed by libcrypto (OpenSSL 3) — the most widely deployed and audited crypto library in existence — through Rust bindings. The protocol engine itself is memory-safe Rust with unsafe code forbidden in the core protocol crate. Legacy ciphers (RC2) load through OpenSSL’s legacy provider, and the server round-trips RC2 through it at startup, in-process, before accepting traffic.

The production container is distroless: no shell, no package manager, no coreutils — the only executable in the image is as2d itself, alongside the OpenSSL shared libraries and the CA bundle (~50 MB total). There is nothing for an attacker to “live off the land” with, and vulnerability scanners have almost nothing to flag.

Enforced invariants

Each of these is protected by regression tests:

  • Inbound security floor. If a partnership declares signing and/or encryption, an inbound message missing those layers is rejected with insufficient-message-security — a partner downgrade (or an attacker stripping layers) cannot slip plaintext through. Per-layer opt-outs exist for migration scenarios and are explicit configuration.
  • Receipt authentication. Asynchronous MDNs are verified against the pinned partner certificate from your keystore — not whatever certificate happens to be embedded in the receipt. A rejected or mismatched receipt cannot consume delivery state.
  • SSRF guard. A receipt-delivery URL supplied by an unknown or unauthenticated party is never honored, and only http(s) URLs are ever delivered to — the receipt channel cannot be turned into an arbitrary-POST primitive against your network.
  • Certificate validity windows are enforced when signing and when verifying against the trust anchor.
  • Replay protection. Message-IDs are remembered (configurable TTL); replays are answered idempotently without reprocessing.
  • Retries are transient-only. Permanent HTTP failures (4xx other than 408/429) never loop.

Key handling

  • Private keys are held in memory in zeroize-on-drop buffers.
  • Keys load from PEM files you mount, or from Azure Key Vault using a managed identity — in that profile no private key ever touches disk.
  • Partner trust is pinned: you install each partner’s certificate; there is no ambient CA trust for partner identity. (Verification against in-band certificates is available per partner for counterparts that rotate certificates that way, and is documented as the weaker mode.)

Network surface

  • One partner-facing HTTP listener (/as2, /as2/mdn, /healthz) — TLS terminates at your ingress.
  • Metrics, when enabled, are served on a separate port that you never expose to partners.
  • No outbound connections except deliveries to configured partner URLs (and your configured storage backends). No telemetry, no license callbacks.

Quickstart (Docker)

A single container, a config file, a partnerships file, your keys, and your license. Working AS2 endpoint in five minutes.

1. Lay out a config directory

as2d/
├── config.toml
├── partnerships.toml
├── license.lic          # issued to you at purchase
└── keys/
    ├── me.key.pem       # your private key
    ├── me.cer.pem       # your certificate
    └── partner.cer.pem  # each partner's certificate

config.toml:

listen = "0.0.0.0:8080"

[storage]
kind = "local"
data_dir = "/data"
partnerships_file = "/config/partnerships.toml"
keys_dir = "/keys"

[license]
path = "/config/license.lic"

[limits]
max_body_mb = 256
decompress_limit_mb = 512

partnerships.toml (one inbound + outbound partner; see Partnerships for every option):

[[partnership]]
id = "acme-inbound"
local_id = "MYCOMPANY"
remote_id = "ACME"
cert_aliases = { local = "me", remote = "partner" }
[partnership.security]
sign = "sha-256"
encrypt = "aes256-cbc"

[[partnership]]
id = "acme-outbound"
local_id = "MYCOMPANY"
remote_id = "ACME"
remote_url = "https://as2.acme.example/as2"
cert_aliases = { local = "me", remote = "partner" }
[partnership.security]
sign = "sha-256"
encrypt = "aes256-cbc"
[partnership.mdn]
request = true
signed_micalg = "sha-256"

2. Run

docker run -d --name as2d \
  -p 8080:8080 \
  -v $PWD/as2d:/config:ro \
  -v $PWD/as2d/keys:/keys:ro \
  -v as2d-data:/data \
  -e AS2D_CONFIG=/config/config.toml \
  <registry>/as2d:<tag>

3. Check

curl http://localhost:8080/healthz
# → ok

If it prints degraded: unlicensed, the license file was not found — the server is alive but refuses AS2 traffic until it is; see Installing your license.

The startup log also tells you: which license is loaded and for how long, any certificate expiring within 30 days, and any partnership configured with an algorithm known to be problematic with certain partners.

4. Expose

Put your TLS-terminating reverse proxy (nginx, Caddy, a cloud load balancer) in front of port 8080 and give partners the resulting URL — messages POST to /as2 (or /), asynchronous receipts to /as2/mdn.

Kubernetes (Helm)

The provided Helm chart runs as2d on any conformant cluster — AKS, EKS, GKE, k3s, bare metal. It has been verified end-to-end on a live cluster in both its storage profiles.

Install

# Your key material (aliases resolve as {alias}.key.pem / {alias}.cer.pem):
kubectl create secret generic as2d-keys \
  --from-file=me.key.pem --from-file=me.cer.pem --from-file=partner.cer.pem

# Your license (key name must be license.lic):
kubectl create secret generic as2d-license --from-file=license.lic

helm install as2d ./as2d \
  --set-file partnerships=partnerships.toml \
  --set licenseSecret=as2d-license \
  --set image.repository=<registry>/as2d --set image.tag=<tag>

The chart refuses to install without a license Secret unless you explicitly pass --set allowUnlicensed=true (which deploys in a degraded state — health answers, AS2 traffic 503s — useful for platform smoke tests before purchase completes).

Storage profiles

ProfileValuesExternal services
Single replica (default)storage.kind=localnone — SQLite + a PVC
Scale-out, self-hostedstorage.kind=postgres postgres.internal=true replicaCount=Nan in-cluster Postgres the chart deploys for you
Scale-out, managed DBstorage.kind=postgres postgres.url=...any managed PostgreSQL

The chart enforces the safety rule for you: more than one replica requires the Postgres backend (local SQLite state is single-replica by design), with payloads stored in the database so every replica sees them.

Azure-native storage on AKS

Payloads in Blob Storage and/or private keys in Key Vault compose with either profile via extraConfig:

extraConfig: |
  [storage.blob]
  account = "myaccount"
  account_key = "..."
  container = "as2-payloads"
  endpoint = "https://myaccount.blob.core.windows.net"

  [storage.key_vault]
  vault_url = "https://myvault.vault.azure.net"

Operational notes

  • License renewal without restart: update the Secret in place —
    kubectl create secret generic as2d-license --from-file=license.lic \
      --dry-run=client -o yaml | kubectl apply -f -
    
    Kubernetes updates the mounted file and as2d re-reads it within the hour. A corrupted update is harmless: the server keeps the last good license and logs the problem.
  • Readiness stays 200 even when the license degrades — deliberately. An expired deployment should tell partners “503, retry later” at the protocol level, not vanish from the load balancer (which partners see as an outage with no diagnosis). Monitor the as2_license_state gauge or the /healthz body instead of tightening the probe.
  • Metrics: --set metrics.enabled=true opens the dedicated metrics port with Prometheus scrape annotations; it is never exposed on the partner-facing Service.
  • TLS terminates at your ingress controller, per the container-first design.
  • With postgres.internal=true the as2d pods may restart a few times on first install while Postgres initializes — connecting at boot is deliberate (a bad database URL should fail loudly, not at the first message). They settle once Postgres is ready.

Azure Container Apps

The serverless profile: scale-to-zero containers. Deliberately not Azure Functions — AS2 needs long uploads and same-connection synchronous receipts, which FaaS models handle poorly. The same as2d image runs here unchanged; a ready-made bicep template deploys the whole environment (Container Apps environment, Log Analytics, ingress, secrets).

Deploy

az group create -n as2d-rg -l westeurope

az deployment group create -g as2d-rg -f aca.bicep \
  -p image=<registry>/as2d:<tag> \
  -p postgresUrl='postgres://...?sslmode=require' \
  -p partnershipsToml=@partnerships.toml \
  -p licenseContent=@license.lic \
  -p keyFiles='{"files":[
       {"name":"me.key.pem","value":"-----BEGIN PRIVATE KEY-----..."},
       {"name":"me.cer.pem","value":"-----BEGIN CERTIFICATE-----..."},
       {"name":"partner.cer.pem","value":"-----BEGIN CERTIFICATE-----..."}]}'

TLS is terminated by the Container Apps ingress; the output of the deployment is your partner-facing HTTPS URL.

Choices baked into the template

  • State must be PostgreSQL — instances are ephemeral at scale-to-zero. Azure Database for PostgreSQL, or a managed free tier for minimal cost.
  • Payloads and keys default to Postgres + secret-mounted PEM files. For Azure-native storage pass extraConfig with [storage.blob] / [storage.key_vault]; the app has a system-assigned managed identity — grant it Key Vault Secrets User and the keystore authenticates with no credentials in config.
  • Scale-to-zero caveat: the retry worker lives inside as2d, so queued retries and asynchronous MDNs only progress while a replica is awake. Set minReplicas=1 if partners depend on async receipts; minReplicas=0 is fine for synchronous-only traffic (cold start of the static binary is comfortably inside partner HTTP timeouts).
  • License renewal: Container Apps secret volumes do not reliably update in place — after renewing, re-run the deployment (or restart the revision) with the new licenseContent. The 14-day grace window exists precisely so this is never an emergency.

Cost

Consumption plan with minReplicas=0 plus a free-tier Postgres is approximately $0 at idle — you pay per request-second of compute. See Cost profiles.

Cost profiles

The free single-node profile is a first-class product feature, not a demo mode — the same binary, the same protocol engine, the same tests. Pick the profile that matches your volume and availability needs; you can migrate between them by changing configuration only.

ProfileInfrastructureTypical monthly infra cost
Single nodeone container, SQLite + local disk$0 beyond the VM/host you already have
Kubernetes, single replicaHelm chart, storage.kind=local, one PVC$0 beyond cluster compute
Kubernetes, scale-outHelm chart + in-cluster Postgres container$0 beyond cluster compute
Kubernetes, managed DBHelm chart + managed PostgreSQLfree tier (Neon/Supabase) → ~$15 for small managed instances
Azure serverlessContainer Apps consumption plan, minReplicas=0, free-tier Postgres≈ $0 at idle; pay per request-second

Guidance:

  • Availability: single-replica profiles restart in seconds and the durable queue means nothing is lost — for most EDI volumes that is plenty. Choose scale-out when you need zero-downtime deploys or more than one node’s throughput.
  • Async MDNs at scale-to-zero: keep one replica warm (minReplicas=1) if partners rely on asynchronous receipts, since queued deliveries only progress while an instance is running.
  • Free-tier databases (Neon, Supabase) are genuinely workable for low volumes; TLS is honored via the connection string’s sslmode. Move to a paid tier when your retention or throughput outgrows them — it is a connection-string change.

Installing your license

Your license is a short signed text file (license.lic). as2d validates its signature against a key built into the binary — locally, instantly, with no network access.

Where it goes

The config file points at it:

[license]
path = "/config/license.lic"   # default: /etc/as2d/license.lic
  • Docker: mount it read-only next to your config (see Quickstart).
  • Kubernetes: a Secret named by the chart’s licenseSecret value, key license.lic (see Kubernetes).
  • Azure Container Apps: the licenseContent deployment parameter.

What happens in each state

StateServer behavior
ValidNormal operation; startup log shows customer, type, days remaining
Expiring within 30 daysWarning in logs and as2_license_days_remaining gauge counts down
Expired, within 14-day graceEverything still works; loud warnings; /healthz body says degraded: license in grace, N day(s) left
Expired past graceInbound messages answered 503 (partners’ retry logic redelivers after renewal); outbound deliveries pause but stay queued; /healthz body degraded: license expired
File missingServer boots “unlicensed”: health answers, AS2 traffic 503s until the file appears
File present but invalidServer refuses to start, with the exact reason on stderr/logs

/healthz always returns HTTP 200 — degraded states are reported in the response body and the metrics gauges, so load balancers keep routing and partners get diagnosable protocol-level errors instead of connection failures.

Renewal

Replace the file; as2d re-reads it hourly. On Kubernetes that means updating the Secret in place — no restart, no pod roll. If a bad file lands (truncated upload, wrong file), the server keeps the last good license and logs the error; a botched renewal can never take a healthy server down.

Entitlement errors at startup

If your configuration exceeds the license — an algorithm not covered, a partnership requesting async MDNs on a license without them, more partnerships than allowed, outbound partners on a receive-only license — as2d refuses to start and names the exact partnership and entitlement. Fix the configuration or upgrade the license; the point of failing at startup is that a partner’s message at 2 am is never the first sign.

Logging and metrics

Logs

Structured logs go to stdout — the collection point every platform scrapes (Log Analytics, CloudWatch, fluentbit/EKS, journald, docker logs). JSON by default; text for humans at a console:

[observability]
log_format = "json"   # or "text"
log_level = "info"    # RUST_LOG env var overrides when set

Every business event in a message’s life is emitted as a structured log line (target as2_journal) and persisted in the message journal, so your dashboards and the durable audit trail always agree. Event kinds include: received, stored, rejected, duplicate, mdn-received, mdn-rejected, mdn-mic-mismatch, delivered, retry-scheduled, gave-up, receipt-request-failed, store-failed.

Startup logs additionally surface the loaded license (customer, type, days remaining), certificates expiring within 30 days, and any partnership configured with an algorithm known to be problematic with BouncyCastle-based partners.

Metrics (optional)

Prometheus metrics are opt-in and served on a dedicated listener that never rides the partner-facing ingress:

[observability.metrics]
enabled = true
listen = "0.0.0.0:9090"
MetricLabelsMeaning
as2_journal_events_totalkind, partnershipbusiness events (same taxonomy as the journal)
as2_http_requests_totalroute, statuspartner-facing HTTP requests
as2_http_request_duration_secondsrouterequest latency histogram
as2_license_state0 valid · 1 grace · 2 expired · 3 unlicensed · 4 build-not-covered
as2_license_days_remainingdays to subscription expiry (−1 when not applicable)

Alerting suggestions: page on as2_license_state > 0, on as2_journal_events_total{kind="gave-up"} increases, and on kind="mdn-mic-mismatch".

The Helm chart adds Prometheus scrape annotations automatically when metrics are enabled. Clients who do not want metrics simply leave them disabled — the metrics listener then does not exist at all.

Partnerships

A partnership is one directed relationship between an AS2 identity of yours (local_id) and one of a partner’s (remote_id). All of them live in partnerships.toml; one server can host many local identities.

Anatomy

[[partnership]]
id = "acme-outbound"              # unique config key (appears in logs/metrics)
local_id = "MYCOMPANY"            # our AS2-From when sending / AS2-To inbound
remote_id = "ACME"                # the partner's AS2 name
remote_url = "https://as2.acme.example/as2"   # present = outbound-capable
cert_aliases = { local = "me", remote = "acme" }

[partnership.security]
sign = "sha-256"                  # omit for unsigned
encrypt = "aes256-cbc"            # omit for unencrypted
compress = "before-signing"       # or "after-signing"; omit for none
content_transfer_encoding = "binary"   # "binary" (default) | "base64" | "quoted-printable"

[partnership.mdn]
request = true                    # ask for a receipt when we send
signed_micalg = "sha-256"         # ask for a SIGNED receipt with this digest
# async_url = "https://my.example/as2/mdn"   # asynchronous receipt delivery

# [partnership.http_auth]         # HTTP Basic for the partner's endpoint
# username = "u"
# password = "p"

# [partnership.interop]           # per-partner compatibility switches, see below

How inbound routing works

An arriving message’s AS2-From/AS2-To headers select the partnership (quoted AS2 names are handled per RFC 4130 §6.2). Unknown pairs are rejected with an authentication-failed disposition — there is no “accept anyone” mode.

The security policy is a floor, not a hint

Declaring sign and/or encrypt does two things:

  1. Outbound: those layers are applied when sending.
  2. Inbound: messages from this partner must carry those layers or they are rejected with insufficient-message-security. A partner (or an attacker) cannot downgrade the exchange to plaintext.

Migration escape hatches (interop.disable_decrypt, interop.disable_verify, interop.disable_decompress) opt out per layer, explicitly, per partner.

Onboarding gotcha — mirror the security attributes. In the BouncyCastle-derived AS2 world (and in as2d, for compatibility with it), whether MIME headers are included in the receipt digest is driven by each side’s own partnership attributes, not by the message. If you declare compression and the partner’s side does not, both implementations can be correct and the receipt MICs will still disagree. When receipts mismatch, compare partnership attributes with your partner first.

Interop switches

All default to sensible values; set them per partner when a counterpart needs it (see Working with specific products):

FlagDefaultPurpose
include_cert_in_signaturetrueembed our certificate in signatures (Peppol requires it)
rfc3851_micalgsfalseuse sha256-style spellings instead of sha-256
quote_header_valuesfalsequote outbound header values (some IBM Sterling setups)
block_error_mdnfalsenever send negative receipts to this partner
bc_style_gcmfalsesend AES-GCM the BouncyCastle way (Java AS2 partners)
force_decrypt / force_verifyfalseattempt the layer even when the content-type does not announce it
disable_decrypt / disable_verify / disable_decompressfalseskip a layer (weakens the floor; migration only)
verify_use_cert_in_body_partfalsetrust the certificate embedded in the message instead of the pinned one (weaker; for partners that rotate certificates in-band)

The full field list with types is in the partnerships.toml reference.

Certificates and keys

What you exchange with a partner

AS2 trust is pairwise and pinned: you give the partner your certificate, they give you theirs, and each side installs the other’s. There is no CA hierarchy involved in partner identity (self-signed certificates are the norm in AS2).

Your certificate/key is used to sign outgoing messages and decrypt incoming ones; the partner’s certificate verifies their signatures and encrypts messages to them.

The keystore

Keys are looked up by alias — the names you chose in each partnership’s cert_aliases:

cert_aliases = { local = "me", remote = "acme" }

PEM directory (default)

keys_dir holds PEM files named by alias:

keys/
├── me.key.pem     # our private key (PKCS#8)
├── me.cer.pem     # our certificate
└── acme.cer.pem   # partner certificate (no key)

Generate a self-signed identity:

openssl req -x509 -newkey rsa:3072 -sha256 -days 1095 -nodes \
  -keyout me.key.pem -out me.cer.pem -subj "/CN=MYCOMPANY AS2"

Convert a partner’s .p7b/.cer/.pfx to PEM with the usual openssl pkcs7 -print_certs / openssl x509 -inform der / openssl pkcs12 -legacy commands.

Azure Key Vault (optional)

With [storage.key_vault] configured, aliases resolve to Key Vault secrets holding the PEM content ({alias}-key and {alias}-cert, alias sanitized to Key Vault’s [A-Za-z0-9-] name alphabet), and authentication uses the platform’s managed identity — no key material on disk, no credentials in config. Entries are cached with a TTL, so a rotated secret takes effect without restart.

Validity and rotation

  • Certificate validity windows are enforced: as2d will not sign with an expired or not-yet-valid certificate, and pinned-cert verification checks the window too.
  • Every certificate within 30 days of expiry is called out at startup, and expired ones are flagged as errors — certificate expiry is the single most common cause of AS2 outages, so as2d makes it loud and predictable.
  • To rotate: install your new certificate alongside the old (a second partnership/alias during the overlap window is the cleanest pattern), send the new public certificate to partners, then retire the old alias.

MDN receipts

The MDN (Message Disposition Notification) is AS2’s proof of delivery: a structured receipt carrying the disposition (processed, or an error) and the Received-Content-MIC — a digest of what the receiver actually got. When it matches the sender’s own digest, the loop is closed.

Requesting receipts (outbound)

[partnership.mdn]
request = true                # ask for a receipt
signed_micalg = "sha-256"     # ask for a SIGNED receipt with this digest
# async_url = "https://my.example/as2/mdn"    # deliver asynchronously
  • Synchronous (no async_url): the receipt comes back on the same HTTP connection as the send. Simplest; use it unless payloads are large enough that partners time out generating receipts inline.
  • Asynchronous: the partner answers the POST immediately and later delivers the receipt to your async_url. as2d correlates it by Original-Message-ID, verifies the signature against the pinned partner certificate, checks the MIC, and journals the outcome. Receipts that fail authentication or match no pending message are rejected without consuming state.

Outbound sends and their receipt state live in the durable queue — a crash or restart between send and receipt loses nothing.

Serving receipt requests (inbound)

as2d honors what the partner’s Disposition-Notification-Options asks for: signed receipts with any supported digest, unsigned receipts, sync or async (async delivery goes through the durable retry queue, so an unreachable partner URL is retried with backoff).

Receipt requests that cannot be honored are answered the way RFC 4130 requires — an explicit failed/Failure receipt rather than a silently different one — for example a request for a signature protocol or digest we do not support, or an async receipt request on a deployment whose license does not include async MDNs.

Security guards on the receipt channel:

  • A Receipt-Delivery-Option URL from an unknown partner is never honored (the error receipt goes back synchronously) — otherwise an unauthenticated request could aim durable, retried POSTs at any URL.
  • Only http:/https: receipt URLs are ever delivered to.

When MICs don’t match

A MIC mismatch on an otherwise successful exchange almost always means the two sides disagree about what to digest, not that data was corrupted. Check, in order:

  1. Mirrored security attributes — see the onboarding gotcha in Partnerships: both sides must declare the same sign/encrypt/compress attributes or the headers-in-digest decision diverges.
  2. The counterpart’s known quirks — several products digest something slightly different in specific corners (documented per product in Working with specific products).
  3. The requested micalg — some products ignore the requested receipt digest and use their default or the signature digest; requesting the digest the partner will actually use makes reconciliation exact.

as2d journals mdn-mic-mismatch distinctly so these are visible and alertable rather than silently logged.

Working with specific products

Everything below was measured against the live product in as2d’s interop rig — not inferred from documentation. Version numbers are the ones tested; newer versions usually behave the same but verify when in doubt.

Java / BouncyCastle family (phase2 · as2-lib · OpenAS2 · mendelson)

The largest AS2 population. Shared traits, handled automatically:

  • AES-GCM: they encode GCM non-conformantly (EnvelopedData with the auth tag appended, instead of RFC 5084 AuthEnvelopedData). as2d receives their form transparently. To send GCM to them, set interop.bc_style_gcm = true on the partnership — the default conformant encoding is unreadable to them.
  • CAST5 / IDEA are unusable with this family in either direction (incompatible parameter encodings between BouncyCastle and OpenSSL). as2d warns at startup if a partnership configures them; use 3DES or AES-CBC instead. These two ciphers are excluded from product support.
  • Signed messages digest receipts over the signed content; unsigned messages over the decompressed payload — as2d follows the same split, so MICs reconcile.

OpenAS2 (tested: 4.10.0)

  • On unsigned + compressed messages OpenAS2 digests the receipt without MIME headers (following the RFC 4130 unsigned rule) while RFC 5402-followers (as2d, phase2) include them. The exchange works; the receipt MIC cannot reconcile. Avoid unsigned+compressed with OpenAS2 — signing fixes it (and is what you want anyway).
  • OpenAS2 signs receipts without embedding its certificate — fine for as2d, which authenticates receipts against your pinned copy of their certificate.
  • Everything else is clean both directions, including RSASSA-PSS, RC2, SHA-512, and compression before/after signing.

mendelson AS2 (tested: Community Edition 1.1b69)

  • Enforces its configured partner security as an inbound floor, exactly like as2d — make sure both sides’ sign/encrypt settings mirror.
  • Sends AES-GCM in the BouncyCastle form; as2d receives it fine.
  • Accepts as2d’s signed receipts in all tested combinations.

pyas2lib / django-pyas2 (tested: pyas2lib 1.4.4)

  • Interops cleanly both directions including compression and MIC reconciliation.
  • Spells digest names without hyphens (sha256); as2d accepts these automatically.
  • Normalizes line endings of text payloads to CRLF before signing — if byte-exactness of text documents matters downstream, be aware the delivered payload is CRLF-normalized. (Binary payloads are unaffected.)

Waarp AS2 (Go; tested at a 2025 development snapshot)

  • Signed, encrypted (AES-CBC/3DES), and compressed exchanges are clean both directions with receipt reconciliation.
  • Rejects both AES-GCM encodings at the tested snapshot — use AES-CBC with this partner.

Ruby as2 gem (tested: 0.12.0)

  • Requires inbound messages to be signed and encrypted; supports synchronous signed receipts only; no compression, no async.
  • Expects the raw (binary) S/MIME body — as2d’s default outer content-transfer-encoding, so it just works.
  • Ignores the requested receipt digest and always uses SHA-256: request signed_micalg = "sha-256" so reconciliation is exact.

node-libas2 (tested: 0.8.2)

  • Cannot parse a binary S/MIME body: set content_transfer_encoding = "base64" on the partnership.
  • Its generated signed receipts have an upstream signature bug — configure it to send unsigned receipts (it verifies as2d’s signed receipts correctly).
  • Ignores the requested receipt digest and digests with the actual signature algorithm (the RFC 4130 §7.4.3 rule): request a signed_micalg matching your signing digest.
  • CBC/3DES only; no compression; no async delivery.

IBM Sterling B2B Integrator

  • Some deployments require quoted header values: set interop.quote_header_values = true.
  • If a specific Sterling deployment rejects otherwise-valid exchanges, contact support with the negative MDN text — Sterling installations vary widely in their strictness settings.

Peppol networks

  • Peppol requires the signer certificate embedded in signatures — as2d’s default (include_cert_in_signature = true).

Troubleshooting

Start with the logs: every message’s journey is journaled (receivedstored/rejected, mdn-received, delivered, retry-scheduled, …) with the partnership id and message id on every line.

The partner’s message is rejected

The disposition in our negative receipt (and the rejected journal entry) says why:

DispositionMeaningUsual cause
authentication-failedno partnership matched AS2-From/AS2-Toidentifier typo, case mismatch, or the partnership is missing — check both sides’ AS2 names character-for-character
insufficient-message-securitymessage lacked a layer the partnership requirespartner sent unsigned/unencrypted; either they misconfigured, or your security floor is stricter than agreed
decryption-failedcould not decryptpartner encrypted to the wrong (old?) certificate; or an incompatible cipher (CAST5/IDEA with Java partners — use 3DES/AES)
integrity-check-failedsignature did not verifywrong partner certificate installed on our side, or the partner rotated certificates
decompression-failedbad or doubled compressiondouble compression is rejected by design
unsupported format / unsupported MIC-algorithmsreceipt request we cannot honorpartner asked for a signature protocol/digest outside the supported set
unsupported receipt-delivery-optionasync receipt requested but not available on this deploymentdeployment/licensing does not include async MDNs
error-storing-transactionstorage backend failedcheck the storage backend’s health; the partner will retry

Our send fails

  • HTTP errors: 4xx (except 408/429) are treated as permanent — check URL, HTTP auth, and that the partner has your partnership configured. 5xx/408/429/connection errors retry with exponential backoff; watch retry-scheduled and the final delivered or gave-up.
  • gave-up journal entries carry the last error text and attempt count.

Receipts mismatch (mdn-mic-mismatch)

Work through the checklist in MDN receipts — it is almost always non-mirrored security attributes or a counterpart quirk listed in Working with specific products.

The server answers 503 to partners

Check /healthz:

  • degraded: unlicensed / degraded: license expired — see Installing your license.
  • Body ok but 503s under load — you may have hit a configured daily message quota; the 503 carries a Retry-After header and partners’ retry logic rides it out.

The server won’t start

The error printed at startup is specific by design:

  • license invalid → the exact verification failure
  • configuration exceeds entitlements → the partnership and entitlement named
  • storage unreachable → connect errors surface at boot (deliberately — fail at boot, not at the first message)
  • a certificate expired → named alias and dates

Getting help

Include: the journal lines for one affected message id, the negative MDN text (if any), your partnership definition (redact keys/passwords), and both sides’ declared security attributes. That is nearly always enough to diagnose an interop issue on the first round-trip.

config.toml

The server reads one TOML file; its path comes from the AS2D_CONFIG environment variable (default /etc/as2d/config.toml).

listen = "0.0.0.0:8080"          # partner-facing listener

[storage]
kind = "local"                    # "local" | "postgres"
data_dir = "/data"                # local: payloads + state.db; postgres: only used when payloads = "filesystem"
partnerships_file = "/config/partnerships.toml"
keys_dir = "/keys"                # PEM keystore directory

# Required when kind = "postgres":
# [storage.postgres]
# url = "postgres://user:pass@host:5432/db?sslmode=require"
# payloads = "database"           # "database" (multi-replica-safe) | "filesystem"

# Optional overrides, composable with either kind:
# [storage.blob]                  # payloads in Azure Blob Storage
# account = "myaccount"
# account_key = "..."
# container = "as2-payloads"
# endpoint = "https://myaccount.blob.core.windows.net"
#
# [storage.key_vault]             # keys/certs from Azure Key Vault
# vault_url = "https://myvault.vault.azure.net"
# (auth: managed identity by default)

[limits]
max_body_mb = 256                 # maximum accepted HTTP body
decompress_limit_mb = 512         # zip-bomb ceiling for decompression

[license]
path = "/etc/as2d/license.lic"    # the signed license file

[observability]
log_format = "json"               # "json" | "text"
log_level = "info"                # RUST_LOG env var overrides

[observability.metrics]
enabled = false                   # opt-in Prometheus
listen = "0.0.0.0:9090"           # DEDICATED listener — never expose to partners

Field notes:

  • listen — TLS is not terminated here; put your ingress/load balancer in front. Routes served: POST / and POST /as2 (messages, with receipts auto-detected), POST /as2/mdn (asynchronous receipts), GET /healthz.
  • storage.kind = "local" — SQLite + filesystem under data_dir. Single replica only; zero external services.
  • storage.kind = "postgres" — all state shared; required for multiple replicas or scale-to-zero. Any PostgreSQL works; TLS honored via sslmode in the URL. With payloads = "filesystem" payload bytes stay on local disk (single replica or shared volume only).
  • Overrides[storage.blob] and [storage.key_vault] replace just the payload store / keystore and compose with either kind.
  • [license].path — a missing file boots the server in the degraded unlicensed state; an invalid file refuses to boot. See Installing your license.
  • The server validates everything it can at startup — storage connectivity, partnership identifiers, license entitlements — and refuses to boot with a specific message rather than failing on the first partner message.

partnerships.toml

An array of [[partnership]] tables. Every field, with types and defaults:

FieldTypeRequiredMeaning
idstringyesunique configuration key; appears in logs, journal, metrics
local_idstringyesour AS2 identifier for this relationship
remote_idstringyesthe partner’s AS2 identifier
remote_urlstringnopartner endpoint; present = this partnership can send
cert_aliases.localstringyeskeystore alias of our key + certificate
cert_aliases.remotestringyeskeystore alias of the partner’s certificate

AS2 identifiers: 1–128 printable ASCII characters; names containing spaces or quotes are automatically sent quoted per RFC 4130 §6.2 and matched unquoted on receive.

[partnership.security]

FieldTypeDefaultMeaning
signdigest nameunsignedsigning digest: md5, sha-1, sha-224, sha-256, sha-384, sha-512; prefix rsassa-pss- for PSS (e.g. rsassa-pss-sha-256)
encryptcipher nameunencryptedsee Supported algorithms
compress"before-signing" | "after-signing"noneZLIB compression placement
content_transfer_encoding"binary" | "base64" | "quoted-printable""binary"outer MIME encoding of outbound messages

Declared sign/encrypt are also enforced as an inbound floor — see Partnerships.

[partnership.mdn]

FieldTypeDefaultMeaning
requestboolfalserequest a receipt when sending
signed_micalgdigest nameunsigned receiptrequest a signed receipt with this digest
async_urlstringsyncdeliver the receipt asynchronously to this URL

[partnership.http_auth]

FieldTypeMeaning
username / passwordstringHTTP Basic credentials sent when POSTing to this partner (messages and async receipts)

[partnership.interop]

All booleans; defaults in parentheses:

FlagMeaning
include_cert_in_signature (true)embed our certificate in CMS signatures (Peppol requires true)
rfc3851_micalgs (false)RFC 3851 micalg spellings (sha256) on the wire
quote_header_values (false)quote outbound header values
block_error_mdn (false)never send negative receipts to this partner
bc_style_gcm (false)send AES-GCM in the BouncyCastle-compatible encoding
force_decrypt (false)attempt decryption even when the content-type does not announce it
disable_decrypt (false)skip decryption (wins over force_decrypt)
force_verify (false)attempt verification even when unannounced
disable_verify (false)skip verification (wins over force_verify)
disable_decompress (false)skip decompression
verify_use_cert_in_body_part (false)verify against the certificate embedded in the message instead of the pinned one

disable_* flags weaken the inbound security floor for that layer and exist for migrations; prefer removing them once the partner is fixed.

The license file

A .lic file is a short piece of armored text: a human-readable payload (base64 of TOML) plus an Ed25519 signature over it. Validation happens locally against a public key embedded in the as2d binary — no network, no telemetry.

AS2D-LICENSE-V1
KEY-ID: prod-1
<base64 payload>
SIGNATURE
<base64 signature>

The file survives careless handling: line-ending conversion and re-wrapping do not invalidate it. Any content change does.

Payload fields

FieldTypeMeaning
productstringalways "as2d"
idstringlicense serial (quote it in support requests)
customerstringlicensee name
issueddateissue date
type"subscription" | "perpetual"commercial model
expiresdatesubscription only — expiry (14-day grace follows)
maintenance_untildateperpetual only — latest build release date this license activates

[features] (anything omitted is granted)

FieldDefaultMeaning
sendtrueoutbound sending
receivetrueinbound receiving
async_mdntrueasynchronous receipts (both requesting and honoring)
algorithms["all"]permitted encryption algorithms (wire names, see Algorithms)

[limits] (anything omitted is unlimited)

FieldMeaning
messages_per_dayinbound messages per UTC day, per running instance; exhaustion answers 503 + Retry-After until midnight UTC
partnerships_maxmaximum configured partnerships

Enforcement summary

  • Signature/structure checked at startup and on each hourly re-read.
  • Missing file → degraded boot (health answers, AS2 traffic 503s).
  • Invalid file at startup → refuse to boot with the reason.
  • Configuration exceeding entitlements → refuse to boot, naming the partnership and entitlement.
  • Subscription expiry → 14 days of grace with warnings, then inbound 503 and outbound paused (queued work is kept, never dropped).
  • Perpetual + newer build than maintenance_until covers → that build refuses to start; your existing build keeps running forever.
  • Receipts for messages already sent are never blocked by license state.

To inspect a license file you have received, any text tool shows the armor; your vendor can decode and re-verify it from the id if there is ever a question.

Supported algorithms

Signing (RSA)

Config value for security.sign / mdn.signed_micalg:

NameNotes
sha-256recommended default
sha-384, sha-512
sha-224
sha-1legacy partners only
md5legacy partners only
rsassa-pss-sha-256 (also -sha-384, -sha-512, …)RSASSA-PSS variants

On the wire, digest names are emitted in RFC 5751 spelling (sha-256) by default, RFC 3851 spelling (sha256) per partner via interop.rfc3851_micalgs; inbound, all common spellings and aliases are accepted and normalized.

Encryption (RSA key transport)

Config value for security.encrypt (also the wire names used in license features.algorithms):

NameNotes
aes128-cbc, aes192-cbc, aes256-cbcrecommended; universally interoperable
3desubiquitous legacy default; fine for compatibility
aes128-gcm, aes192-gcm, aes256-gcmsee GCM note below
rc2legacy partners only (OpenSSL legacy provider, loaded automatically)
cast5, ideapresent but excluded from product support — see below

The AES-GCM note

RFC 5084 specifies AuthEnvelopedData for GCM; the Java/BouncyCastle AS2 family instead ships GCM in plain EnvelopedData with the tag appended. as2d receives both forms (tag always enforced). Sending defaults to the conformant form; set interop.bc_style_gcm = true for Java-family partners. Some non-Java counterparts support no GCM at all — AES-CBC is the safe universal choice.

Why CAST5 and IDEA are unsupported

BouncyCastle and OpenSSL encode these ciphers’ CMS parameters incompatibly; exchanges fail in both directions with the entire Java AS2 family (measured, not theoretical). The ciphers remain in the binary for the rare non-Java counterpart, but they are outside the support and license scope, and as2d warns at startup when a partnership configures them.

Compression

ZLIB (RFC 3274 / RFC 5402), before-signing (the common form) or after-signing. Inbound double compression is rejected (decompression-failed), and decompression is capped by limits.decompress_limit_mb.

Error dispositions

The disposition field of an MDN describes the outcome. as2d emits and understands the standard set; this table is what as2d sends and what each value means to your partner (and vice versa when reading partners’ receipts).

DispositionClassWhen as2d sends it
processedsuccessmessage fully processed and stored
processed/error: authentication-failederrorAS2-From/AS2-To matched no partnership
processed/error: insufficient-message-securityerrorrequired signing/encryption layer missing (security floor)
processed/error: decryption-failederrorcould not decrypt (wrong certificate, incompatible cipher, corrupt data)
processed/error: integrity-check-failederrorsignature verification failed
processed/error: decompression-failederrorbad ZLIB data, or double compression
processed/error: error-storing-transactionerrorstorage backend failure (retryable by the partner)
processed/error: unexpected-processing-errorerroranything else
failed/Failure: unsupported formatfailurereceipt requested with an unsupported signature protocol
failed/Failure: unsupported MIC-algorithmsfailurereceipt requested with only unsupported digests, marked required
failed/Failure: unable to sign MDNfailuresigned receipt requested but our signing identity is unusable
failed/Failure: unsupported receipt-delivery-optionfailureasynchronous receipt requested but not available on this deployment

Notes for integrators:

  • error vs failure: processed/error means the message failed a processing step; failed/Failure means the receipt request itself could not be honored (RFC 4130 §7.5.3) — the message is not processed in that case either, and the partner should fix the request and resend.
  • Inbound receipts are parsed leniently: folded header lines, bare modifiers (processed/error without text), spelling variations of digest names, and the nonstandard forms some products emit are all accepted and normalized before your journal sees them.
  • A partnership can set interop.block_error_mdn = true to suppress negative receipts to a specific partner (some products react badly to them); rejections are still journaled locally.