Skip to main content

Capturing values and chaining requests


description: "Capture values from a JSON body, header or status with a capture block, then reuse them as variables in later requests to chain calls."

Capturing values and chaining requests

A request can pull values out of its response and hand them to the requests after it, under an optional capture key: variable names mapped to JSON paths.

name: Log in
method: POST
url: '{{base_url}}/login'
capture:
auth_token: $.token
user_id: $.user.id

Every name captured this way is usable as {{name}} in every request after this one, in file order, through exactly the substitution an environment file feeds — there is one variable syntax, not two:

name: Fetch the user
method: GET
url: '{{base_url}}/users/{{user_id}}'
headers:
Authorization: 'Bearer {{auth_token}}'
sendra run examples/capture-chain.yaml # a real capture-then-use flow

File order is real order. A request sees the captures of every request before it and none of the captures of any request after it. A {{name}} referenced before the request that captures it is a VariableNotFound — the same failure as a typo, which is what it usually is.

Nothing persists. Captured values live for one sendra run or sendra test invocation and no longer; nothing is written to disk, and a fresh process starts with nothing captured. Running the second request of a chain on its own fails loudly rather than quietly reusing a stale token.

What a path may select. Exactly one value, and one that has a text form to substitute:

SelectedCaptures as
a stringthe string, unquoted — "ada" becomes ada
a numberits value: 42 is 42, 1.50 is 1.5
a booleantrue / false
nothinga capture failure — the path matched no value
more than one valuea capture failure — there is no single answer
null, array, objecta capture failure — nothing to substitute

null has no text form that is not a guess between "" and null. An array or an object has one, compact JSON, but the reason substitution is safe at all is that a substituted value cannot change the shape of what it lands in, and pushing {"a":1} into a URL is exactly that hazard.

Numbers going through serde_json means 1.50 in a body captures as 1.5 — the value, not the spelling. That differs from an environment file, where port: 8080 is the string 8080 and nothing is normalised. An endpoint whose exact digits matter should send them as a JSON string.

A bare string is not the only source. It is the default, and unchanged, but a capture entry can also be an object naming a response header or the status code instead of a JSON path:

capture:
auth_token: $.token # unchanged: bare string = JSON path
session_id:
header: Set-Cookie # a response header, matched case-insensitively
request_status:
status: true # the numeric status, as a string

A header: capture reads from the final response only — the one capture always evaluates against — so with follow_redirects on, a Set-Cookie set by an intermediate hop is not reachable this way; disable follow_redirects to capture it from the 3xx response itself, or, for a Set-Cookie specifically, prefer cookie_jar — it sits underneath redirect-following rather than downstream of it, so it sees every hop without giving up automatic redirect-following to do it. A header name that repeats in the response (Set-Cookie is the common case) is a capture failure rather than a first-or-last guess, the same rule a JSON path selecting several values already follows — a capture binds a name to one value, and silently picking between repeats would make the same file behave differently depending on header order a server happens to send in.

sendra run examples/capture-header-status.yaml # header and status capture, chained

When a capture does not work

A capture that produces no value is reported under the request that declared it, in the same shape as the assertions above it:

capture
✓ auth_token from `$.token`
✗ user_id from `$.user.id` — matched nothing in the response body

The captured value is not printed. Every other block shows what it compared, so the omission is deliberate: a capture exists to carry a token or a session id, and putting those on a terminal — a CI log, most of the time — would be a decision the file's author never made. Nothing is hidden by it: under sendra run the body it came from is printed in full just above, and --json carries the values because it already carries that same body.

A failed capture is a failed check, and the run carries on. It counts exactly as a failed assertion or a post_request throw does: visible in the output, sendra test exits 4, and sendra run's exit code is untouched. It happens after a response arrived — the request was sent, the server answered, and the answer did not hold what the file said it would — which is that category and not "could not send".

The requests after it are still sent, in keeping with every other per-request failure in Sendra: one request's problem does not cancel its siblings. A downstream request that needed the variable then fails on its own terms, with VariableNotFound naming it, and that is a "never got a response" — so a run whose broken capture broke a chain exits 1, the more serious of the two, with the original failure reported at the request that caused it:

→ Captures nothing
200 OK 3662 ms

capture
✗ auth_token from `$.json.token` — matched nothing in the response body

→ Needs it
error: no variable named `auth_token` in the active environment: no environment
file was found

→ Independent
200 OK 1771 ms

summary
3 requests: 0 passed, 1 failed, 1 without assertions, 1 no response

A capture that works is not a check. A login that captures a token and asserts nothing is counted under without assertions, not under passed: a capture is a dependency of the rest of the run, not an expectation about the response, and counting it as a pass would report a check nobody wrote. The asymmetry with the paragraph above is the point — a capture only enters the verdict when it fails.

Name collisions

A capture whose name the environment file already defines is refused.

capture
✗ base_url from `$.host` — `.sendra/environments/staging.yaml` already
defines this variable; rename the capture or the environment entry

Neither value silently wins, and that is the whole reason: if the capture won, {{base_url}} would mean the file's value in the requests before the capturing one and the captured value in the requests after it — the same name, two meanings, in one run, discoverable only by reading both files and counting positions. If the environment won, the capture block would be a no-op that still looks like it did something. Refusing says which two lines are in conflict and costs one rename. It is also the reversible choice: this can be relaxed into a precedence rule later, while a build that had been silently shadowing could not be tightened. Sendra rejects duplicate request names in a collection for the same reason.

The refused capture defines nothing, so the environment's value stands for every request in the run — the file keeps meaning what it says.

Two captures of the same name are fine, and the later one wins. That is not the same situation: both come from the same mechanism, file order fully decides which is in force at any point, and re-logging in or reading the next page's cursor is a real flow that has to be expressible.

What a capture does not touch

  • The block itself is never substituted. A {{var}} in a capture path or name stays those characters. A JSON path selects which part of the response is read, exactly as it does in an assertions block where paths are left literal so that --env cannot silently redirect a check; and a variable name that changed with the environment could not be written as {{name}} in the request that uses it.
  • Scripts and captures cannot see each other. A pre_request script cannot rewrite a capture block, a post_request script cannot read what was captured, and a script cannot stash a value for a later request. All three are natural extensions and none of them is here.
  • Assertions and captures cannot see each other either. They are evaluated independently against the same response: a failed assertion does not stop the capture beside it, and a failed capture does not fail an assertion. Both are reported, and a request that broke both is one failed request.