Skip to content

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:

Terminal window
$ export NEXUS_KEY=nxk_4f19c0b27ad3489e93f5c1e6a70d2b84

The 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.

Terminal window
$ 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 OK
content-type: application/json
{"orderId":"A-1042","total":249.5,"receivedAt":"2026-08-06T09:14:22Z"}

Rejected by a validate rule:

Terminal window
$ 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).

Terminal window
$ 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 OK
content-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:

  1. SOAP: a SOAP envelope, detected as on /run (text/xml or application/soap+xml, with an Envelope in the first 512 bytes or a SOAPAction header). The envelope is unwrapped and the message is queued as the content of its Body; the flow receives the $ that /run would give it for the same envelope. A Body holding text, not an element, reaches the flow as that string. The Header, the SOAP version and SOAPAction are not queued.
  2. XML: application/xml or text/xml, so text/xml is read as application/xml. An XML body is queued as the text that arrived, byte for byte, and the worker parses it with the parser /run uses, 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.
  3. Text: any other text/* type (text/csv, text/plain, text/html, …). A text/* 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. A charset parameter, when present, must be utf-8 or us-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.
  4. JSON: anything else, including no Content-Type and application/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.

Terminal window
$ 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.

Terminal window
$ 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.

Terminal window
$ 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.

Terminal window
$ 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 Accepted

GET /health

Terminal window
$ 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: forward
effects: [http_egress]
endpoint: https://orders.example.com/ingest
method: POST
headers: {"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:

Terminal window
$ sudo grep NEXUS_WEBHOOK_SECRET /etc/nexus/nexus.env
NEXUS_WEBHOOK_SECRET=s3cr3t-shared-with-the-caller
$ sudo systemctl restart nexus

Here 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.

Terminal window
$ 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 OK

Sign 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 on POST .../run, POST .../enqueue, POST /connector/events/... and GET /flows/{tenant}/{name} unless the server was started with --no-auth; GET /health and GET .../wsdl are public. The NEXUS_WEBHOOK_SECRET signature is a separate, server-wide check, not a substitute for a key.
  • There is no route to run a specific version of a flow; /run always executes latest.
  • There is no route to read a flow’s output after an /enqueue; the result reaches the log and whatever the flow sends onward.