Skip to main content

mock-edi

CI Python 3.8+ License: MIT PyPI

A mock EDI trading partner. Not an EDI library and not an AS2 server — the thing on the other end. Send it an 850 and it sends back a 997, then an 855 that answers line by line, then an 856 with a shipment tree, then an 810 that bills what shipped. Send it an EDIFACT ORDERS and the same thing happens in CONTRL / ORDRSP / DESADV / INVOIC.

   you ──850──▶  mock-edi
       ◀──997──  the syntax parsed
       ◀──855──  2 lines: one confirmed, one short
       ◀──856──  shipment / order / item, with a tracking number
       ◀──810──  1132.80, terms 2% 10 net 30

There is plenty of open source for speaking EDI — OpenAS2 and mendelson will terminate an AS2 connection, and a dozen libraries will parse an X12 segment. What none of them is, is a counterparty. To test the code that runs when an 856 arrives unannounced, or when the invoice comes twice, or when the acknowledgment never comes at all, you need a partner that does those things on demand. Real ones do them on their own schedule, and getting one to do it deliberately is a support ticket and a fortnight.

  • Zero dependencies. Python 3.8+ standard library and SQLite, nothing else. It installs in a locked-down CI image.
  • Real wire shapes. A 106-character fixed-width ISA that declares its own delimiters, EDIFACT's ? release character, composite elements, TDS with two implied decimals, HL parent pointers, AK3/AK4 error codes, MDNs with a Received-Content-MIC.
  • Failure on demand. Short shipments, rejected lines, refused orders, duplicate invoices, a strict partner, and a partner that never answers — each one PATCH away.
  • It validates its own output. Every document the mock writes is checked against the same dictionary it checks yours against. There is a test for it.

MIT licensed. ASC X12 and UN/EDIFACT are standards published by their respective bodies; AS2 is RFC 4130. This project implements publicly documented wire formats for testing purposes and is not affiliated with or endorsed by any standards body or vendor.

By Rick Seufert. The projects page has this mock, mock-sap, mock-bank and the worked examples that use them together.


Quick start

pip install mock-edi
mock-edi --port 8080
curl -X POST --data-binary @order.edi http://127.0.0.1:8080/edi
{
  "accepted": true,
  "partner": "ACME",
  "dialect": "X12",
  "orders": ["4500000042"],
  "transactionSets": [
    {"code": "850", "control": "0001", "kind": "order", "accepted": true, "findings": []}
  ],
  "queued": [
    {"kind": "acknowledgment", "code": "997", "reference": "000000077", "dueAt": "..."},
    {"kind": "response",       "code": "855", "reference": "4500000042", "dueAt": "..."},
    {"kind": "despatch",       "code": "856", "reference": "4500000042", "dueAt": "..."},
    {"kind": "invoice",        "code": "810", "reference": "4500000042", "dueAt": "..."}
  ]
}

Then collect what it sent you:

curl "http://127.0.0.1:8080/_mock/mailbox?raw"
ISA*00*          *00*          *ZZ*MOCKEDI        *ZZ*ACME           *260924*1030*U*00401*000000001*0*T*>~
GS*PR*MOCKEDI*ACME*20260924*1030*2*X*004010~
ST*855*0002~
BAK*00*AD*4500000042*20260924****5100002*20260924~
...
PO1*1*100*EA*12.50**VP*WIDGET-001*UP*076123400003~
ACK*IA*100*EA*068*20260926~

Run it from a checkout with no install at all, or in a container:

python3 -m mockedi --port 8080
docker build -t mock-edi . && docker run -p 8080:8080 mock-edi

The container binds 0.0.0.0, because 127.0.0.1 inside a container is unreachable from outside it. On anything but your own machine, give it a password — the control plane can reset the database, rewrite the partners and read every archived document, and the mock says so on startup if you have not:

docker run -p 8080:8080 mock-edi --auth edi:s3cret

A guided tour of every endpoint, in curl:

bash examples/demo.sh

Driving it from a test

The control plane is plain HTTP and JSON so that any language can drive it. From Python, the package ships the client rather than leaving you to write it:

from mockedi.testing import Mock

with Mock.start(invoice_delay_ms=3600_000) as mock:   # own port, in this process
    summary = mock.send(order)                        # dialect sniffed from the bytes
    assert summary["accepted"]

    response = mock.document(partner="ACME", kind="response")
    assert response.find("ACK").get(1) == "IA"        # no splitting on ~

    assert mock.order("4500000042")["status"] == "confirmed"
    mock.settle()                                     # nothing left undelivered

A document also reads as a model, when the assertion is about what the seller said rather than about one segment:

response = mock.document(partner="ACME", kind="response").as_response()
assert response.short[0].short_by == 40          # confirmed 60 of 100

invoice = mock.document(partner="ACME", kind="invoice").as_invoice()
assert invoice.line_total == invoice.total       # the supplier's own arithmetic

as_order, as_response, as_change_response, as_despatch and as_invoice cover every business document in both dialects, and they are forgiving about how the other side writes one — which is the point, since the documents your partner sends are not the ones this mock writes.

Two mocks wired to each other — a seller and a buyer — are a conversation rather than a request, and one call runs it to a stop:

seller.exchange(buyer)

Settling one mock leaves the other holding work, and a document that is only promised — a delayed invoice, which does not exist yet — is never ready however often you settle. exchange moves each clock and settles each mock until a whole pass changes nothing, and raises with what each side still holds rather than hanging. Pass advance=False for a test about when something arrives rather than about what.

Mock("http://host:9000") talks to one that is already running, wherever it is. Mock.start(**config) starts one on a port the OS picks and stops it again, and takes the same keywords as the command line.

A call that comes back with an unexpected status raises MockError carrying what the mock said, because a refusal here names the field, the flag or the finding. mock.get(...) and friends return the status instead, for a test that is about the refusal.

Still no dependencies. tests/support.py in this repository is written on top of it, which is the only test of such a thing that means anything.

And an example of the code it exists to test: examples/po_bridge.py sends SAP purchase orders as 850s and posts the 855s back into SAP, and examples/test_po_bridge.py tests it against this mock and mock-sap. The other half of the same integration lives in mock-sap: examples/invoice_check.py checks this mock's 810 invoices against the purchase order and the 856 ship notice before posting them into SAP, and its tests cover a short shipment, a price disagreement and the duplicate-invoice behaviour.

Both are walked through, test by test, in Testing an SAP-to-EDI Integration Without SAP or a Trading Partner.

What it serves

