Skip to content

Chaining requests

Use these recipes to connect requests in one collection. Replace api.example.com and the example fields with your API. For an ordered workflow, start with captures and the Collection Runner. Use an awaited saved or direct request call when a pre/post script needs the result immediately.

Saved child requests run their complete lifecycle, including inherited blocks. Each phase uses collection → outermost folder → nearest folder → request. Put a login call on the dependent request: a collection-wide login hook also runs on the login request itself and can trigger cycle protection.

Use this when several requests in one collection run need the same generated test data. This two-request workflow creates a customer, then looks it up by email. It needs no environment inputs. Put both files in your collection and ensure Create customer precedes Find customer in collection order.

Save the first request as customers/create.yml:

name: Create customer
method: POST
url: https://api.example.com/customers
body_type: json
body: '{"name":"Cookbook Customer"}'
scripts:
pre: |-
const email = "cookbook+" + noodle.crypto.randomBytes(8, "hex") + "@example.com";
noodle.run.set("customer_email", email);
noodle.request.body.setJson({ ...noodle.request.body.json(), email });

The first request sets the shared value and writes it directly into its body. Save the later request as customers/find.yml. It can use ordinary substitution:

name: Find customer
method: GET
url: https://api.example.com/customers
params:
- name: email
value: $customer_email

Run them together with the CLI or the TUI Runner:

noodle collection run ./api customers/create customers/find

Selected requests execute in collection order. The second request sends the same email as the first. noodle.run.get("customer_email") reads it from a later script; noodle.run.unset("customer_email") removes it from the transient scope.

noodle.run.set() commits when the script succeeds, before HTTP. The email remains available even if customer creation later fails, so add suitable assertions and use --fail-fast if later requests depend on a successful creation.

The value disappears when the collection run ends. Separate manual sends and request run calls have isolated scopes, so they cannot share this value. Do not put $customer_email in the first request: its substitution would run before the script creates the variable. Use a capture when you need a server-generated customer ID instead.

Create auth/login.yml for your API’s login endpoint, with its required credentials configured through authentication and secrets. It should return JSON containing a non-empty string token. Save this dependent request as users/profile.yml. The child runs its scripts, captures, assertions, and tests:

name: Log in and fetch a profile
method: GET
url: https://api.example.com/me
scripts:
pre: |-
const login = await noodle.runRequest("auth/login");
const token = login.json().token;
if (typeof token !== "string" || !token.trim()) {
throw new Error("Login must return a non-empty token");
}
noodle.run.set("TOKEN", token);
noodle.request.auth.setBearer(token);

The outgoing profile request uses the returned bearer token. A failed child or missing token stops the pre script and skips the profile request.

Saved child captures and writes are readable with noodle.run.get. Later child calls see these and the parent’s staged values. Failed children roll back their variables; a failed parent rolls back the combined variables. Nested persistence is transient. On a manual send, the parent may explicitly persist with noodle.run.set("TOKEN", token, { persist: "secret" }). Collection runs suppress persistence. HTTP and cookie effects cannot be rolled back.

Use a folder pre script for APIs with a custom login endpoint. Configure LOGIN_USER in the selected environment and store LOGIN_PASSWORD as a secret. Save this as auth/login.yml, outside the folder that will use the shared login hook:

name: Get a session token
method: POST
url: https://api.example.com/session
scripts:
pre: |-
noodle.request.body.setJson({
username: noodle.env.get("LOGIN_USER"),
password: noodle.env.get("LOGIN_PASSWORD"),
});

The login must return JSON with a non-empty token and a numeric expires_in in seconds, such as {"token":"example-token","expires_in":3600}. Merge this into users/folder.yml:

scripts:
pre: |-
let token = noodle.run.get("SESSION_TOKEN");
let expiresAt = noodle.run.get("SESSION_EXPIRES_AT");
if (typeof token !== "string" || !token.trim() ||
!Number.isSafeInteger(expiresAt) || expiresAt <= noodle.time.now() + 30000) {
const requestedAt = noodle.time.now();
const session = (await noodle.runRequest("auth/login")).json();
token = session.token;
expiresAt = requestedAt + session.expires_in * 1000;
if (typeof token !== "string" || !token.trim() ||
typeof session.expires_in !== "number" ||
!Number.isSafeInteger(expiresAt) || expiresAt <= noodle.time.now() + 30000) {
throw new Error("Login must return a token with more than 30 seconds remaining");
}
noodle.run.set("SESSION_TOKEN", token);
noodle.run.set("SESSION_EXPIRES_AT", expiresAt);
}
noodle.request.auth.setBearer(token);

Requests under users/ inherit this hook. For example, save this as users/profile.yml:

name: Fetch my profile
method: GET
url: https://api.example.com/me

The first request logs in; later requests in the same collection run reuse the token while it has more than 30 seconds left. The margin allows time to send the next request. An expired or nearly expired token triggers another login. A failed login stops that request before HTTP. This does not automatically retry 401 responses from a revoked token.

