>_ Analyst Engineering
Developer AnalystQA Analyst Follow

Chaining API Requests With JavaScript in Bruno and Postman: The Scripts Analysts Need

Written by Ahmed at Analyst Engineering, a Senior Technical Business Analyst with 10+ years in banking and payments delivery.

Cover for chaining API requests with JavaScript, showing four chained Stripe requests passing customerId and paymentIntentId through scripts.

Key takeaways

  • Request chaining captures a value from one API response, stores it in a variable, and uses it in the next request, so a sequence of calls reproduces a real business flow end to end.
  • Scripts run at two points around every request: a pre-request script prepares data, headers, and tokens; a post-response script captures values, decides what runs next, and asserts results.
  • Store captured IDs and tokens in runtime variables (bru.setVar in Bruno, pm.variables or collection variables in Postman), because in Bruno v4 bru.setEnvVar writes the value to the environment file on disk.
  • Polling an asynchronous API in a collection run is a self-loop: the post-response script waits, then calls bru.runner.setNextRequest or pm.execution.setNextRequest with the same request name until a final status or an attempt limit.
  • Stripe's sandbox supports a complete chain without writing application code: create a customer, create and confirm a PaymentIntent with test card pm_card_visa, retrieve it, and refund part of it, with pm_card_visa_chargeDeclined for the decline branch.

Request chaining captures a value from one API response and feeds it into the next request, so a collection reproduces a real business flow instead of isolated calls. Scripts make it work: a pre-request script prepares each call, a post-response script captures values, branches, and asserts. An analyst needs about ten JavaScript constructs to do all of it.

To chain API requests in Bruno or Postman: in the post-response script of the first request, read the response body and store the value you need in a runtime variable (bru.setVar in Bruno, pm.collectionVariables.set or pm.variables.set in Postman); reference it as {{variableName}} in the next request; run the sequence with the collection runner. Add pre-request scripts for unique data and token refresh, a self-loop for polling asynchronous statuses, and guards that skip dependent requests when an earlier step fails.

APIs for Analysts, part 6 of 8. Previous: Part 5, how to write API test cases. Next: Part 7, API proof of concept and demos. All parts: series overview.

The worked example is a complete card payment flow in Stripe’s sandbox, so you can build and run every line without access to a company system. Collection setup, environments, and secrets come from Part 2; this article assumes them. The same chaining approach applied to event-driven systems, where the next step is a Kafka message rather than an HTTP response, is in Automate Kafka Validation with Postman.

What is request chaining, and why does it matter?

Chaining is what turns endpoints into a process. A real payment is never one call: you create a customer, take a payment for that customer, check the outcome, and maybe refund part of it. Each step needs an identifier the previous step returned.

Create customer ──> customerId ──> Create and confirm payment ──> paymentIntentId

                        Partial refund <── Retrieve payment <───────────┘

Without chaining, you copy cus_ and pi_ identifiers between tabs by hand, which is slow, error prone, and impossible to rerun. With chaining, one click or one command replays the whole flow with fresh data. That is the difference between checking an endpoint and proving a process, a distinction API testing makes central.

Where do scripts run in the request lifecycle?

Around every request, at two points, and at three levels.

PointRunsTypical use
Pre-requestBefore the request is sentGenerate unique IDs, set headers, refresh tokens, build the body
(request is sent)
Post-responseAfter the response arrivesCapture values, decide the next request, log
TestsAfter the response arrivesAssert status, body, and headers

Both tools also let you attach scripts at collection and folder level, which run for every request inside them. That is where shared logic belongs, such as a token refresh that every request needs. In Bruno, request files store these as before-request, after-response, and tests script types; in Postman they are the Pre-request and Post-response tabs under Scripts.

A useful discipline: scripts prepare and capture, tests assert. Keep assertions out of capture logic and your reports stay readable.

What JavaScript does an analyst actually need?

About ten constructs. Here they are against a realistic response body:

const body = res.getBody();            // Postman: pm.response.json()

