HTTP API reference
Every route the runtime serves, what it accepts, and every status it can answer with.
Start the server with nexus serve; the examples below assume
http://localhost:9090.
| Method | Path | Purpose | Auth |
|---|---|---|---|
POST |
/flows/{tenant}/{name}/run |
Execute a flow and return its output | key |
POST |
/flows/{tenant}/{name}/enqueue |
Persist a message for asynchronous execution | key |
GET |
/flows/{tenant}/{name} |
Artifact metadata for the deployed flow | key |
GET |
/flows/{tenant}/{name}/wsdl |
Generated WSDL 1.1 document | public, for a visible tenant |
POST |
/connector/events/{tenant}/{flow} |
Report a connector event to the log | key |
GET |
/health |
Liveness check | public |
The four /flows/{tenant}/{name} routes serve a flow by its name. A flow that declares routes:
is served by them only when it also declares serve_by_name: true; otherwise they answer for it as
for a flow that is not published. See Served by name.
There is one more door, and it is not an HTTP route: with nexus serve --grpc-port <p> a flow
declaring inbound: grpc answers gRPC calls on a separate port. It goes through the same gate
as the routes above, with the credential in authorization: Bearer metadata, and the same delivery
bracket — so deduplication, correlation and the size ceiling apply there without anything extra.
See Exposing a service.
Conventions
$NEXUS_KEY. Every route marked key needs Authorization: Bearer <key>, and the examples
below pass it as $NEXUS_KEY. Issue one with
nexus keys create — it is printed once, at creation:
$ export NEXUS_KEY=nxk_4f19c0b27ad3489e93f5c1e6a70d2b84The two public routes are shown without it, which is the difference being illustrated: /health and
/wsdl answer to anyone who can reach the port — /wsdl only for a tenant that is registered and
enabled. A server started with --no-auth accepts the header too, and ignores it.
Path parameters. {tenant} and {name} (and {flow} on the connector route) are matched
against the rule a name has to meet to be published at all — the rule for a name that becomes a
path: lowercase letters, digits,
- and _, starting with a letter or digit, 1 to 128 bytes. Anything else is 400 with the body
{"ok":false,"error":"invalid flow identifier"}, before the flow is looked up. This is the same
predicate the compiler applies, so a name that publishes is a name every route accepts, and a
segment with an uppercase letter or a dot is refused at the door even where an older build accepted
it.
Methods. A method a route on this page does not accept is answered 405 with an Allow header
naming the methods it does, before the gate: GET /flows/acme/orders/run gets 405 and
Allow: POST, with or without a key.
Which version runs. The execution and metadata routes always resolve the latest ref for
{tenant}/{name} — the version most recently deployed. There is no way to address an older
version over HTTP.
Error body. Failures answer with a JSON object:
{"ok": false, "error": "flow 'acme/orders' not found"}"ok": false is what tells a platform error from the flow’s own answer, which never has the
key unless the flow writes it. A refusal that names a step adds step: a rejected validate
rule and a delivery that lost its claim answer {"ok": false, "error": "...", "step": "..."},
and a flow over flow_max_duration_secs also adds limit_secs. A key the refusal does not carry
is absent, never null. A SOAP request gets a SOAP Fault envelope instead.
Refusals at the gate
Every route marked key can answer with one of these instead of running. They are listed once,
here, rather than repeated in each route’s table below.
| Status | When | Body |
|---|---|---|
401 |
No Authorization header, a malformed one, or a key that is unknown, revoked or expired |
{"ok":false,"error":"unauthorized"}, with WWW-Authenticate: Bearer when no key or a malformed header was presented, and WWW-Authenticate: Bearer error="invalid_token" for a key that is unknown, revoked or expired |
403 |
The key lacks the flow’s required_scope, or the flow is not in the key’s allowlist |
{"ok":false,"error":"forbidden: insufficient scope"} / "forbidden: flow not permitted for this key" |
404 |
The tenant is not registered, or is disabled | {"ok":false,"error":"flow 'acme/orders' not found"} — the same body as a flow that is not deployed |
429 |
The key’s rate limit is exhausted | {"ok":false,"error":"rate limit exceeded"}, with Retry-After and X-RateLimit-Limit / -Remaining / -Reset |
503 |
The gate could not reach storage to decide | {"ok":false,"error":"authorization temporarily unavailable"} |
Four of these are deliberate, and worth stating rather than discovering:
A disabled tenant is 404, not 403, with the same body, word for word, as a tenant that never
existed. Whether a name is a customer of this installation is not something a caller gets to learn
by being refused.
The gate runs before the body is read. So an unauthenticated caller announcing an oversized body
gets 401, not 413 — the size ceiling is platform state, and is not disclosed before
authentication. Nothing is read from the registry, from the artifact store, or from the request body
until the gate has decided.
503 is never a 401. If storage cannot answer, the gate did not decide, and it says so rather
than guessing in either direction. Retry it; do not treat it as a bad key.
A refusal costs a rate-limit token, on every route. Including the two that charge nothing on
success (/connector/events and the metadata route): the exemption there is so that a connector does
not pay twice for its normal traffic, and a refusal is not normal traffic. A caller that keeps
presenting a bad scope will eventually get 429 instead of 403.
POST /flows/{tenant}/{name}/run
Executes the flow synchronously and returns the value the last step produced.
Request content types
Content-Type |
Parsed as |
|---|---|
application/json (or anything unrecognised) |
JSON |
application/xml |
XML, kept as an XML value throughout the flow |
text/xml, application/soap+xml |
SOAP, if an envelope is detected — see below |
Letter case does not matter, as HTTP specifies for media types: Application/XML is
application/xml, and TEXT/XML is text/xml.
A flow that declares body: opaque parses nothing. Any Content-Type, or none, is accepted, the
body reaches the flow as the bytes that arrived, and the answer is the last back-end answer’s
bytes with its Content-Type, or application/octet-stream when it had none. A request with a
Content-Encoding other than identity is refused with 415: the flow does not run. See
Relaying a body unread.
A request counts as SOAP when the content type contains text/xml or application/soap+xml
and either a SOAPAction header is present or an Envelope element appears in the first
512 bytes of the body. application/soap+xml, or the SOAP 1.2 namespace in the body, selects
SOAP 1.2; otherwise SOAP 1.1.
Request headers
| Header | Meaning |
|---|---|
X-Correlation-ID |
Correlation ID for this execution, if valid; see Correlation IDs |
X-Request-ID |
Used as the correlation ID when X-Correlation-ID is absent or invalid; it is not echoed, X-Correlation-ID is the one answer header |
X-Nexus-Signature |
HMAC of the body; required when the server runs with a signing secret |
SOAPAction |
Marks the request as SOAP and lands in ctx.soap_action |
Every other request header is readable inside the flow as ctx.headers.<lowercase-name>.
Duplicates are joined with ", ". Hop-by-hop headers are stripped, and authorization,
proxy-authorization, client-cert, cookie and x-api-key never enter the flow context.
Responses
| Status | When | Body |
|---|---|---|
200 |
The flow ran to completion, and did not declare a status of its own | The flow’s output, unwrapped — see The response body |
400 |
Bad path component, malformed JSON or XML body, a JSON body that is not I-JSON (a member name repeated in one object, or a Unicode noncharacter: see Inbound JSON), an XML body nested deeper than 100 elements, the flow’s dedup_key: produced nothing from the message or could not be read from it, or a syntactic validation rule rejected the message |
{"ok":false,"error":"..."} or {"ok":false,"error":"...","step":"..."} |
401 |
A signing secret is configured and X-Nexus-Signature is absent, or holds a byte other than visible ASCII, a space or a tab. Recorded as access_denied (signature_missing) |
{"ok":false,"error":"missing X-Nexus-Signature header"} |
401 |
A signing secret is configured and X-Nexus-Signature is present but wrong. Recorded as access_denied (signature_mismatch) |
{"ok":false,"error":"signature mismatch"} |
404 |
No flow deployed under {tenant}/{name} |
{"ok":false,"error":"flow '...' not found"} |
413 |
Body larger than the flow’s max_message_bytes (1 MiB by default) |
{"ok":false,"error":"message body ... exceeds the ...-byte limit for this flow ..."} |
415 |
The flow declares body: opaque and the request has a Content-Encoding other than identity. Nothing is run or queued; recorded as delivery_refused (encoded_opaque_body) |
{"ok":false,"error":"an opaque body is relayed as received; Content-Encoding is not accepted"} |
409 |
A correlation is already open for the same key | {"ok":false,"error":"a correlation is already open for this key..."} |
422 |
A semantic validation rule rejected the message, or a correlation was refused |
{"ok":false,"error":"...","step":"..."} or {"ok":false,"error":"..."} |
500 |
A defect in the flow, a ## Fault: handler that itself failed, the stored artifact failing its integrity check, or the flow hitting flow_max_duration_secs |
{"ok":false,"error":"flow execution failed"}, {"ok":false,"error":"fault handler failed"}, {"ok":false,"error":"internal error"} or {"ok":false,"error":"flow exceeded its maximum duration","step":"...","limit_secs":N} |
502 |
A call out of the flow did not succeed | {"ok":false,"error":"flow execution failed"} |
503 |
Too many flow executions already in flight | {"ok":false,"error":"server busy: too many flow executions in flight"}, with Retry-After |
503 |
The flow uses deduplication or correlation and its state cannot be read or written right now | {"ok":false,"error":"flow state unavailable: this flow cannot be delivered right now"}, with Retry-After: 1 only when the state store of a flow with deduplication failed; a correlation state error answers without it |
503 |
The flow uses deduplication and the delivery lost its claim on the key — released with nexus state release, or not confirmed in time — so it stopped before a step |
{"ok":false,"error":"flow execution stopped before it finished; some of its steps may have run","step":"..."}, with Retry-After: 1 |
Without a ## Fault: section, a validate rule answers its own
message in error, as rendered, and a rule whose message renders empty answers
{"ok":false,"error":"validate rule failed","step":"..."}; every other error the flow raises answers the
platform’s fixed phrase, never the error’s own text. With one, a failure in the main step sequence
runs the section, unless the delivery lost its claim on its deduplication key, and the answer is
what it produces; it carries the error’s own text only if the
section copies $.fault_error into it. When no section handles the error, the error itself, with
the step and any downstream address, is in the server log and the request’s L1 record, under its
correlation id. A section that itself fails has not handled the error: the record then holds both, the
section’s own error first and the original after original error:. When the section handles it, the request’s verdict in L1 is a completed event
whose outcome reads fault_handled, without an error. Its payload is the answer the section
produced, so on a flow whose log_level keeps payloads it holds the error’s text if the section
copied it there.
Plus the gate’s refusals, which come first and are the only statuses this
route can answer without reading the body. Note that 401 therefore has two sources: a missing or
invalid API key, checked first because it needs no body, and the webhook signature, checked second
because it does.
503 is the one refusal that is safe to retry immediately. It does not always mean that nothing
ran. Too many executions in flight is answered before the flow starts, so nothing ran and nothing
was sent anywhere. Flow state is also answered 503 when a run that ended could not record its
outcome, and a lost claim stops a run whose steps may already have run: there, as with every other
failure on this list, an execution that began has already produced its
effects. Honour Retry-After, and prefer jittered
backoff if you are one of many clients: a fleet that all retries after exactly one second recreates
the saturation it is backing off from.
A flow with a ## Fault: section that handles the error answers with the status the error
would otherwise have produced — the mapping is the table in
Handling failures, one list read by every door — and
the fault steps’ own output as the body, unwrapped like any other. That status is the only signal that a
fault handler ran; there is no field in the body that says so. A fault step can override it with
response_status: and add headers with response_header_<name>:. See
Handling failures.
A flow that declares dedup_key: adds one more shape of answer. While the key is remembered, a
delivery of a key whose earlier execution finished without failing, or failed under
dedup_on_failure: absorb, is answered from the record instead of running, marked with
X-Nexus-Idempotent-Replay and X-Nexus-First-Correlation-Id. What those headers carry — and the
one surprise in the second one — is described once, under
Deduplication.
The response body
The body is the flow’s output, unwrapped. There is no envelope — no ok field, no output
field. Whatever the last step produced is what goes on the wire, and the form of that value
decides the content type:
| The flow’s output | Body on the wire | Content-Type |
|---|---|---|
| Anything with a JSON representation — object, array, number, boolean | that JSON | application/json |
| A string | the text, verbatim — no quotes, no escaping | text/plain; charset=utf-8 |
| Null | no bytes at all | absent |
| An XML document | the serialised document | application/xml; charset=utf-8 |
The body of a body: opaque flow |
its bytes, unchanged | the last back-end answer’s, verbatim; otherwise application/octet-stream |
charset=utf-8 on text/plain is not decoration: RFC 2616 §3.7.1 defaults text/* to
ISO-8859-1, so without it every non-ASCII character reaches the caller as mojibake.
The consequence a client has to code for: success is the status code, and nothing else. There
is no field in the body to branch on. Until 1.0 there was one — every JSON answer arrived as
{"ok":…,"output":…} — and removing it was a deliberate break, because the envelope could not
describe two of the four rows above. An XML document has no place inside it that does not escape
the document into a JSON string, so XML answers already came bare and already carried no ok
field; and a string output arrived quoted, so a caller expecting text had to unquote it before
using it. One contract that holds for every output beats an envelope with two exceptions.
So a client that calls a mix of flows branches on the response Content-Type. If you control the
flow, the shape is a decision you already made when you wrote its last step: build an object and
the caller gets JSON, build an element and it gets the document, produce nothing and it gets an
empty body.
A flow can override both halves of this. response_status: on any step sets the status —
including passing the backend’s own through with response_status: "{{ ctx.HTTP_SC }}" — and
response_header_<name>: sets or replaces a response header, which is also how a flow overrides
the content type derived from the table above. A response_headers: list in the frontmatter passes
named headers back from the backend the flow called. See Handling failures.
$ curl -i -X POST http://localhost:9090/flows/acme/forward-order/run \ -H "Authorization: Bearer $NEXUS_KEY" \ -H 'Content-Type: application/json' \ -H 'X-Correlation-ID: 7f3a1c02-9b6e-4f11-8a2d-0d55c3b41e90' \ -d '{"orderId":"A-1042","total":249.5}'
HTTP/1.1 200 OKcontent-type: application/json
{"orderId":"A-1042","total":249.5,"receivedAt":"2026-08-06T09:14:22Z"}Rejected by a validate rule:
$ curl -i -X POST http://localhost:9090/flows/acme/forward-order/run \ -H "Authorization: Bearer $NEXUS_KEY" \ -H 'Content-Type: application/json' -d '{"total":249.5}'
HTTP/1.1 400 Bad Request{"ok":false,"error":"orderId is required","step":"check-input"}SOAP. A SOAP request gets a SOAP response. The flow sees the content of <soap:Body>, and
whatever the last step produces is wrapped back into an envelope with the matching content type
(text/xml; charset=utf-8 for 1.1, application/soap+xml; charset=utf-8 for 1.2).
$ curl -i -X POST http://localhost:9090/flows/acme/meter-sync/run \ -H "Authorization: Bearer $NEXUS_KEY" \ -H 'Content-Type: text/xml; charset=utf-8' \ -H 'SOAPAction: SyncMeters' \ -d '<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"> <soap:Body><SyncMeters><meterId>M-77</meterId></SyncMeters></soap:Body> </soap:Envelope>'
HTTP/1.1 200 OKcontent-type: text/xml; charset=utf-8
<?xml version="1.0" encoding="UTF-8"?><soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Header/><soap:Body><SyncMetersResponse><accepted>true</accepted></SyncMetersResponse></soap:Body></soap:Envelope>If the flow’s output is not XML, it is still returned inside an envelope, wrapped in
<nexus:RawContent>, with an extra X-Nexus-Soap-Warning: non-xml-output header. A malformed
envelope, or a Body nested deeper than 100 elements, is refused before the flow runs with a SOAP
Fault that puts the fault on the caller: 500 with soap:Client on SOAP 1.1, whose HTTP binding
carries every fault on 500, and 400 with soap:Sender on SOAP 1.2. A failed execution answers a
SOAP Fault too, with the status its version prescribes; see SOAP and WSDL.
POST /flows/{tenant}/{name}/enqueue
Writes the message to the flow’s write-ahead log and returns immediately. A worker picks it up and executes the flow with the retry policy compiled into the artifact.
202 is a promise about storage, not about processing. A server runs a bounded number of queue
workers. Once that ceiling is reached, a queue that does not already have one will not get one until
the server restarts — its messages stay durable and unprocessed, and the condition is recorded
server-side rather than signalled to you. If a queue matters, watch that it is draining instead of
inferring it from 202.
The body is read in one of four forms, chosen in this order; the type and subtype of
Content-Type are compared in any letter case:
- SOAP: a SOAP envelope, detected as on
/run(text/xmlorapplication/soap+xml, with anEnvelopein the first 512 bytes or aSOAPActionheader). The envelope is unwrapped and the message is queued as the content of its Body; the flow receives the$that/runwould give it for the same envelope. A Body holding text, not an element, reaches the flow as that string. The Header, the SOAP version andSOAPActionare not queued. - XML:
application/xmlortext/xml, sotext/xmlis read asapplication/xml. An XML body is queued as the text that arrived, byte for byte, and the worker parses it with the parser/runuses, so the flow gets the same$on both: attributes, namespaces, repeated elements and text, in document order. See Queued submission. Messages queued before the upgrade arrive in the old, flattened shape. - Text: any other
text/*type (text/csv,text/plain,text/html, …). Atext/*body reaches the flow as a string: the text that arrived, unchanged, a byte-order mark and CRLF line ends included; an empty body is the empty string. Acharsetparameter, when present, must beutf-8orus-ascii, and the body must be UTF-8. The content type itself is not queued. The log records a text body as its size and digest, never its content: see A string with no field name. - JSON: anything else, including no
Content-Typeandapplication/octet-stream.
A malformed envelope on /enqueue gets a JSON 400, never a SOAP Fault, with nothing queued.
A flow that declares body: opaque is the exception: any Content-Type, or none, is queued with
the bytes that arrived, and the worker sends those bytes. A Content-Encoding other than
identity is refused with 415 and nothing is written.
Reads the same X-Correlation-ID / X-Request-ID / X-Nexus-Signature headers as /run, and
applies the same correlation ID rule. The ID is decided before the gate, and
an accepted message carries it in the queue. The 202 answer returns it in the X-Correlation-ID header, and the body is unchanged: it is the ID the worker uses and the journal shows.
| Status | When | Body |
|---|---|---|
202 |
Message durably written | {"queued":true,"offset":N,"tenant":"...","flow":"..."} |
503 |
— | /enqueue is never refused for load; writing to the queue costs no execution budget |
400 |
Bad path component, malformed JSON or XML body, a JSON body that is not I-JSON (a member name repeated in one object, or a Unicode noncharacter: see Inbound JSON), an XML body nested deeper than 100 elements or not UTF-8 (invalid XML body: the body is not UTF-8), a SOAP envelope that does not unwrap or is not UTF-8 (invalid SOAP envelope: ..., never a SOAP Fault), a text body whose charset is not UTF-8 (invalid text body: the charset is not UTF-8) or whose bytes are not (invalid text body: the body is not UTF-8), or a JSON body nested 127 levels (126 are accepted): the message was not queued: its JSON is nested too deeply; send it with fewer levels of nesting. Nothing is written. A refused body is recorded as delivery_refused: message_unqueueable for the JSON nesting, json_not_i_json for a body I-JSON forbids, xml_too_deep for an XML body nested too deeply, soap_envelope_malformed for an envelope that does not unwrap, body_malformed for the others |
{"ok":false,"error":"..."} |
401 |
Signature absent, or holding a byte other than visible ASCII, a space or a tab. Nothing is written; recorded as access_denied (signature_missing) |
{"ok":false,"error":"missing X-Nexus-Signature header"} |
401 |
Signature present but wrong. Nothing is written; recorded as access_denied (signature_mismatch) |
{"ok":false,"error":"signature mismatch"} |
404 |
No flow published under {tenant}/{name}; nothing is written |
{"ok":false,"error":"flow '...' not found"} |
413 |
Body larger than the flow’s max_message_bytes (1 MiB by default) |
{"ok":false,"error":"message body ... exceeds the ...-byte limit for this flow ..."} |
415 |
The flow declares body: opaque and the request has a Content-Encoding other than identity. Nothing is run or queued; recorded as delivery_refused (encoded_opaque_body) |
{"ok":false,"error":"an opaque body is relayed as received; Content-Encoding is not accepted"} |
500 |
The queue could not be written | {"ok":false,"error":"internal error"} |
Plus the gate’s refusals. required_scope is enforced here as on /run: the
artifact is loaded once the body is read, and a key without the flow’s scope gets the gate’s 403
before anything is written.
$ curl -i -X POST http://localhost:9090/flows/acme/order-processor/enqueue \ -H "Authorization: Bearer $NEXUS_KEY" \ -H 'Content-Type: application/json' \ -d '{"orderId":"A-1042","total":249.5}'
HTTP/1.1 202 Accepted{"queued":true,"offset":8192,"tenant":"acme","flow":"order-processor"}offset is the byte position of the record in the write-ahead log. It identifies the message
for the rest of its life, and it appears in the log lines the worker writes.
The flow must be published before a message is accepted. A 404 has three causes, and nothing is
written for any of them: no flow has been deployed under {tenant}/{name}; the tenant is not
registered or is disabled, which the gate answers (see Refusals at the gate);
or the flow declares routes: without serve_by_name: true, so it is not served by its name (see
Served by name). All three give the body /run
gives, so they cannot be told apart from outside. A deployed flow is never
withdrawn: no command removes one, and the next nexus deploy replaces it. The body is read before
the name is looked up, so a malformed body sent to a name nothing is published under is 400, not
404. See
Queues, retries and the DLQ.
GET /flows/{tenant}/{name}
Metadata for the currently deployed version. No body; needs a key.
| Status | When |
|---|---|
200 |
Flow found |
400 |
Bad path component |
404 |
No flow deployed under {tenant}/{name} |
500 |
Storage error |
Plus the gate’s refusals. A refusal is recorded under the correlation ID
taken from X-Correlation-ID or X-Request-ID by the same rule as /run, and the refusal returns that ID in the X-Correlation-ID header (the ID in the answer).
This route is behind the gate although it reads
nothing but registry state, precisely because registry state — which version is live, its content
hash, when it was deployed — is internal, and answering it to anyone would say more about an
installation than its own callers need.
$ curl -H "Authorization: Bearer $NEXUS_KEY" \ http://localhost:9090/flows/acme/forward-order
{"tenant":"acme","name":"forward-order","version":"1.2.0","hash":"9f2b7c4d1ae05836b1c0d4f7a83e2916d5b0c7314ea9f6d8b2c5107e43ab9d02","created_at":"2026-08-04T11:02:37Z"}hash is the content address of the compiled artifact. It changes whenever the compiled output
changes, which makes it a reliable way to tell two deployments apart.
GET /flows/{tenant}/{name}/wsdl
Returns a WSDL 1.1 document/literal description of the flow. Only flows that declare
soap_operation and soap_namespace in their front matter have one.
The service address in the document is built from the request’s Host header and the connection
scheme, so calling through a reverse proxy that sets Host correctly yields a usable address.
| Status | When |
|---|---|
200 |
WSDL generated; Content-Type: text/xml; charset=utf-8 |
400 |
Bad path component |
404 |
Flow not found, the flow has no SOAP configuration, or the tenant is not visible |
500 |
Storage error |
Public, deliberately. A WSDL is a discovery contract: svcutil and wsimport fetch it without
credentials, and requiring a key would mean handing one to every developer who only needs to generate
a client. Put the port behind a network boundary if that is not acceptable for your installation —
there is no per-route switch.
Public means public for a visible tenant. A tenant that was never registered, or that is
disabled, gets the same 404 here as it gets on every other route with a tenant in the path — the
body of a flow that does not exist, in place of every other answer this route could give. That is
what keeps a disabled tenant indistinguishable from one that never existed. The check does not
involve the gate, so it applies under --no-auth too, and it costs one primary-key lookup per
request.
$ curl http://localhost:9090/flows/acme/meter-sync/wsdl
<?xml version="1.0" encoding="UTF-8"?><wsdl:definitions name="meter-sync" targetNamespace="urn:acme:meter-sync" xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:tns="urn:acme:meter-sync"> ... <wsdl:service name="meter-syncService"> <wsdl:port name="SyncMetersPort" binding="tns:SyncMetersBinding"> <soap:address location="http://localhost:9090/flows/acme/meter-sync/run"/> </wsdl:port> </wsdl:service></wsdl:definitions>The message schema is xsd:anyType: the platform does not know the shape of your messages, so
the document describes the operation and the binding, not the payload. The binding declared is
always SOAP 1.1, even though the /run route accepts both versions. The published address is the
flow’s /run route, which is the only one that reads a SOAP envelope — until 2026-08-20 it was
generated without the /run, so a generated client called a path with no POST handler and got
404 on its first request.
POST /connector/events/{tenant}/{flow}
Records an event from a connector in the structured log. Connectors call this so that a message picked up from a queue or a directory appears in the same log as an HTTP request, under the same correlation ID. See The JMS connector and The filesystem connector.
Body is JSON:
| Field | Type | Required | Meaning |
|---|---|---|---|
event_type |
string | yes | connector_received or connector_oversized |
correlation_id |
string | no | Correlation ID to record, under the same rule as the headers; generated if absent, null or invalid |
message_id |
string | no | The connector’s own identifier for the message |
worker_id |
number | no | Which connector worker handled it |
body_bytes |
number | no | Size of the message the connector saw |
worker_id and body_bytes are recorded only when message_id is also present.
| Status | When |
|---|---|
202 |
Event accepted; empty body |
400 |
Bad path component, unparseable body, or an event_type that is neither of the two values |
500 |
Internal error |
Plus the gate’s refusals. A successful report is not rate-limited: the
bucket is per credential, so a connector sharing its key with /run would spend twice per message
and quietly lower its own /run ceiling. A refusal still costs a token. The body limit here is 64
KiB, not the flow’s max_message_bytes — an event report is control-plane metadata, not a message.
$ curl -i -X POST http://localhost:9090/connector/events/acme/order-processor \ -H "Authorization: Bearer $NEXUS_KEY" \ -H 'Content-Type: application/json' \ -d '{"event_type":"connector_oversized", "correlation_id":"7f3a1c02-9b6e-4f11-8a2d-0d55c3b41e90", "message_id":"ID:queue-42-991","worker_id":2,"body_bytes":4194304}'
HTTP/1.1 202 AcceptedGET /health
$ curl http://localhost:9090/health
{"ok":true}Always 200 while the process is listening. It does not touch the database, so it tells you the
server is up, not that storage is healthy.
Correlation IDs
Every execution carries one. The server takes it from X-Correlation-ID; if that is absent or
invalid it takes X-Request-ID; if both are absent or invalid it generates a UUID v4.
A value is valid when, after spaces and tabs at either end are trimmed, it is 1 to 128 characters
of A-Z a-z 0-9 . _ : -; the trimmed value is used. A UUID, a W3C trace id, a traceparent value
and a JMS ID: message id all fit. An invalid value is replaced whole, never cut or cleaned up,
and the request is not refused for it. An empty X-Correlation-ID counts as invalid. Each replaced value leaves one INFO line in the
server log, caller correlation id not adopted, with the header’s name, the reason and the length,
never the value; see Observability.
The ID is written to every log event for the execution and is readable inside the flow as
ctx.correlation_id, which makes it easy to pass on to a downstream system:
## Step: forwardeffects: [http_egress]endpoint: https://orders.example.com/ingestmethod: POSTheaders: {"X-Correlation-ID": "{{ ctx.correlation_id }}"}The ID in the answer
Every answer of these doors carries the ID in the X-Correlation-ID response header: a flow’s
output, a fault, a SOAP Fault, a 202 from /enqueue or /connector/events, and every refusal,
including the gate’s 401, 403 and 429, a 404, a 413, a 400 for a malformed path segment and
a 409 or 422. It is the ID the log and the journal carry for that request, so a caller that gets
an error quotes it to whoever runs the platform. The value is always one that passed the rule above:
an ID you sent that is valid comes back as sent, anything else comes back as the generated UUID, and
a refused value is never echoed. A request refused before the platform could tell who is calling still
gets its own valid ID, or a new one.
A reverse proxy in front must pass the header through. A flow cannot set or change it:
response_header_x_correlation_id:, the same key in a ## Fault: section and x-correlation-id in
response_headers: are refused when the flow is published, and a flow published before this
rule has its value overwritten. Copying the ID into the body from a transform step is still
possible, with {{ ctx.correlation_id }}. A message replayed from the dead-letter queue and the
cron and queue workers have no caller to answer; their ID is in the journal.
On a deduplicated replay X-Correlation-ID is the ID of this delivery, and
X-Nexus-First-Correlation-Id still names the delivery that opened the key.
Timeouts and retries
Abandoning a request does not abandon the work. If your client times out, if you close the connection, or if a proxy in front of the server cuts the request short, the flow keeps running and completes every call it makes on the way. Nothing a caller does stops an execution in progress. Apart from a step that fails, two things end one between its steps, the execution ceiling and a lost claim; neither interrupts a step that has started.
So a timeout on /run is not an answer. It says no response arrived within your budget; it says
nothing about whether the flow ran. Retry only with an operation that is safe to perform twice,
and reuse the same identifying key on every attempt. The platform deduplicates only a flow that
declares dedup_key: (Deduplication); without it,
two identical requests are two executions. With it, while the key is remembered, a retry of a
key whose earlier execution finished without failing does not run the flow again: /run
answers from the record, with the X-Nexus-Idempotent-Replay header. A retry while that
execution still holds its claim on the key gets 409 with Retry-After on JSON. After a
failure, even one a ## Fault: section answered, the retry runs the flow again, unless the flow
sets dedup_on_failure: absorb.
On /enqueue, 202 is returned before the flow runs, so resubmitting produces a second
message. Without dedup_key: it is a second execution. With it, the worker claims the key when
it reaches that message, and the same rules hold: the message is acknowledged without running
once the earlier execution finished without failing, and held back, without spending an attempt,
while that execution still holds its claim on the key; after a failure it runs the flow again,
unless the flow sets dedup_on_failure: absorb, in which case it is acknowledged without running.
Bound the work instead of interrupting it: set read_timeout_ms and connect_timeout_ms on every
outbound step, keep your own client timeout above their sum, and read
What stops a started execution before you
design a retry policy.
Request size
Bodies on /run and /enqueue are capped at 1 MiB by default, and the cap is configurable per
flow with max_message_bytes: in frontmatter — the ceiling belongs to the access point, so two flows
in one installation can have different ones. Over the cap, the request is answered with 413 and the
flow never runs.
The cap is applied before the body is read, not after: it is resolved from the registry before the
first byte is pulled, so refusing a 100 MB request does not cost 100 MB of memory. A declared
Content-Length over the cap is refused without pulling a chunk. See
Limits and constraints for all three levels and for what a message above
the platform’s transform threshold can still be used for.
This paragraph said “the limit is fixed; it is not configurable” until 2026-08-12. It was true when written and stopped being true a day earlier, which is the failure mode this page is most prone to.
Depth is bounded apart from size: an XML body nested deeper than 100 elements is refused with
400, or with a SOAP Fault on a SOAP request, whatever its size. The bound is fixed; see
Limits and constraints.
The connector event route keeps its own 64 KiB cap and reports it as 400 invalid JSON, because it
parses the body as JSON rather than reading it raw.
The cap is on the inbound request only. What a flow sends outbound, and what it receives back from a downstream system, is bounded separately — see Limits and constraints.
Webhook signatures
Start the server with a signing secret in NEXUS_WEBHOOK_SECRET and every /run and /enqueue
request must carry a matching X-Nexus-Signature header. The variable goes in the service’s
environment (EnvironmentFile, .env), never in the command, where every user of the host can
read it in the process list:
$ sudo grep NEXUS_WEBHOOK_SECRET /etc/nexus/nexus.envNEXUS_WEBHOOK_SECRET=s3cr3t-shared-with-the-caller$ sudo systemctl restart nexusHere systemd reads the file, through the unit’s EnvironmentFile= (see
Installing); the binary never opens it.
The header value is the hex-encoded HMAC-SHA256 of the raw request body, keyed with the secret. The signature is checked before the body is parsed, so a tampered payload is rejected without ever reaching a parser. Comparison is constant time.
$ BODY='{"orderId":"A-1042","total":249.5}'$ SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac 's3cr3t-shared-with-the-caller' -hex \ | awk '{print $2}')
$ curl -i -X POST http://localhost:9090/flows/acme/forward-order/run \ -H "Authorization: Bearer $NEXUS_KEY" \ -H 'Content-Type: application/json' \ -H "X-Nexus-Signature: $SIG" \ -d "$BODY"
HTTP/1.1 200 OKSign the bytes you send, exactly. Re-serialising the JSON before signing — a different key
order, added whitespace — produces a different digest and a 401.
Without NEXUS_WEBHOOK_SECRET, no signature is checked and the header is ignored. The secret is
server-wide, not per tenant.
The key is checked before the signature, because the key needs no body and the signature needs
the whole one. With the gate on, a webhook sender therefore needs both: a key to get past the gate,
and the signature to get past this check. With --no-auth the gate does not run and the signature is
the only control on the route, as it was before the gate existed.
Not supported
- There is no anonymous access to a flow.
Authorization: Bearer <nxk_...>is required onPOST .../run,POST .../enqueue,POST /connector/events/...andGET /flows/{tenant}/{name}unless the server was started with--no-auth;GET /healthandGET .../wsdlare public. TheNEXUS_WEBHOOK_SECRETsignature is a separate, server-wide check, not a substitute for a key. - There is no route to run a specific version of a flow;
/runalways executeslatest. - There is no route to read a flow’s output after an
/enqueue; the result reaches the log and whatever the flow sends onward.