Skip to content

SOAP and WSDL

SOAP works in both directions: a flow can accept a SOAP request and answer with a SOAP response, and a flow can call a SOAP service. Envelope handling is automatic — you work with the body content.

Both SOAP 1.1 and 1.2 are supported. The version is the namespace the Envelope element is in, whatever prefix binds it: <soap:Envelope>, <env:Envelope> and <Envelope xmlns="…"> are all recognised.

Accepting SOAP

Nothing in the flow declares this. A request is treated as SOAP when it arrives on the ordinary /run route and both of these hold:

  1. Content-Type contains text/xml or application/soap+xml, in any letter case, and
  2. a SOAPAction header is present, or the first 512 bytes of the body contain an Envelope element.

The 512-byte window matters for one case only: an envelope preceded by a long preamble — comments, processing instructions, generous whitespace — is not recognised unless the caller also sends SOAPAction.

The value of SOAPAction is not checked and does not choose the operation: a request with the WSDL’s action, another value, an unquoted value, "" or no header at all runs the same flow.

The envelope is unwrapped and the flow receives the body content as an XML value — not the envelope, not the header block.

The body is the envelope’s own Body child, and only that one: an optional Header first, then exactly one Body, then nothing, as SOAP 1.1 §4 and SOAP 1.2 Part 1 §5 prescribe. An element named Body inside the Header is header content and never reaches the flow. Any other shape (a second Body, a Body nested inside another element, an element after the Body, a DOCTYPE anywhere in the document, an element declaring more than 256 namespace bindings) is a malformed envelope and is refused before the flow runs; see Faults. Elements are matched by namespace and local name, so any prefix works, the default namespace included.

The version comes from the envelope, not from the content type and not from the text of the body: an Envelope in http://schemas.xmlsoap.org/soap/envelope/ is SOAP 1.1, one in http://www.w3.org/2003/05/soap-envelope is SOAP 1.2, and the request runs and is answered in that version even when its Content-Type names the other. Header and Body count only in the envelope’s own namespace. An Envelope in any other namespace, or in none, is refused before the flow runs with a VersionMismatch fault; see Faults.

Two variables are set:

Variable Contents
ctx.soap_action The SOAPAction header value, if any
ctx.soap_version 1.1 or 1.2
Terminal window
$ curl -X POST http://localhost:9090/flows/acme/order-intake/run \
-H "Authorization: Bearer $NEXUS_KEY" \
-H 'Content-Type: text/xml' \
-H 'SOAPAction: SubmitOrder' \
-d '<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<SubmitOrder><orderId>A-1</orderId></SubmitOrder>
</soap:Body>
</soap:Envelope>'

The message is the <SubmitOrder> element, so $.orderId reads A-1: field access on an XML value returns the attribute of that name if there is one, and otherwise the first child element with that local name. See JSON and XML for the whole rule, including what comes back as text and what stays a node.

One-way submission on /enqueue

The same envelope can be sent to the flow’s /enqueue route, detected by the same rule. The envelope is unwrapped when it arrives and the content of its Body is queued; the flow receives the same $ as on /run. The Header and SOAPAction are not queued, so for a queued message ctx.soap_action and ctx.soap_version are absent.

The answer is 202 with the plain queue receipt, not an envelope. A malformed envelope, or one that is not UTF-8, is answered with a JSON 400 and nothing is queued, never a SOAP Fault. A sender that retries only a 5xx does not send it again.

Answering SOAP

If the request was SOAP, the response is wrapped for you. Produce an XML value and it becomes the body of a correctly versioned envelope, with the matching content type:

Version Response content type
1.1 text/xml; charset=utf-8
1.2 application/soap+xml; charset=utf-8

A flow whose last step produces something other than XML still gets a valid envelope: the value is serialised as text inside the body, the content type is still correct, and the response carries X-Nexus-Soap-Warning: non-xml-output so the mismatch is visible rather than silent. Treat that header as a bug report about your flow.

A complete SOAP-to-SOAP flow

---
flowmarkdown_version: "0.1"
flow: order-intake
tenant: acme
soap_operation: SubmitOrder
soap_namespace: urn:acme:orders
---
Accepts a SOAP order submission and answers with an acknowledgement.
## Step: check
```validate
$["$tag"] == "SubmitOrder" | "unexpected operation" | syntactic
```
## Step: acknowledge
```ntd
<SubmitOrderResponse xmlns="urn:acme:orders">
<accepted>true</accepted>
<receivedAt>{{ date_format(now()) }}</receivedAt>
<correlationId>{{ ctx.correlation_id }}</correlationId>
</SubmitOrderResponse>
```

Calling a SOAP service

Add soap_version to an http_egress step. Its presence is what turns the call into a SOAP call.