body.id                                // dot notation: "pi_3Q..."
body.charges?.data?.[0]?.id            // optional chaining: undefined instead of a crash
body.data.find(p => p.status === "succeeded")        // first match
body.data.filter(p => p.amount > 100000)             // all matches
body.data.map(p => p.id)                             // just the IDs
body.data.some(p => p.currency !== "eur")            // any match? true or false
`E2E-${Date.now()}`                    // template literal for unique values
new Date().toISOString()               // "2026-09-15T10:42:00.000Z"
if (res.getStatus() !== 200) { /* branch */ }
test("status is succeeded", () => expect(body.status).to.equal("succeeded"));

That is genuinely most of it. If find, filter, and optional chaining are new, they are worth twenty minutes of practice, because they are also exactly what you need to read JSON fluently in JSON for analysts.

How do the Bruno and Postman script APIs compare?

The concepts are identical; the names differ. Keep this table open while you work.

TaskBrunoPostman
Response body as JSONres.getBody()pm.response.json()
Status coderes.getStatus()pm.response.code
Response headerres.getHeader("request-id")pm.response.headers.get("request-id")
Response time (ms)res.getResponseTime()pm.response.responseTime
Set a runtime variablebru.setVar("k", v)pm.variables.set("k", v)
Set a variable that survives the runbru.setEnvVar("k", v)pm.collectionVariables.set("k", v)
Read a variablebru.getVar("k"), bru.getEnvVar("k")pm.variables.get("k")
Read a secret from the processbru.getProcessEnv("K"){{vault:k}} in a field
Set a request headerreq.setHeader("k", v)pm.request.headers.upsert({ key: "k", value: v })
Replace the bodyreq.setBody(obj)pm.request.body.update({ mode: "raw", raw: JSON.stringify(obj) })
Assertiontest("name", fn) + expectpm.test("name", fn) + pm.expect
Run a named request nextbru.runner.setNextRequest("Name")pm.execution.setNextRequest("Name")
Skip this requestbru.runner.skipRequest()pm.execution.skipRequest()
Stop the runbru.runner.stopExecution()pm.execution.setNextRequest(null)
Waitawait bru.sleep(2000)setTimeout(() => {}, 2000)
Send a side requestawait bru.sendRequest({...})pm.sendRequest({...}, callback)
Dynamic data in a scriptbru.interpolate("{{$randomUUID}}")pm.variables.replaceIn("{{$guid}}")

One Bruno detail changes a habit: since Bruno v4, bru.setEnvVar persists the value to the environment file on disk. Capture a token with it and the token lands in a file that may be committed. Use bru.setVar for anything captured at runtime, and reserve bru.setEnvVar for values you genuinely want saved.

Worked chain: a Stripe sandbox payment from customer to refund

Setup

Create a free Stripe account, open a sandbox, and copy the test secret key. Put it in the collection’s .env:

STRIPE_SECRET_KEY=sk_test_replace_me

Environment environments/sandbox.yml:

name: sandbox
variables:
  - name: baseUrl
    value: https://api.stripe.com
  - name: stripeKey
    value: "{{process.env.STRIPE_SECRET_KEY}}"
  - name: testCard
    value: pm_card_visa

At collection level, set auth to Bearer Token with {{stripeKey}}. Stripe accepts form-encoded request bodies and returns JSON. The folder:

stripe-payment-flow/
├── opencollection.yml
├── .env
├── environments/
│   └── sandbox.yml
└── 10-card-payment/
    ├── 01-create-customer.yml
    ├── 02-create-and-confirm-payment.yml
    ├── 03-retrieve-payment.yml
    └── 04-partial-refund.yml

Step 1: create a customer and start a clean run

info:
  name: Create customer
  type: http
  seq: 1

http:
  method: POST
  url: "{{baseUrl}}/v1/customers"
  body:
    type: form-urlencoded
    data:
      - name: email
        value: "{{runId}}@example.com"
      - name: name
        value: Analyst Engineering Demo
      - name: metadata[run_id]
        value: "{{runId}}"
  auth: inherit