Surface Endpoint
AS2 inbound POST /as2 — answers with an MDN, synchronous or asynchronous
Asynchronous MDN inbound POST /as2/mdn — a partner's receipt for something the mock sent
Plain EDI inbound POST /edi — the same pipeline, answering with a JSON summary
Validate only POST /_mock/validate — findings, and nothing changed
Mailbox GET /_mock/mailbox — collect what is waiting; ?leave to peek, ?raw for payloads
Outbox GET /_mock/outbox — the queue, including what is not due yet
Release the queue POST /_mock/advance — ?seconds=N or ?all
Send out of band POST /_mock/send — replay an invoice or a despatch advice, or send one unprompted; "shipment" names the consignment when an order shipped more than once, and the latest is meant without it
Partners GET/POST /_mock/partners, GET/PATCH/DELETE /_mock/partners/<id>, GET/PUT/DELETE /_mock/partners/<id>/profile
Orders GET /_mock/orders, GET /_mock/orders/<po>, ?partner= when two partners hold the number
Buying POST /_mock/purchase, POST /_mock/purchase/<po>/change
Where a supplier disagrees GET /_mock/disagreements?partner=&po=
Remittance advices received GET /_mock/remittances?partner=
Archive GET /_mock/documents, GET /_mock/interchanges, GET /_mock/interchanges/<id>?raw
Receipts GET /_mock/mdns
Outstanding documents GET /_mock/unacknowledged?older-than=60
Work promised, not done GET /_mock/scheduled
Directory trading GET /_mock/drop, POST /_mock/drop/scan
The dictionary GET /_mock/dictionary, /_mock/dictionary/X12/850, /_mock/dictionary/X12/envelope
Health and state GET /_mock/health, GET /_mock/state, GET /_mock/requests
Reset POST /_mock/reset
Index page GET /

A request is matched on its method and its path exactly. The method is the one in the table: a read sent with POST, or an action sent with GET, is a 405 that says what to send and carries an Allow header. The path is the one in the table, segment for segment: a trailing slash, a segment too many or a path that only begins with /_mock is a 404 that lists what there is. The three doors also answer under the names a partner's AS2 software may have been configured with: /as2/, /as2/receive, /as2/mdn/ and /edi/.

Every GET also answers HEAD, for a liveness probe, and OPTIONS answers with the methods the mock takes. A query value that cannot be read - ?seconds=abc, ?limit=ten - is a 400 naming the parameter. A 500 is always a bug: its traceback goes to stderr, even with -q.

The documents

Business document X12 EDIFACT
Purchase order 850 ORDERS
Purchase order response 855 ORDRSP
Purchase order change 860 ORDCHG
Change acknowledgment 865 ORDRSP
Despatch advice / ship notice 856 DESADV
Invoice 810 INVOIC
Remittance advice, received 820 REMADV
Syntax acknowledgment 997 CONTRL

Both dialects are read and written from one dictionary (mockedi/schema.py), and one pipeline drives both, so what you assert about an X12 flow holds for the EDIFACT one. GET /_mock/dictionary/X12/850 serves that dictionary as JSON — the actual rules, not a description of them that can go stale.

A set runs from ST to SE, or UNH to UNT. What goes round it belongs to no set, so it is served once for each dialect: GET /_mock/dictionary/X12/envelope is ISA, GS, GE and IEA, and /_mock/dictionary/EDIFACT/envelope is UNA, UNB, UNG, UNE and UNZ, each described as any other segment is, in the order they are on the wire, with a level (interchange or group) and a role (header, trailer, or advice for UNA). Every set's entry names its envelope's path, so the whole of one interchange can be read from two requests. A set, version or dialect the mock does not have is a 404 saying which.

Versions. X12 004010 and 005010, and EDIFACT D.96A. An X12 set is read against the version its group's GS08 names — an industry suffix such as 004010VICS or 005010X222A1 is the same version — and served that way at /_mock/dictionary/X12/850?version=005010. The sets are declared at 004010, and 005010 is recorded as what differs: ST03, AK103 and AK203, and data element 127, Reference Identification, widened from 30 characters to 50 - wherever it is used, since a length belongs to the element: REF02, BAK08, TRN02, RMR02 and the rest. A group in any other version is refused in its 997 with AK905 = 2, functional group version not supported, rather than read by rules its sender never used, and a partner cannot be set to a version the mock would write on the wire without having the dictionary for it.

A 997 at every version, not a 999. A 005010 partner may prefer a 999, but a 999 reports conformance to an implementation guide — the TR3 named in ST03 — and the mock checks documents against the base standard, not against any guide. A 999 from it would claim a check it never made; the 997 says exactly what it did.

A remittance advice is sent to the mock, as the payee, and answered with a 997 or CONTRL like anything else it receives. An 820 whose BPR01 instructs a bank - D, P, U or X - is a payment order, a bank's document, and is refused by name rather than half-read; I and C are the remittance uses. An advice is filed under its TRN02 trace number, or its BGM number. Whether its total is the sum of what it says was paid is not a syntax question - an advice that disagrees with itself can still be read, and is acknowledged - so a total that does not add up is a disagreement beside the 997, with the same shape as a supplier's (remittance-total-not-parts), in the summary and at GET /_mock/disagreements. BPR02 is checked against the RMR04 amounts plus any ADX directly in an ENT loop - a deduction not tied to one invoice, which is part of what was paid - and the MOA+12 after UNS against each DOC's MOA+12. That is how a remittance still claiming an invoice whose payment came back gets caught before a supplier starts dunning for it.

Two more findings are about when. An 820 whose BPR16 effective date is still ahead of the mock's clock is remitted-before-settlement: the payee is being told to reconcile cash that has not arrived, and /_mock/advance moves what counts as early. And an 820 with BPR03 = D reverses the advice with the same TRN02 trace - the correction a payer owes once the bank returns a payment - so GET /_mock/remittances lists that earlier advice as reversed and names the one that reversed it; a debit for a trace never advised is a reversal-of-nothing. An advice left advised after its payment came back is the failure to test for. Both read the 820 only: which REMADV date is the value date, and how one REMADV reverses another, vary too much between guides to guess.

Coverage is the commonly traded core of each set, not the full standard. A real 850 admits some fifty segment types and almost nobody sends more than a dozen; the mock implements the dozen, validates them properly, and reports an unrecognised segment rather than pretending to understand it.

The same goes inside a segment, and the dictionary says where the line is. SAC has sixteen elements in 004010 and the five the mock declares carry almost every real allowance — so every segment also reports the width the standard gives it beside the checkedTo the mock validates. A position between the two is carried and not checked: your SAC15 description is not wrong, it is untested. A position beyond the width is reported as error 3, because there the element really does not exist.

Money

Every amount is rounded to the cent, half up: 0.625 is 0.63. That covers a line's extended amount, tax, an order's or invoice's total, and the integer TDS carries, and it is the same for a negative amount, which rounds the same distance from zero. A line is rounded before it is added, so a total is the sum of what each line says. 12.50 at --tax-rate 0.05 is TXI*ST*0.63.