Keep auth/login outside users/ so it does not inherit its own login hook. The folder pre runs before the request pre, following the usual inherited order. Set bearer auth directly here: $SESSION_TOKEN in the same request’s saved auth would be substituted before this hook can create it.

Both cached values disappear at the end of the run. Separate manual sends and dataset rows have fresh scopes, so each logs in independently. For OAuth2, prefer the built-in authentication configuration.

Use this when a lookup can legitimately return 404. Configure API_TOKEN as a secret in the selected environment. This example fetches a profile before sending a health request with its ID, or guest when the profile is absent:

name: Check health with an optional profile
method: GET
url: https://api.example.com/health
scripts:
pre: |-
const token = noodle.env.get("API_TOKEN");
if (!token || !token.trim()) throw new Error("API_TOKEN is required");
try {
const profile = await noodle.sendRequest({
url: "https://api.example.com/me",
headers: { Authorization: `Bearer ${token}` },
timeout: 5000,
});
noodle.run.set("USER_ID", profile.json().id);
} catch (error) {
if (!error.response || error.response.status !== 404) throw error;
noodle.run.set("USER_ID", "guest");
}
noodle.request.headers.set("X-User-ID", String(noodle.run.get("USER_ID")));

Direct values are literal, with no substitution or inherited parent credentials. For JSON uploads, set method: "POST", body: JSON.stringify(value), and headers: { "Content-Type": "application/json" }. Both call APIs throw on HTTP status 400 or higher, validation, or transport failures; saved calls also throw on failed execution diagnostics. Errors expose response and diagnostics when available. Caught failures remain visible in Results without failing the parent.

Await calls sequentially: one outstanding call per script, ten calls per top-level request, four nested levels, and a 30-second ancestor-bounded wall deadline. See the full contract.

Use captures for a straightforward ordered workflow. The create endpoint must return HTTP 201 and a JSON id. Save this as users/create.yml:

name: Create a cookbook user
method: POST
url: https://api.example.com/users
body_type: json
body: '{"name":"Cookbook User"}'
capture:
created_user_id:
value: body.id
assert:
- expression: status
operator: equals
value: 201

Save the second request as users/get.yml:

name: Fetch the created user
method: GET
url: https://api.example.com/users/$created_user_id
assert:
- expression: status
operator: equals
value: 200
tests: |-
test("fetched the created user", () => {
expect(noodle.response.json().id).toEqual(noodle.run.get("created_user_id"));
});

Place Create a cookbook user before Fetch the created user in collection order, then run both with the shared scope:

noodle collection run ./api users/create users/get --fail-fast

Selected requests run in collection order, not argument order. A failed creation or capture prevents the dependent request from running because of --fail-fast. On success, the fetched ID matches the captured ID. Separate manual sends do not share this transient value; use a saved child call when you need both operations within one manual send. Clean up test resources according to your API.

Use this when a list and a detail endpoint must agree on the same record. The list should return an array containing exactly one user with ID 7; both endpoints should expose the same name and active values. This example uses public endpoints and a stable test user that will not change between calls:

name: Compare a user with the list representation
method: GET
url: https://api.example.com/users/7
scripts:
pre: |-
const list = await noodle.sendRequest({
url: "https://api.example.com/users", timeout: 5000,
});
if (list.status !== 200) throw new Error("User list must return HTTP 200");
const users = list.json();
if (!Array.isArray(users)) throw new Error("Expected a user array");
const matches = users.filter(user => user && user.id === 7);
if (matches.length !== 1) throw new Error("Expected exactly one user with ID 7");
noodle.run.set("listed_user", matches[0]);
tests: |-
test("detail matches the listed user", () => {
expect(noodle.response.status).toBe(200);
const listed = noodle.run.get("listed_user");
expect(listed.name).toBeTypeOf("string");
expect(listed.active).toBeTypeOf("boolean");
expect(noodle.response.json()).toMatchObject({
id: listed.id,
name: listed.name,
active: listed.active,
});
});

The pre script fetches the list and retains the matching user for the test. A missing or duplicate match stops the detail request. The test allows extra detail fields while failing differences in the three compared fields. It searches only the returned list page, so select a page or filter that includes the test user. For protected endpoints, configure authentication on the saved request and supply the appropriate headers in the direct call too.

Verify idempotency with a repeated request

Section titled “Verify idempotency with a repeated request”

Use a test API that supports Idempotency-Key for order creation. It must return HTTP 201 with a positive integer id on creation, then HTTP 200 or 201 with the same ID when the exact request is repeated with the same key. Configure API_TOKEN as a secret in the selected environment:

