Skip to main content

JSON

JSON is the default for queues and web APIs, and the natural fit for header/lines documents. There are two ways to work with it in X++ — this page shows both, and explains why real integrations end up using the second one.

The two approaches

SerializationFormJsonSerializer::deserializeObject and FormJsonSerializer::serializeClass convert between JSON and an X++ class. You describe the document once as a contract class, and the whole payload moves in a single call.

DynamicDEVIntegJObject reads and DEVIntegJsonWriter writes, property by property, with no class to declare. Both wrap Newtonsoft.Json.

Both are shown below on the same sales order:

{
"companyId": "USMF",
"customerId": "US-002",
"lines": [
{ "lineQuantity": 1, "linePrice": 11, "lineItemBarcode": "M0001" },
{ "lineQuantity": 2, "linePrice": 22, "lineItemBarcode": "M0004" }
]
}

At a glance: serialization

First a class per level — one parm method per property tagged with its JSON name, and the array declared with DataCollectionAttribute so the serializer knows what the elements are (DEVIntegTutorialSOHeaderContract):

[DataContractAttribute]
class DEVIntegTutorialSOHeaderContract
{
private str customerId;
private List lines;

[DataMemberAttribute('customerId')]
public str parmCustomerId(str _customerId = customerId)
{
customerId = _customerId;
return customerId;
}

[DataMemberAttribute('lines'),
DataCollectionAttribute(Types::Class, classStr(DEVIntegTutorialSOHeaderLinesContract))]
public List parmLines(List _lines = lines)
{
lines = _lines;
return lines;
}
// ... one method per property, plus a second class for the line
}

Reading is then one call, and the lines come back as a plain List:

DEVIntegTutorialSOHeaderContract header =
FormJsonSerializer::deserializeObject(classNum(DEVIntegTutorialSOHeaderContract), payload);

staging.CustAccount = header.parmCustomerId();

ListEnumerator le = header.parmLines().getEnumerator();
while (le.moveNext())
{
DEVIntegTutorialSOHeaderLinesContract lineContract = le.current();

lineStaging.clear();
lineStaging.ItemBarCode = lineContract.parmLineItemBarcode();
lineStaging.SalesQty = lineContract.parmLineQuantity();
lineStaging.insert();
}

Writing is the mirror image: fill the contracts and call FormJsonSerializer::serializeClass(header) once.

Samples: DEVIntegTutorialImportSalesTableJSON reads this document; DEVIntegTutorialExportPurchContractHeader fills a header-and-lines contract from a purchase order and serializes it.

At a glance: dynamic

The same document, no classes at all:

DEVIntegJObject header = DEVIntegJObject::parse(payload);

staging.CustAccount = header.getValueString('customerId');

while (header.readNextArrayItem('lines'))
{
DEVIntegJObject line = header.getCurrentArrayItemValue();

lineStaging.clear();
lineStaging.ItemBarCode = line.getValueString('lineItemBarcode');
lineStaging.SalesQty = line.getValueReal('lineQuantity');
lineStaging.insert();
}

and writing it back:

DEVIntegJsonWriter jsonWriter = DEVIntegJsonWriter::construct();

jsonWriter.writeStartObject();
jsonWriter.writePropertyString('companyId', salesTable.DataAreaId);
jsonWriter.writePropertyString('customerId', salesTable.CustAccount);

jsonWriter.writeStartArray('lines');
while select salesLine
where salesLine.SalesId == salesTable.SalesId
{
jsonWriter.writeStartObject();
jsonWriter.writePropertyString('lineItemBarcode', salesLine.ItemId);
jsonWriter.writePropertyReal('lineQuantity', salesLine.SalesQty);
jsonWriter.writePropertyReal('linePrice', salesLine.SalesPrice);
jsonWriter.writeEndObject();
}
jsonWriter.writeEndArray();
jsonWriter.writeEndObject();

str json = jsonWriter.getJsonString();

Classes: DEVIntegJObject and DEVIntegJsonWriter.

Practical advice