A unit price is not an amount and is not rounded. It is stored, written and compared exactly as it was given, with at least two decimals: 12.5 is 12.50 and 0.125 stays 0.125, in PO104, POC06, IT104 and PRI. A thousand at 0.125 is an order for 125.00, and a supplier confirming at 0.124 is a price-differs between 0.125 and 0.124.

Timestamps

Every timestamp the control plane returns has one shape: UTC, second precision, trailing Z.

{"due_at": "2026-09-25T08:34:19Z", "at": "2026-09-25T07:34:19Z"}

That is not cosmetic. The mock compares these as strings when it decides what is due and what has gone unacknowledged, so they have to be produced by one formatter to sort correctly — and a test driving the Docker image, which runs on UTC, is usually not on UTC itself. A due_at in the host's local time with no zone on it is wrong by the host's offset, in whichever direction the host happens to be: the kind of failure that passes on a laptop and fails in CI.

The dates on the wire are the opposite case and stay as they are. ISA09/10, GS04/05 and UNB S004 carry no zone and are the sender's local time by the standards' long convention, so they are written as the host's clock reads them. So is every date and time inside a document - BAK09, BSN03/04, BIG01, an ORDRSP's DTM+137 - which is the mock's clock read in the host's zone, so a document and its envelope always name the same day. A date a partner sends without a zone is read the same way: BPR16 is compared with the day the mock would write today.

One order, one conversation

When a test fails, the question is always the same: what actually happened to this order?

curl -s http://127.0.0.1:8080/_mock/orders/4500000042/timeline
received 850 0001 (order)
order recorded: 2 line(s), 1416.00 USD
despatch promised, due 2026-09-26T21:14:19Z
invoice promised, due 2026-09-26T22:14:19Z (not done yet)
packed SHP8000002: 2 line(s), 6 carton(s), United Parcel Service
sent 997 0001 (acknowledgment) accepting your 850 0001; delivery delivered
sent 855 0002 (response); delivery delivered after 2 attempts
the partner's receipt accepted our 855 0002

Both directions, in order, with findings inline, whether each document was delivered and how many attempts it took, and what the seller promised but has not done yet. Every event carries the structured form beside that prose, so a test can assert on it. ?raw adds the payloads, which makes one call enough to attach to a bug report.

An acknowledgment the mock sent says what it is for. Its event carries answers: the envelope it answers, its own verdict on all of it (AK901, the UCI action or TA104), and each set it names with that set's code, control number and verdict. A TA1 answers an envelope and no set, so its sets is empty. The event's own interchange is still the envelope the acknowledgment travelled in.

Nothing new is recorded — this is the same rows /_mock/documents, /_mock/outbox and /_mock/scheduled return, sorted into the sequence they happened in.

Partner behaviours

Five partners are seeded. The mock sells to four customers - ACME, GLOBEX, INITECH and EURODIS - and buys from one supplier, NORTHWIND. Change any of them at runtime:

curl -X PATCH -H 'Content-Type: application/json' \
     -d '{"behaviour":"short-ship"}' \
     http://127.0.0.1:8080/_mock/partners/ACME

POST /_mock/partners adds a partner and only ever adds one: for an id that is already there it answers 409 and changes nothing. PATCH is how an existing partner is changed, and it changes only the fields it is given.

A body of the wrong shape is the caller's mistake and is answered as one: a null, a list or a number where a field is text, a line that is not an object, or a body that is not a JSON object at all, is a 400 that names the field and says what it was given - id must be a string, not a number - on /_mock/partners, /_mock/purchase and /_mock/send alike.

A partner is refused anything the mock could not then act on: an unknown field is named rather than dropped, a version has to match the dialect (004010 or D:96A:UN), test is a flag, as2_url needs a scheme the courier can use, and an id has to fit the envelope — fifteen characters for X12, because ISA06 is fixed at that width and a longer one would be truncated to something the partner could never be found by. It may use only letters, digits, and ., - or _ between them: narrower than the standards allow, because an id also becomes a pickup filename and part of a URL, and one with a / in it once wrote documents outside --pickup-dir.

A partner's role is what it is to the mock: a customer the mock sells to, the default and every partner there was before, or a supplier it buys from. A behaviour that only a seller can have - one that changes what the mock does with an order it received, such as short-ship or no-invoice - is refused on a supplier, and so is a role change that would strand such a behaviour. The Partner column below says which roles each behaviour fits: the ones that change how the mock answers, or damage what it sends, fit both. The mock places orders with a supplier; see Buying from a supplier.

A role change is refused, too, while the partner holds an order that is not finished - invoiced, cancelled or rejected - in either direction, and the refusal names those orders. A partner's role and the direction of its orders say the same thing: a supplier holds what the mock placed, a customer what it received, and the mock reads the pair as one fact when it decides whose order it is acting on. Let the orders finish, or DELETE the partner and POST it again, which says what becomes of its work. Orders that are already finished stay, read-only, as history - nothing will act on them again, so nothing can act on them wrongly, and they are still the evidence of what the mock did.

Behaviour Partner What the partner does
accept customer or supplier Confirms everything in full and ships what was ordered.
short-ship customer Confirms less than was ordered (855 IQ, ORDRSP QTY+83), and ships and invoices the confirmed quantity.
reject-line customer Refuses one line outright (IR) and leaves it out of the shipment and the invoice.
reject-all customer Acknowledges the syntax, then refuses the order (BAK RD, every line detailed as IR).
no-ack customer or supplier Says nothing at all. No 997, no 855. For testing your chase-up timer — the failure that actually costs money.
duplicate-invoice customer Sends the invoice twice with the same invoice number, as a partner with a retry bug does.
strict customer or supplier Rejects a transaction set for any finding, not only a fatal one.
out-of-order customer Sends the invoice before the despatch advice and the order response after both — 997, 810, 856, 855 — which is where a buyer's matching usually breaks. The documents still describe one consignment.
late customer or supplier Answers everything an hour late, on top of any configured delay: after the chase-up window rather than never. POST /_mock/advance?seconds=3601 brings it in.
corrupt customer or supplier Sends every business document with its trailer count (SE01 or UNT) one out, so your translator's own 997 or CONTRL has something to reject. That one fault and no other; its acknowledgments are sound.
reject-ack customer or supplier Rejects every transaction set in its 997 or CONTRL (AK5*R with no reason, UCM action 4), however clean, and acts on none of them — a partner whose translator is misconfigured.
no-invoice customer Ships and never invoices, so a three-way match has to give up waiting.
duplicate-order supplier Sends the order twice under one PO number, a moment apart, as a buyer with a retry bug does. The mirror of duplicate-invoice, and what a supplier's own duplicate handling should be tested against.
change-after-confirm supplier Sends an 860 lowering a line the moment the 855 confirming it arrives: a change that lands after the order was answered rather than before.
cancel-late supplier Sends an 860 cancelling the order the moment the 856 arrives - after the goods have left - which a supplier must refuse rather than honour.

