HTTP
The HTTP server connector, HTTP client connector, and the rest blocks.
Octo ships two HTTP connectors. http is a server connector whose sources turn inbound requests into flow executions and write the flow's result back as the response. http-client is an outbound client that the rest block runs requests through.
http connector
The http connector (label: HTTP Server) owns a single HTTP server: host, port, base path, server timeouts, and CORS. Each flow fronted by an http source registers a route on that shared server. It provides the HTTP route source (below) and no blocks; rest binds to http-client instead.
Settings
| Setting | Type | Required | Default | Description |
|---|---|---|---|---|
host | string | No | $HTTP_HOST, else 0.0.0.0 | Bind address. An explicit value wins over the HTTP_HOST environment variable. |
port | int | No | $HTTP_PORT, else 8080 | Bind port. An explicit 0 lets the OS pick a free port. An explicit value wins over the HTTP_PORT environment variable. |
basePath | string | No | unset | Prefix for all routes (e.g. /api/v1). |
keepAlive | boolean | No | unset | Enable HTTP keep-alives. |
requestTimeout | duration | No | 30s | How long a handler waits for the flow to finish. |
readTimeout | duration | No | unset | Server read timeout. |
writeTimeout | duration | No | unset | Server write timeout. |
idleTimeout | duration | No | unset | Server idle timeout. |
cors | object | No | unset | Cross-origin resource sharing. Inert unless allowedOrigins is set. |
Duration settings accept Go duration strings (5s, 250ms).
CORS settings (cors)
CORS is off unless allowedOrigins is non-empty. When set, the connector answers OPTIONS preflights and adds CORS headers on every route.
| Setting | Type | Required | Default | Description |
|---|---|---|---|---|
allowedOrigins | string[] | No | unset | Origins allowed to make cross-origin requests. Exact matches; a single * allows any origin. |
allowedMethods | string[] | No | GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS | Methods allowed on preflight. |
allowedHeaders | string[] | No | echo requested headers | Request headers allowed on preflight; when unset, the requested headers are echoed back. |
exposedHeaders | string[] | No | unset | Response headers exposed to the browser on actual responses. |
allowCredentials | boolean | No | false | Allow credentialed (cookie/authorization) requests. When true, the origin is echoed rather than *, per the CORS spec. |
maxAge | duration | No | unset | How long a browser may cache the preflight response. |
http source
Each http source binds one route pattern to a flow. The request becomes the flow's input message; the flow's final message becomes the response.
| Setting | Type | Required | Default | Description |
|---|---|---|---|---|
path | string | Yes | none | Route pattern; {name} params become vars.name. |
methods | string[] | No | unset (every method) | HTTP methods the route answers. Naming any means every other method gets a 405 (with an Allow header) from the router, before any flow runs. Listing GET also answers HEAD, and the flow runs for it. A name that is not an HTTP method fails at startup. |
headers | string[] | No | unset | Request header names to copy into variables. |
responseHeaders | string[] | No | unset | Response header names to propagate from message variables of the same name after the flow completes. Content-Type is managed by the source. |
correlationIdHeader | string | No | unset | Header to source the correlation ID from. |
rawBodyVar | string | No | unset | Capture the exact request bytes into vars.<name> (e.g. to verify an HMAC signature over the raw body). |
rawBody | boolean | No | false | Source the request as raw content: skip the JSON check and store the body as {contentType, rawData} using the request's Content-Type. Lets non-JSON payloads (forms, XML, binary text) enter the flow. A multipart/form-data request is decoded either way, see Multipart bodies. |
timeout | duration | No | connector's requestTimeout | Per-route wait for the flow. On an SSE route this bounds the wait for the first frame instead. |
maxBodyBytes | int | No | 1048576 | Request body size cap (via http.MaxBytesReader). Oversized requests get 413. |
sse | object | No | unset | Serve the route as a server-sent event stream. Inert unless sse.enabled. |
How the request maps into the message
| Request part | Lands in |
|---|---|
| Path parameters | Every {name} in path lands in vars.name (e.g. /orders/{id} sets vars.id). |
| Method | vars.method holds the HTTP method, so a flow can route on vars.method == "POST". |
| Query string | vars.query is a map of query parameters (first value of each key). It is always present, possibly empty. |
| Headers | Each header listed in headers is copied into a variable of the same name, e.g. vars["X-Tenant"]. |
| Body | A JSON body becomes body. A malformed JSON body is rejected with 400 before the flow runs, unless rawBody: true, in which case the body enters the flow as raw content carrying the request's Content-Type. A multipart/form-data body is decoded into body.parts regardless of rawBody (see Multipart bodies). An empty body is valid (body stays null). |
| Raw bytes | With rawBodyVar set, the exact request bytes are also stored in that variable as a string. |
How the response is written
A completed flow returns its final body as JSON with status 200, or as raw content with its own Content-Type when the body is raw content. Setting vars.httpStatus to a valid code (100 to 599) overrides the response status. Variables named in responseHeaders are emitted as response headers (scalar values only).
A dropped message (e.g. a filter matched nothing) responds 204 No Content. A failed flow responds 500 with a JSON {"error": ...} body, see error handling. A flow that exceeds the timeout responds 504.
Multipart bodies
A request whose Content-Type is multipart/form-data is decoded automatically:
its parts land on body.parts, reachable by name.
source:
connector: api
type: http
settings:
path: /upload
methods: [POST]
maxBodyBytes: 10485760 # raise this for uploads; the default is 1 MiBcurl -F 'username=ann' -F 'avatar=@photo.png' localhost:8080/upload gives the
flow a body of:
{
"contentType": "multipart/form-data; boundary=------abc123",
"rawData": "--------abc123\r\nContent-Disposition: form-data; name=\"username\"...",
"parts": {
"username": { "name": "username", "filename": "", "contentType": "",
"encoding": "text", "size": 3, "data": "ann", "headers": {} },
"avatar": { "name": "avatar", "filename": "photo.png", "contentType": "image/png",
"encoding": "base64", "size": 12345, "data": "iVBORw0K...", "headers": {} }
}
}There is no setting to enable this, and rawBody does not turn it off. parts
is added alongside contentType and rawData, which stay exactly what was
received, so one flow can verify an HMAC over the raw body and read
body.parts.avatar. A part with a filename always has encoding: base64; a
plain form field is always text. Read binary data with
base64.decode:
- type: set-payload
settings:
value: 'base64.decode(body.parts.avatar.data)'size is always the decoded byte length. A repeated part name becomes a
list, so body.parts.tag[0].data reaches the first. A body that claims to be
multipart but will not parse is rejected with 400 before the flow runs, like a
malformed JSON body.
A decoded upload is held in memory twice: once as rawData and once as the
parts, where base64 costs about 4/3 of the original bytes. A 10 MiB upload
occupies roughly 23 MiB per message, and more again if the flow forks or
crosses a queue, so set maxBodyBytes deliberately.
To send a multipart body, see the rest block's bodyType.
The multipart CEL functions build
and decode one anywhere else.
The runtime reads the whole request into memory and writes the whole response at once. See Raw Content and Streaming for non-JSON payloads, or server-sent events to push output incrementally.
Example
Adapted from samples/http-orders.yaml:
service:
name: http-orders
env:
- name: HTTP_PORT
default: "8080"
connectors:
- name: api
type: http
settings:
port: ${HTTP_PORT}
basePath: /api/v1
requestTimeout: 5s
flows:
- name: orders-api
source:
connector: api
type: http
settings:
path: /orders/{id} # {id} -> vars.id
correlationIdHeader: X-Request-Id
headers: [X-Tenant] # captured as vars["X-Tenant"]
process:
- type: set-payload
settings:
value: '{"orderId": vars.id, "tenant": vars["X-Tenant"], "status": "found"}'curl -s localhost:8080/api/v1/orders/42 -H 'X-Tenant: acme'Server-sent events
Setting sse.enabled on a route turns it into a server-sent event stream: the connection stays open and blocks push frames to the caller with sse-event, instead of the flow returning one buffered response.
| Setting | Type | Required | Default | Description |
|---|---|---|---|---|
sse.enabled | boolean | No | false | Serve this route as an event stream. |
sse.streamVar | string | No | sseStream | Variable the stream's address lands in. |
sse.finalEvent | boolean | No | false | Emit the flow's response message as one last frame before closing. |
sse.finalEventName | string | No | unset | The event: name for that final frame. |
sse.closeOnComplete | boolean | No | true | Close the stream when the flow's own invocation terminates. |
sse.heartbeat | duration | No | 15s | How often an idle stream writes a : ping comment. 0 means the default, not "off". |
sse.maxDuration | duration | No | unlimited | Hard cap on how long one stream may stay open. |
sse.maxStreams | int | No | unlimited | Concurrent streams this route holds open; over the cap, requests get 503. |
The stream address
A stream is addressed by an id of its own, minted when the route accepts the request and published in vars.sseStream as <connector>:<id>. It is not the message's EventID, which is rekeyed whenever a message crosses into another invocation (flow-ref, split and queue-dispatch all do it).
The address is an ordinary string in an ordinary variable, so it survives every copy, rekey and serialization: an sse-event in a flow-ref'd sub-flow writes to the caller's connection with no configuration. Because it names its own connector, a flow can hand it somewhere else entirely:
# Hand the stream to a queued job, which writes progress back to the same caller.
- type: queue-dispatch
settings:
subject: '"jobs.render"'The worker points the block's stream setting at wherever the address landed, say body.stream.
A stream is a live connection on one process. A queued job picked up by another replica resolves the address and finds nothing, landing on the block's ifClosed policy. Cross-replica streaming is not supported yet.
The response is committed by the first frame
Nothing is written until a block emits, so everything before that behaves like an ordinary route. A filter that rejects (jwt-validate, validate) still returns a real 401/400 with its own body. vars.httpStatus and responseHeaders still apply, and responseHeaders are read off the message that emits the first frame. A flow that emits nothing gets the ordinary buffered JSON response. A dropped message responds 204, and a failure before any frame is an ordinary 500.
Once a frame goes out the response is a committed 200 text/event-stream, so a failure can only be told in-band: an unhandled failure sends event: error with a {"error": ...} payload and closes. Put an sse-event in the flow's error: chain to say something else instead.
When the stream closes
Whichever comes first: an sse-event with close: true; the flow's terminal event (unless closeOnComplete: false); the client disconnecting; maxDuration; or connector shutdown.
closeOnComplete: false is for a flow that hands its work off and finishes early (a split, or a queue-dispatch), where the detached invocations are the ones with something to say. Pair it with maxDuration, or a stream nothing closes leaks, and raise timeout to cover the wait for the first frame.
The client disconnecting does not cancel the flow; a flow keeps running once it has accepted a message. The block's default ifClosed: stop is how a flow notices and stops producing output nobody will read.
sse-event block
Writes one frame to an open stream.
| Setting | Type | Required | Default | Description |
|---|---|---|---|---|
event | string | No | unset | The event: name. Empty means an unnamed event, which browsers deliver to EventSource.onmessage. |
data | CEL | No | the message body as JSON | The frame's payload. Multi-line values are encoded as multiple data: lines. |
stream | CEL | No | vars.sseStream | Which stream to write to. Set it when the route renames the variable, or when the address reached this flow another way. |
connector | connector ref | No | http | Which http connector owns the stream. Rarely needed: an address published by a route already names its connector. It applies only to a bare id. |
close | boolean | No | false | End the stream after this frame. |
stop | boolean | No | false | Stop the flow after this frame. Usually paired with close. |
ifClosed | enum | No | stop | What to do when the stream has already ended: stop, ignore, or error. |
A block with neither event nor data writes nothing, which is how you close a stream without sending a final frame.
Being wired up wrong (no address resolvable, or a connector that is not configured) always fails the block, whatever ifClosed says. Nobody listening (the client hung up, the stream expired, or it lives on another replica) is decided by ifClosed.
Authenticating a browser stream. EventSource cannot set request headers, and jwt-validate reads the token from one, so putting it in front of an SSE route covers non-browser callers only: curl, server-to-server clients, and fetch-based SSE libraries, which can all send Authorization.
For a browser, authenticate the stream with a same-origin session cookie, marked HttpOnly, Secure, and SameSite. EventSource sends cookies on same-origin requests by itself; cross-origin it needs withCredentials: true and the connector's cors.allowCredentials. Check the session at your edge (proxy or gateway), or copy the Cookie header into a variable with the source's headers setting and reject in a filter ahead of the stream.
Do not put the token in the query string: URLs reach browser history, Referer headers, and proxy and access logs, and a long-lived stream keeps the credential there for as long as it stays open.
Example
Adapted from samples/http-sse.yaml:
flows:
- name: progress
source:
connector: api
type: http
settings:
path: /progress
sse:
enabled: true
finalEvent: true
finalEventName: done
process:
- type: sse-event
name: accepted
settings:
event: status
data: '"started"'
- type: sse-event
name: halfway
settings:
event: progress
data: '{"percent": 50}'
# The final message is sent as `event: done` by finalEvent, then the
# stream closes.
- type: set-payload
settings:
value: '{"percent": 100}'
error:
# The response is already a committed 200, so failures are told as frames.
- type: sse-event
settings:
event: error
data: 'vars.error.message'
close: truecurl -N localhost:8080/progresshttp-client connector
The http-client connector (label: HTTP Client) owns a configured client for calling external HTTP APIs: base URL, authentication, default headers, a request timeout, retry on 429, and an opt-in in-memory response cache for GETs. The rest block references it by name.
Provides a source: no, it is outbound only. Blocks that bind to it: rest and rest-dynamic.
Settings
| Setting | Type | Required | Default | Description |
|---|---|---|---|---|
baseURL | string | Yes | none | Prepended to each request path. Must be absolute (scheme and host). |
timeout | duration | No | 30s | Bounds each request. |
headers | map | No | unset | Applied to every request unless already set by the block. |
maxResponseBytes | int | No | 1048576 | Response body size cap. |
auth | object | No | none | Authentication applied to every request (below). |
retry | object | No | unset | Retry policy for rate-limited (429) responses (below). |
pool | object | No | unset | Connection pool sizing for outbound requests (below). |
cache | object | No | disabled | In-memory response cache for GET requests (below). Not exposed in the editor schema; configure it in YAML. |
Authentication (auth)
| Setting | Type | Required | Default | Description |
|---|---|---|---|---|
type | enum: bearer | basic | oauth2 | gcp | No | none | Authentication scheme. |
token | string | With type: bearer | none | Token sent as Authorization: Bearer. |
username | string | With type: basic | none | Basic auth username. |
password | string | No | none | Basic auth password. |
tokenURL | string | With type: oauth2 | none | OAuth2 token endpoint (client-credentials grant). |
clientID | string | With type: oauth2 | none | OAuth2 client identifier. |
clientSecret | string | With type: oauth2 | none | OAuth2 client secret. |
scopes | string[] | No | unset | Requested OAuth2 scopes. |
gcpToken | enum: identity | access | No | identity | Which token the GCP metadata server mints. |
gcpAudience | string | No | the connector's base URL (origin) | Audience for a GCP identity token. |
gcpScopes | string[] | No | the service account's own scopes | Scopes for a GCP access token. |
For oauth2, the connector mints and refreshes a bearer token via the client-credentials grant and applies it to each request. A request that already carries an Authorization header is never overwritten.
GCP workload identity (type: gcp)
On Cloud Run, GCE, or GKE, type: gcp authenticates as the service account the
process runs as. The token comes from the instance metadata server, so nothing
secret is configured and nothing is stored.
identity (the default) is an OIDC token stamped with an audience: what one
Cloud Run service presents to another, and what an IAP-protected endpoint
expects. gcpAudience defaults to the connector's baseURL (origin only),
which is what the receiving service validates against, so calling another
service usually needs no more than type: gcp. access is an OAuth token
carrying scopes, for calling a Google API (Storage, Pub/Sub, BigQuery) directly.
With no gcpScopes the metadata server returns the service account's own, which
on Cloud Run is cloud-platform.
Tokens are cached until shortly before they expire and refreshed on demand. Nothing is written to the secret store.
type: gcp only works where a metadata server exists. Off Google Cloud the
first request fails with an error saying so. Drive the scheme from an
environment variable (type: ${API_AUTH}, empty locally and gcp in
production) rather than expecting a local fallback; see
Add authentication.
Retry (retry)
When an upstream returns 429 Too Many Requests, the connector honors the Retry-After header when present, otherwise backs off exponentially. Each wait is capped at maxBackoff.
| Setting | Type | Required | Default | Description |
|---|---|---|---|---|
maxAttempts | int | No | 3 | Total attempts including the first; <= 1 disables retrying. |
maxBackoff | duration | No | 30s | Upper bound on each wait between attempts (also caps a large Retry-After). |
Connection pool (pool)
The connector keeps a pool of idle connections so outbound requests reuse them instead of dialling and re-handshaking every time. Size it against the flow's workers: workers: 512 asks for up to 512 concurrent outbound requests, so set maxIdleConnsPerHost to at least the workers of the busiest flow calling this connector. Below that, the excess is closed and re-dialled every round.
| Setting | Type | Required | Default | Description |
|---|---|---|---|---|
maxIdleConns | int | No | 100 | Idle connections kept across all hosts. |
maxIdleConnsPerHost | int | No | 100 | Idle connections kept per host. This is the one that matters, since a connector talks to a single base URL. |
idleConnTimeout | duration | No | 90s | How long an idle connection is kept before being closed. |
disableKeepAlives | boolean | No | false | Disable connection reuse entirely, one connection per request. For an upstream that mishandles persistent connections. |
Since 0.5.0 the notion and slack connectors pool too, at these same defaults.
Keep maxResponseBytes in mind when you size the pool: a connection returns to the pool only once its response body is read to the end. The connector drains what the cap hid, but only up to 4 KiB; past that the connection is dropped instead. Set maxResponseBytes above the responses you expect.
Cache (cache)
| Setting | Type | Required | Default | Description |
|---|---|---|---|---|
enabled | boolean | No | false | Enable the GET response cache. |
ttl | duration | No | 60s | How long a cached response is served. |
maxEntries | int | No | 256 | Cache size cap. |
rest block
REST Call makes an HTTP request through an http-client connector. Method and path are static; query parameters, headers, and the request body are CEL expressions evaluated per message. The response is folded into the message body: JSON parses into body, anything else becomes raw content carrying the response's Content-Type, and an empty response leaves body null.
| Setting | Type | Required | Default | Description |
|---|---|---|---|---|
connector | string | Yes | none | Name of the http-client connector to use. |
method | enum: GET | POST | PUT | PATCH | DELETE | HEAD | No | GET | HTTP method. |
path | string | No | unset | Path appended to the connector base URL. |
query | map of expression | No | unset | Query params; each value is a CEL expression. |
headers | map of expression | No | unset | Request headers; each value is a CEL expression. Setting Authorization here sends a per-request credential, see Sending a per-caller credential. |
body | expression | No | unset | CEL expression for the request body. A string result is sent verbatim; any other value is JSON-encoded (with Content-Type: application/json unless a header overrides it). With bodyType: multipart it must evaluate to a parts map instead. |
bodyType | enum: raw | multipart | No | raw | How the body is sent. raw sends the evaluated body as-is. multipart treats it as a parts map and renders a multipart/form-data body, see Sending multipart. |
failOnError | boolean | No | true | Turn a 400+ status into a flow error. |
statusVar | string | No | statusCode | Variable to store the response status code in. |
Sending a per-caller credential
The connector's auth describes the deployment's own
credential. A flow acting on behalf of its caller carries a different one that
arrives with the message: a token relayed from the inbound request, or one an
earlier block exchanged or minted.
Set the Authorization header on either block. rest takes it as one entry in
its headers map; rest-dynamic renders it with the rest of the map:
flows:
- name: proxy
source:
connector: api
type: http
settings:
path: /me
headers: [Authorization] # arrives as vars.Authorization
process:
- type: rest
settings:
connector: upstream
method: GET
path: /v1/me
headers:
Authorization: 'vars["Authorization"]'The value is the whole header, scheme included, so relaying an inbound one needs
no change; a bare token needs its scheme written in, as '"Bearer " + vars.token'.
A block credential and a connector credential do not contend: the connector
applies its own only when the request does not already carry an Authorization.
Sending the caller's credential upstream lets that upstream use it for anything
the credential permits. Send it only to an upstream you trust with it, and
prefer an exchange for a narrower token across trust domains. On
rest-dynamic, pathPrefix and allowMethods bound what a credential can be
spent on; the connector's baseURL bounds where it can be sent at all.
Sending multipart
Set bodyType: multipart and let body evaluate to a parts map: the same shape
body.parts holds on an inbound upload, and the one
multipart() and addPart build.
- type: rest
settings:
connector: upstream
method: POST
path: /v1/media
bodyType: multipart
body: |
multipart()
.addPart("caption", body.caption)
.addPart("avatar", body.parts.avatar)The block generates the boundary per request and sets Content-Type itself,
overriding any Content-Type set in headers.
A scalar part value is shorthand for a text field. An object may name only the
keys it needs; encoding defaults to text:
body: |
multipart().addPart("report", {
"data": vars.csv,
"filename": "report.csv",
"contentType": "text/csv"
})A decoded part is already the shape addPart takes, so forwarding an upload
keeps its filename and content type. Forward one untouched:
body: 'body.parts'Or augment it on the way through:
body: 'body.parts.addPart("source", "octo")'A part name, filename, or content type containing a carriage return or line feed is rejected when the body is rendered, so names chosen by an uploader cannot smuggle extra parts into the upstream request.
Example
Adapted from samples/weather.yaml, a scheduled GET against a public API, cached for 60 seconds:
service:
name: weather
connectors:
- name: open-meteo
type: http-client
settings:
baseURL: https://api.open-meteo.com
timeout: 10s
cache:
enabled: true
ttl: 60s
- name: ticker
type: cron
flows:
- name: weather
source:
connector: ticker
type: cron
settings:
schedule: "@every 30s"
process:
- type: rest
name: fetch-forecast
settings:
connector: open-meteo
method: GET
path: /v1/forecast
query: # values are CEL expressions
latitude: '"52.52"'
longitude: '"13.41"'
current: '"temperature_2m"'
- type: log
settings:
message: '"current temperature: " + string(body.current.temperature_2m)'Keep credentials out of the flow file: declare them in the env section and reference them as ${API_TOKEN}, see environment and config.
rest-dynamic block
Dynamic REST Call is the same request with the method, path, query, headers and body all built from CEL expressions per message. Use rest when the flow calls a known endpoint and only the values vary; use this one when the endpoint itself is data, such as a flow relaying a request it was handed, or an agent choosing a route from an API description.
Query and headers differ in shape from rest. Each is a single expression evaluated to a whole map, not a map of expressions. Values are rendered like every other expression result: a string verbatim, anything else as compact JSON.
| Setting | Type | Required | Default | Description |
|---|---|---|---|---|
connector | string | Yes | none | Name of the http-client connector to use. |
method | expression | Yes | none | CEL expression for the HTTP method. Rendered, upper-cased, and checked against GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS. |
path | expression | Yes | none | CEL expression for the path, resolved against the connector's base URL. |
query | expression | No | unset | CEL expression evaluating to a map of query parameters. |
headers | expression | No | unset | CEL expression evaluating to a map of request headers, Authorization included, see Sending a per-caller credential. |
body | expression | No | unset | CEL expression for the request body, as in rest. |
bodyType | enum: raw | multipart | No | raw | How the body is sent, as in rest. |
allowMethods | list of string | No | unset | Methods this block may issue. Empty allows any of the standard set. |
pathPrefix | string | No | unset | Path prefix this block may call under, matched on segment boundaries. Empty allows the whole base URL. |
failOnError | boolean | No | true | Turn a 400+ status into a flow error. |
statusVar | string | No | statusCode | Variable to store the response status code in. |
What a rendered request may not be
A rendered path is data, so three things are checked before the request is made, and a failure makes no request at all:
- The method must be one of the seven above.
- The path must be a relative reference. A full URL is refused rather than silently rewritten. The connector resolves the request against its own base URL and takes only the path, query and fragment, so no expression here can reach another host.
- The path may not contain a
..segment, which would otherwise escape a base URL carrying a prefix like/v1. The check runs on the parsed path, so%2e%2eis refused with the literal form, and a..inside a query value is left alone.
allowMethods and pathPrefix are the restriction you configure on top. Both are empty by default, and an allowMethods entry that is not an HTTP method fails at startup. pathPrefix matches on segment boundaries: /integrations admits /integrations and /integrations/abc, but not /integrations-internal.
The connector's baseURL is the security boundary: nothing this block evaluates can change the host it calls. Everything under that host is reachable unless you set pathPrefix. Point the connector at the narrowest base URL that does the job.
Example
Relaying a request described by the message, the shape an agent tool takes:
- type: rest-dynamic
name: call-api
settings:
connector: octo
method: body.method
path: body.path
query: 'has(body.query) ? body.query : {}'
body: 'has(body.payload) ? body.payload : null'
# Report the status as data rather than failing the flow, so the caller can
# act on a 404 instead of the message ending.
failOnError: false