Serialization looks like the better option at first — one call each way, and the document described once in a class. In practice, integrations built that way run into the same problems again and again:

  • The message changes. A partner adds a variant, and now different scenarios need different properties out of the same feed. A contract is one fixed shape, so every variation means another class or a wider one full of properties that are empty most of the time.
  • Code is hard to read. contract.parmSalesId() tells you nothing about which tag the value came from — the mapping is buried in an attribute on another class. Debugging why a field is empty means opening two or three files.
  • The types do not match. Dates arrive in the sender's format, numbers arrive as strings, flags arrive as "Y". A contract cannot convert them, so you write the conversion after deserialization anyway.
  • The rules are conditional. Real specifications say things like "if this tag is present, use it, otherwise fall back to that one". Serialization has no answer for that; a contract cannot tell a missing property from an empty one.

So prefer the dynamic approachDEVIntegJObject for reading and DEVIntegJsonWriter for writing. You read each value at the point where you use it, with the tag name right there in the code, the conversion and the format stated explicitly, and conditional rules written as ordinary X++.

Serialization is still worth recognising, because some of the framework samples use it and you will meet it in existing code.

Reading with DEVIntegJObject

Parse the payload and pull out what you need:

DEVIntegJObject header = DEVIntegJObject::parse(messageTable.getDataString());

staging.BusinessEventId = header.getValueString('BusinessEventId');
staging.OrderDate = header.getValueDate('OrderDate', 'yyyy-MM-dd');
staging.TotalAmount = header.getValueReal('TotalAmount');

Reader methods

