Skip to content

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:

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.

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.

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
{"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 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.

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.

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.

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

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

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

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