Skip to main content

x12-doctor

Your 850 got rejected. This tells you why — in plain English.

A trading partner sends back a 997 that says AK3*PO1*8**8 and AK4*3*355*7, and somebody has to work out that the eighth segment of the transaction set had an invalid code value in element 3. Or an order silently never arrives and the only clue is an SE01 that was correct before someone added a segment. This tool reads X12 documents that are already wrong and says what is wrong with them, where, why documents end up that way, and what to change.

Built by Adeloi, an engineering partner for industrial suppliers and manufacturers.

CI PyPI Python License: Apache 2.0

Status: Beta. 28 diagnoses across the 004010 order-to-invoice flow.


See it in action

A purchase order that a partner rejected:

$ x12doctor check rejected_po.edi
rejected_po.edi — 850 Purchase Order (004010)

  WARN   B01  segment 10 (PO1), element 03
         Unit of measure 'EACH' is not an X12 code. Did you mean 'EA'?
         Why this happens: The value came straight from an ERP field that
         stores display text, so a spelled-out unit passes internal
         validation — internally it was never a code at all.
         Fix: Map the internal unit to its X12 code at the boundary and
         reject unmapped values instead of passing them through.

  ERROR  A02  segment 13 (SE), element 01
         SE01 says 12 segments, but ST through SE actually contains 11 — 1
         fewer than declared.
         Why this happens: The count is written when the transaction set is
         assembled, then a segment is added or removed afterwards — a
         conditional REF, an extra line item, a stripped-out empty segment —
         and the count is never recalculated.
         Fix: Count ST through SE inclusive and write that number into SE01.
         Compute it after the transaction set is complete, never before.

2 findings (1 error, 1 warning)

And the 997 that came back, read together with the order it rejected:

$ x12doctor explain rejected.997 --original original.850
rejected.997 — 997 Functional Acknowledgment

  Rejected

  Group 837337 (PO) — rejected
         0 of 1 transaction sets accepted.

    850 0001 — rejected
         segment 8 of the transaction set (PO1): segment has data element
         errors.
           The segment reads: PO1*1*228*EACH*220.64*PE*BP*AX-31566
           element 03: invalid code value — the value was 'EACH' (data
           element 355).

  Positions above were resolved against the original document.

That last line is the point. A 997 on its own reports positions, not content. Paired with the document it acknowledges, "element 3 of segment 8" becomes EACH, which is a thing you can go and fix.


Contents

Install

pipx install x12-doctor      # as a command line tool
pip install x12-doctor       # as a library

Python 3.11 or later. No runtime dependencies.

Quickstart

x12doctor check order.edi                    # diagnose one document
cat order.edi | x12doctor check -            # read from a pipe
x12doctor check order.edi --format json      # for CI and further processing
x12doctor explain ack.997 --original order.edi
x12doctor scan ./outbound --missing-997      # a whole directory
x12doctor codes B01                          # explain one diagnosis

In Python:

from x12doctor import diagnose

report = diagnose(open("order.edi", "rb").read())

if not report.ok:
    for finding in report.errors:
        print(finding.code, finding.location, finding.message)

What this is

  • A diagnosis, not a verdict. Every finding says what is wrong, where it is, why real integrations produce it, and what to change. A code and a line number is not an answer.
  • A 997 decoder. The AK error codes are numbers with no meaning in the file itself. This carries the 004010 code lists and pairs an acknowledgment with the document it rejected.
  • Tolerant. The files this exists for are already broken. Nothing here raises on malformed input — a document too damaged to parse comes back as a report saying exactly that.
  • Deterministic. The same bytes always produce the same report, and the JSON output is stable and versioned, so it can be diffed and gated on.
  • Zero runtime dependencies. Standard library only, which is what makes it safe to drop into a build pipeline that has to run for a decade.

