Skip to content

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-order
tenant: acme
effects: [http_egress]
---
## Step: forward
effects: [http_egress]
endpoint: https://orders.example.com/ingest
method: POST

Step 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 }}/status
endpoint: {{ 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}/ingest

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.

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:

  • Authorization in headers together with a bearer_token key on the same step.
  • Content-Type in headers on 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-partner
effects: [http_egress]
endpoint: https://partner.example.com/api/lookup
method: GET
connect_timeout_ms: 2000
read_timeout_ms: 15000

How 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 answers 502 with {"ok":false,"error":"flow execution failed"}. The detail stays in the server log. Until 2026-09-26 that answer was 422, which told the caller its own message was at fault for somebody else’s outage; 502 is what a failed call out has always meant to a flow that does handle it.
  • With a ## Fault: section, that section runs. $.fault_step names the step, $.fault_error carries the text, and ctx.HTTP_SC is still readable. The response status is the same 502, which the fault section can override.
## Fault: report-upstream-failure
response_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-error
response_status: 502
response_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-reduce
effects: [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-order
tenant: acme
effects: [http_egress]
---
## Step: shape
```ntd
{
"id": "{{ $.orderId }}",
"total": {{ $.total }},
"receivedAt": "{{ date_format(now()) }}"
}
```
## Step: forward
effects: [http_egress]
endpoint: https://orders.example.com/ingest
method: POST
content_type: application/json
read_timeout_ms: 10000

A bearer token taken from a variable

---
flowmarkdown_version: "0.1"
flow: push-invoice
tenant: acme
effects: [secret_read, http_egress]
---
## Step: load-token
effects: [secret_read]
key: invoicing_token
## Step: push
effects: [http_egress]
endpoint: https://invoices.example.com/v2/documents
method: POST
bearer_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-partner
tenant: acme
effects: [http_egress]
---
## Step: notify
effects: [http_egress]
endpoint: https://partner.example.com/hooks/orders
method: POST
headers: {"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-shipment
tenant: acme
effects: [http_egress]
---
## Step: validate-input
```validate
$.shipmentId != null | "shipmentId is required" | syntactic
```
## Step: fetch-status
effects: [http_egress]
endpoint: https://logistics.example.com/shipments/{{ url_encode($.shipmentId) }}/status
method: GET
accept: application/json

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