A Mule 4 intake for retailer purchase orders: receive an X12 004010 850 over HTTPS, validate it with the X12 connector, always return a 997 (accepting, or rejecting down to the segment and element), map accepted orders with MuleSoft's published Order-to-Cash 850 map, and post them to an order API that is idempotent on the PO number. Run end to end with a good and an over-length-BEG03 850: the bad PO is refused with AK5*R and never reaches the order system. Covers where the official map reads ship-to, control-number collisions in testing, and the Partner Manager path with the deploy failures hit along the way.
Tags: b2b-edi, x12, partner-manager, dataweave, mule-4, order-to-cash, consumer-goods, retail, cloudhub-2
Inbound X12 850 Purchase Orders to an Order API: Validate, Acknowledge with a 997, Map with the Official O2C Map
Deliverable: A Mule 4 intake that receives a retailer's X12 004010 850 over HTTPS, validates it against the standard schema, generates the 997 functional acknowledgment (accepted or rejected, down to the segment and element), maps accepted orders with MuleSoft's published Order-to-Cash 850 map, and posts them to an order API that stores one record per PO. Bad documents are refused with a precise 997 and never reach the order system.
Objective: A supplier's largest retailers send purchase orders as EDI, and most suppliers still run that through a VAN mailbox, a legacy translator and a nightly batch into the ERP. When an 850 is malformed, nobody finds out until a buyer calls about a missing shipment, and the trading partner gets no machine-readable reason. Working looks like this: every 850 is validated on arrival, the partner gets a 997 within seconds that says exactly which element failed, good orders land in the order system as canonical JSON with the PO number as the business key, and replaying the same PO never creates a second order.
What's Getting Built
- X12 intake (Process API, Mule app with the X12 connector) — reads and validates the interchange, writes the 997, maps and forwards accepted 850s ·
POST /x12/850 (Content-Type: application/x12)
- Order API (System API, ERP stand-in or your ERP's order API) — stores the canonical order keyed by PO number ·
POST /orders, GET /orders, GET /orders/{po}, DELETE /orders/{po} (test reset), GET /health
- Retailer inbox (optional, for outbound 855/856/810 testing) ·
POST /retailer-inbox/{doc}, GET /retailer-inbox
- 850 test generator (operator tool) — writes a valid 004010 850, or one with an over-length
BEG03, so the reject path can be shown on demand
Prerequisites
- Your trading partner's interchange and group IDs and qualifiers (for example
ZZ/{partner-id}), plus your own. These go into the X12 connector config, and a mismatch is the first failure every new partner hits.
- The partner's implementation guide for the 850: which segments they send, where they put ship-to (header
N1 loop or line-level PO1/N1 loop), and which REF qualifiers carry vendor and bill-to codes.
- The B2B/EDI: Order to Cash – Mapping Examples asset from Anypoint Exchange (the version used here is 1.1.4). Its
IN-X12-4010-850-to-PO-JSON.dwl map is the starting point; adapt it rather than writing one from scratch.
- Mule runtime 4.9 LTS or later on Java 17, X12 connector 3.x, HTTP connector 1.x. Use Maven 3.8.x with
mule-maven-plugin 4.10.x; Maven 3.10 breaks that plugin version.
- A Connected App (client credentials) for deployment, with the ID and secret kept in environment variables or a vault, never in
pom.xml or the repo.
- If the partner connects through a VAN or AS2 instead of HTTPS, the transport agreement and certificates. These usually take longer to get than the build takes to write.
Choose your path
- Mule app with the X12 connector (built and run here) — Choose this when you want the intake in code, under your normal CI/CD and MUnit, or when you have a handful of partners. Everything below is this path.
- Anypoint Partner Manager (not run end to end) — Choose this when business users will onboard many partners and you want partner profiles, message tracking and replay in a UI. It uses the same X12 schema and accepts the same O2C map. In the test environment behind this blueprint the Partner Manager flow was fully configured but its document-processing service never deployed (see Troubleshooting), so this path is documented here but was not run end to end.
Architecture
1. Configure the X12 connector for one trading pair
- Set
interchangeIdQualifierSelf, interchangeIdSelf and groupIdSelf to your IDs, and the …Partner attributes to the retailer's. Load only the schemas you process: /x12/004010/850.esl and /x12/004010/997.esl.
- Keep one config per trading pair. Sharing one config across partners with different IDs hides envelope errors until production.
- Capture the raw body as a string before
x12:read, so the original interchange can be logged or replayed after parsing changes the payload.
2. Validate, then always build the 997
x12:read returns three things to act on: TransactionSets.v004010.'850' (the sets that passed), Errors (each with segTag, errorText, errorDetails, fatal) and FunctionalAcksGenerated (the 997 data the connector built).
- Write the 997 back out as X12 text with
x12:write and the content {TransactionSets: {v004010: {'997': FunctionalAcksGenerated}}}. Do this for accepted and rejected documents alike. The partner's system expects an acknowledgment either way, and an AK9*A is how they know you have the order.
- Wrap the 997 write in its own
try with on-error-continue. A failure to build the acknowledgment must not lose an order that passed validation; record the failure in the response and the log instead.
- Decide acceptance from the parsed result, not from the HTTP status of anything downstream: accepted means at least one 850 transaction set came through.
3. Map with the official O2C map, and know where it reads ship-to
- Use
IN-X12-4010-850-to-PO-JSON.dwl from the mapping examples as is first, prove it end to end, then adapt it. It emits an array of {B2BMessage: {Header, Data: {PurchaseOrder}}}, with Header.BusinessKey and PurchaseOrder.PONumber both set from BEG03.
- The map reads ship-to from the line-level
N1 loop inside each PO1 loop (position 350, N101 = ST), not from the header N1 loop. If your partner sends ship-to once in the header, every line's ShipToLocation comes out empty. Either adapt the map to fall back to the header loop or agree the placement in the implementation guide.
VendorRefNum and BillToCode default to UNKNOWN when no REF*VR or REF*BT is present. Decide whether UNKNOWN is acceptable to your ERP or should be a rejection.
- Line IDs are
{PO}-{n} in arrival order, which gives the order system a stable key per line.
4. Post to an order API that is idempotent on the PO number
POST /orders accepts the map's array (or a single B2BMessage), takes the PO number from PurchaseOrder.PONumber (falling back to Header.BusinessKey), and stores the record under that key, replacing any earlier copy. That is what makes a resend or a replay safe.
- A resend is safe only when it is the same order. If a PO arrives again with different lines, quantities or prices, flag it for review (compare a hash of the canonical order with the stored one) instead of silently replacing the booked order. Order changes belong in an 860 change order, which is out of scope here.
- Store the derived fields you will query on (status, sender ID, line count, order value from quantity × unit price, receipt time, correlation ID) next to the full canonical order.
- Return
201 with {orderId, po, status}. GET /orders/{po} returns 404 with {error, po} for an unknown PO, which is how the rejected path is proven: the bad PO must not exist.
- If you are posting into a real ERP order API rather than a stand-in, put the same idempotency rule in front of it (look up by customer PO before create). ERPs rarely enforce it themselves.
5. Shape the intake response for operators
- Return
201 for accepted and 422 for rejected, with a JSON body: result (ACCEPTED/REJECTED), receivedAt, errors, the 997 as data and as X12 text, the mapped order, and the order API's status and body.
- Log one line per interchange: result and
ISA13 control number. Do not log the full document at INFO. 850s carry buyer names, emails and ship-to addresses.
6. Security and compliance
- Transport: HTTPS only, with Client ID Enforcement or mutual TLS on the intake if it is exposed beyond your network. Use AS2 with signed MDNs when the partner requires non-repudiation.
- Data: the buyer contact (
PER) and ship-to address are personal and commercial data. Keep full payloads out of standard logs. If you need the original interchange for disputes, store it encrypted with a retention period your legal team agrees.
- Audit: the
ISA13/GS06 control numbers, the 997 you returned and the order API response together are the audit trail for "we received it, we told you, we booked it." Keep all three.
- Secrets: deployment credentials and any partner certificates come from environment variables or a vault. Nothing secret goes in
config.yaml.
Sample payload — rejecting 997 for an over-length BEG03
ISA*00* *00* *ZZ*{SUPPLIER-ID} *ZZ*{RETAILER-ID} *261031*1327*U*00401*000000006*0*P*>~
GS*FA*{SUPPLIER-ID}*{RETAILER-ID}*20261031*1327*1*X*004010~
ST*997*0001~
AK1*PO*132704~
AK2*850*0001~
AK3*BEG*2**8~
AK4*3*324*5*{PO}-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX~
AK5*R*5~
AK9*R*1*1*0~
SE*8*0001~
GE*1*1~
IEA*1*000000006~
The connector reports the same failure as data: segTag: "BEG", errorText: "Data element too long", and errorDetails naming BEG03 with an effective length of 40 against a maximum of 22. The accepted case returns AK9*A*1*1*1, and the order API answers {"orderId": "{prefix}-{PO}", "po": "{PO}", "status": "ACCEPTED"}.
Deterministic checks
- A valid 850 returns
201 from POST /x12/850, the response has result: "ACCEPTED", an empty errors array, and a 997 whose AK9 is A with the received, accepted and included counts all equal to the number of sets sent.
- An 850 with
BEG03 longer than 22 characters returns 422, result: "REJECTED", a 997 with AK5*R and AK9*R, an AK3 naming segment BEG, and an AK4 naming element 3. GET /orders/{bad-po} then returns 404.
- For an accepted three-line 850,
GET /orders/{po} returns lines: 3, and orderValue equals the sum of PO102 × PO104 across the lines.
- Posting the same 850 twice leaves exactly one record for that PO in
GET /orders.
- Posting the same PO again with a changed quantity is flagged for review, and the booked order is not silently replaced.
Header.BusinessKey, PurchaseOrder.PONumber and the stored po are all equal to the sent BEG03.
- MUnit: one test per case above with the 850 files as fixtures and the order API mocked at the HTTP request, plus a test that a failing 997 write still forwards an accepted order.
Testing and validation
- Run a good and a bad 850 back to back against the deployed intake and the real order API, then read both POs back. That one run proves the happy path, the reject path and that rejects never reach the order system.
- The edge case that breaks naive builds is the control number. If your test generator derives
ISA13 from the clock, two documents sent in the same second share it and the second can be treated as a duplicate interchange. Wait at least a second between test sends, or use a counter.
- Prove the mapping, not just that it runs: compare every mapped field for one real partner 850 against the implementation guide, especially ship-to, units of measure (
PO103) and item qualifiers (BP buyer part, UP UPC).
Phased rollout
Phase 1 — Connect
- Deploy the intake and the order API to a sandbox, configure one trading pair, and prove a generated 850 returns an accepting 997.
Phase 2 — Map and validate
- Run three to five of the partner's real historical 850s through the intake. Fix ship-to placement,
REF qualifiers and units in the map. Agree with the partner how they want the 997 delivered (the HTTP response, a callback, or AS2).
Phase 3 — Production
- Cut one partner over from the existing translator, run both in parallel for a week and compare order counts per day, then add partners one config at a time.
Success metrics
- Time from 850 receipt to order booked, compared with the current batch schedule.
- Share of 850s rejected on arrival, and the average time for the partner to correct and resend one.
- Duplicate orders created from resends (target zero).
- Buyer or customer-service tickets about orders "not received" that turn out to be EDI failures.
Troubleshooting
- Every line has an empty ship-to — Likely cause: the partner sends ship-to in the header
N1 loop and the O2C map reads the line-level loop. Fix: add a header fallback in POLineLocation, or agree line-level placement with the partner.
- Everything is rejected with envelope errors — Likely cause: the interchange or group IDs or qualifiers in the connector config don't match the
ISA/GS the partner sends (including padding to 15 characters in ISA06/ISA08). Fix: copy the values from a real partner interchange.
mule-maven-plugin fails during build or deploy — Likely cause: Maven 3.10 with plugin 4.10.x. Fix: build with Maven 3.8.x. On CloudHub 2.0, publish the app to Exchange (mvn clean deploy) before mule:deploy, or the deploy returns 404.
- Partner Manager: the flow deploy stays in
DRAFT and the document-processing service shows UNDEPLOYED — Likely cause: the Partner Manager environment is still on an out-of-support Mule runtime (4.6 standard support ended in August 2026). Fix: set the environment runtime to 4.9 LTS, run Apply environment settings, then redeploy the flow. In the environment behind this blueprint that upgrade failed repeatedly with "Upgrade process failed unexpectedly. Please retry." If you hit the same thing, open a support case and run the Mule-app path meanwhile; the schema and map are the same.
- Partner Manager: the flow won't deploy at all — Likely cause: no payload storage endpoint. Fix: deploy a Content Storage API implementation and register it as a host endpoint with role
STORAGE_API before deploying any flow.
- Partner Manager: "Invalid message type assigned as Source/Target" — Likely cause: the message type was created on the trading partner instead of the host, or with the wrong role. All of a flow's message types live on the host partner, and a message type's role can't be changed afterwards (
409). Fix: recreate it on the host with the right direction and role.
- Partner Manager: HTTPS send endpoint rejected on create — Likely cause: a missing TLS context or disallowed characters in the description. Fix: set
tlsContext (an empty object accepts default trust), use only letters, digits, spaces and &!,.'_- in descriptions, and give host-side target endpoints INTERNAL visibility.
- Partner Manager sandbox caps the partners you can deploy — Likely cause: sandbox entitlements (one deployed trading partner in the environment used here). Fix: check the entitlement before planning a multi-partner sandbox test.