Some rules apply whatever the behaviour says, because they are what real sellers actually do. The first that fires wins:

  1. A quantity of zero or less is rejected (IR). Nothing downstream can make sense of a line that asks for nothing.
  2. An item that is not in the catalogue is rejected (IR). The most common real rejection there is.
  3. The partner's behaviour, above.
  4. Confirmed is capped at what is in stock — IQ when it falls short, IB when there is none. This cap outranks the price rule, so a line that is both short and mispriced comes back IQ with the price named in its reason rather than changed in silence.
  5. A price the seller disagrees with is billed at the seller's price and flagged IP. Price discrepancies are the commonest EDI dispute there is.

Timing

By default every document is released the moment it is produced, so a test can POST an order and read four documents back on the next line. Give them delays when what you are testing is the waiting:

mock-edi --ack-delay 2000 --response-delay 30000 --invoice-delay 86400000

Nothing is released on a timer of its own. POST /_mock/advance?all releases whatever is queued, whenever it was due — a test that has to sleep is slow and flaky, and one that advances the clock is neither. It moves the mock's clock as far as it has to and no further: the clock steps to each due time in turn, the work due then is done and stamped then, and the clock is left at the last of them. With no delays nothing is waiting and the clock does not move.

The mock has one clock. Every timestamp about the conversation - the at on an order, a document, a shipment, an invoice, a promise, an acknowledgment, and every dueAt - is read from it, so nothing is stamped before the promise it keeps was due. Two things stay on the host's clock, because they are about the process and not the conversation: the request log (/_mock/requests) and started in /_mock/health.

POST /_mock/advance?seconds=N moves the mock's clock forward by N seconds and releases whatever that makes due. The clock stays moved: two advances of 60 release a document due in 90, and everything written afterwards - document dates, due times, timestamps, MDN dates - is dated by the moved clock, and /_mock/unacknowledged?older-than= and --retention-days count from it. The response says where the clock is (clock) and how far it has been moved in all (advancedSeconds). It only goes forward, and /_mock/reset puts it back. On a --db file the advance is kept with the data, so a mock that is restarted comes back as far ahead as it was stopped, and nothing it stamps afterwards is earlier than what the file already holds. It can be at most 100 years (3,153,600,000 seconds) ahead in total: an advance that would take it further is a 400 that says how much room is left, and leaves the clock where it was. A parameter the endpoint does not take is a 400 as well, naming the ones it does - seconds, all, failed, and partner with failed - so ?days=30 is refused and told what it is in seconds, where it used to answer 200 and move nothing.

AS2

curl -X POST --data-binary @order.edi \
  -H 'Content-Type: application/edi-x12' \
  -H 'AS2-From: ACME' -H 'AS2-To: MOCKEDI' \
  -H 'Message-ID: <po-1@acme.example>' \
  -H 'Disposition-Notification-To: edi@acme.example' \
  -H 'Disposition-Notification-Options: signed-receipt-protocol=optional, pkcs7-signature; signed-receipt-micalg=optional, sha256' \
  http://127.0.0.1:8080/as2

comes back as a multipart/report MDN with the MIC of what arrived:

Disposition: automatic-action/MDN-sent-automatically; processed
Received-Content-MIC: +H1EWvEMSJH/IHGsjy7c/dviFRwLgRoGBmxnTEbMkGA=, sha256

Name a Receipt-Delivery-Option and the response is 202 with the MDN posted back to that URL instead, carrying the headers it was built with — including the MIME boundary, without which no MIME library can read it.

S/MIME is deliberately not implemented. Signing and encrypting AS2 payloads needs certificates and a cryptography library, and this project has no dependencies on purpose. A message that arrives encrypted or signed is refused with an MDN saying exactly that, rather than being mangled. If your integration must be tested against signed AS2, this mock is the wrong tool and will tell you so on the first message.

That applies to the receipt as well. signed-receipt-protocol=required says the sender will not accept an unsigned MDN, so it is answered with a failed/Failure MDN saying the mock cannot sign, and the interchange is not read — RFC 4130 asks for a failure here, not an unsigned success the sender has already said it cannot use. =optional is answered normally.

A body may arrive with a Content-Length or chunked, as an AS2 client streaming a large interchange sends it. What cannot be read is refused rather than guessed at, and the connection is closed so the next request on it is not parsed out of the leftovers: a length that is not a number is 400, a body over --max-body (16 MiB) is 413 before it is read, another transfer coding is 501, and a body that stops arriving for --request-timeout seconds (60) is 408.

Making the mock come to you

A partner with no as2_url is a mailbox. Give one a URL and the mock stops being something you poll and becomes something that arrives:

curl -X PATCH -H 'Content-Type: application/json' \
     -d '{"as2_url":"http://localhost:9000/as2"}' \
     http://127.0.0.1:8080/_mock/partners/ACME

Documents are then POSTed to your listener with AS2 headers, in the order they were queued, and whatever MDN you return is recorded against them in /_mock/outbox.

A delivery that failed can be tried again

A partner's listener restarts between the 997 and the 855, and the mock has four documents in flight. The 997 fails; the rest arrive. The partner now holds a response, a ship notice and an invoice for an order whose acknowledgment it never received.

curl -X POST http://127.0.0.1:8080/_mock/outbox/3/retry     # one document
curl -X POST "http://127.0.0.1:8080/_mock/advance?failed"   # everything that failed

The same bytes and the same control numbers go out again, in the order they were queued. That is a retry, not a resend: /_mock/send builds a new document with a new control number, which is a different event on the wire — and being idempotent about a control number it has already seen is exactly the thing a listener has to get right.

/_mock/outbox carries the history: attempts, last_error and last_attempt_at, so a document delivered on the second try says so.

Nothing retries on a timer. A test that wants a retry asks for one, the same reason /_mock/advance exists.

Changing an order

A buyer changes an order it has already placed with an 860 (or an ORDCHG, or an 850 or ORDERS restated as a change), and the seller answers with an 865. EDIFACT has no separate change acknowledgment message, so an ORDCHG is answered by an ORDRSP — the difference most likely to catch out someone porting a mapping from X12.

A restated 850 (BEG01 = 04 or 05) is read as the whole order: a line it leaves out is deleted, and the 865 says so with DI — or refuses, if that line has already shipped. For 05 (Replace) that is the only reading; for 04 it is the mock's choice, because an 850 has no other way to drop a line. To change some lines and leave the rest alone, send an 860. BEG01 = 01 cancels the order, as an 860 with BCH01 = 01 does.

