Skip to content

JSON and XML

The message travelling through a flow is not JSON and not a string — it is a value model that covers JSON’s types and XML as a first-class citizen. This page is about what that means when you write expressions and templates.

The value model

Kind Notes
null
boolean
number Integer or floating point
string
array Ordered
object Keys keep insertion order, not alphabetical order
XML element Tag, namespace, attributes, children — all preserved in document order

Object key order is preserved on the way out, so a response comes back in the order your template wrote it. That matters when the receiving system is order-sensitive, or when a human is reading it.

An XML element is a distinct kind, not an object with conventions. It carries:

  • a tag name,
  • an optional namespace URI,
  • attributes, in document order,
  • children — elements and text — in document order.

Because it is a real node, serialising it back to XML is exact: no escaping mistakes, no lost attributes, no reordered elements.

Two kinds of character are exceptions. The first is a character XML 1.0 does not allow anywhere in a document, not even escaped: a control character below U+0020 other than tab, line feed and carriage return, or U+FFFE or U+FFFF. In the XML or SOAP answer of a flow, in a SOAP fault, and in an XML or SOAP body that http_egress builds from a value, the platform does not write such a character: for each one in a text or an attribute value, it writes U+FFFD (�) in its place. The second is tab, line feed and carriage return, which the platform writes as they are. The receiver’s XML parser reads a tab or a line break in an attribute value as a space, and a carriage return in text, alone or before a line feed, as one line feed. A string that http_egress sends verbatim, under an XML content_type (see Outbound encoding) or as a SOAP body whose first non-blank character is < (see Calling a SOAP service), is sent unchanged: it keeps such a character, and the receiver’s XML parser refuses the document.

An element or attribute name is not replaced, it is checked. A name must be a legal XML name without a colon (a letter or underscore first, then letters, digits, hyphens, dots or underscores), or two such names joined by one colon, as in soap:Envelope. When a flow’s value holds any other name, for example a JSON key a b or 1x, the platform writes no document: the answer is the error that any failure to write XML gets, and a SOAP answer carries the X-Nexus-Soap-Warning: non-xml-output header. The name is not repaired, because dropping the offending character could merge two names into one.

Inbound JSON

A body with Content-Type: application/json becomes the message directly.

Terminal window
$ curl -X POST http://localhost:9090/flows/acme/orders/run \
-H "Authorization: Bearer $NEXUS_KEY" \
-H 'Content-Type: application/json' \
-d '{"orderId":"A-1","items":[{"sku":"X","qty":2}]}'
$.orderId → "A-1"
$.items[0].sku → "X"
length($.items) → 1

JSON a caller sends is read under I-JSON (RFC 7493), on /run, on /enqueue and on a declared route, with no setting to turn it off. Two things valid JSON allows are refused with 400 before the flow runs or the message is queued:

  • A repeated member name in one object, at any depth. Names are compared after escapes, so {"a":1,"\u0061":2} repeats a. The same name in two different objects is not a repetition.
  • A Unicode noncharacter in a name or a string, written literally or escaped: U+FDD0 to U+FDEF, and the last two code points of every plane (U+FFFE, U+FFFF, … U+10FFFF).
Terminal window
$ curl -X POST http://localhost:9090/flows/acme/orders/run \
-H "Authorization: Bearer $NEXUS_KEY" \
-H 'Content-Type: application/json' \
-d '{"a":1,"a":2}'
{"ok":false,"error":"invalid JSON body: a member name appears more than once in one object (line 1, column 10)"}

A noncharacter is answered invalid JSON body: a string holds a Unicode noncharacter, which I-JSON forbids (line L, column C). The line and column locate the problem, at the closing quote of the name or the string; the answer never repeats the name or the string. Each refusal leaves one delivery_refused event with the reason json_not_i_json (see Observability). JSON that does not parse at all keeps its own answer, invalid JSON body: <parser message>, and the reason body_malformed. Integers beyond 2^53 and a scalar at the top level are accepted, and integers are kept exact.

JSON from a back-end is not checked this way. When an http_egress response, an OAuth2 token response or an identity provider’s document names one member twice, the last value is kept and the earlier ones are dropped, as most JSON parsers do. The member keeps the position of its first occurrence: {"z":1,"a":2,"z":3} reads as {"z":3,"a":2}.

