HTTP API
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 |
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.
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"}The two exceptions are a rejected validate rule, which answers {"error": "...", "step": "..."}, and a SOAP request, which gets a SOAP Fault envelope.
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"} — plus WWW-Authenticate: Bearer when nothing was presented at all |
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 |
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; generated if absent |
X-Request-ID |
Fallback source for the correlation ID |
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, 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, or a syntactic validation rule rejected the message |
{"ok":false,"error":"..."} or {"error":"...","step":"..."} |
401 |
A signing secret is configured and X-Nexus-Signature is missing or wrong |
{"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) |
payload reached size limit (plain text) |
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 |
{"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 {"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 |
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 is returned before the flow
starts, so nothing ran and nothing was sent anywhere — unlike every other failure on this list,
where 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: the second delivery of a key
already seen gets the recorded status instead of an execution, 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 |
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{"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 answers 400 with a SOAP Fault; a failed execution answers 500 with a SOAP Fault.
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.
Accepts JSON, or XML when the content type is application/xml. XML is flattened to JSON before
it is stored: child elements become object keys and the root tag is dropped, so
<order><id>A-1</id></order> reaches the flow as {"id":"A-1"} and $.id works.
Reads the same X-Correlation-ID / X-Request-ID / X-Nexus-Signature headers as /run.
| 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, or malformed JSON or XML body | {"ok":false,"error":"..."} |
401 |
Signature missing or wrong | {"ok":false,"error":"signature mismatch"} |
413 |
Body larger than the flow’s max_message_bytes (1 MiB by default) |
payload reached size limit (plain text) |
500 |
The queue could not be written | {"ok":false,"error":"internal error"} |
Plus the gate’s refusals. required_scope is not enforced here: it is
read from the artifact, and /enqueue does not load one.
$ 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.
This route does not check that the flow exists. A message enqueued for a name that was never deployed is accepted, and the worker fails on it once it starts. 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. 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 JMS and Filesystem.
Body is JSON:
| Field | Type | Required | Meaning |
|---|---|---|---|
event_type |
string | yes | connector_received or connector_oversized |
correlation_id |
string | no | Correlation ID to record; generated if absent |
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 it
takes X-Request-ID; if both are absent it generates a UUID v4. The value 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 server does not put the correlation ID in the response. If you want it echoed, put it in the
body from a transform step, or set it on any step with response_header_x_correlation_id:.
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 to its last step and completes every call it makes on the way. The platform has no way to cancel an execution in progress.
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 does not deduplicate
repeated requests on any route. On /enqueue the same applies with one attempt already
acknowledged: 202 is returned before the flow runs, so resubmitting produces a second message
and a second execution.
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
An execution runs to completion 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.
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 and every /run and /enqueue request must carry a
matching X-Nexus-Signature header:
$ nexus serve --port 9090 --secret 's3cr3t-shared-with-the-caller'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 --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. The--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.