runtime:
  scripts:
    - type: before-request
      code: |-
        // Reset anything a previous run left behind
        bru.setVar("customerId", null);
        bru.setVar("paymentIntentId", null);

        const runId = `run-${Date.now()}`;
        bru.setVar("runId", runId);
        req.setHeader("Idempotency-Key", `${runId}-customer`);
    - type: after-response
      code: |-
        if (res.getStatus() === 200) {
          bru.setVar("customerId", res.getBody().id);
        }
    - type: tests
      code: |-
        test("customer created", function () {
          expect(res.getStatus()).to.equal(200);
          expect(res.getBody().id).to.match(/^cus_/);
        });

Three habits are packed into that file. The reset at the top stops a stale ID from a previous run silently feeding the chain. The run ID makes every run’s data unique and traceable in the Stripe dashboard through metadata. The idempotency key makes the call safe to retry, as Stripe documents for all POST requests.

Note the test expects 200, not 201. Stripe returns 200 when it creates an object. If you wrote 201 from convention, this is where you find out, which is the point Part 1 made about reading the docs and sending the request.

Step 2: create and confirm the payment, with a branch

info:
  name: Create and confirm payment
  type: http
  seq: 2

http:
  method: POST
  url: "{{baseUrl}}/v1/payment_intents"
  body:
    type: form-urlencoded
    data:
      - name: amount
        value: "2000"
      - name: currency
        value: eur
      - name: customer
        value: "{{customerId}}"
      - name: payment_method
        value: "{{testCard}}"
      - name: confirm
        value: "true"
      - name: automatic_payment_methods[enabled]
        value: "true"
      - name: automatic_payment_methods[allow_redirects]
        value: never
      - name: metadata[run_id]
        value: "{{runId}}"
  auth: inherit

runtime:
  scripts:
    - type: before-request
      code: |-
        if (!bru.getVar("customerId")) {
          bru.runner.skipRequest();
        }
        req.setHeader("Idempotency-Key", `${bru.getVar("runId")}-payment`);
    - type: after-response
      code: |-
        const body = res.getBody();
        if (res.getStatus() === 200) {
          bru.setVar("paymentIntentId", body.id);
        } else {
          bru.setVar("declineCode", body.error?.decline_code);
          console.log(`Declined: ${body.error?.code} / ${body.error?.decline_code}`);
        }
    - type: tests
      code: |-
        const expectDecline = bru.getEnvVar("testCard") !== "pm_card_visa";

        if (expectDecline) {
          test("decline returns 402 with a decline code", function () {
            expect(res.getStatus()).to.equal(402);
            expect(res.getBody().error.code).to.equal("card_declined");
            expect(res.getBody().error.decline_code).to.be.a("string");
          });
        } else {
          test("payment succeeded for 20.00 EUR", function () {
            expect(res.getStatus()).to.equal(200);
            expect(res.getBody().status).to.equal("succeeded");
            expect(res.getBody().amount).to.equal(2000);
          });
        }

amount is 2000 because Stripe amounts are in the smallest currency unit, so this is 20.00 EUR, the data mapping trap from Part 3. allow_redirects=never keeps the sandbox from requiring a return URL for redirect-based payment methods, which a server-side chain cannot follow.

The branch is driven by data, not by editing the script: switch testCard in the environment to pm_card_visa_chargeDeclined and the same request proves the decline path, with Stripe’s documented card_declined code and generic_decline decline code. Try pm_card_visa_chargeDeclinedInsufficientFunds for insufficient_funds.

Step 3 and 4: retrieve, then refund part of it, with guards

03-retrieve-payment.yml is a GET {{baseUrl}}/v1/payment_intents/{{paymentIntentId}} with this before-request guard:

if (!bru.getVar("paymentIntentId")) {
  bru.runner.skipRequest();   // the payment was declined; nothing to retrieve
}

and a test that the retrieved status still reads succeeded and customer equals bru.getVar("customerId"). That second assertion checks the chain itself: the payment belongs to the customer created in this run.

04-partial-refund.yml posts to {{baseUrl}}/v1/refunds with payment_intent = {{paymentIntentId}} and amount = 500, the same guard, and this test:

test("partial refund of 5.00 EUR created", function () {
  expect(res.getStatus()).to.equal(200);
  expect(res.getBody().amount).to.equal(500);
  expect(["succeeded", "pending"]).to.include(res.getBody().status);
});

