All updates

Tediware's JSON Format Explained, Part 1: Readable Keys and Values

Adrian Duyzer

Adrian Duyzer

This is the first of three posts about Tediware’s JSON format, i.e. the JSON Tediware produces when you use it to convert EDI to JSON.

As an EDI platform, the X12 EDI format is, of course, native to Tediware. But EDI was originally designed for an era when bandwidth was at a premium, and as a result it is notoriously complex and opaque.

Much of the information communicated in an EDI document is actually contained in other documents (like code lists); even worse, the structure of an EDI document - i.e. loops and hierarchy - is invisible.

Using JSON solves these problems, which is why Tediware’s customers send JSON to the platform and receive JSON when inbound documents arrive in Tediware.

There are many different ways EDI can be converted to JSON, including some that produce garbage (the subject of a different upcoming blog post). In these posts, I’ll walk through the design of Tediware’s JSON format and explain its benefits. This post covers keys and values. The second covers structure: tables, loops, repeats, and composites. The third is about the 856 and other documents built on hierarchical levels, which get their own treatment.

Note: The first version of Tediware’s JSON format was loosely based on Stedi’s “JEDI” format. It has since diverged in certain key respects (e.g. with how it handles composites), but I wanted to acknowledge the important work that Stedi did in the early 2020s in this area. I am uncertain what Stedi’s JSON format is like today.

The Format

Rather than describe the format in the abstract, I will work from an example. Here is the start of a real purchase order from a large retailer:

ST*850*0001~
BEG*00*SA*536213576**20260428~
CUR*X6*CAD~
REF*X9*3747044980~
REF*IA*70003548~
N1*ST*L'ACADIE - 7146*93*7146~
N3*1000 RUE SAUVE OUEST~
N4*MONTREAL*QC*H4N 3L5*CA~

And here is what Tediware does with the BEG and CUR segments:

"beginning_segment_for_purchase_order_BEG": {
  "transaction_set_purpose_code_01": "original_00",
  "purchase_order_type_code_02": "stand_alone_order_SA",
  "purchase_order_number_03": "536213576",
  "date_05": "20260428"
},
"currency_CUR": {
  "entity_identifier_code_01": "international_organization_X6",
  "currency_code_02": "CAD"
}

Here’s what you’re seeing and why.

Keys carry the name and the position

Every segment key is the segment’s name from the X12 standard, lowercased and underscored, with the segment ID on the end: beginning_segment_for_purchase_order_BEG. Every element key is the element’s name with its position on the end: purchase_order_number_03.

The name is there so that a developer who has never opened an X12 spec can read the document. If you are given purchase_order_number_03 you know what it is. If you are given BEG03 you have to look it up: and looking things up is a pain.

The ID and the position are there for the opposite reader. When your trading partner’s implementation guide says “we require BEG05” or “put the store number in N1-04”, you need to find that element, and you need to find it without guessing which of the several English names for a concept the standard happened to use.

Searching for _04 inside the name_N1 object gets you there. It also means the keys survive the standard’s own inconsistencies. The 850 has three elements called Product/Service ID in the PO1 segment, at positions 07, 09, and 13. With the position in the key they are three distinct fields, product_service_id_07, product_service_id_09, product_service_id_13, and each is next to the qualifier that says what kind of ID it is.

The names come from the X12 reference data for the release named in the envelope, so an 004010 document and an 005010 document get the names each release actually uses.

Coded values arrive decoded

BEG01 is a Transaction Set Purpose Code, and the value on the wire is 00. Tediware writes it as original_00. BEG02 is SA on the wire and stand_alone_order_SA in the JSON. CUR01 is X6, which is translated as international_organization_X6.

The rule is: if an element is an ID type and the standard publishes a code list for it, the description is prepended to the code, with the code kept as the suffix. Codes that are not in the standard’s list, like a partner’s own store number in N1-04, pass through untouched.

There are consequences of keeping the code as the suffix: first, the value is still unambiguous. Descriptions get abbreviated and two codes can have similar descriptions, but _SA is _SA. Second, if your code only cares about the code, matching on the end of the string works and is stable:

const isShipTo = party.name_N1.entity_identifier_code_01.endsWith("_ST");

I considered emitting the code alone and putting the description somewhere else, and I considered emitting an object with code and description keys. Both make the common case worse. The common case is a developer reading a payload in a log or a debugger and trying to understand what a document says. ship_to_ST reads at a glance. {"code": "ST", "description": "Ship To"} takes four times the space and reads worse.

Numbers are numbers

X12 has numeric types. N0 is an integer, N2 is a number with two implied decimal places, R is a decimal. Tediware reads the type from the standard and emits a JSON number of the right kind:

"baseline_item_data_PO1": {
  "assigned_identification_01": "10",
  "quantity_ordered_02": 1.0,
  "unit_or_basis_for_measurement_code_03": "each_EA",
  "unit_price_04": 2.88,
  "basis_of_unit_price_code_05": "contract_price_per_each_TE"
}

quantity_ordered_02 is a float because PO1-02 is type R. The segment count in the SE trailer and the line count in CTT are integers because they are N0. Identifiers stay strings even when they happen to be all digits, because assigned_identification_01 is type AN, and turning "010" into 10 would be a bug.

A lot of integration code is parseFloat calls sprinkled over fields that the developer had to discover were numeric by looking at sample data. The type is in the standard. We already have to read the standard to name the field, so we read the type too.

Empty elements are gone

BEG*00*SA*536213576**20260428 has an empty fourth element. There is no release_number_04 key in the output. An element that was not sent is absent from the JSON. There is no "" and no null.

The same applies at every level: an empty segment, an empty composite, and an empty loop are all dropped the same way.

Dates and times

X12 dates are CCYYMMDD or YYMMDD strings and times are HHMM or HHMMSS. Tediware leaves them as digit strings in the JSON: "date_05": "20260428", "time_05": "120000". These are not converted to ISO 8601 because an X12 date has no timezone and often no time at all. A DTM*002*20260502 is a requested delivery date and the honest representation of it is the eight digits the partner sent. Converting it to 2026-05-02T00:00:00Z invents a midnight and a timezone.

People who know X12 really well may protest at these claims about dates and times, because implementation guides usually require you to send timezones when you send times. However, those are in a different field, and changing the contents of one field because of the contents of another is problematic. Secondly, valid timezones include “LT” (local time), which is not an actual timezone!

The key tests are whether a developer with no EDI background can open a translated purchase order and understand it in under a minute, and whether a developer who lives in implementation guides can still find N1-04 without a search. Both of those readers exist on most integration teams, and a format that serves one at the expense of the other makes the other one do the lookup work by hand.

The second post is about the structure around these values: why some segments are objects and some are arrays, how loops are laid out, and what a composite element looks like.

If you want to convert EDI to JSON online with a document of your own before then, paste one into the EDI Translator.