What this is NOT

  • Not a generator. For realistic test documents — including the eighteen ways they break in production — see @adeloi/edi-fixtures, which is the sister project this one is tested against.
  • Not a repair tool. It diagnoses and never rewrites. Silently correcting a production document is how a wrong order becomes a wrong order nobody can trace.
  • Not a parser framework. It reads X12 only as far as a diagnosis needs. For general parsing see node-x12 or the Stedi ecosystem.
  • Not a mapper or translator.
  • Not a source of trading-partner implementation guides. Guides for specific partners are proprietary. PartnerProfile gives you a generic, configurable profile instead.
  • Not EDIFACT, TRADACOMS, or HIPAA transactions (270/271/837/…).
  • Not an AS2/SFTP/VAN transport client.

The diagnostic catalog

Twenty-eight diagnoses in four categories. x12doctor codes lists them all; x12doctor codes A02 explains one in full. The complete reference is in docs/diagnostics.md.

A — Structure & envelope A00 not X12 · A01 ISA not 106 characters · A02 SE segment count · A03 control numbers do not match · A04 duplicate interchange control · A05 nonstandard separators · A06 GE transaction set count · A07 IEA group count · A08 unexpected version · A09 content outside the interchange · A10 envelope not closed · A11 envelope out of order

B — Semantics & content B01 nonstandard unit of measure · B02 suspected implied decimals · B03 wrong date format · B04 CTT totals · B05 missing partner reference · B06 missing required segment

C — Acknowledgments (997) C01 997 reports errors · C02 997 references an unknown group · C03 997 rejected the document · C04 rejection located in the original · C05 no acknowledgment received · C06 interchanges out of order

D — Advance ship notices (856) D01 broken HL hierarchy · D02 invalid SSCC-18 check digit · D03 shipped quantity does not match the order · D04 implausible dates

Eighteen of these mirror a fault in @adeloi/edi-fixtures one for one, and the test suite asserts that correspondence in both directions.

Commands

x12doctor check FILE...        diagnose documents; '-' reads standard input
x12doctor explain FILE         translate a 997; --original resolves positions
x12doctor scan DIR             diagnose a directory, including cross-file checks
x12doctor codes [CODE]         show the catalog, or explain one diagnosis

Shared options: --format human|json, --partner FILE, --fail-on error|warning|info, --brief, --no-color.

Three diagnoses can only be made across files, and so are only reachable via scan: a duplicate control number needs a second document to be a duplicate of, a missing acknowledgment is defined by absence, and an out-of-order flow is a property of the sequence.

scan --missing-997 reports every functional group with no matching acknowledgment. Without the flag only groups whose ISA14 is 1 are held to it, because that field is literally the one that asks to be acknowledged.

Exit codes

Code Meaning
0 no findings at or above the --fail-on threshold
1 findings at or above the threshold
2 a file could not be read, or is not X12 at all

The 2 is deliberately distinct: a CI job needs to tell "this document has problems" apart from "this is not a document".

- name: Validate outbound EDI
  run: x12doctor scan ./outbound --fail-on warning --format json > edi-report.json

Partner profiles

Most of what a trading partner rejects is not in the X12 standard — it is in their implementation guide. A profile carries those rules:

# grainger.toml
name = "Example Partner"
isa_qualifier = "ZZ"
sender_id = "SENDERID"
receiver_id = "RECEIVERID"
required_refs = ["DP", "IA"]
uom_whitelist = ["EA", "CS", "BX"]
usage_indicator = "P"
version = "004010"

[required_refs_by_doc]
856 = ["BM"]

[required_segments]
850 = ["FOB"]
x12doctor check order.edi --partner grainger.toml

The fields mirror definePartner() in @adeloi/edi-fixtures, so the same profile shape describes a partner in both projects.

JSON works too, and is the better choice when the profile is generated rather than hand-written — emitted by a provisioning script, pulled from a config service, or shared with a system that has no TOML reader:

x12doctor check order.edi --partner partners/grainger.json