Accepting pending as well as succeeded is deliberate. Refund status is not guaranteed to be final at response time, and a test that demands succeeded will eventually flake for a reason that is not a defect.

Run the folder:

bru run 10-card-payment --env sandbox --reporter-html reports/card-payment.html

Four requests, one command, fresh data every time, and a report you can attach to a ticket or show in a sprint review.

The same chain in Postman

The Postman version maps line for line. Capture in the post-response script of Create customer:

if (pm.response.code === 200) {
  pm.collectionVariables.set("customerId", pm.response.json().id);
}
pm.test("customer created", () => {
  pm.response.to.have.status(200);
  pm.expect(pm.response.json().id).to.match(/^cus_/);
});

Guard in the pre-request script of Partial refund:

if (!pm.collectionVariables.get("paymentIntentId")) {
  pm.execution.skipRequest();
}

Set bodies to x-www-form-urlencoded with the same keys, and run with the Collection Runner or newman run. Remember to clear the captured variables at the start of the run, the same reset habit as in Bruno.

How do you refresh an OAuth token automatically?

Put it in a collection-level pre-request script so every request gets a valid token without a separate “get token” step. This pattern suits APIs that use the OAuth 2.0 client credentials flow, which most bank and enterprise APIs do:

// Collection pre-request script (Bruno)
const expiresAt = bru.getVar("tokenExpiresAt") || 0;

if (Date.now() > expiresAt - 60000) {          // refresh 60s before expiry
  const response = await bru.sendRequest({
    method: "POST",
    url: bru.interpolate("{{authUrl}}/oauth/token"),
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    data: [
      "grant_type=client_credentials",
      `client_id=${encodeURIComponent(bru.getProcessEnv("CLIENT_ID"))}`,
      `client_secret=${encodeURIComponent(bru.getProcessEnv("CLIENT_SECRET"))}`,
      "scope=payments:write"
    ].join("&")
  });

  bru.setVar("accessToken", response.data.access_token);
  bru.setVar("tokenExpiresAt", Date.now() + response.data.expires_in * 1000);
}

Collection auth then uses {{accessToken}}. The client secret lives only in .env, the token lives only in a runtime variable, and nothing sensitive is ever written to a file. The credential side of this, and why short-lived tokens beat long-lived keys, is covered in API keys, PATs, and OAuth tokens.

How do you poll an asynchronous API until the status is final?

Many payment APIs accept a request immediately and settle it later, like the payments API from Part 4 whose 201 means ACCP, not settled. To prove the final outcome, loop on the status request until it reaches a final status or an attempt limit.

Bruno, in the post-response script of a request named Poll payment status:

const status = res.getBody().status;
const attempt = Number(bru.getVar("pollAttempt") || 0) + 1;
bru.setVar("pollAttempt", attempt);

const finalStatuses = ["ACSC", "RJCT"];

if (!finalStatuses.includes(status) && attempt < 15) {
  await bru.sleep(2000);
  bru.runner.setNextRequest("Poll payment status");
} else {
  bru.setVar("finalStatus", status);
  bru.setVar("pollAttempt", 0);
}

Postman, same request:

const status = pm.response.json().status;
const attempt = Number(pm.collectionVariables.get("pollAttempt") || 0) + 1;
pm.collectionVariables.set("pollAttempt", attempt);

if (!["ACSC", "RJCT"].includes(status) && attempt < 15) {
  pm.execution.setNextRequest(pm.info.requestName);
  setTimeout(() => {}, 2000);   // the runner waits for the timer before moving on
} else {
  pm.collectionVariables.set("finalStatus", status);
  pm.collectionVariables.set("pollAttempt", 0);
}

Then a separate test in the next request asserts finalStatus. Three rules make polling trustworthy:

  • Always cap the attempts. An uncapped loop turns a stuck payment into a runner that never ends, and a stuck payment is precisely the defect you want reported.
  • Treat hitting the cap as a failure, with the last observed status in the message. “Still ACSP after 30 seconds” is a useful defect; a timeout is not.
  • Loops only work in a collection run. Sending the single request by hand ignores setNextRequest, which confuses everyone exactly once.

