How to Convert EDI Documents to JSON in Node.js (X12 or EDIFACT)

This guide focuses on implementation: parsing EDI, producing JSON you can actually use, validating it, and building a pipeline that’s observable and testable in production.

 

What “EDI → JSON” should mean (pick the target)

There are three practical JSON targets. In real systems, you usually implement two of them:

 

  1. Lossless “EDI-as-JSON” (debuggable, reversible)
    Preserve segments/elements exactly.
  2. Business object JSON (developer-friendly)
    Purchase orders, invoices, ASNs as clean objects.
  3. Canonical JSON (enterprise contract)
    One internal schema for all partners/versions; adapters in/out.

 

Recommendation: store lossless EDI-as-JSON for traceability and produce canonical JSON for applications and APIs.

 

Architecture: EDI → Parse → Validate → Transform → Publish

  1. Ingest (AS2, SFTP, VAN, API)
  2. Parse delimiters + envelope + segments
  3. Structural validate (required segments, loops, element formats)
  4. Transform to canonical JSON (your contract)
  5. Business validate (SKU exists, UOM conversions, price rules)
  6. Publish (ERP/OMS/API/event bus)
  7. Observe (correlation IDs, mapping versions, acks/errors)

 

Node.js implementation options

You can do this three ways:

 

 

Below I’ll show a practical Node.js approach with:

 

 

Step 1: Parse EDI into lossless JSON (Node.js)

Important: EDI separators aren’t always * and ~. X12 usually declares them in ISA; EDIFACT may declare them in UNA. A robust parser reads them; a minimal parser often assumes them.

 

For a minimal but working example, we’ll assume:

 


 // edi-parse.js
 export function parseEdiToLosslessJson(ediText, opts = {}) {
  const elementSep = opts.elementSep ?? '*';
  const segmentTerm = opts.segmentTerm ?? '~';
  const componentSep = opts.componentSep ?? ':';

  // Split segments, trim empties
  const rawSegments = ediText
    .split(segmentTerm)
    .map(s => s.trim())
    .filter(Boolean);

  const segments = rawSegments.map(seg => {
    const parts = seg.split(elementSep);
    const tag = parts[0];
    const elements = parts.slice(1).map(el => {
      // Preserve composite elements as arrays, e.g. "A:B" -> ["A","B"]
      if (el.includes(componentSep)) return el.split(componentSep);
      return el;
    });

    return { tag, elements };
  });

  return {
    delimiters: { element: elementSep, segment: segmentTerm, component: componentSep },
    segments
  };
} 


Production note: This is intentionally minimal. For X12 you should detect separators from ISA (fixed-width ISA segment) and for EDIFACT from UNA/UNB to avoid silent parsing errors.

 

Step 2: Transform lossless JSON into business / canonical JSON

Here’s a simple example transforming an X12 850 purchase order into a developer-friendly object. The pattern is the point: treat EDI as an event stream and build a deterministic state machine for loops.

 


 // x12-850-transform.js
 export function transform850(lossless) {
  const out = {
    documentType: 'X12_850',
    purchaseOrderNumber: null,
    orderDate: null,
    shipTo: null,
    billTo: null,
    lines: [],
    refs: []
  };

  let currentLine = null;

  for (const seg of lossless.segments) {
    switch (seg.tag) {
      case 'BEG': {
        // BEG*00*SA*PO12345**20260217
        out.purchaseOrderNumber = seg.elements[2] ?? null;
        const yyyymmdd = seg.elements[4];
        out.orderDate = yyyymmdd ? toIsoDate(yyyymmdd) : null;
        break;
      }

      case 'REF': {
        // REF*IA*123456
        out.refs.push({
          qualifier: seg.elements[0] ?? null,
          value: seg.elements[1] ?? null
        });
        break;
      }

      case 'N1': {
        // N1*ST*ACME PLANT*92*100
        const entityId = seg.elements[0];
        const party = {
          name: seg.elements[1] ?? null,
          idQualifier: seg.elements[2] ?? null,
          id: seg.elements[3] ?? null
        };
        if (entityId === 'ST') out.shipTo = party;
        if (entityId === 'BT') out.billTo = party;
        break;
      }

      case 'PO1': {
        // PO1*1*10*EA*12.34**BP*ABC-123
        if (currentLine) out.lines.push(currentLine);

        currentLine = {
          lineNumber: seg.elements[0] ?? null,
          quantity: seg.elements[1] ? Number(seg.elements[1]) : null,
          uom: seg.elements[2] ?? null,
          unitPrice: seg.elements[3] ? Number(seg.elements[3]) : null,
          buyerPartNumber: null,
          vendorPartNumber: null,
          description: null
        };

        // PO1 product IDs often come in qualifier/value pairs
        // e.g., **BP*ABC-123*VP*VEND-999
        // Elements after unitPrice can vary; walk pairs.
        for (let i = 5; i < seg.elements.length; i += 2) {
          const qual = seg.elements[i];
          const val = seg.elements[i + 1];
          if (!qual || !val) continue;
          if (qual === 'BP') currentLine.buyerPartNumber = val;
          if (qual === 'VP') currentLine.vendorPartNumber = val;
        }
        break;
      }

      case 'PID': {
        // PID*F****Some description
        if (currentLine) {
          const desc = seg.elements[4];
          if (desc) currentLine.description = desc;
        }
        break;
      }

      case 'CTT': {
        // End of transaction summary - flush last line
        if (currentLine) {
          out.lines.push(currentLine);
          currentLine = null;
        }
        break;
      }

      default:
        break;
    }
  }

  // Flush if file ended without CTT
  if (currentLine) out.lines.push(currentLine);

  return out;
}