Key Meaning
soap_version 1.1 or 1.2. Required to enable SOAP. Any other value is refused at publication
soap_action Value for the SOAPAction header
soap_operation Fallback for SOAPAction when soap_action is absent. It does not appear in the envelope
## Step: build-body
```ntd
<GetOrderStatus xmlns="urn:partner:orders">
<orderId>{{ $.orderId }}</orderId>
</GetOrderStatus>
```
## Step: call-partner
effects: [http_egress]
endpoint: https://partner.example.com/OrderService
method: POST
soap_version: "1.1"
soap_action: urn:partner:orders/GetOrderStatus
soap_operation: GetOrderStatus
read_timeout_ms: 10000

The message is wrapped in an envelope, the content type is set for the version, and the response envelope is unwrapped so the next step sees the body content. The response is held to the same shape as a request (see Accepting SOAP). One that is not a well-formed envelope, a misplaced or second Body included, or whose Envelope is in a namespace that is neither SOAP 1.1 nor SOAP 1.2, is not unwrapped: the next step receives the whole response as text, and the platform logs a warning.

Do not set content_type yourself on a SOAP step — it is derived from soap_version.

A SOAP call is an http_egress step, so it shares the circuit breaker of its destination with every other HTTP call to the same scheme, host and port — see Circuit breaker.

How the body is built depends on its value. An XML value is serialised as XML. A string whose first non-blank character is < is taken for markup and sent as written. Any other string is escaped and wrapped in <value>…</value>. A value of any other kind is refused, with an error naming the step.

The version vocabulary is closed

soap_version accepts 1.1 and 1.2, and nothing else. A value outside that vocabulary is refused when the flow is published — by nexus deploy and by nexus validate alike — on ordinary steps, on ## Fault: steps, and inside a library expanded through call_flow or foreach. The refusal names every wrong step in one verdict, not just the first one:

step 'call-partner': `soap_version: 1.0` is not a SOAP version — write one of "1.1", "1.2"

Before this refusal existed, a misspelled version left a warning in the log and turned SOAP mode off: the step sent bare XML with no envelope, no SOAPAction and the ordinary content type, and the difference showed up only at the far end. An artifact published before the refusal still behaves that way, with a warning that names what the request became and asks for the flow to be republished. Republish such a flow and the typo becomes a refusal you can see.

Faults

When a SOAP request fails after the access gate, the response is a SOAP fault of the request’s version, with the correct envelope and content type. No configuration is needed. The few answers that stay plain HTTP are listed below.

The fault string is a fixed phrase per error, or the flow’s own message, never the error’s text unless the flow copies it. It is the same phrase the JSON door sends in error, and the gRPC door sends it as its message for every row but the last, which gRPC answers flow execution failed:

What failed Fault string
A validate rule the rule’s message as rendered; validate rule failed when it renders empty
The message lacks the dedup key this message does not carry the field this flow deduplicates on
The message lacks the correlation key this message does not carry the field this flow correlates on
The state kept for a reply is too large the state this flow keeps for the reply is over the platform's ceiling for it
A reply with no open correlation no correlation is open for this key; nothing here asked for this reply
A reply whose correlation is closed the correlation for this key is already closed; this reply was already processed
A correlation already open for the key a correlation is already open for this key; the earlier exchange is still awaiting its reply
The flow’s maximum duration flow exceeded its maximum duration
Flow state unavailable flow state unavailable: this flow cannot be delivered right now
The delivery lost its claim on its dedup key flow execution stopped before it finished; some of its steps may have run
The ## Fault: section itself failed fault handler failed
Anything else: a failed call, a route with no match, an expression error flow execution failed
The platform could not load or run the flow internal error

On both SOAP versions, the two refusals of deduplication that answer 409 on JSON are faults too, and their fault string is the sentence the JSON door sends in error: another delivery with this key is in flight or the recorded state of this key is not one this build understands for a delivery held back, and this delivery already ran (recorded status <status>), but its response was larger than `dedup_max_response_bytes` and was not kept — it cannot be replayed for a replay that cannot be served. Both are a soap:Server (1.1) or soap:Receiver (1.2) fault on 500, with the headers they carry on JSON (below).

The error itself goes to the operator. It carries, for example, the step, the downstream address of a failed connection, HTTP <status> for an HTTP answer that is not 2xx (whose body is in ctx.HTTP_RESPONSE_BODY), the code of a failed gRPC call, and up to 512 characters of an OAuth2 token endpoint’s error body; an error of the flow-state store names the flow and carries the store’s own message, and a correlation refusal carries its own text. It is in the server log and in the request’s L1 record, under the correlation id the caller sent in X-Correlation-ID or X-Request-ID, or the one the platform generated when it sent none. The same id is returned to the caller in the X-Correlation-ID HTTP response header of the Fault and of a successful answer alike, never inside the envelope: a SOAP client reads it from the HTTP headers, and quotes it to support.