An ORDERS says the same in BGM's message function code, 1225, in EDIFACT's own values: 1 cancels, 4 changes and 5 replaces.

A restated order is never a new one. One that cancels, changes or replaces an order the mock does not hold is refused beside the acknowledgment, as an 860 for an unknown order is: the 997 or CONTRL accepts it, refusals says no such purchase order, and nothing is recorded, answered, shipped or billed. A cancellation that arrives before its order, or instead of it, would otherwise be fulfilled as the order it withdraws.

POC*1*QD*60**EA*12.50**VP*WIDGET-001~     the buyer wants 60, not 100
ACK*IA*60*EA*068*20260926~                 the seller agrees
POC*2*DI*40**EA*4.15**VP*BRKT-050~         the buyer drops line 2
ACK*IR*0*EA~
REF*ZZ**Line deleted at the buyer's request~

A change cannot unmake what has already happened. A quantity cannot go below what shipped, a shipped line cannot be deleted, an order that shipped cannot be cancelled, and an order that has been invoiced cannot be changed at all. A refused line comes back IR with the reason, and the order keeps what it had — reporting the order's state instead would tell the buyer its request succeeded.

Nor promise what will never happen. Between despatch and invoice a quantity can still be raised and a line added, and what the 865 confirms then ships as a second consignment: its own 856 carrying the difference, and its own 810 naming it. Every consignment is billed by one invoice, so an order that shipped twice is invoiced twice — which is the buyer-side matching worth testing. A change that revives a cancelled order is packed and billed the same way. The second consignment is advised before it is billed: when the first invoice comes due before the new despatch, it bills what has shipped, and the second consignment gets an invoice of its own after its 856.

Give yourself a window. A change is only meaningful before the goods leave, and with every delay at zero the order is invoiced before the POST returns, so every change would be refused. That is correct behaviour, not a limitation to work around:

mock-edi --despatch-delay 3600000 --invoice-delay 3600000

Delays postpone the work, not merely the posting. A despatch that is not due yet has not been packed, so a change arriving in the meantime affects it — which is the whole point, and why GET /_mock/scheduled shows work promised but not done, separately from /_mock/outbox, which shows documents that already exist.

Buying from a supplier

The mock sells to its customers and buys from its suppliers - a partner whose role is supplier, such as the seeded NORTHWIND. It places an order when told to; nothing is ordered on a timer.

curl -X POST localhost:8080/_mock/purchase -d '{
  "partner": "NORTHWIND", "po_number": "4500001001", "requested_on": "2026-10-15",
  "lines": [{"sku": "WIDGET-001", "quantity": "100", "uom": "EA", "price": "12.50"}]}'

The order is stored with direction: placed, and an 850 (an ORDERS for an EDIFACT supplier) goes out like anything else the mock writes: through the delays, to the partner's as2_url or the pickup directory, and into /_mock/mailbox. With no po_number the mock numbers the order from its own range. POST /_mock/purchase/<po>/change sends an 860 or ORDCHG: {"lines": [{"line": "1", "quantity": "80"}]}, with action add or delete on a line, or {"cancel": true}.

An order or change the document could not carry is refused with a 400 before anything is stored or sent: a PO number longer than BEG03 allows, a currency or unit that is not one of the element's codes. The check is the mock's own validator run on the document it was about to send, in that supplier's dialect, so it says exactly what /_mock/validate would have said about the bytes afterwards - and each problem names the field of the request it came from: currency 'DOLLARS': CUR02 is 7 characters, the maximum is 3.

What the supplier sends back - an 855, 856, 810 or 865, or their EDIFACT counterparts - is accepted, acknowledged and filed under the order it names, and /_mock/orders/<po>/timeline shows the whole exchange from the order going out. The receipt lists these under filed. A document naming an order the mock never placed with that supplier is rejected in the 997 or CONTRL, at the element holding the order number: with no order there is nothing to hold it against. A supplier's 850 is refused as a customer's 855 is, as the wrong document for the relationship.

Where a supplier disagrees

What the supplier says is kept document by document and compared against what the mock asked for. Where they disagree is a business finding, not a syntax finding: the 997 or CONTRL is exactly what it would have been for a document that agreed, and the set is accepted. A buyer that receives a well-formed 855 confirming 120 against an order for 100 acknowledges it and takes the dispute up elsewhere, and a mock that bounced it would teach a supplier's integration something false.

The rules for an 855, ORDRSP or 865, each one comparison naming both numbers:

Rule When
confirmed-unknown-line a line the order does not have
confirmed-more more than was ordered
confirmed-less less than was ordered, a refused line included; the sentence says whether the supplier gave a reason, or claimed IA
price-differs a price other than the ordered one, even with IP
substituted a different item on the line, without IS

And for an 856 or DESADV, against the order and the 855, with quantities counted across every consignment so far rather than one document at a time, because a split delivery is ordinary:

Rule When
shipped-before-confirmed no 855 has arrived for the order
shipped-unknown-line a consignment against a line the order does not have
shipped-ambiguous-item a consignment with no line number naming an item the order has on more than one line; it is counted against none of them
shipped-more-than-confirmed more shipped than the latest 855 confirmed, a line confirmed at nothing included
shipped-more-than-ordered more shipped than was ordered; both quantity rules can fire on one line, since exceeding a promise and exceeding an order are different things to say

And for an 810 or INVOIC, the three-way match against the order, the 855 and the 856:

Rule When
billed-before-shipped no 856 has arrived for the order
billed-more-than-shipped a line billed, over every invoice so far, beyond what shipped
price-not-agreed a price that is neither the ordered nor the confirmed one
total-not-lines MOA+79 is not the sum of the lines, or the total (TDS01, MOA+139) is not the lines plus allowances and charges (SAC, ALC) plus tax; not judged when an allowance gives only a percentage
invoice-repeated an invoice number already received for the order; it is counted once
billed-cancelled the order was cancelled

They are reported in four places: the receipt's disagreements, beside findings; /_mock/orders/<po>, which for a placed order shows ordered, confirmed, shipped and billed per line; the timeline, on the event of the document that said it; and GET /_mock/disagreements?partner=&po=.

One file, several interchanges

A file from a VAN or an SFTP drop often holds more than one interchange, one after another. Every one of them is read: each is its own envelope, with its own control number, its own acknowledgment and its own verdict, so one being refused says nothing about the rest. Each may even declare its own delimiters, because the VAN concatenated what its senders gave it and they need not agree.

The summary keeps the shape it has for a single interchange — the keys beside interchanges describe the whole payload, and interchanges describes each one in turn:

{
  "accepted": true,
  "orders": ["4500000042", "4500000043"],
  "interchanges": [
    {"interchange": "000000077", "accepted": true, "orders": ["4500000042"], "...": "..."},
    {"interchange": "000000078", "accepted": true, "orders": ["4500000043"], "...": "..."}
  ]
}

An interchange that is refused carries its own error and leaves the others alone; the payload as a whole is accepted only when every interchange in it was. Over AS2 one MDN answers the whole file, and says processed only when all of it was. A dropped file is filed as processed on the same terms.

An order belongs to the partner that placed it

A purchase order number is not an identity: real numbers are unique per buyer, and two customers may both use 4500000042. The mock keeps an order by its partner and its number, so both are held, each answered, packed and billed on its own, and neither's documents touch the other's.

Where a URL names an order by number, ?partner= says whose:

curl -s 'http://127.0.0.1:8080/_mock/orders/4500000042?partner=GLOBEX'

Without it the number has to be unambiguous. When two partners hold it, /_mock/orders/<po> and its /timeline answer 409, naming both, rather than guessing. /_mock/orders lists every order, and mock.order(po, partner=...) in mockedi.testing takes the same choice.

A change, a cancellation or a restated 850 is about the sender's own order. One naming a number the sender holds no order under is answered exactly as one against an order that does not exist, in the same words, because a customer has no business learning which numbers its competitors use. The same partner may send a number again while the order is still only received, and it replaces its own order as it always has. The despatch and invoice the first was promised are withdrawn - /_mock/scheduled?all shows them closed with the note order restated - and the restated order is promised its own, so the goods are packed, advised and billed once.

Once that order has been acted on - shipped, invoiced, cancelled or refused - the number is spoken for, and a fresh 850 under it is refused with an 855 that says order number already in use. A buyer with a retry bug sends the order again as an original, not as a change, so nothing above catches it, and replacing an order that has already been fulfilled starts the fulfilment again: the mock would ship and bill the whole order twice. A change that arrives as a restated 850 still works, because BEG01 says 04 or 05 and that is read as the change it is. --allow-duplicates turns this off with the rest of the duplicate refusals.

The one number a partner cannot reuse is that of an order the mock placed with it as a supplier, should it later become a customer: that order is the mock's own, and an 850 on its number is refused with an 855 that says order number already in use.

A replayed interchange is refused

A retry bug on the sender's side is ordinary, and processing a duplicate order is expensive. An interchange control number a partner has used before is refused in the envelope's own words — a TA1 with note code 025, duplicate interchange control number, or a CONTRL whose UCI carries 0085 = 26, duplicate detected.

TA1*000000077*260925*0820*R*025~

Nothing behind a refused envelope is read: no 997, no 855, and the order it carried is not shipped and invoiced a second time. The replay is still archived — refusing it is not forgetting it.

Control numbers belong to a pair of partners, so another partner may use the same one. --allow-duplicates turns the check off for a test that wants the older behaviour of replacing the order.

Acknowledgments, both ways

The mock sends a 997 for everything it receives — except a 997, which is never acknowledged with another (nor a CONTRL with a CONTRL); two systems that both did that would answer each other for ever. It also reads one for everything it sends, which is what makes the most expensive EDI failure testable: nobody acknowledged my invoice.

curl "http://127.0.0.1:8080/_mock/unacknowledged?older-than=60"
[
  {"code": "810", "kind": "invoice", "reference": "4500000042",
   "group_control": "4", "control": "0004", "partner": "ACME", "at": "..."}
]

Send a 997 back and the document it names is marked with the verdict:

{"acknowledged": [
  {"code": "810", "control": "0004", "matched": true, "status": "rejected",
   "note": "BIG at segment 2: Segment has data element errors; element 4: Invalid code value ('BADPO')"}
]}

The two dialects address what they are acknowledging differently, and both are matched properly. X12 names a transaction set inside a functional group — AK102 quotes GS06, AK202 quotes ST02, and both are needed because ST02 is only unique within its group. EDIFACT names a message inside an interchange, with UCI01 quoting UNB's control reference and UCM01 quoting UNH01.

Either can also answer for everything at once, and most translators do when nothing went wrong: a 997 of AK1 and AK9 with no AK2 loop marks every set of that functional group with AK901's verdict (only sets of the group AK101 names — an AK1*PR answers 855s), and a CONTRL with UCI and no UCM marks every message in the interchange it quotes.

An acknowledgment naming something the mock never sent comes back "matched": false rather than being silently dropped — it is real and common, and usually evidence of the bug you are looking for.

Set a partner to no-ack and nothing is ever acknowledged, so /_mock/unacknowledged keeps filling up. That is the point.

Trading over a directory

Not all EDI is AS2. A great deal of it is still a folder: the partner writes a file into it, you pick the file up; you write a file back, they pick it up.

mock-edi --drop-dir ./edi/in --pickup-dir ./edi/out

Anything dropped in ./edi/in goes through the same pipeline a POST does, and the answers are written into ./edi/out as <partner>-<code>-<control>.edi — written to a temporary name and renamed, so nothing watching the directory ever sees a half-written file. Nothing already there is overwritten: after a /_mock/reset the control numbers start again, so a name can recur before the first file is collected, and the new one is written as ...-1.edi instead and listed under renamed in GET /_mock/drop.

Two things every directory integration meets are handled rather than left to bite. A file still being written is not read: anything modified within --drop-settle-ms is left for the next pass, and .tmp, .part and dotfiles are never read at all. A file that has been read is not read again: it is moved into processed/, or into failed/ if it could not be read — moved rather than deleted, because a mock that eats the evidence is no use when a test fails. While it is being read it is renamed <name>.processing, so the poller and a manual scan cannot both take it. And if it cannot be moved — a read-only processed/, say — it keeps its name, is listed under stuck in GET /_mock/drop and on stderr, and is not read again until it changes.

The poller runs every --drop-interval-ms, but it is not the only way in:

curl -X POST http://127.0.0.1:8080/_mock/drop/scan

scans once and returns what it found, so a test never has to wait for a poll interval — the same reason /_mock/advance exists.

Validation

Every inbound document is checked against the dictionary, and the findings become a real 997 or CONTRL — AK3/AK4 with X12 error codes, UCS/UCD with EDIFACT ones. AK404 quotes the offending value back, up to 99 characters, unless it holds a control character: a 997 may not carry one either, so the copy is left out and AK401–AK403 say which element and why. The CONTRL uses 0085's own word where it has one: 39 and 40 for an element too long or too short, 37 for a letter in a number, and in the UCM 29 or 28 for a UNT that miscounts or names another message and 14 for a message type the mock does not know, with the service segment named. UCI says 4 only when the interchange itself is at fault; a sound one carrying a refused message is 7, with the 4 on that message's UCM.

