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
Serialization — FormJsonSerializer::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.
Dynamic — DEVIntegJObject 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 approach — DEVIntegJObject 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
| Method | Returns | Behaviour when missing or null |
|---|---|---|
getValueString(name) | str | Empty string |
getValueInt(name) / getValueInt64(name) | int / int64 | 0 |
getValueReal(name) | real | 0 |
getValueDate(name, format) | date | Empty date; throws if the text does not match the format |
getValueDateTime(name, format) | utcdatetime | Null datetime; throws on a format mismatch |
getValueEnum(name, enumNum(NoYes)) | int | 0; throws naming the enum and the bad value |
getValueObject(name) | DEVIntegJObject | Throws — use it for blocks that must be there |
readNextArrayItem(name) | boolean | false when the array is absent or exhausted |
getCurrentArrayItemValue() | DEVIntegJObject | The item the cursor is on |
selectTokenPathStr(path) | System.String | Throws naming the path |
selectTokenPathJSON(path) | DEVIntegJObject | Throws; 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....
| Method | Emits |
|---|---|
writeStartObject() / writeEndObject() | { … } |
writeStartArray(name) / writeEndArray() | "name": [ … ] |
writePropertyObject(name) | "name": { — close it with writeEndObject() |
writePropertyString / Int / Int64 / Real / Bool | Typed 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
- Azure Service Bus integration — sales orders from JSON queue messages, both directions.
- Import sales orders from an external web application — incremental REST polling, a root-level array, parent/child messages, and mapping.
- Implement outbound web integration — posting JSON documents to an external API.
- Implement service-based integration — synchronous request/response with JSON contracts.