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.
$ 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) → 1JSON 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}repeatsa. 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).
$ 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.
$ 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 wholeOne 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.
$ 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")) → 2The 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: sendeffects: [http_egress]endpoint: https://orders.example.com/ingestmethod: POSTcontent_type: application/xmlIf 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>```$ 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: opaqueThe 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 withsecret_readandoauth2_token; - on an
http_egressstep:soap_version,soap_action,soap_operation,save_body,restore_bodyand thecorrelation_*keys; - any template or expression that reads
$or a path under it;ctx.andconfigstay allowed; - a
## Faultsection; - the frontmatter keys
dedup_key,correlation_close,correlation_close_required,soap_operation,soap_namespace,schedule,inbound,proto,serviceandmethod.
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.