An EDIFACT date is read by the format it states. DTM's third component says what the second is: 102 a date, 203 a date and a time, 204 with seconds, 101 with a two-digit year - and the mock takes the date from each. A value that is not what its format says, 2026-10-10 called 102 or eight digits called 203, is reported on 2380 as an invalid date (0085 code 12 in the CONTRL) and is not read; a DTM that states no format is read by its length.

The mock sells, so it refuses what a seller sends. An 855, 856, 810 or 865 - or an ORDRSP, DESADV or INVOIC - arriving from a partner is rejected the way a real translator rejects a set its relationship with that partner does not process: AK5*R*1 or UCM 0085 = 14, not supported, with the reason in the findings. It is archived, rejected, and not filed under the PO number it names. A supplier-side integration pointed here by mistake is told so rather than handed a clean 997 for its ASN.

Ask for the findings as prose instead:

curl -X POST --data-binary @broken.edi http://127.0.0.1:8080/_mock/validate
{
  "clean": false,
  "groupCode": "R",
  "explain": [
    "850/0001: rejected",
    "  BEG at segment 2: ZZ is not a code BEG01 accepts (00, 01, 04, 05, 06, 07, ...)",
    "  BEG at segment 2: BEG05 is not a valid date: '2026-09-24'",
    "  PO1 at segment 3 in the PO1 loop: PO102 must be a number, got 'ten'",
    "  SE01 counts 99 segments, the message holds 6 (4)"
  ]
}

Severity is the mock's own policy, and it is stated rather than implied. A fatal finding rejects the transaction set — an unknown set, a missing mandatory segment or element, a control number that does not match its trailer, a segment count that does not add up, a line number used twice in one order (the standards allow it; almost no implementation guide does). Everything else is accepted with errors noted: an invalid code, a length violation, a malformed date, a segment the set does not define. A partner set to strict rejects on either.

The envelope is checked too, and a fault there outranks anything inside it. A GE whose count or control number disagrees with what arrived, or a group with no GE at all, rejects the group: AK9*R with the standard's reason in AK905. AK902 quotes the count the sender's own GE01 gave and AK903 says how many sets arrived, so a miscount shows which way it was out. Every set inside it is then AK5*R as well, however clean it was - no set in a rejected group is acted on, so none is acknowledged as accepted - and a set with a fault of its own keeps its AK3/AK4. An IEA that disagrees with the ISA, or a file that stops before its IEA, rejects the whole interchange, and the answer is a TA1 with TA104 = R and no 997, because no group inside it was read. A TA1 also comes back whenever ISA14 = 1 asks for one - kind=interchange-acknowledgment in the mailbox. An ISA element off its fixed width is noted (TA104 = E) but the interchange is still read; that is the mock's choice, since receivers differ. On the EDIFACT side a UNZ that miscounts, names another interchange or never arrives rejects the interchange in the CONTRL's UCI, with 0085 codes 29, 28 and 13.

Two limits, stated plainly: loop membership and repetition counts are checked but loop sequence is not, and conditional requirements ("if PO104 is present then PO103 must be") are not modelled. Both would need a rule language to express, and the mock would rather leave them out than pretend.

A partner's implementation guide

The standard allows more than any one partner accepts. Their guide makes REF*IA mandatory, never uses PO4, allows EA and CS and nothing else, gives REF02 fifteen characters — and a document that satisfies 004010 but not the guide is the one that bounces in a real onboarding. Give a partner a profile and the mock bounces it too:

curl -X PUT -H 'Content-Type: application/json' \
     http://127.0.0.1:8080/_mock/partners/ACME/profile -d '{
  "name": "Acme 850 guide v2",
  "sets": {"850": {
    "require": ["REF"],
    "forbid": ["PO1/PO4"],
    "segments": {
      "REF": {"elements": {"1": {"codes": ["IA"]}, "2": {"maxLength": 15}}},
      "PO1": {"elements": {"3": {"codes": ["EA", "CS"]}}}},
    "loops": {"PO1": {"repeat": 100}}}}}'

A profile is a narrowing of the dictionary and nothing else: a segment or loop required or forbidden, a segment's uses or a loop's repeats lowered, a code list cut down, an element shortened or made mandatory. A segment is named by where it is — REF is the header's, PO1/REF the one on each line — because a guide that requires the header's REF*IA says nothing about a line's REF*VN. One that would widen the dictionary — a code it does not have, a longer maximum, a mandatory segment forbidden — is refused when it is loaded, with every reason at once; accepting more than 004010 does is another version.

What the guide rejects, the 997 rejects: the set is refused, with the standard's codes (AK304 = 3 for the missing REF, 2 for the PO4, AK403 = 7 for the DZ), and the finding says whose rule it broke — ACME's guide 'Acme 850 guide v2': REF01 .... 005010 has implementation codes of its own for these; the mock uses the standard codes, and the words. The profile is kept with the partner, so it survives a restart on --db; a file loads the same way (curl -T guide.json). GET /_mock/dictionary/X12/850?partner=ACME serves the rules as the guide narrows them, and POST /_mock/validate?partner=ACME checks a document against them before you send it.

Configuration

Everything is a command-line flag; mock-edi --help prints the same list. With none at all the mock is a throwaway partner on 127.0.0.1:8080, holding everything in memory.

Where it listens and what it keeps

Flag What it is for
--host ADDRESS The bind address (default 127.0.0.1). 0.0.0.0 to reach it from another machine or a container.
--port N The port (default 8080).
--db PATH A SQLite file instead of :memory:. Partners, orders, the archive, the control-number ranges and how far the clock was advanced then survive a restart, and documents left undelivered are picked up again; an older file is upgraded in place when it is opened.
-q, --quiet No access log.
--version Print the version and exit.

Who the mock is on the wire

Flag What it is for
--as2-id ID Its interchange and AS2 identifier (default MOCKEDI): ISA08, UNB recipient, AS2-To.
--name NAME Its company name in N1 / NAD (default Mock EDI Supply Co).
--qualifier Q Its interchange id qualifier, ISA07 (default ZZ).

How it behaves

Flag What it is for
--ack-delay, --response-delay, --despatch-delay, --invoice-delay MS Hold each document back; see Timing.
--tax-rate RATE Tax on invoices, as a fraction: 0.0825 (default 0). Checked at startup: anything that is not a number from 0 to 1 is refused there, so 8.25 is not billed as 825%.
--allow-duplicates Accept a control number a partner has used before; see A replayed interchange is refused.
--no-mdn Never return an MDN, whatever the sender asks for - for testing a client that waits for one.
--any-receiver Accept an interchange addressed to someone other than --as2-id. The usual first-run failure is an interchange addressed to the real partner's id; this is the switch for it.
--compact Write documents without a newline after each segment.

