HTTP requests
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 |
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 |
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 |
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.
endpoint
Required. The URL is built in two stages before the request goes out.
{{ 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) }}${VAR} substitution. After interpolation, every ${NAME} is replaced with the value of that
environment variable, read at request time. An unset variable fails the step and names itself; it
is never sent as the literal text ${NAME}.
endpoint: ${ORDERS_BASE_URL}/ingestmethod
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.
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, with ${VAR} substituted from the environment after the template is rendered |
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.
${VAR} is not substituted in a header value. 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
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: 15000How the body is encoded
The request body is built from the current message according to content_type:
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:
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 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.
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. Six names — authorization, www-authenticate,
proxy-authorization, 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 six
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.