function toIsoDate(yyyymmdd) {
  const s = String(yyyymmdd);
  if (s.length !== 8) return null;
  return `${s.slice(0,4)}-${s.slice(4,6)}-${s.slice(6,8)}`;
}


Implementation tip: Keep transformation logic transaction-specific (850/810/856), but keep your canonical JSON schema stable across partners.

 

Step 3: Validate the JSON with JSON Schema (catch “valid JSON, wrong meaning”)

Use ajv (fast JSON Schema validator) to enforce required fields, types, and formats.

 


// validate.js
import Ajv from 'ajv';
import addFormats from 'ajv-formats';

const ajv = new Ajv({ allErrors: true, removeAdditional: false });
addFormats(ajv);

export const poSchema = {
  type: 'object',
  required: ['documentType', 'purchaseOrderNumber', 'lines'],
  properties: {
    documentType: { const: 'X12_850' },
    purchaseOrderNumber: { type: 'string', minLength: 1 },
    orderDate: { type: ['string', 'null'], format: 'date' },
    shipTo: {
      type: ['object', 'null'],
      properties: {
        name: { type: ['string', 'null'] },
        idQualifier: { type: ['string', 'null'] },
        id: { type: ['string', 'null'] }
      }
    },
    lines: {
      type: 'array',
      minItems: 1,
      items: {
        type: 'object',
        required: ['lineNumber', 'quantity', 'uom'],
        properties: {
          lineNumber: { type: ['string', 'null'] },
          quantity: { type: ['number', 'null'] },
          uom: { type: ['string', 'null'] },
          unitPrice: { type: ['number', 'null'] },
          buyerPartNumber: { type: ['string', 'null'] },
          vendorPartNumber: { type: ['string', 'null'] },
          description: { type: ['string', 'null'] }
        }
      }
    }
  }
};

export function validatePo(po) {
  const validate = ajv.compile(poSchema);
  const ok = validate(po);
  return { ok, errors: validate.errors ?? [] };
}



Step 4: Put it together in a Node service

This example ingests raw EDI and returns canonical JSON (or validation errors).

 


// server.js (Express example)
import express from 'express';
import { parseEdiToLosslessJson } from './edi-parse.js';
import { transform850 } from './x12-850-transform.js';
import { validatePo } from './validate.js';
import crypto from 'crypto';

const app = express();
app.use(express.text({ type: '*/*', limit: '5mb' }));

app.post('/ingest/edi', (req, res) => {
  const correlationId = req.header('x-correlation-id') || crypto.randomUUID();
  const edi = req.body;

  // 1) parse to lossless
  const lossless = parseEdiToLosslessJson(edi);

  // 2) (example) transform 850
  const po = transform850(lossless);

  // 3) validate canonical JSON
  const { ok, errors } = validatePo(po);

  if (!ok) {
    return res.status(422).json({
      correlationId,
      status: 'rejected',
      errors
    });
  }

  // 4) publish downstream (stub)
  // await publishToEventBus({ correlationId, document: po })

  return res.status(200).json({
    correlationId,
    status: 'accepted',
    document: po
  });
});

app.listen(3000, () => console.log('EDI ingest listening on :3000'));

 

Step 5: Acks, observability, and “publish proof” (don’t skip)

If you’re serious about reducing EDI exceptions, add:

 

 

This is exactly where “Product Truth SLAs” become real: you can measure detect/decide/publish for transaction truth.

 

Testing: treat EDI maps like code

At minimum:

 

 


// example.test.js (Jest)
import fs from 'node:fs';
import { parseEdiToLosslessJson } from './edi-parse.js';
import { transform850 } from './x12-850-transform.js';

test('transforms 850 fixture to canonical JSON', () => {
  const edi = fs.readFileSync('./fixtures/850-sample.edi', 'utf8');
  const lossless = parseEdiToLosslessJson(edi);
  const po = transform850(lossless);

  expect(po.purchaseOrderNumber).toBeTruthy();
  expect(po.lines.length).toBeGreaterThan(0);
});

 

Where teams get stuck (and how to avoid it)

 

If you want to modernize without breaking EDI, the quickest win is a canonical JSON layer with validation, observability, and partner adapters—so EDI becomes a transport, not your application boundary.

 

Talk with Layer One about adapters, accelerators and using AI to jumpstart your conversion.