Trading over a directory - --drop-dir, --pickup-dir, --drop-interval-ms and --drop-settle-ms; see Trading over a directory.

Testing the unhappy paths

Flag What it is for
--auth USER:PASSWORD Require HTTP basic authentication on every request, control plane included - before pointing a shared staging environment at it. Binding a non-loopback address without it prints a warning. The password may hold any characters; a client may send it as UTF-8 or as Latin-1.
--deliver-to HOST[,HOST] Hosts the courier may POST to; anywhere by default. The mock posts released documents to whatever as2_url a partner carries, and asynchronous MDNs to whatever Receipt-Delivery-Option an AS2 sender names - and /as2 cannot require authentication and still be AS2. This says which hosts are allowed: a partner as2_url outside the list is refused by the control plane with a 400, a document already bound for one fails without being posted, and a Receipt-Delivery-Option outside it is refused with a failure MDN.
--latency-ms MS Add a delay to every request.
--error-rate FRACTION Answer that fraction of requests with a 500, for a client's retry logic. Only the trading endpoints are failed - never anything under /_mock/.
--seed N Seed for the demo data and for --error-rate's choices (default 42), so a run can be repeated exactly.
--no-request-log Keep requests out of the request_log table, and out of /_mock/requests.

Keeping it bounded - --keep-requests and --retention-days, for a mock left running on a --db file; see Leaving it running.

Layout

mockedi/schema.py        elements, segments, loops, transaction sets  (add shapes here)
mockedi/envelope.py      the shape both dialects share, and delimiter handling
mockedi/x12.py           reading and writing ASC X12 interchanges
mockedi/edifact.py       reading and writing UN/EDIFACT interchanges
mockedi/charsets.py      which character set a document is in, bytes and back
mockedi/validate.py      checking a document against the dictionary
mockedi/ack.py           turning findings into a 997 or a CONTRL
mockedi/reconcile.py     reading an acknowledgment for something we sent
mockedi/claims.py        what a supplier says about a placed order, and where it disagrees
mockedi/remittance.py    what a remittance advice says that does not add up
mockedi/timeline.py      one order's whole conversation, in order
mockedi/testing.py       the client a test drives the mock with
mockedi/transactions.py  business documents in, business documents out
mockedi/documents.py     what the seller decides, and the shipment and invoice
mockedi/partners.py      who we trade with, and how each one misbehaves
mockedi/profiles.py      a partner's implementation guide, as a narrowing of the dictionary
mockedi/pipeline.py      the choreography: an order in, four documents back
mockedi/delivery.py      posting to a partner that has somewhere to receive
mockedi/as2.py           AS2 headers, the MIC, and the MDN
mockedi/drop.py          trading over a directory rather than over HTTP
mockedi/db.py            SQLite: schema, number ranges, demo data
mockedi/money.py         the one rounding rule: to the cent, half up
mockedi/server.py        HTTP: the handler and the mock's state
mockedi/routes/__init__.py  the route table every endpoint registers with
mockedi/routes/transport.py the doors: /as2, /as2/mdn and /edi
mockedi/routes/index.py     the index page
mockedi/routes/control.py   health, state, reset, and the 404 for an unknown /_mock path
mockedi/routes/validate.py  /_mock/validate and the dictionary served as data
mockedi/routes/orders.py    orders, their timelines, purchasing, disagreements
mockedi/routes/documents.py documents, interchanges and MDNs as they crossed the wire
mockedi/routes/partners.py  partners, their guides, and the catalog
mockedi/routes/mailbox.py   the mailbox, the outbox and the drop directory
mockedi/routes/clock.py     scheduled work, advance, send, and what is unacknowledged

docs/ARCHITECTURE.md explains how they fit together; docs/FILES.md is an index of every file in the repository.

Tests

python3 -m unittest discover -s tests -v

Every test talks to a real mock over real HTTP. Nothing is stubbed. The most valuable one is in tests/test_dictionary.py: every document the mock generates is validated against the same dictionary it validates yours with, so the day someone adds a segment to a writer and forgets the definition, the suite says so.

Extending it

Add a segment or a transaction set in schema.py and it is parsed, validated and published in /_mock/dictionary without touching anything else. Add a behaviour in documents.decide(). Add an endpoint in mockedi/routes/. CONTRIBUTING.md says where each kind of change goes and what a good pull request carries.

See also

mock-sap — the same idea for SAP: OData V2 and V4, BAPI/RFC and IDoc shapes over SQLite, also with zero dependencies. An IDoc ORDERS05 and an X12 850 are the same business document, so the two mocks make a reasonable pair of ends for testing a middleware layer. examples/po_bridge.py is one, and mock-sap's examples/invoice_check.py is another.

Testing an SAP-to-EDI Integration Without SAP or a Trading Partner uses the two mocks together: purchase orders out and confirmations in, then invoices checked against what was ordered and shipped, with the failure modes each test exercises.

mock-bank is the third: a mock bank that takes an ISO 20022 pain.001 payment file and answers with a pain.002 status report, a camt.054 debit notification and a camt.053 statement, on a bank clock a test can move. It is the payment that follows an approved invoice, the one an 810 from this mock asks for; a payment_run example joining it to mock-sap is planned for its 0.2 release.

rickseufert.com lists all three mocks side by side, with the worked examples and how to run each one.

Metadata

Release files for mock-edi 0.7.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 mock-edi 0.7.0
File Size Uploaded
mock_edi-0.7.0.tar.gz 535.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for mock-edi 0.7.0
File Interpreter ABI Platform
mock_edi-0.7.0-py3-none-any.whl Python 3 none any Details

Total release size: 784.7 kB

Release files / mock_edi-0.7.0.tar.gz

Download URL mock_edi-0.7.0.tar.gz
Size 535.7 kB
Tags Source
SHA-256 checksum
How to use checksums
6cc68f7d52378032ba1ac152dc67c63df054b3ed0d7349216aa12e0d9c512230
BLAKE2b-256 checksum
How to use checksums
565aedf15a89a2768e7fb4e8385ac52b90b76ef8d529eebe7938f45ab3102a13
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 Oct 2, 2026.

Transparency log

Release files / mock_edi-0.7.0-py3-none-any.whl

Download URL mock_edi-0.7.0-py3-none-any.whl
Size 248.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
52c009764fb9f1c7a24cec879a0d5c90454ea231ff7177b469e62b975f4829a4
BLAKE2b-256 checksum
How to use checksums
dd1d90288eaa1022afe8e20ed9bff2476ba2bd43d5ddd035cde7759f29d5e048
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 Oct 2, 2026.

Transparency log

Release history Release notifications | RSS feed

0.9.0

2 release files

0.8.0

2 release files

This release

0.7.0 This release

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.0

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