What each status in that loop actually guarantees is in ISO 20022 payment status codes. When a provider offers webhooks, they replace polling with a push; specifying and testing them is covered in webhooks explained for analysts.

How do you debug a chain that breaks?

Work outward from the variable, because nearly every broken chain is a variable problem.

  1. Log the value at capture. console.log("captured", bru.getVar("paymentIntentId")). Bruno shows logs in the Timeline; Postman in the Console.
  2. Check the capture ran on the right branch. A capture inside if (status === 200) does nothing when the call returned 402.
  3. Check the scope. A value set with pm.variables.set is gone when the run ends; a stale environment value with the same name may be winning. The precedence ladder is in Part 2.
  4. Check the request order. Numbered file names and seq values control it. A renamed request can silently change the order.
  5. Check you are in a runner. setNextRequest, skipRequest, and iteration data only apply in collection runs.
  6. Check the resolved request. Both tools show the final URL and body actually sent. Compare it with what you intended.

What rules keep scripts maintainable?

  • Keep scripts under 20 lines. Longer means logic that belongs in a shared collection or folder script.
  • Scripts prepare and capture; tests assert. Mixing them makes reports unreadable.
  • Reset captured variables at the start of every chain. Stale IDs produce passing tests against the wrong data.
  • Generate unique data per run. Hardcoded references collide on the second run.
  • Guard dependent requests. Skip with a clear reason instead of cascading failures.
  • Never write secrets with a persisting call. Runtime variables for tokens, always.
  • Name requests as business steps, because setNextRequest references names, and so do reports.

The APIs for Analysts series

  1. What is an API and how it works
  2. Your first API collection in Bruno and Postman: requests, environments, variables, and secrets
  3. How to analyze an API: capability, data, behavior, limits, and change
  4. How to document an API: the sections consumers need, OpenAPI, and the error catalogue
  5. How to write API test cases: deriving a complete suite from one endpoint
  6. Chaining API requests with JavaScript (you are here)
  7. API proof of concept and demos: POCs and demos that settle decisions
  8. The analyst who can send a request: why it is an edge, and a 30-day plan

Beyond the core series, the APIs for Analysts learning path organizes companion articles by level: the API glossary and troubleshooting failed requests for beginners, webhooks and GraphQL at intermediate level, and API design review, versioning and breaking changes, API security testing, and API tests in CI for advanced analysts.

The takeaway

Request chaining turns a collection into a replayable business flow: capture a value in one post-response script, reference it as a variable in the next request, and run the sequence. Pre-request scripts generate unique data, set idempotency keys, and refresh tokens; post-response scripts capture, branch, and poll; tests assert. Use runtime variables for anything captured, reset them at the start of each chain, guard dependent requests, and cap every polling loop. The Stripe sandbox flow above, customer to payment to refund with a data-driven decline branch, is a complete template you can adapt to any payment API.

For the event-driven half of payment flows, see Automate Kafka Validation with Postman, and for the assertion and automation strategy behind the scripts, API Testing and QA Mastery for BAs. If a chain against your own API is fighting you, a 1:1 Tech BA Coaching Call is the fastest way to get it running.

Ahmed is a Senior Technical Business Analyst with 10+ years in banking and payments. He builds practical guides and tools for analysts at The Tech BA Toolkit.

Tags: Postman, Bruno, JavaScript, API Testing, Automation

About the author

Analyst Engineering is written by Ahmed, a Senior Technical Business Analyst with 10+ years of banking and payments delivery experience: ISO 20022 and SWIFT messaging, payments API integration, Kafka event validation, and production support. Every article comes from real delivery work, and each one is reviewed and updated as tools and standards change.

Free account

Practice on the Labs, keep your progress

A free account, no password: an email link signs you in. It saves your steps and self-assessments on the Labs, shows your missions on a dashboard, unlocks the solutions, and, if you tick the box, sends you new missions and articles when they ship.

Your email is used to sign you in. Nothing else, unless you ask. Privacy.