MethodReturnsBehaviour when missing or null
getValueString(name)strEmpty string
getValueInt(name) / getValueInt64(name)int / int640
getValueReal(name)real0
getValueDate(name, format)dateEmpty date; throws if the text does not match the format
getValueDateTime(name, format)utcdatetimeNull datetime; throws on a format mismatch
getValueEnum(name, enumNum(NoYes))int0; throws naming the enum and the bad value
getValueObject(name)DEVIntegJObjectThrows — use it for blocks that must be there
readNextArrayItem(name)booleanfalse when the array is absent or exhausted
getCurrentArrayItemValue()DEVIntegJObjectThe item the cursor is on
selectTokenPathStr(path)System.StringThrows naming the path
selectTokenPathJSON(path)DEVIntegJObjectThrows; also strips a ```json fence

The scalar getters are deliberately forgiving — a missing property gives you a blank, not an exception. That is right for optional fields and wrong for mandatory ones, so validate explicitly:

staging.CustAccount = header.getValueString('customerId');
if (! staging.CustAccount)
{
throw error(strFmt("Message %1: customerId is missing", messageTable.Name));
}

Do that in the staging step and the message log shows the user exactly which message was wrong and why.

Dates and types

JSON has numbers, strings, and booleans — nothing else. Dates are strings by convention, so always state the format:

// { "orderDate": "2024-03-04", "updatedAt": "2024-03-04T09:15:22.1234567" }
staging.OrderDate = header.getValueDate('orderDate', 'yyyy-MM-dd');
staging.UpdatedAt = header.getValueDateTime('updatedAt', 'yyyy-MM-ddTHH:mm:ss.fffffff');

Without a format the value goes through System.Convert::ToDateTime, which follows the server culture — and 2024-03-04, 04/03/2024, and 1709510400 are all "the same date" to somebody.

Numbers sent as strings ("quantity": "1.5") still work — getValueReal converts. A number sent where you expect a string works too, because getValueString calls ToString(). What does not work is reading an object or array with a scalar getter: getValueString on a nested object returns its raw JSON text.

Nested objects

{
"customerId": "US-002",
"deliveryAddress": { "street": "123 Main St", "city": "Seattle", "postalCode": "98101" }
}
DEVIntegJObject address = header.getValueObject('deliveryAddress');

staging.Street = address.getValueString('street');
staging.City = address.getValueString('city');
staging.ZipCode = address.getValueString('postalCode');

getValueObject throws when the block is missing, which is what you want for a mandatory address. For an optional block, look for a marker field first or use a JSON path (below).

Arrays

readNextArrayItem walks an array and getCurrentArrayItemValue hands you each element as its own document, as shown above. It returns false for an absent array too, so count the rows and fail explicitly rather than importing nothing:

if (! lineNum)
{
throw error(strFmt("Message %1 is empty", messageTable.Name));
}

The cursor lives on the object, so iterate one array at a time per object. Nesting works because each element is its own DEVIntegJObject with its own cursor:

while (order.readNextArrayItem('lines'))
{
DEVIntegJObject line = order.getCurrentArrayItemValue();

while (line.readNextArrayItem('serialNumbers'))
{
DEVIntegJObject serial = line.getCurrentArrayItemValue();
// ...
}
}

An array at the root

Web APIs commonly return a bare array, with no object to start from:

[
{ "id": "1042", "orderAccount": "US-002", "lines": [ { "itemId": "M0001", "quantity": 2 } ] },
{ "id": "1043", "orderAccount": "US-004", "lines": [ { "itemId": "M0004", "quantity": 1 } ] }
]

Use JArray directly and walk it with First / Next:

using Newtonsoft.Json.Linq;

JArray orders = JArray::Parse(messageTable.getDataString());
JToken order = orders.First;

while (order)
{
DEVIntegJObject result = DEVIntegJObject::parse(order.ToString());

messageProcessResult.parmProcessPrefix(strFmt("Web order %1", result.getValueString('id')));

salesTableStaging.clear();
salesTableStaging.MessageId = this.initChildMessage(true).RecId;
salesTableStaging.Identifier = result.getValueString('id');
salesTableStaging.ExternalAccount = result.getValueString('orderAccount');
salesTableStaging.insert();

// ... read the 'lines' array into the line staging table

order = order.Next;
}

initChildMessage turns each element into its own message, so one bad order does not block the rest of the batch. Full sample: DEVIntegTutorialWebSalesProcess.

Reaching deep with a JSON path

When the value you want is buried under wrappers you do not care about, skip the levels:

{ "candidates": [ { "content": { "parts": [ { "text": "{ \"HEADER\": { ... } }" } ] } } ] }
DEVIntegJObject payload = response.selectTokenPathJSON('candidates[0].content.parts[0].text');

selectTokenPathStr returns the raw text at a path, selectTokenPathJSON parses it as a document. Both throw naming the path when it is not found — a much better error than a silent empty value. This is how the AI provider unwraps an LLM response.

Writing with DEVIntegJsonWriter

The writer is a streaming builder: open an object, write properties, close it. Every writeStart... needs its matching writeEnd....

MethodEmits
writeStartObject() / writeEndObject(){}
writeStartArray(name) / writeEndArray()"name": []
writePropertyObject(name)"name": { — close it with writeEndObject()
writePropertyString / Int / Int64 / Real / BoolTyped scalar
writePropertyDate(name, value, format)String, default yyyy-MM-dd
writePropertyDateTime(name, value, format)String, default yyyy-MM-ddTHH:mm:ssZ
writePropertyContainer(name, con)Array of scalars
writePropertyNull(name)null
getJsonString()The finished, indented document — closes the writer

writePropertyReal converts through decimal, so amounts never come out in scientific notation.

The header-and-lines pattern is shown above. Finish by handing the string to the connector — in an event-based export that is one message per document:

str json = jsonWriter.getJsonString();

this.sendMessageServiceBusCache(_exportDocumentLog, loadAzureServiceBusCache, json,
strFmt("Order Confirmed %1", salesTable.SalesId));

Nested objects and arrays of scalars

jsonWriter.writePropertyObject('deliveryAddress'); // opens the object
jsonWriter.writePropertyString('street', address.Street);
jsonWriter.writePropertyString('city', address.City);
jsonWriter.writePropertyString('country', address.CountryRegionId);
jsonWriter.writeEndObject(); // and closes it

jsonWriter.writePropertyContainer('tags', ['web', 'priority']);

gives:

"deliveryAddress": { "street": "123 Main St", "city": "Seattle", "country": "USA" },
"tags": [ "web", "priority" ]

Nesting arrays inside arrays is the same idea, just deeper. The public class DEVIntegAIProviderGemini builds a three-level request this way — a contents array holding an object holding a parts array. Comment every writeEnd... with what it closes; it is the only way to keep a deep document readable.

Tutorials