Outbound HTTP calls
http_egress sends an HTTP request and replaces the current message with the response. This page
is the complete reference: every step key, how the body is encoded and decoded, and what happens
when the other end answers with an error.
The smallest useful form is a step with no fence — the current message goes out as the request body, and the response comes back as the new message:
---flowmarkdown_version: "0.1"flow: forward-ordertenant: acmeeffects: [http_egress]---
## Step: forwardeffects: [http_egress]endpoint: https://orders.example.com/ingestmethod: POSTStep keys
| Key | Type | Default | Meaning |
|---|---|---|---|
endpoint |
string | required | Full URL to call |
method |
string | POST |
HTTP method |
content_type |
string | application/json |
Content-Type of the request, and how the body is encoded |
accept |
string | application/json; */* in a body: opaque flow |
Accept header sent with the request |
bearer_token |
string | none | Sends Authorization: Bearer <value> |
headers |
inline JSON object | none | Additional request headers |
connect_timeout_ms |
integer > 0 | 10000 |
Budget for establishing the connection |
read_timeout_ms |
integer > 0 | 30000 |
Budget for the whole request |
breaker_failure_threshold |
integer ≥ 1 | 3 |
Refused connections in a row before the destination is skipped — see Circuit breaker |
breaker_window_ms |
integer ≥ 1 | 60000 |
How long a refused connection stays relevant |
breaker_cooldown_ms |
integer | 30000 |
How long a skipped destination rests before a probe. Must be at least connect_timeout_ms |
breaker_cooldown_factor |
number ≥ 1.0 | 2.0 |
Multiplier on the cooldown after a failed probe |
breaker_cooldown_max_ms |
integer | 300000 |
Ceiling for the escalating cooldown. Must be at least breaker_cooldown_ms |
error_body_max_bytes |
integer | 4096 |
How much of a non-2xx response body is kept in ctx.HTTP_RESPONSE_BODY — see Reading an error response |
max_message_bytes |
integer | from the flow, then the platform | Ceiling on the body this step sends and on the response it accepts — see Limits |
tls_ca_cert |
path | none | PEM file of the CA certificates the server’s chain must end at; replaces the built-in roots — see TLS |
tls_client_cert |
path | none | PEM client certificate, presented for mutual TLS. Requires tls_client_key |
tls_client_key |
path | none | PEM private key of tls_client_cert. Requires tls_client_cert |
A timeout that is not a positive integer is a compile error, so read_timeout_ms: 5s never reaches
production as “the default”.
The step also accepts soap_version: and its companions, which change the encoding entirely — see
SOAP and WSDL.
endpoint
Required. The URL is built from what you write: literal text and {{ expr }} expressions,
evaluated per request.
{{ expr }} interpolation. The endpoint is a template, compiled at deploy time and evaluated
per request. Any expression is allowed; {{ if }} and {{ for }} blocks are not.
endpoint: https://orders.example.com/orders/{{ $.orderId }}/statusendpoint: {{ ctx.base_url }}/customers/{{ $.customerId }}An endpoint that evaluates to an empty string fails the step.
Values interpolated from the message are not percent-encoded. If a value can contain /, ?,
#, &, = or a space, encode it yourself — otherwise an orderId of ../admin reroutes the
request:
endpoint: https://orders.example.com/orders/{{ url_encode($.orderId) }}Encoding cannot keep a value of . or .. in its segment: the URL parser treats %2E, %2e%2E
and the other percent-encoded spellings as the same dot segment and removes it. So when the
evaluated endpoint’s path has a dot segment, . or .. in any of those spellings, the step fails
before any request is sent, with endpoint path has a "." or ".." segment, and the flow’s fault
handling applies. This holds whether the value went through url_encode or not; the query and the
fragment are not checked.
Values from the environment. A base URL that differs between installations is a
config: key, read from
NEXUS_CONFIG_<KEY> and written as {{ ctx.<KEY> }}:
config: [ORDERS_BASE_URL]
## Step: forwardeffects: [http_egress]endpoint: "{{ ctx.ORDERS_BASE_URL }}/ingest"An environment variable written directly in the text is refused at publication by
nexus validate and nexus deploy, naming the step and the key; see
What a flow may read. A value produced by
{{ }} is sent as it is and never reads the environment.
method
Default POST. GET, POST, PUT, PATCH and DELETE all work. The body is built and sent
regardless of the method.
content_type and accept
content_type sets the request’s Content-Type header and selects how the message is encoded.
accept sets the Accept header and has no other effect. Both default to application/json,
except in a body: opaque flow, where accept defaults to */*. The request carries one Accept:
the one in headers if the step sets it there, otherwise accept, otherwise the default.
bearer_token
Sends Authorization: Bearer <value>. The value is a template, exactly like endpoint::
| Form | Resolution |
|---|---|
"{{ ctx.<name> }}" |
Read from a flow variable — one produced by secret_read or oauth2_token, or declared in config:. Dotted paths work: {{ ctx.creds.token }} |
| a literal | Used as written. An environment variable written in the text is refused at publication, as for endpoint: |
The name inside {{ }} is checked at publication like every other ctx. read: an undeclared one
is refused by nexus validate and nexus deploy. At run time a template that renders to nothing
(null) or to an empty string fails the step before anything is sent, with the step, the key
and which of the two shapes it was in the error — never the value. It is a credential, so an empty
Authorization header is never sent in its place.
The bare bearer_token: ctx.<name> spelling accepted before 2026-09-16 is a compile error naming
the fix. A flow already published with it refuses to start the server until it is republished; a
literal published before keeps working.
headers
An inline JSON object, on one line, giving additional request headers.
headers: {"X-Tenant-Id": "acme", "X-Trace-Id": "{{ ctx.correlation_id }}"}Each value is a string or an array of strings, and every string is a template — the same kind
as an endpoint:, with the same {{ ... }} interpolation and the same check at publication: a
{{ ctx.<name> }} that names nothing the flow declares or the platform writes is a compile error,
so nexus validate sees it.
| Value | Result |
|---|---|
| a literal string | Sent as-is |
a string with {{ ... }} |
Rendered against the message and the context at request time |
| an array of strings | One instance of the header per element, each rendered the same way |
A template whose value is absent — a ctx. variable nothing has written, a field the message
does not have — omits the header, and the server log carries a warning naming the tenant, the
flow, the step and the header. A template that renders to the empty string sends the header
empty: absent and empty are different things, and the request shows which one happened. That
is the opposite of the rule for bearer_token, and deliberately so: a missing header is visible
in the request, while a missing credential is not.
Nothing in a header value is read from the environment. The environment reaches a header through a
declared config: name: {{ ctx.<NAME> }}.
Inbound request headers are available under ctx.headers, lowercased, so forwarding one is:
headers: {"X-Request-Id": "{{ ctx.headers['x-request-id'] }}"}The bracket form is the one to use: a hyphenated name after a dot reads as a subtraction.
The bare "ctx.<name>" spelling accepted before 2026-09-17 is a compile error naming the header
and the fix. A flow already published with it refuses to start the server until it is republished.
Names that are refused
Nine header names are rejected at compile time, because they describe the connection rather than the message and the client sets them itself:
host, content-length, transfer-encoding, connection, keep-alive, upgrade,
proxy-connection, te, trailer
traceparent and tracestate are refused as well: the engine sends them itself, naming the
effect’s span, when spans are exported.
Two conflicts are rejected as well:
Authorizationinheaderstogether with abearer_tokenkey on the same step.Content-Typeinheaderson a step that uses SOAP mode, which controls the content type itself.
All of these are compile errors rather than silent corrections. A request that goes out with a
different Authorization header than the one written in the file is a failure nobody can see from
the flow; a deploy that stops is a failure everybody sees.
Header names must be valid tokens — letters, digits, and !#$%&'*+-.^_`|~ — or the file does not
parse. A value that is neither a string nor an array of strings is refused the same way.
If your headers map sets Content-Type or Accept, the defaults from content_type and
accept are not also emitted, so the request never carries the header twice.
Timeouts
connect_timeout_ms bounds establishing the connection. read_timeout_ms bounds the request as a
whole. A connect timeout fails the step with HTTP connect timed out, a read timeout with
HTTP read timed out.
## Step: call-slow-partnereffects: [http_egress]endpoint: https://partner.example.com/api/lookupmethod: GETconnect_timeout_ms: 2000read_timeout_ms: 15000Circuit breaker
A destination that refuses to be contacted is not dialled on every call. After three refused or
timed-out connections to one destination in a row, each within 60 s of the one before
(breaker_failure_threshold, breaker_window_ms), its circuit opens, and every call to it
fails at once, without touching the network. A failed TLS handshake counts as a refused connection.
| What happened | Effect on the circuit |
|---|---|
A response, any status, 5xx included |
Counter back to zero. The server answered, so the destination is reachable |
| A read timeout | Counter back to zero. The connection was made |
| DNS failure, connection refused, connect timeout, TLS handshake failure | Counter +1 |
| An error before anything was sent (a header, the body, the TLS material) | Nothing |
| An error on a redirect to another host | Nothing |
So a partner that answers 503 to everything stays in use, and a mistake in your own step is never
reported as “circuit open”.
The destination is scheme, host and port. Every path on that host and port shares one circuit,
and so does every flow of the tenant that calls it, SOAP steps
included. https://orders.example.com/ingest and https://orders.example.com/status share a
circuit; https://orders.example.com and https://orders.example.com:443 are two destinations.
Circuits are kept per tenant. grpc_egress has its own breaker and does not share circuits with
http_egress.
While the circuit is open the step fails with a message the platform writes, which never carries the path, a credential or anything the server said:
http_egress: circuit open for https://orders.example.com/<redacted>; the next probe is in 12400msor ...; a probe is in flight while one is. A ## Fault: section handles it as it handles a
refused connection, and the caller gets the same 502. ctx.http_circuit_open tells the two
apart — see What the flow sees.
When breaker_cooldown_ms (default 30 s) has passed, the next call makes one probe: an
ordinary call, so if the destination is back the flow gets its answer. Other calls during the probe
fail as open rather than wait. A successful probe closes the circuit; a failed one keeps it open
and multiplies the cooldown by breaker_cooldown_factor, up to breaker_cooldown_max_ms
(300 000 ms).
A value outside the rules in Step keys is a step error when the step runs, naming the
key. breaker_cooldown_ms below connect_timeout_ms is refused, with both values in the message,
because a probe would still be connecting when the next one became due. There is no switch to turn
the breaker off.
The state is per engine and per (tenant, destination), kept in memory and lost at restart. The
number of open circuits and their transitions are in /metrics — see
Metrics.
A queued message on an open circuit
A message delivered by the queue that meets an open circuit before any effect of the flow ran
is deferred until the next probe is due, and the defer does not spend an attempt: the attempt of
the message stays as it was. The delay is the time to the next probe, or connect_timeout_ms while
a probe is in flight, never less than 1 s and never more than breaker_cooldown_max_ms. The first
deferral of a message writes one queue_circuit_deferred event to the log; later deferrals of the
same message write none, even after a retry in between. After a restart of the engine, a message
can write one more. A deferral is not a failure: in a flow with dedup_key: it records nothing for
the key, under dedup_on_failure: retry or absorb alike, so the delivery after the probe runs the
flow and records its outcome.
If an effect already ran in that delivery, a branch of a parallel fence or an iteration of a
foreach included, the message is retried as after any other failure and the attempt advances.
A fork branch that has an effect step counts as soon as it is started, whether or not its call
has been made yet.
A ## Fault: section runs on the open-circuit error on every channel. Its output acknowledges the
message; if the section itself fails, the message is retried and the attempt advances.
TLS
An https:// endpoint is verified against the public certificate authorities built into the
platform. A server whose certificate chains to a private or internal CA fails that check, so name
the CA on the step:
## Step: submiteffects: [http_egress]endpoint: https://registry.internal.example/api/submitmethod: POSTtls_ca_cert: /etc/nexus/pki/internal-ca.pemtls_ca_cert holds one or more PEM CERTIFICATE blocks: a root alone, or a root and its
intermediates. A named CA replaces the built-in roots for that step: the server’s chain must
end at a certificate in the file, and a server signed by a public CA is refused unless that CA is
in the file too. A CA certificate is public material; it does not belong in a secret.
Mutual TLS
When the server authenticates the caller by certificate, present one with its key:
## Step: submiteffects: [http_egress]endpoint: https://partner.example.com/api/submitmethod: POSTtls_ca_cert: /etc/nexus/pki/partner-ca.pemtls_client_cert: /etc/nexus/pki/our-client.crttls_client_key: /etc/nexus/pki/our-client.keytls_client_cert holds the leaf certificate first, then any intermediates the server needs.
tls_client_key holds one unencrypted private key: PRIVATE KEY (PKCS#8), RSA PRIVATE KEY or
EC PRIVATE KEY. An encrypted key, a PKCS#12 or JKS store, or a DER file is refused. Keep the key
file readable only by the account that runs the platform; it is never copied into the platform’s
storage.
Both keys or neither: a flow that sets one without the other is refused when it is published,
by nexus validate and nexus deploy alike. A SOAP call (soap_version:) uses the same keys,
since it goes through the same effect.
Rotation
The files are read at every call, at most 256 KiB each. Replacing a file on disk is the whole procedure: the next call uses the new certificate, with no republication and no restart.
When the material cannot be used
The step fails, and nothing is sent. The step error names the key, never the path; the path and the cause go to the operational log.
| Situation | Step error |
|---|---|
| A file is missing, is not a regular file, or is over 256 KiB | `tls_ca_cert`: the TLS material could not be read; see the operational log (the key named is the one at fault) |
| A file reads but no client can be built from it: no certificate, a malformed PEM, an encrypted key | tls: the TLS configuration was rejected; see the operational log |
TLS keys on an endpoint that is not https:// |
TLS material is configured but the endpoint is not `https://` — the call would go out in the clear and unauthenticated |
| The server’s certificate does not chain to the named CA, or the server refuses the client certificate | A connection failure, like a refused connection; the operational log carries the TLS cause |
A step with TLS keys never falls back to a call without them.
The request body is built from the current message according to content_type. Letter case does
not matter for the choice (Application/XML encodes as application/xml does), and the header is
sent as you wrote it:
content_type contains |
Encoding |
|---|---|
json |
The message serialised as JSON |
xml or text |
An XML message serialised as XML; anything else written as text — a string as-is, a number or boolean as its text form, null as an empty body |
| anything else | The message serialised as JSON |
How the response is decoded
The response’s own Content-Type decides, not accept, in any letter case:
Response Content-Type contains |
Result |
|---|---|
json |
Parsed as JSON. An empty body becomes null. A body that is not valid JSON fails the step |
xml |
Parsed into an XML value, so field access and XML transforms work on it. An empty body becomes null. A body that will not parse, or that nests deeper than 100 elements, falls back to the raw text as a string |
| anything else | The raw text as a string |
A response with no Content-Type header is treated as JSON.
What the flow sees
ctx.HTTP_SC holds the numeric status of the response. It is set on every response, before success
or failure is decided, so it is readable both by later steps and by a ## Fault: handler.
On 2xx, the decoded response becomes the current message and the flow continues.
On 4xx or 5xx, the step fails with HTTP <code> and the current message is left exactly as it
was — but the response is not thrown away: it is put in variables, so a fault handler can use it.
See Reading an error response below. What the caller receives depends
on the flow:
- With no
## Fault:section, the request answers502with{"ok":false,"error":"flow execution failed"}. The detail stays in the server log. Until 2026-09-26 that answer was422, which told the caller its own message was at fault for somebody else’s outage;502is what a failed call out has always meant to a flow that does handle it. - With a
## Fault:section, that section runs.$.fault_stepnames the step,$.fault_errorcarries the text, andctx.HTTP_SCis still readable. The response status is the same502, which the fault section can override.
## Fault: report-upstream-failureresponse_status: 502```ntd{ "error": "upstream rejected the order", "upstreamStatus": {{ ctx.HTTP_SC }}, "step": "{{ $.fault_step }}"}```Connection failures, DNS failures and timeouts fail the step the same way, but leave ctx.HTTP_SC
unset — there was no response.
ctx.http_circuit_open is written on every execution of the step: false, or true when the step
failed because its destination’s circuit was open and nothing was sent — see
Circuit breaker.
Reading an error response
Two variables carry the response itself, independently of whether the call succeeded.
| Variable | Set on | Contents |
|---|---|---|
ctx.HTTP_RESPONSE_HEADERS |
every response, 2xx and non-2xx | An object, header names lowercased. Repeated names are joined with ", " |
ctx.HTTP_RESPONSE_BODY |
non-2xx only | The backend’s error body, decoded by the same rules as a success body — JSON into an object, XML into an XML value |
Both are written before the step fails, so a ## Fault: handler reads them like any other
variable — the fault sequence keeps the main sequence’s variables.
HTTP_RESPONSE_BODY is not set on 2xx, because a successful response is already the message.
## Fault: relay-partner-errorresponse_status: 502response_header_content_type: application/problem+json```ntd{ "title": "The partner rejected the order", "upstreamStatus": {{ ctx.HTTP_SC }}, "upstreamDetail": "{{ ctx.HTTP_RESPONSE_BODY.message }}", "retryAfter": "{{ ctx.HTTP_RESPONSE_HEADERS['retry-after'] }}"}```Write ctx.HTTP_RESPONSE_BODY, not ctx.vars.HTTP_RESPONSE_BODY — the second resolves to a
variable named vars, which never exists, and then fails a field access on null.
Size. The captured error body is capped at 4096 bytes; over that it is kept as a string with a
[truncated] suffix rather than as a parsed document, since a truncated document would not parse
anyway. Raise the cap for one step with error_body_max_bytes: <n>.
Credentials never travel. Seven names — authorization, www-authenticate,
proxy-authorization, client-cert, cookie, set-cookie and x-api-key — are stripped at
capture time, so they are not in the object whatever the flow does with the rest. It is the same list on all three
paths a header can reach a flow by: an inbound request, a backend response, gRPC metadata.
Returning a backend header to the caller
Reading a response header is one thing; sending it on to the caller is another, and it does not happen unless the flow says so. The permission is a front-matter list:
response_headers: [etag, retry-after]Only listed names travel. What can travel is exactly what the flow can already read, so the seven
withheld above can never be returned — naming one is refused at compile time, as is a protected
header or anything in the x-nexus- namespace. A listed header the backend did not send is
omitted: an absent value, not an empty header.
The rule holds identically on 2xx and non-2xx, which is what lets a ## Fault: return the
Retry-After of a 429. Two egress steps write into the same set, so the last one to send a given
header wins per name — a header sent only by the first survives.
The backend’s status does not travel. A non-2xx stays an EffectFailed, which is what makes
queue retries and the DLQ work; to answer with the backend’s status, declare it with
response_status: "{{ ctx.HTTP_SC }}". See
The response to the caller.
Transforming the response in the same step
Effects run before the step’s body. So a fence on an http_egress step operates on the response,
not on the request:
## Step: fetch-and-reduceeffects: [http_egress]endpoint: https://catalog.example.com/products/{{ $.sku }}method: GET```ntd{ "sku": "{{ $.id }}", "price": {{ $.pricing.net }}}```To shape the request instead, put the transform in the step before.
Examples
A plain JSON POST
---flowmarkdown_version: "0.1"flow: forward-ordertenant: acmeeffects: [http_egress]---
## Step: shape```ntd{ "id": "{{ $.orderId }}", "total": {{ $.total }}, "receivedAt": "{{ date_format(now()) }}"}```
## Step: forwardeffects: [http_egress]endpoint: https://orders.example.com/ingestmethod: POSTcontent_type: application/jsonread_timeout_ms: 10000A bearer token taken from a variable
---flowmarkdown_version: "0.1"flow: push-invoicetenant: acmeeffects: [secret_read, http_egress]---
## Step: load-tokeneffects: [secret_read]key: invoicing_token
## Step: pusheffects: [http_egress]endpoint: https://invoices.example.com/v2/documentsmethod: POSTbearer_token: "{{ ctx.invoicing_token }}"Set NEXUS_SECRET_INVOICING_TOKEN in the server’s environment. See
Secrets and OAuth2.
Custom headers
---flowmarkdown_version: "0.1"flow: notify-partnertenant: acmeeffects: [http_egress]---
## Step: notifyeffects: [http_egress]endpoint: https://partner.example.com/hooks/ordersmethod: POSTheaders: {"X-Tenant-Id": "acme", "X-Trace-Id": "{{ ctx.correlation_id }}", "X-Feature": ["batching", "compression"]}That sends X-Tenant-Id: acme, an X-Trace-Id carrying the execution’s correlation ID, and two
separate X-Feature headers.
A dynamic endpoint built from the message
---flowmarkdown_version: "0.1"flow: check-shipmenttenant: acmeeffects: [http_egress]---
## Step: validate-input```validate$.shipmentId != null | "shipmentId is required" | syntactic```
## Step: fetch-statuseffects: [http_egress]endpoint: https://logistics.example.com/shipments/{{ url_encode($.shipmentId) }}/statusmethod: GETaccept: application/jsonA GET still sends the current message as its body. If that matters to the server, put a step
before it that reduces the message to what you want on the wire.