Skip to content

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-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; */* 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 }}/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) }}

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: forward
effects: [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:

  • 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

Circuit 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 12400ms

or ...; 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: submit
effects: [http_egress]
endpoint: https://registry.internal.example/api/submit
method: POST
tls_ca_cert: /etc/nexus/pki/internal-ca.pem

tls_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: submit
effects: [http_egress]
endpoint: https://partner.example.com/api/submit
method: POST
tls_ca_cert: /etc/nexus/pki/partner-ca.pem
tls_client_cert: /etc/nexus/pki/our-client.crt
tls_client_key: /etc/nexus/pki/our-client.key

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

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