When a ## Fault: section itself fails, the record holds both errors: the section’s own first, the original after original error:.

A failed flow that no ## Fault: section handles leaves one flow-error line in the server log, with the tenant and the flow, at the level of its error: WARN for a validate refusal, for a correlation refusal or conflict, and for every other error, whose line reads execution error: …; ERROR for the maximum duration and for a failed ## Fault: section. Only the execution error line carries status, the number sent. A control character in the error text is written as its escape (\n, \r, \u{1}), so the line stays one line. An http_egress call whose request or answer is over the message limit or whose answer is not 2xx also writes a WARN line of its own, naming the step, and so does a grpc_egress call whose request or streamed answer is over its limit, that cannot connect, or that is answered with an error status. Not every failed grpc_egress call writes one: none is written for a call refused because every circuit is open, its contract is unknown or its request does not match the contract, or for one whose answer cannot be converted for the flow. The platform’s own refusals, and its failures outside the flow, keep their own line, none with status; for example ERROR for unavailable state on a flow with deduplication, WARN for a message that lacks the dedup key or for a delivery held back, WARN for an access refusal, a rate limit or a signature refusal at the gate, and a line without the correlation id for internal error.

The fault code and the HTTP status of that fault are decided together, by who made the mistake. When the caller’s message is wrong or lacks something the flow needs — a validate rule that refuses it, a dedup key that comes out empty, a correlation key that cannot be formed — the fault names the sender. Every other failure names the server.

Inbound envelope The caller’s mistake Any other failure
SOAP 1.1 soap:Client on 500 soap:Server on 500
SOAP 1.2 soap:Sender on 400 soap:Receiver on 500

The 1.1 HTTP binding (§6.2) requires a fault to be carried on 500. SOAP 1.2 Part 2 (§7.5.2, Table 20) names 400 for a Sender fault and 500 for every other. A client generated from the WSDL can therefore tell a request that will never be accepted (Client, Sender) from one worth retrying (Server, Receiver) by the code. The JSON door keeps its own statuses (422, 502, 409 and so on); a SOAP caller never sees them. A flow that sets ctx.soap_fault_code keeps its code, and the status follows the code written: soap:Sender is 400 on SOAP 1.2, every other code is 500. A response_status: the flow declares is honoured as before.

A request refused before the flow runs is the caller’s fault, and says so. Two refusals happen before any flow is looked at: an envelope the platform cannot unwrap (a Body that is not the envelope’s own single child is one), and a Body nested deeper than 100 elements (see Limits and constraints). Both answer a SOAP fault whose code names the sender, with the status its version prescribes:

Inbound envelope HTTP status Fault code
SOAP 1.1 500 soap:Client
SOAP 1.2 400 soap:Sender
Envelope in a namespace that is neither SOAP 1.1 nor SOAP 1.2 500 on both versions soap:VersionMismatch

The fault takes the version of the envelope’s namespace. A refused envelope that declares neither version is answered in the content type’s version: SOAP 1.2 for application/soap+xml, SOAP 1.1 otherwise. That is also the version of the VersionMismatch fault, on 500 in both versions; its fault string is Invalid SOAP envelope: envelope namespace is neither SOAP 1.1 nor SOAP 1.2, and it does not repeat the namespace that was sent. On SOAP 1.2 the fault carries an Upgrade header block listing the supported envelopes, SOAP 1.2 first, then SOAP 1.1.

The fault string starts with Invalid SOAP envelope: or Invalid SOAP body:, followed by the reason; for the depth refusal the reason is the limit alone. A Body that is not XML at all is not refused: it reaches the flow as text.

What stays plain HTTP

These answers to a SOAP request are not faults:

  • the access gate’s refusals, 401, 403, 404, 413, 429 and 503, decided before the envelope is read; with a signing secret configured, the 401 for a missing or wrong X-Nexus-Signature;
  • the 404 for a flow or tenant the API key cannot see, the same bytes as the gate’s 404 for an unknown tenant;
  • the 503 when the execution budget is full;
  • a dedup replay of a delivery whose answer was not kept (dedup_memoize_response: false) and whose first answer succeeded: the recorded status with an empty body and X-Nexus-Idempotent-Replay: true.

A dedup delivery that is refused is a fault, and keeps its headers. A delivery held back because another with the same key is in flight carries Retry-After and no replay header. A replay that cannot be served (its answer was over dedup_max_response_bytes, or the recorded state of its key is not one this build understands) carries X-Nexus-Idempotent-Replay: unavailable and X-Nexus-First-Correlation-Id, and no Retry-After. Both are soap:Server or soap:Receiver on 500. The replay of a delivery that failed, when its answer was not kept, is a fault too: the phrase is flow execution failed, the code names the sender when the recorded status is 400 or 422 and the server otherwise, and the replay headers stay. It does not repeat the text of the first answer.