Inbound XML

A body with Content-Type: application/xml is parsed into an XML value. The two entry points differ, and the difference is easy to trip over.

Synchronous execution

The message is the XML element itself, root included.

Terminal window
$ curl -X POST http://localhost:9090/flows/acme/orders/run \
-H "Authorization: Bearer $NEXUS_KEY" \
-H 'Content-Type: application/xml' \
-d '<order id="A-1"><customer>Acme Ltd</customer></order>'

Field access on an XML value reads attributes and child elements, plus two special names:

Expression Reads
$.id The id attribute if there is one, otherwise the first child element named id
$.customer The child <customer> — its text, since it has no element children
$["$tag"] The tag name — "order"
$["$ns"] The namespace URI, or null

Dotted access chains, so $.Body.Order.Id walks three levels of a document. It matches local names, so a prefix in the message is not part of the expression: $.Body reads <soapenv:Body>. An element whose only content is text reads as that text; one with element children stays an element so the chain can continue.

For anything deeper than “the child right here”, use descendants(), which searches the whole subtree by local name and returns every match:

{{ string(descendants($, "OrderId")[0]) }} the text of the first match
{{ length(descendants($, "Item")) }} how many there are
{{ for x in descendants($, "Item") }}{{ $ }}{{ end }} copy each one whole

One trap, which produces plausible output rather than an error: descendants() returns elements, so interpolating one directly emits the element and not its text — wrap it in string() when you meant the value. See the function reference.

Inside {{ for x in … }} you can use either $ or x for the current element; they are the same value, and a name that is not bound is refused at publish time.

Queued submission

/enqueue gives the flow the same $ as /run. The queue holds the request body as the text that arrived, byte for byte, and the worker parses it with the same parser when it delivers the message, so repeated elements, attributes, namespaces and mixed content all reach the flow, and an expression reads the same way on both entry points.

Terminal window
$ curl -X POST http://localhost:9090/flows/acme/orders/enqueue \
-H "Authorization: Bearer $NEXUS_KEY" \
-H 'Content-Type: application/xml' \
-d '<order id="A-1"><line>a</line><line>b</line></order>'
$.id → "A-1"
length(descendants($, "line")) → 2

The body is refused with 400 before anything is queued when it does not parse, when it is nested deeper than the limit, or when it is not UTF-8 (invalid XML body: the body is not UTF-8).

Messages queued before the upgrade arrive in the old, flattened shape: child elements as object keys, the root tag dropped. Drain a queue fed with XML before upgrading if its flow is written for the new shape.