name: Verify an order is created only once
method: POST
url: https://api.example.com/orders
auth:
type: bearer
token: $API_TOKEN
body_type: json
body: '{"product_id":"demo-product","quantity":1}'
scripts:
pre: |-
noodle.request.headers.set("Idempotency-Key", noodle.crypto.randomBytes(16, "hex"));
post: |-
if (noodle.response.status !== 201) throw new Error("Creation must return HTTP 201");
const id = noodle.response.json().id;
if (!Number.isSafeInteger(id) || id <= 0) {
throw new Error("Creation must return a positive integer order ID");
}
const repeated = await noodle.sendRequest({
url: noodle.request.url,
method: "POST",
headers: {
Authorization: "Bearer " + noodle.env.get("API_TOKEN"),
"Content-Type": "application/json",
"Idempotency-Key": noodle.request.headers.get("Idempotency-Key"),
},
body: noodle.request.body.text(),
timeout: 5000,
});
noodle.run.set("repeated_order", { status: repeated.status, id: repeated.json().id });
tests: |-
test("repeating the same key returns the same order", () => {
const repeated = noodle.run.get("repeated_order");
expect([200, 201]).toContain(repeated.status);
expect(repeated.id).toBe(noodle.response.json().id);
});

Each manual send starts a new experiment with a fresh key. Only the child call reuses that key and the exact prepared body; generating a new key for the child would test a different operation. Use an endpoint that accepts the POST directly without redirects, and preserve any other headers your API uses to define the operation’s identity.

A different returned ID or an unacceptable status fails. Matching IDs confirm the response contract for this repeat; they do not prove the absence of every server-side side effect. This creates a real test order, or more than one if the API ignores idempotency. Clean up the created resources according to your API. See idempotency keys for configuring retries of an existing operation.

Use a public endpoint that returns an ETag on HTTP 200 and supports If-None-Match. Choose a stable fixture: a resource changed between these two calls can legitimately return HTTP 200 instead of 304.

name: Revalidate an unchanged catalog
method: GET
url: https://api.example.com/catalog
scripts:
pre: |-
const initial = await noodle.sendRequest({ url: noodle.request.url, timeout: 5000 });
if (initial.status !== 200) throw new Error("Initial catalog must return HTTP 200");
const etag = initial.headers.get("ETag");
if (typeof etag !== "string" || !etag.trim()) {
throw new Error("Initial catalog must include an ETag");
}
noodle.request.headers.set("If-None-Match", etag);
tests: |-
test("unchanged catalog returns 304 without a body", () => {
expect(noodle.response.status).toBe(304);
expect(noodle.response.text()).toBe("");
});

The pre script first fetches the resource without a conditional header, then sets If-None-Match on the outgoing saved request. Keep the returned ETag unchanged, including quotes and any W/ prefix. Both calls must select the same representation; if your API uses authorization or Accept headers, supply the same values on both requests.

Missing ETags stop the saved request. A 200 response fails the test because this fixture is expected to remain unchanged. Use text() for the empty 304 body and avoid inherited checks that require JSON from every response.

Use this against a test API to create a resource, update it, verify the saved fields, and delete it. Configure API_TOKEN as a secret in the selected environment. The create endpoint must return HTTP 201 with a positive integer id; update accepts PATCH, fetch returns HTTP 200, and delete returns HTTP 204.

name: Verify the user lifecycle
method: POST
url: https://api.example.com/users
auth:
type: bearer
token: $API_TOKEN
body_type: json
body: '{"name":"Cookbook User"}'
scripts:
post: |-
if (noodle.response.status !== 201) throw new Error("Creation must return HTTP 201");
const id = noodle.response.json().id;
if (!Number.isSafeInteger(id) || id <= 0) {
throw new Error("Creation must return a positive integer ID");
}
const url = "https://api.example.com/users/" + id;
const headers = {
Authorization: "Bearer " + noodle.env.get("API_TOKEN"),
"Content-Type": "application/json",
};
let failure;
try {
await noodle.sendRequest({
url, method: "PATCH", headers,
body: JSON.stringify({ name: "Updated Cookbook User" }), timeout: 5000,
});
const saved = await noodle.sendRequest({ url, headers, timeout: 5000 });
const user = saved.json();
if (saved.status !== 200 || user.id !== id || user.name !== "Updated Cookbook User") {
throw new Error("Fetched user must contain the updated fields");
}
} catch (error) {
failure = error;
} finally {
try {
const deleted = await noodle.sendRequest({ url, method: "DELETE", headers, timeout: 5000 });
if (deleted.status !== 204) throw new Error("Deletion must return HTTP 204");
} catch (error) {
console.error("Cleanup failed for user", id);
failure ??= error;
}
}
if (failure) throw failure;

The post block succeeds only after verifying the update and deleting the new user. Direct calls need their own authorization header. These calls belong in post because scripted tests cannot send requests.

finally attempts deletion even when update or verification fails. A cleanup failure logs the resource ID and fails the block; if an earlier step also failed, its error is preserved. Inspect Results for the failed child call. HTTP changes cannot be rolled back, and cleanup cannot be guaranteed after cancellation, the script deadline, or a creation response without a usable ID. Use the logged ID for manual cleanup when available.