Choosing the fault code and string

A flow can choose the fault code and string by writing two variables before the failure. Each one wins over the default on its own: a flow that sets only the code gets its code and the phrase above.

Variable Effect
ctx.soap_fault_code Fault code, if it is a code of the request’s SOAP version (below); otherwise the default code
ctx.soap_fault_string Fault string, when it holds a string, sent as written, except that a character XML 1.0 does not allow (U+0000 to U+001F other than tab, line feed and carriage return, U+FFFE, U+FFFF) is sent as U+FFFD. It holds even when a ## Fault: section sets it and then fails

The fault code is one of the request’s version, with the soap: prefix the fault declares:

  • SOAP 1.1: soap:Client, soap:Server, soap:VersionMismatch, soap:MustUnderstand. A dot refinement is accepted: soap:Client.Authentication.
  • SOAP 1.2: soap:Sender, soap:Receiver, soap:VersionMismatch, soap:MustUnderstand, soap:DataEncodingUnknown, with no refinement.
  • The match is exact and case-sensitive. Spaces, tabs and line breaks around the code are removed.
  • Anything else is replaced by the default code of the error (above): soap:Client or soap:Server on 1.1, soap:Sender or soap:Receiver on 1.2: no prefix, another prefix, another spelling, the other version’s code, a value that is not a string. The server log gets a WARN that names the flow and the correlation id, not the value.
  • To pick a code that suits both versions, branch on ctx.soap_version.
  • The HTTP status does not depend on the code.

$.fault_error is internal. In a ## Fault: section it holds the error as the operator sees it. It carries, for example, the step, the downstream address of a failed connection, HTTP <status> for an HTTP answer that is not 2xx (whose body is in ctx.HTTP_RESPONSE_BODY), the code of a failed gRPC call, and up to 512 characters of an OAuth2 token endpoint’s error body; an error of the flow-state store names the flow and carries the store’s own message, and a correlation refusal carries its own text. Copying it into ctx.soap_fault_string, or into any response body, sends all of that to the caller. To give the caller something to quote, send a fixed phrase and $.fault_correlation_id.

A ## Fault: section works as it does elsewhere, and its response_status: is honoured for SOAP responses too — so you can answer a validation failure with 400 and a fault envelope. What a SOAP branch does not pick up is the response-header set: it composes its own reply, so response_header_<name>: does not reach it. See Handling failures.

Serving a WSDL

GET /flows/{tenant}/{name}/wsdl

The document is generated from the flow’s front matter:

Front-matter key Meaning
soap_operation Operation name
soap_namespace Target namespace of the generated WSDL

Both keys or neither. A flow that declares only one of the two keys, an empty value, an operation that is not an XML name without a colon (NCName) or a namespace that is not an absolute URI is refused at publication: nexus validate and nexus deploy exit with an error that names the key. There is no default for either one. That is deliberate — an operation name guessed from the flow name, or a namespace invented by the platform, would appear in a contract handed to somebody else’s code generator.

The route needs no credentials, but it does need a visible tenant. A tenant that was never registered, or that has been disabled, gets the same 404 here as everywhere else, so a WSDL request cannot be used to find out which organisations exist on an installation.

A flow with routes: has a WSDL only with serve_by_name: true. Without the key, its WSDL route answers the same 404 as for a flow that is not published. See Served by name.

Terminal window
$ curl http://localhost:9090/flows/acme/order-intake/wsdl

What the generated document contains: one service, one port, one operation, a SOAP 1.1 document/literal binding, and an address pointing at the flow’s /run endpoint.

The operation’s soapAction is the WS-Addressing default action of its input, {namespace}{d}{op}PortType{d}{op}Request, where {namespace} is soap_namespace and {op} is soap_operation. The delimiter {d} is : for a urn: namespace and / for any other; a namespace that already ends with / gets no second one. A non-ASCII letter of the operation is percent-encoded from its UTF-8 bytes, so Notificăre is written Notific%C4%83re. For the operation SubmitOrder:

soap_namespace soapAction
urn:acme:orders urn:acme:orders:SubmitOrderPortType:SubmitOrderRequest
http://acme.example/orders http://acme.example/orders/SubmitOrderPortType/SubmitOrderRequest

A client sends that value, in double quotes, in the SOAPAction header.

What it does not contain: a schema for your messages. The message types are declared as xsd:anyType, because the platform does not know the shape of your payloads — it moves them. The WSDL is enough for a loosely typed client to discover the endpoint and the operation; a client that generates strongly typed stubs from a schema will not get useful types out of it. If the consumer needs a typed contract, hand them a hand-written schema alongside.

The binding is always declared as SOAP 1.1, even for a flow that accepts 1.2 requests.