The format is chosen by the file extension; the resulting profile is identical.

Python API

from x12doctor import diagnose, explain_997, scan, PartnerProfile

report = diagnose(raw_bytes)
report.ok            # False when anything is an error
report.doc_type      # "850" | "855" | "856" | "810" | "997" | "unknown"
report.findings      # list[Finding]
report.to_json()     # stable, versioned, diffable

finding = report.findings[0]
finding.code         # "A02"
finding.severity     # "error" | "warning" | "info"
finding.segment_index, finding.segment_id, finding.element, finding.line
finding.message      # what is wrong, with this document's values in it
finding.why          # why integrations produce this
finding.hint         # what to change

ack = explain_997(fa_bytes, original=po_bytes)
ack.status           # "accepted" | "accepted_with_errors" | "rejected" | ...
ack.rejections       # located AK3/AK4 objections, mapped onto the original

result = scan("./outbound", partner=PartnerProfile.load("grainger.toml"))
result.entries       # per-file reports
result.findings      # the cross-file findings

Testing with edi-fixtures

The two projects are designed to be used together: one generates documents that are broken in a specific realistic way, the other proves your pipeline notices. A pytest plugin ships with the package and needs no wiring:

from x12doctor.pytest_plugin import assert_valid_x12, assert_diagnoses

def test_our_order_writer_emits_valid_x12():
    assert_valid_x12(build_purchase_order(order))

def test_we_reject_spelled_out_units():
    assert_diagnoses(document_from_fixtures, "B01", exactly=True)

The corpus under test/corpus is generated by edi-fixtures at fixed seeds and committed, so the test suite needs no Node. tools/build-corpus.mjs rebuilds it. Each filename states the diagnosis the file must produce:

850_seed42_A02_seCountWrong.edi
^^^ ^^^^^^ ^^^ ^^^^^^^^^^^^
|   |      |   `- the edi-fixtures fault that produced it
|   |      `----- the x12-doctor code it must be diagnosed with
|   `------------ the seed, so the content is reproducible
`---------------- the transaction set

The most important assertion in the suite is the boring one: every clean document must produce zero findings. A linter that cries wolf gets switched off, and then it catches nothing.

Contributing

Issues and pull requests are welcome. A new diagnosis needs an entry in src/x12doctor/catalog.py — the single source of truth that docs/diagnostics.md and x12doctor codes are both generated from — plus a corpus file that triggers it and a clean file that does not.

python -m pytest          # the suite, including the golden corpus
python -m mypy            # strict
python -m ruff check .
python tools/gen-docs.py  # regenerate docs/diagnostics.md from the catalog

License

Apache License 2.0. Copyright 2026 Olevis LLC.

Metadata

Release files for x12-doctor 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for x12-doctor 0.1.0
File Size Uploaded
x12_doctor-0.1.0.tar.gz 111.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for x12-doctor 0.1.0
File Interpreter ABI Platform
x12_doctor-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 196.7 kB

Release files / x12_doctor-0.1.0.tar.gz

Download URL x12_doctor-0.1.0.tar.gz
Size 111.8 kB
Tags Source
SHA-256 checksum
How to use checksums
fe4a2d8505b16b2ea115d47b04de53d9ebe66d25570f77aece53982c9d757e39
BLAKE2b-256 checksum
How to use checksums
5ea4ce3db361ad3aa62279b2abdb6ff946fd87d74f5bf83cb4b72fa8055853e4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 15, 2026.

Transparency log

Release files / x12_doctor-0.1.0-py3-none-any.whl

Download URL x12_doctor-0.1.0-py3-none-any.whl
Size 85.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
fd62b5ad74309fa4102442d5c1c771359c902f7e81efb8176bfce9467bc7bdaa
BLAKE2b-256 checksum
How to use checksums
dececb77cc81df440d6d0eae7b0bdfa07c0109d7d82bbba2c3a8404d7632ab6f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 15, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page