/enqueue reads application/xml and text/xml as XML; a SOAP envelope and any other text/* body are queued too, in the order /enqueue gives.

Outbound encoding

content_type on an http_egress step selects the encoding. Letter case does not matter for the choice, and the header is sent as you wrote it:

content_type Encoding
application/json (default) Serialised as JSON
contains xml An XML value is serialised as XML; a string is sent verbatim; anything else is refused
contains text The text string() gives the value: a scalar as its text, null as an empty body, an object or an array as its JSON text, an XML element as its text content. An object or an array that holds an XML element is refused. For markup under text/html, declare application/xhtml+xml
anything else Serialised as JSON

XML and text/* are deliberately not the same rule. XML has a shape and the value either has it or does not, so an object under application/xml is an error naming the step, not a body silently serialised as JSON under a header that promises XML — that only moves the failure to the far end, where it arrives as someone else’s parse error. text/* has no shape to violate, so it keeps its tolerance.

A string is taken at its word in the XML case: either it is already markup, or it is text the flow means to send verbatim. Neither invents structure.

So sending XML is a matter of producing an XML value and saying so:

## Step: build
```ntd
<order id="{{ $.orderId }}">
<customer>{{ $.customer.name }}</customer>
</order>
```
## Step: send
effects: [http_egress]
endpoint: https://orders.example.com/ingest
method: POST
content_type: application/xml

If the step produces a string rather than an XML value with an XML content type, the string is sent as-is. That is a useful escape hatch and a common accident — see the normalisation rules in Transforming data, because a template with a punctuation error yields a string.

Answering with XML

The same rule applies to what the caller gets back. A flow whose last step builds an element answers /run with that document and Content-Type: application/xml; charset=utf-8:

## Step: normalise
```ntd
<Result>
<Status>FOUND</Status>
<Count>{{ length(descendants($, "Item")) }}</Count>
</Result>
```
Terminal window
$ curl -s -X POST http://localhost:9090/flows/acme/lookup/run \
-H "Authorization: Bearer $NEXUS_KEY" -d '{"id":"A-1"}'
<Result>
<Status>FOUND</Status>
<Count>3</Count>
</Result>

The whitespace between elements is the template’s own — the serialiser does not reformat, so indentation you write is indentation the caller receives.

The body is the output, unwrapped, whatever its form — a flow that builds an object answers with that JSON and application/json. So the shape is decided by the last step, success is the status code, and a client calling a mix of flows branches on the response Content-Type. Full table in The response body.

Constructing XML

Write elements literally in an ntd fence. Attributes interpolate, children may be loops:

```ntd
<order id="{{ $.orderId }}" xmlns="urn:acme:orders">
<customer>{{ $.customer.name }}</customer>
<total currency="{{ $.currency }}">{{ $.total }}</total>
{{ for item in $.items }}
<line sku="{{ item.sku }}" qty="{{ item.qty }}"/>
{{ end }}
</order>
```

Keep the template purely XML. A template that mixes an element with surrounding text produces an array of segments rather than a document — put nothing outside the root element.

Converting between the two

XML in, JSON out — read what you need and build an object:

## Step: to-json
```ntd
{
"orderId": "{{ $.id }}",
"tag": "{{ $["$tag"] }}"
}
```

JSON in, XML out — build the document, then send it with an XML content type, as above.

For anything more elaborate on the XML side, reach for ntd with descendants() first. An xslt step is an option, but only a subset of the language is compiled, and a sheet outside that subset is refused by nexus validate and nexus deploy, naming the construct. See Transforming data for exactly which constructs compile.

Relaying a body unread

A flow without body: opaque parses the body, so it writes numbers in their shortest form and rewrites XML prefixes: 10.50 leaves as 10.5. When the body must reach the back-end as the bytes that arrived, a signed document, a PDF, a payload above 1 MiB, declare it in the frontmatter:

body: opaque

The three doors. /run, /enqueue and the declared routes take the body unread: any Content-Type, or none, is accepted and kept verbatim, an empty body is a body of zero bytes, and the ceiling, the access gate and the 413 are as for every flow. A request with a Content-Encoding other than identity is refused with 415, because a body the server had decoded would no longer be the bytes the header describes. $ is null in such a flow.

The egress. Each http_egress step sends the bytes it holds and takes the answer’s bytes in their place. The request Content-Type is the step’s content_type: when it declares one, otherwise the one that came with the bytes, otherwise application/octet-stream. The request carries one Accept: an Accept in the step’s headers:, otherwise its accept:, otherwise */*. The request asks for Accept-Encoding: identity, and a 2xx answer with a Content-Encoding other than identity fails the step, naming the header. A non-2xx answer, a timeout or the egress ceiling fail the step as for every flow, and the caller gets the platform’s error answer.

The answer. The caller gets the last answer’s bytes, unchanged, with its Content-Type, or application/octet-stream when it had none. response_status: and response_headers: apply as for every flow. /enqueue answers 202, and the worker, its retries and nexus dlq replay send the same bytes.

What is refused at publication. The flow forwards a body it does not read, so nexus validate and nexus deploy refuse, naming the step or the key:

  • a step with any fence (ntd, xslt, condition, route, validate, include, call_flow, foreach, split, fork);
  • a step whose effects are not http_egress, optionally with secret_read and oauth2_token;
  • on an http_egress step: soap_version, soap_action, soap_operation, save_body, restore_body and the correlation_* keys;
  • any template or expression that reads $ or a path under it; ctx. and config stay allowed;
  • a ## Fault section;
  • the frontmatter keys dedup_key, correlation_close, correlation_close_required, soap_operation, soap_namespace, schedule, inbound, proto, service and method.

The body is never logged, at any log_level: payload_trimmed and full record its size and blake3 digest, {"_opaque":{"bytes":N,"blake3":"…"}}, and event_only records nothing.