Callman — Zero to Hero

A complete user guide to Callman: from sending your first HTTP request to running scheduled, multi-system validation pipelines from CI.

This document covers only what ships in the current Callman build. If something is not on these pages, it is not in the product yet.


Table of contents

  1. Introduction
  2. Quick start: ten minutes from install to passing test
  3. Core concepts
  4. Request builder reference
  5. Variables, environments, and dynamic data
  6. Scripting: the pm.* API
  7. Assertions
  8. Database testing — Oracle, MongoDB, PostgreSQL, MySQL
  9. Redis testing
  10. Kafka testing
  11. Scenario builder — node by node
  12. Scenario data flow
  13. Real-world workflows
  14. Data-driven runs
  15. Callme CLI
  16. Scheduling
  17. Reports and analytics
  18. Tools
  19. Workspace collaboration
  20. Best practices
  21. Mocks
  22. Performance testing
  23. Pipeline gating & scenario priority
  24. Query cookbook — every connection type, every syntax
  25. Appendix A — pm.* API index
  26. Appendix B — Glossary

1. Introduction

1.1 What Callman is

Callman is a desktop application, a CLI, and a backend service that together let you build, run, and schedule HTTP-driven test pipelines against systems that involve more than just HTTP — databases, Kafka topics, and Redis caches.

A request in Callman is not just an HTTP call. It is an HTTP call plus the database state checks that confirm the call had the right side-effects, plus the Kafka events the call was supposed to emit, plus the Redis keys it was supposed to populate, plus the contract the response payload must satisfy. A scenario is a directed graph of those requests with branching, retries, and notifications. A schedule turns a scenario into a recurring server-side job. The CLI lets the same scenario run from a build pipeline.

You can use Callman as a plain request client and never touch the rest. You can also drive an entire post-deploy verification pipeline through it without writing a separate test framework.

1.2 Why it exists

Most API clients treat the request as the unit of work. That maps well to public REST APIs and stops mapping the moment your service does anything asynchronous. A "POST /orders" call returns 202 and then the truth lives in three places: the orders table, a Kafka orders.events.v1 topic, and a Redis pending-payment hash. Verifying that flow with a generic API client requires three separate tools and a script that glues them together.

Callman folds all four checks into one request. The HTTP call, the database row assertion, the Kafka event match, the Redis state read — all live in the same request tab, all run together, all show up in the same test report. When the flow grows past a single request, the scenario builder turns it into a graph without leaving the app.

1.3 Positioning

Callman sits in a niche that the generic-API-client category leaves empty: it treats the full side-effect surface of a request — HTTP response, database write, message-broker event, cache mutation — as one atomic test, then composes those atomic tests into directed scenario graphs with retries, branching, scheduling, and CI hooks.

Callman is not trying to be a load tester (use k6 or Gatling). It is not a contract-first API designer (use Stoplight or OpenAPI tooling). It is a workflow validation tool for systems that already exist — specifically the systems that fail in subtle ways because the HTTP response was fine and the database row never landed.

What you give up by picking Callman: a public marketplace of shared collections. What you get: assertions across HTTP + DB + Kafka + Redis in one tab; a visual scenario builder; a server-side scheduler; a CLI that mirrors the same execution engine; and a Slack-wired notification path that closes the loop on a failure.

1.4 Who it's for

1.5 Capability matrix

CapabilityWhere it livesScriptableDeclarative
HTTP request executionRequest tab, Scenario
Response assertionsTests script
JSON-schema contract validationContract tab
Oracle / MongoDB / PostgreSQL / MySQL queriesDB tab, Scenario
Kafka event matchingKafka tab, Scenario
Redis command pre/post hooksRedis tab, Scenario
Visual workflow orchestrationScenario builder
Conditional branchingCondition node
Data-driven iterationRun with Data
CI executioncallme CLI
Scheduled scenario runsScenarios → Schedules
Slack notificationsNotification node
Multi-user workspacesSettings → Workspace
Mock HTTP servicesMocks
Performance / load testingRequest → Perf test
Pipeline exit-code gating--fail-threshold
Folder-batch CI runs--folder-id

1.6 What you can do after reading this guide

1.7 Moving from Postman? One click and you're in

If your team is on Postman today, you do not need to rebuild anything. Callman imports Postman collections and Postman environments in their original native format — no schema conversion, no script rewriting, no manual mapping.

In practical terms: export your Postman collection, open Callman, click Import, pick the file, and your tests run on the next Send. The same is true for the CLI — callme run collection.json --env env.json accepts the Postman export directly.

Where to next: §2 Quick start.


2. Quick start: ten minutes from install to passing test

This walkthrough takes you from a clean install to a green test in ten minutes. No previous Callman experience required.

2.1 Install the desktop app

Download the build for your platform from the Callman release page. The desktop app ships as a notarized DMG (macOS), an MSI installer (Windows), and an AppImage (Linux). Open the installer and launch Callman.

On first launch the app prompts you to sign in or create an account. Sign in if you have one. Otherwise create one — the app will create a personal workspace for you automatically.

2.2 Send your first request

  1. Click the + button at the top of the sidebar and choose Create Collection. Name it Smoke tests.
  2. With the new collection selected, click the + beside its name and choose Create Request. Name it Get post.
  3. In the URL bar set the method to GET and the URL to https://jsonplaceholder.typicode.com/posts/1.
  4. Press Send (or Cmd+Enter/Ctrl+Enter).

The response panel opens with a 200 status, the JSON body, the response headers, and the duration in milliseconds.

2.3 Write your first test

Switch to the Tests tab in the request editor and paste:

pm.test("response is 200", () => {
  pm.expect(pm.response.status).toBe(200);
});

pm.test("post has a title", () => {
  const body = pm.response.json();
  pm.expect(body.title).to.be.a("string");
  pm.expect(body.title.length).to.be.greaterThan(0);
});

Send again. The Tests tab in the response panel now shows two green checkmarks. If you change toBe(200) to toBe(201), the first test goes red and the failure message names the actual vs expected status.

2.4 Capture a value for the next request

Add to the Tests script:

pm.environment.set("lastPostId", pm.response.json().id);

The value is now stored in the active environment and reusable as {{lastPostId}} in any other request's URL, headers, query, or body. You can confirm by opening the environment drawer with Cmd+E.

2.5 Run the collection from the CLI

The same collection works from the command line. Export it via the sidebar (right-click the collection → Export) into a file named smoke.json, then:

npm install -g @callman/cli
callme run smoke.json --report json --output smoke-report.json

The CLI prints a per-request line, a green summary, and writes the machine-readable report. Exit code is 0 on success, 1 on failures, 2 on fatal/invalid input. That is enough for CI to gate a deploy.

Where to next: §3 Core concepts.


3. Core concepts

Callman has a small vocabulary. Internalising it now will save you re-reading later sections.

3.1 The eight nouns

Workspace
   └── Environments      (named variable sets — dev / staging / prod)
   └── Collections       (folders of related requests)
        └── Folders
             └── Requests
   └── Scenarios         (graphs that orchestrate requests + integrations)
        └── Schedules    (cron-style triggers for scenarios)
   └── Mocks             (short-lived fake HTTP APIs — see §21)
   └── Runs              (a single execution of a collection or scenario)
        └── Reports      (JSON/HTML output of a run)

3.2 Where things execute

Run typeExecutes onPersists
Single request (Send)DesktopNo
Prepare-Run collectionDesktopLocally
Run with DataDesktopLocally
Scenario manual runBackend workerServer
Scheduled scenarioBackend workerServer
callme run (file)CLI machineLocal
callme run (remote)CLI machineLocal
callme scenario runBackend workerServer

This split matters: scripts in scenario nodes run on the server, so they cannot touch your laptop's filesystem. Local desktop runs can do more (open files, interact with the Electron host) but only on the machine that has the request open.

3.3 Variable precedence

When the engine encounters {{userId}} it resolves it in this order, stopping at the first match:

  1. Per-iteration row column (Data-Driven Runner)
  2. Active environment (pm.environment)
  3. Workspace globals (pm.globals)
  4. Built-in dynamic tokens (random.*, counter)
  5. Unresolved → left as {{userId}} literally (so you notice)

A {{...}} that fails to resolve does not stop the request — it is sent literally so you can spot the missing variable in the trace.

Where to next: §4 Request builder reference.


4. Request builder reference

Every Callman request has the same set of tabs. This section walks through each one with the cases it solves.

4.1 The URL bar

The URL bar accepts a method (left dropdown) and a URL with {{var}} interpolation. Three kinds of substitution work in the URL:

Pasting a curl command into the URL bar auto-populates the entire request: method, URL, headers, and body. Useful when teammates share reproductions in chat.

Copying the URL bar with Cmd+C while the focus is in it copies the fully-resolved URL (every {{...}} is replaced). Copying with the sidebar focused (after a right-click → "Copy as curl") copies a curl command instead.

4.2 Params tab

The Params tab is a key/value editor for the query string. Each row has a checkbox to enable or disable it without deleting it — useful when you want to keep a "debug=1" toggle around but off by default.

Path variables also surface here. If your URL is /orders/{{orderId}}/items/{{itemId}}, the Params tab shows orderId and itemId rows you can set without editing the URL string. Query and path values both go through {{...}} interpolation.

4.3 Headers tab

Standard key/value editor. Two extra capabilities:

Common-mistake callout: setting Content-Type manually when the Body tab also implies one. The Body tab wins. If you need to override (say, send application/vnd.acme.v2+json), do it from the Headers tab and switch the body to raw.

4.4 Auth tab

Three first-class auth modes ship today:

OAuth2 with refresh, AWS Signature, NTLM, and Digest are not first-class. The pattern for OAuth2 is a pre-request script that checks expiry and uses pm.sendRequest to refresh — see §6.10 pm.sendRequest for the full pattern.

4.5 Body tab

Supported modes:

For GraphQL, use raw / JSON and send {"query": "...", "variables": {...}}. Callman does not ship a separate GraphQL mode — it doesn't need one.

4.6 Pre-request script tab

A Monaco editor (full IntelliSense for the pm.* API) that runs before the request is sent. Three typical jobs:

The script has access to pm.request (mutable) and the full variable namespace. You can change the URL, headers, and body before they go on the wire.

Example: HMAC-signed request.

const body = pm.request.body || "";
const secret = pm.environment.get("apiSecret");

// Compute HMAC-SHA256, base64-encode, set header.
const enc = new TextEncoder();
const keyBytes = enc.encode(secret);
const msgBytes = enc.encode(body);
const cryptoKey = await crypto.subtle.importKey(
  "raw", keyBytes,
  { name: "HMAC", hash: "SHA-256" },
  false, ["sign"],
);
const sig = await crypto.subtle.sign("HMAC", cryptoKey, msgBytes);
const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sig)));

pm.request.setHeader("X-Signature", sigB64);

4.7 Tests tab

The Tests tab runs after the response arrives. Use it for everything the response itself proves — status, body shape, business invariants.

pm.test("order created", () => {
  pm.expect(pm.response.status).toBe(201);
  const body = pm.response.json();
  pm.expect(body.id).to.match(/^ord_[0-9a-f]{16}$/);
  pm.expect(body.total).to.equal(99.99);
});

Tests can be async — pm.sendRequest and pm.db.query both return promises, and pm.test accepts an async callback.

4.8 Kafka tab

Configure a Kafka connection (broker list, security, group id), the topic to listen on, the match mode (regex or rules), and the timeout window. When the request runs, the listener subscribes before the HTTP call goes out, captures messages during the window, and the matched event becomes available as pm.kafka.event() inside the Tests script.

See §10 Kafka testing for the full mechanics.

4.9 DB Assertions tab

Pick a saved database connection (Oracle, MongoDB, PostgreSQL, or MySQL), choose Pre-request or Post-response stage, write the query, and define declarative rules against the returned rows. Rules support equals, not_equals, exists, gt, lt. Field expressions use dot notation: db_post.firstRow.status, db_post.rowCount, db_post.rows.0.amount.

For ad-hoc queries from inside a test script, use pm.db.query(...) — see §8 Database testing.

4.10 Redis tab

Like the DB tab: pick a saved Redis connection, choose pre or post stage, write commands (one per line, e.g. GET cart:{{userId}}), and add assertions against the captured results. Use pm.redis.get(key) inside scripts for ad-hoc reads.

4.11 Contract Validation tab

A declarative JSON-schema-style checker. You point at a path in the response body and pick rules: required, type, notEmpty, min/max (numbers), minLength/maxLength (strings), minItems/maxItems (arrays), pattern (regex string), enum, contains.

path: data.orders[*].id
rules: required, type=string, pattern=^ord_[0-9a-f]{16}$

Path syntax uses [*] for "every item in this array." When a rule fails, the report names the path that failed and the actual value.

Use Contract rules for schema-shape checks (the field exists, the type matches, the format is right) and the Tests script for business-logic invariants (the total equals quantity × price, the status transitioned from "pending" to "paid").

Where to next: §5 Variables and environments.


5. Variables, environments, and dynamic data

This is the chapter that turns Callman from a request tool into an automation tool. Everything chainable, everything dynamic, lives here.

5.1 Three scopes

Precedence: per-iteration > environment > globals > dynamic > literal.

5.2 Interpolation syntax

Three forms are accepted everywhere {{...}} works (URL, headers, query, body, scripts via pm.variables.resolve):

FormMeaning
{{name}}Look up name (env → globals)
{{env.name}}Force environment lookup
{{global.name}}Force globals lookup
{{random.uuid()}}Dynamic generator (see §5.4)
{{counter}}Per-request increment counter

Resolution runs up to four passes, so nested templates work: {{env.{{counter}}}} will first resolve {{counter}} to (say) 7, then look up the env variable named 7.

5.3 Setting and reading from scripts

pm.environment.set("orderId", pm.response.json().id);
pm.environment.get("orderId");        // "ord_abc..."
pm.environment.has("orderId");        // true
pm.environment.unset("orderId");

pm.globals.set("vendorKey", "xxx");   // same shape on globals

A set from a pre-request script is visible to that request and every subsequent request in the run. A set from a Tests script is visible to the next request. Setting writes go to the active environment — they do not silently leak across environments.

5.4 Built-in dynamic tokens

These do not need to be defined anywhere. They resolve at send time.

TokenReturnsExample
{{random.uuid()}}UUID v41d7fe6b6-...-be51
{{random.int(min, max)}}Integer in [min, max]48291
{{random.string(len)}}Alphanumeric string, len ≥ 1aZ19xPq20LmN
{{random.email()}}firstname.NN@example.com stylesam.user42@example.com
{{random.phone()}}E.164-ish phone+15558675309
{{random.name()}}FirstName LastNameAylin Karimli
{{random.date("YYYY-MM-DD")}}Date in given format2026-04-06
{{random.boolean()}}true or falsetrue
{{random.bigdecimal(0, 10000, 2)}}Decimal, configurable scale1842.55
{{counter}}Counter (default starts at 1)1, 2, 3...
{{counter.start(100)}}Reset/initialize counter to 100100, 101, 102...

Defaults: random.int defaults to [0, 999_999], random.string to length 12, random.date to YYYY-MM-DD, random.bigdecimal to scale 6 over [0, 999_999_999].

5.4.1 $xxx dynamic tokens (60+ aliases)

Alongside the random.* syntax above, every {{$xxx}} token below also resolves at send time. These are dollar-prefixed identifiers (no parentheses, no arguments) and are convenient when a script or collection imported from a different tool already uses the form.

IDs / time:

TokenReturns
{{$randomUUID}}UUID v4
{{$guid}}UUID v4 (alias)
{{$timestamp}}Unix seconds
{{$isoTimestamp}}ISO 8601 datetime
{{$randomInt}}Integer 0-1000
{{$randomBoolean}}true or false
{{$randomAlphaNumeric}}8-char alphanumeric
{{$randomHexaDecimal}}0x4f3a1c2d
{{$randomString}}12-char alphanumeric

People / identity:

TokenReturns
{{$randomFirstName}}First name
{{$randomLastName}}Last name
{{$randomFullName}}First Last
{{$randomUserName}}first.last42
{{$randomNamePrefix}}Mr., Dr., ...
{{$randomNameSuffix}}Jr., PhD, ...

Internet:

TokenReturns
{{$randomEmail}}Email address
{{$randomUrl}}Full URL
{{$randomDomainName}}acme.io
{{$randomUserAgent}}Realistic UA string
{{$randomIP}} / {{$randomIPV4}}IPv4
{{$randomIPV6}}IPv6
{{$randomMACAddress}}1A:2B:3C:4D:5E:6F
{{$randomPassword}}12-char mixed
{{$randomSemver}}3.7.42

Phone / Location:

TokenReturns
{{$randomPhoneNumber}}+15558675309
{{$randomCity}}City name
{{$randomCountry}}Country name
{{$randomCountryCode}}ISO code (e.g. AZ)
{{$randomStreetAddress}}742 Maple St
{{$randomZipCode}}5-digit
{{$randomLatitude}} / {{$randomLongitude}}coordinate

Business:

TokenReturns
{{$randomCompanyName}}Acme Labs Inc.
{{$randomJobTitle}}Senior Software Engineer
{{$randomJobArea}}Engineering
{{$randomDepartment}}Engineering
{{$randomProduct}}Acme Cloud
{{$randomCatchPhrase}}Scalable mesh

Finance:

TokenReturns
{{$randomCreditCardMask}}****-****-****-4242
{{$randomCreditCardNumber}}16-digit Luhn-valid
{{$randomBankAccount}}10-digit
{{$randomBankRoutingNumber}}9-digit
{{$randomCurrencyCode}}USD
{{$randomPrice}}42.99
{{$randomTransactionType}}payment / refund

Date / time:

TokenReturns
{{$randomDatetime}}ISO datetime ±1 year
{{$randomDatePast}}ISO datetime in the past
{{$randomDateFuture}}ISO datetime in the future
{{$randomDateRecent}}Last 7 days
{{$randomDateSoon}}Next 7 days
{{$randomTime}}HH:mm:ss
{{$randomWeekday}}Wednesday
{{$randomMonth}}April

Colors / lorem:

TokenReturns
{{$randomColor}}purple
{{$randomHexColor}}#A78BFA
{{$randomWord}}ipsum
{{$randomWords}}lorem ipsum dolor
{{$randomSentence}}Lorem sentence
{{$randomParagraph}}Lorem paragraph

Files / images:

TokenReturns
{{$randomFileName}}q1w2e3r4.pdf
{{$randomFileExt}}json
{{$randomFileType}}image
{{$randomMimeType}}application/json
{{$randomImageUrl}}https://picsum.photos/...

Autocomplete in the URL bar, headers, body, and Monaco script editors opens the moment you type {{$.

5.5 Request chaining

The two-request "create then fetch" pattern is the simplest example.

Request A (Tests script):

pm.test("order created", () => {
  pm.expect(pm.response.status).toBe(201);
});
pm.environment.set("orderId", pm.response.json().id);

Request B (URL):

GET https://{{host}}/orders/{{orderId}}

Request B inherits the value as long as it runs after A in the same collection run (or in the same scenario branch).

5.6 Extracting deep values

pm.response.json() returns a parsed JSON object. Walk it like any JS object. For deep extraction, write a small helper inline:

const body = pm.response.json();
const firstItemId = body.data.items?.[0]?.id;
pm.expect(firstItemId).to.be.a("string");
pm.environment.set("itemId", firstItemId);

5.7 When to use which scope

ValueRecommended scope
Host nameEnvironment
API base pathEnvironment
Per-environment userEnvironment
Vendor API keyGlobals (or shared env)
Auth token (short-lived)Environment, set from script
Order ID captured at runEnvironment, set from script
Random test data per sendInline random.* tokens
CSV row columnPer-iteration (auto)

Where to next: §6 Scripting.


6. Scripting: the pm.* API

Scripts run in a sandbox. They have access to the pm namespace and to the JavaScript runtime's standard globals (Date, JSON, Math, crypto.subtle, crypto.randomUUID, TextEncoder, TextDecoder, console, setTimeout / clearTimeout / setInterval / clearInterval, Promise, URL, URLSearchParams, atob, btoa, typed arrays). They do not have access to require, process, the filesystem, fetch, or arbitrary npm packages.

Two vendor libraries are bundled and bound at the top level — no import needed:

setTimeout and friends are throttled (max 60 s per timer) and any timer still pending when the script's promise settles is cancelled automatically — a stray setInterval cannot outlive the run.

This chapter covers every pm.* namespace. Use Appendix A for a flat alphabetical reference.

6.1 pm.request

Read and mutate the outgoing request. All mutations from a pre-request script take effect; mutations from a Tests script affect nothing on the wire (the response is already back) but are visible to later assertions.

pm.request.method;                     // "POST"
pm.request.url;                        // resolved URL string
pm.request.headers;                    // HeaderList (see below)
pm.request.query;                      // HeaderList of query params
pm.request.body;                       // { mode, raw, formEntries }

pm.request.setMethod("PATCH");
pm.request.setUrl("https://api.example.com/v2/orders");
pm.request.setHeader("X-Trace-Id", crypto.randomUUID());
pm.request.unsetHeader("X-Trace-Id");
pm.request.getHeader("Authorization");
pm.request.setQueryParam("expand", "items");
pm.request.unsetQueryParam("expand");
pm.request.getQueryParam("expand");
pm.request.setRawBody(JSON.stringify({ ok: true }));

HeaderList API

pm.request.headers and pm.request.query are both HeaderList instances. They behave as a case-sensitive record for bracket access AND expose case-insensitive helpers:

// Case-sensitive bracket access (record-like)
pm.request.headers["X-Custom"] = "value";
const v = pm.request.headers["X-Custom"];

// Case-insensitive lookup
pm.request.headers.get("content-type");      // "application/json"
pm.request.headers.has("Authorization");     // true

// Mutation helpers
pm.request.headers.add({ key: "X-Run", value: "smoke" });    // append
pm.request.headers.upsert({ key: "X-Trace", value: crypto.randomUUID() });
pm.request.headers.remove("X-Old-Header");

// Inspection
pm.request.headers.idx(0);                   // first entry { key, value, description? }
pm.request.headers.all();                    // [{ key, value, description? }, ...]

// Iterable
for (const h of pm.request.headers) {
  console.log(h.key, "=", h.value);
}

6.2 pm.response

Read the response. Available in Tests scripts and any callback that runs after send.

pm.response.status;              // 200
pm.response.code;                // 200 (alias)
pm.response.statusText;          // "OK"
pm.response.headers;             // HeaderList (read-only)
pm.response.body;                // parsed body when JSON, else raw string
pm.response.data;                // alias of .body
pm.response.rawBody;             // raw string
pm.response.json();              // parse on demand, throws on non-JSON
pm.response.text();              // raw string
pm.response.responseTime;        // ms (alias of duration)
pm.response.duration;            // ms
pm.response.responseSize;        // body bytes
pm.response.contentType;         // "application/json; charset=utf-8"

pm.response.headers is a read-only HeaderList:

pm.response.headers.get("content-type");     // case-insensitive
pm.response.headers.has("set-cookie");
for (const h of pm.response.headers) {
  // { key, value, description? }
}

6.2.1 Response assertion shortcuts

pm.response.to, pm.response.be, and pm.response.have all point at a response-aware assertion chain. The same chain is reachable via each entry point — read whichever phrasing reads best at the call site.

Status range (HTTP class):

pm.response.to.be.success;          // 2xx
pm.response.to.be.ok;               // 2xx (alias)
pm.response.to.be.info;             // 1xx
pm.response.to.be.redirection;      // 3xx
pm.response.to.be.clientError;      // 4xx
pm.response.to.be.serverError;      // 5xx
pm.response.to.be.error;            // 4xx OR 5xx

Status exact:

pm.response.to.be.notFound;         // 404
pm.response.to.be.unauthorized;     // 401
pm.response.to.be.forbidden;        // 403
pm.response.to.be.badRequest;       // 400
pm.response.to.be.rateLimited;      // 429
pm.response.to.be.accepted;         // 202
pm.response.to.have.status(201);    // exact, any code

Content type (read from Content-Type header):

pm.response.to.be.json;             // application/json or +json
pm.response.to.be.xml;              // application/xml / text/xml / +xml
pm.response.to.be.html;             // text/html

Headers:

pm.response.to.have.header("X-Trace-Id");
pm.response.to.have.header("Content-Type", "application/json; charset=utf-8");

Body (raw + JSON dot-path):

pm.response.to.have.body();                       // body is non-empty
pm.response.to.have.body("hello");                // substring match
pm.response.to.have.jsonBody();                   // is valid JSON
pm.response.to.have.jsonBody("data.id");          // path exists
pm.response.to.have.jsonBody("data.status", "PAID"); // path equals

Negation: every predicate above accepts .not:

pm.response.to.not.be.serverError;
pm.response.to.not.have.header("X-Internal");

6.3 pm.environment (alias pm.env)

Read and mutate the active environment. pm.env is provided as a short alias.

pm.environment.get("host");
pm.environment.set("token", "abc");
pm.environment.unset("token");
pm.environment.has("token");
pm.environment.toObject();       // snapshot as plain object
pm.environment.values;            // same as toObject()
pm.environment.name;             // "Staging" — read-only label
pm.environment.clear();          // wipe every key in this scope
pm.environment.replaceIn("https://{{host}}/orders/{{id}}");
                                 // resolve {{...}} against THIS scope only

A common mistake: setting an env variable from a Tests script and expecting the currently-running request to see it. It can't — the URL was already resolved by the time the Tests script runs. The next request gets it.

6.4 pm.globals

Same shape as pm.environment but writes to workspace globals. Use for cross-environment state (a shared correlation id during one run). Exposes .has/.get/.set/.unset/.has/.clear/.toObject/.replaceIn and the read-only .name label.

6.5 pm.variables

Read / write / resolve against the merged scope (per-iteration row > collection > environment > globals).

pm.variables.has("traceId");                 // boolean across scopes
pm.variables.get("traceId");                 // first match
pm.variables.set("traceId", crypto.randomUUID());
                                             // request-local write
pm.variables.unset("traceId");
pm.variables.toObject();                     // merged snapshot

pm.variables.resolve("https://{{host}}/orders/{{orderId}}");
pm.variables.replaceIn("Hello {{userName}}"); // alias of resolve

Writes via pm.variables.set are request-scoped — they survive across scripts in the same request (pre-request → Tests) but do not persist to the environment / globals. Use pm.environment.set or pm.collectionVariables.set if you need persistence.

6.5.1 pm.collectionVariables

A variable scope that survives across requests within a single collection run but does not persist to the workspace. Same shape as pm.environment:

pm.collectionVariables.has("runId");
pm.collectionVariables.get("runId");
pm.collectionVariables.set("runId", crypto.randomUUID());
pm.collectionVariables.unset("runId");
pm.collectionVariables.clear();
pm.collectionVariables.toObject();
pm.collectionVariables.replaceIn("trace={{runId}}");
pm.collectionVariables.name;                 // "Collection"

Use case: write a correlation id in the first request's pre-request script, read it from every later request in the same run without polluting the environment.

6.5.2 pm.info

Read-only snapshot of "where am I" in the run. Driven by the runner / scenario worker context.

pm.info.eventName;       // "prerequest" | "test" | "scenario-script" | ...
pm.info.iteration;       // 1-based row index in Data-Driven Run; 1 outside
pm.info.iterationCount;  // total iterations; 1 outside DDR
pm.info.requestName;     // "Create order"
pm.info.requestId;       // internal id

6.5.3 pm.execution

Flow-control surface used by the collection runner and scenario worker. No-op on an ad-hoc single Send.

pm.execution.setNextRequest("Retry");        // jump to a named request
pm.execution.setNextRequest(null);           // stop the branch

// Pre-request only. Tells the runner to skip the HTTP call AND the
// Tests script for the current request.
pm.execution.skipRequest();

// Hierarchical location:
pm.execution.location;                       // ["Smoke", "Auth", "Login"]
pm.execution.location.current;               // "Login"

6.6 pm.test

Define a named test. Sync or async.

pm.test("status is 200", () => {
  pm.expect(pm.response.status).toBe(200);
});

pm.test("user is present in DB", async () => {
  const row = await pm.db.query({
    connectionId: "primary",
    query: `SELECT id FROM users WHERE email = '${pm.environment.get("email")}'`,
  });
  pm.expect(row.rowCount).toBe(1);
});

A test that throws is recorded as a failure with the error message. A test that completes without throwing is a pass. There is no "skipped" state inside pm.test itself — to skip, simply don't call it.

6.7 pm.expect

A Chai-flavoured assertion chain. Both BDD-style getters and toBe/toEqual-style methods are accepted.

Type and existence:

pm.expect(value).to.be.true;
pm.expect(value).to.be.false;
pm.expect(value).to.exist;
pm.expect(value).toBeDefined();
pm.expect(value).to.be.a("string");
pm.expect(value).to.be.an("array");

Equality:

pm.expect(7).to.equal(7);
pm.expect(7).toBe(7);
pm.expect({ a: 1 }).to.eql({ a: 1 });   // deep
pm.expect({ a: 1 }).toEqual({ a: 1 });  // deep

Containment and comparison:

pm.expect("hello world").to.contain("hello");
pm.expect([1, 2, 3]).to.include(2);
pm.expect(10).to.be.greaterThan(5);
pm.expect(10).toBeGreaterThan(5);
pm.expect(10).to.be.lessThan(20);
pm.expect(10).toBeLessThan(20);

Truthiness, patterns, navigation:

pm.expect("nonempty").toBeTruthy();
pm.expect("").toBeFalsy();
pm.expect("abc-123").to.match(/^[a-z]+-\d+$/);
pm.expect(body).to.have.property("id");
pm.expect(body).to.have.property("status", "PAID");
pm.response.to.have.header("Content-Type");
pm.response.to.have.status(200);

Fail explicitly:

pm.expect.fail("Unexpected branch reached");

The chain getters to, be, been, is, that, which, and, has, have, with, same, deep are no-ops for fluency — pick the form that reads best.

6.8 pm.random

Same generators as the {{random.*}} tokens, callable from script. Useful when you need a value twice in one script (once in a request body, once in an assertion).

pm.random.uuid();
pm.random.int(1000, 9999);
pm.random.string(20);
pm.random.email();
pm.random.phone();
pm.random.name();
pm.random.date("YYYY-MM-DD HH:mm");
pm.random.boolean();
pm.random.bigdecimal(0, 1000, 2);

6.9 pm.counter

A monotonically increasing integer scoped to the run. Useful for ordering, idempotency keys, or batch numbering.

pm.counter.next();        // advance the default counter, return new value
pm.counter.start(100);    // advance a separate counter keyed by start value,
                          // returning 100, 101, 102, ... on subsequent calls

Each unique start argument is its own counter, so pm.counter.start(100) and pm.counter.start(500) are independent sequences. The default counter (pm.counter.next()) is the same one the {{counter}} template token reads.

6.10 pm.sendRequest

Issue an HTTP request from inside a script. Three call signatures are accepted so existing snippets port over without rewriting: URL + callback, options + callback, or options as a Promise.

// Form 1: URL + callback
pm.sendRequest("https://api.example.com/health", (err, res) => {
  if (err) return console.error(err);
  pm.expect(res.code).toBe(200);
});

// Form 2: options + callback
pm.sendRequest(
  {
    url: "https://auth.example.com/oauth/token",
    method: "POST",
    header: { "Content-Type": "application/x-www-form-urlencoded" },
    body: {
      mode: "urlencoded",
      urlencoded: [
        { key: "grant_type", value: "client_credentials" },
        { key: "client_id", value: pm.environment.get("clientId") },
        { key: "client_secret", value: pm.environment.get("clientSecret") },
      ],
    },
  },
  (err, res) => {
    if (err) throw err;
    const json = JSON.parse(res.body);
    pm.environment.set("accessToken", json.access_token);
    pm.environment.set("tokenExpiresAt", String(Date.now() + json.expires_in * 1000));
  },
);

// Form 3: Promise (no callback)
const res = await pm.sendRequest({ url: "https://api.example.com/me", method: "GET" });
const me = JSON.parse(res.body);

The token-refresh pattern lives here. Put it in a pre-request script shared by every authenticated request (collection-level pre-request scripts apply to every request inside).

const expiresAt = Number(pm.environment.get("tokenExpiresAt") || 0);
if (Date.now() > expiresAt - 30_000) {
  await pm.sendRequest(/* ... refresh ... */);
}
pm.request.setHeader("Authorization", `Bearer ${pm.environment.get("accessToken")}`);

The response object exposes code, status, headers, body, json(), text(), responseTime.

6.11 pm.kafka

Available inside the Tests script of a request that has the Kafka tab configured (or inside a Kafka scenario node). The listener is opened before the HTTP call goes out and stays open for the configured timeout window.

pm.kafka.event();          // matched message (parsed JSON) or null
pm.kafka.messages();       // every captured message, raw + parsed

pm.test("OrderCreated was published", () => {
  const evt = pm.kafka.event();
  pm.expect(evt).to.exist;
  pm.expect(evt.orderId).to.equal(pm.environment.get("orderId"));
});

If the listener times out without a match, pm.kafka.event() returns null. The test fails with a clear "no matching Kafka event" error.

6.12 pm.redis

Available when the Redis tab is configured on the request (or in a Redis scenario node).

pm.redis.get("cart:{{userId}}");     // synchronous read of last cached value
pm.redis.results.pre;                // array of pre-stage command results
pm.redis.results.post;               // array of post-stage command results

For ad-hoc commands not in the declarative list, use a scenario Redis node or write the command into the pre/post-stage area.

6.13 pm.db

Same shape as Redis. Pre and post results come from the rules defined in the DB Assertions tab; pm.db.query is the ad-hoc escape hatch.

pm.db.pre;                 // { rows, rowCount, firstRow(), ... }
pm.db.post;                // same shape after the HTTP call
const r = await pm.db.query({
  connectionId: "ordersPrimary",
  query: "SELECT status, total FROM orders WHERE id = ?",
});
pm.expect(r.rowCount).toBe(1);
pm.expect(r.firstRow().status).to.equal("PAID");

Notice pm.db.query is async (returns a Promise). The Tests script can await it directly.

6.14 pm.node

Only meaningful in a scenario. Use to read the current node's label and to redirect flow.

pm.node.nodeLabel;                  // "verify-payment"
pm.node.setNextRequest("retry");    // jump to another node by name
pm.node.setNextRequest(null);       // stop the current branch

pm.execution.setNextRequest(...) (see §6.5.3) is the preferred entry point going forward; pm.node.setNextRequest remains for back-compatibility with older scripts.

6.15 pm.log

Append a line to the request console. Visible in the Console drawer of the desktop app and in CLI --verbose output.

pm.log("computed signature", sigB64);
pm.log({ orderId: pm.environment.get("orderId"), retry: 3 });

6.16 CryptoJS (top-level global, not under pm.*)

CryptoJS is bound as a top-level identifier — no import needed. Useful for request signing, hashing, encryption.

// HMAC-SHA256 signed request (pre-request script)
const body = pm.request.body?.raw ?? "";
const secret = pm.environment.get("apiSecret");
const signature = CryptoJS.HmacSHA256(body, secret).toString();   // hex
pm.request.headers.upsert({ key: "X-Signature", value: signature });

// Base64 wrap for binary signatures
const b64 = CryptoJS.enc.Base64.stringify(
  CryptoJS.HmacSHA256(body, secret),
);

// SHA hashes
CryptoJS.SHA256("hello").toString();          // hex
CryptoJS.MD5("hello").toString();
CryptoJS.SHA512("hello").toString();

// AES round-trip
const cipher = CryptoJS.AES.encrypt("secret payload", "passphrase").toString();
const plain = CryptoJS.AES.decrypt(cipher, "passphrase").toString(CryptoJS.enc.Utf8);

// PBKDF2
const key = CryptoJS.PBKDF2("password", "salt", { keySize: 8, iterations: 1000 });

// Encoders
CryptoJS.enc.Hex.stringify(CryptoJS.enc.Utf8.parse("hello")); // "68656c6c6f"
CryptoJS.enc.Base64.parse("aGVsbG8=");                        // WordArray

The full crypto-js surface ships with the sandbox — every hash, HMAC variant, symmetric cipher, encoder, and lib.WordArray / lib.CipherParams helper from the upstream library is callable.

6.17 _ (lodash, top-level global)

Lodash is bound as the global identifier _:

const body = pm.response.json();

// Filter / map / group response collections
const active = _.filter(body.users, { status: "active" });
const byRegion = _.groupBy(active, "region");
const totals = _.map(byRegion, (rows, region) => ({
  region,
  count: rows.length,
}));

// Deep extraction with a default
const orderId = _.get(body, "data.order.id", null);

// Schema shape: assert nothing extra leaked into the payload
const expected = ["id", "email", "createdAt"];
pm.test("no extra user fields", () => {
  pm.expect(_.isEqual(_.keys(body.user).sort(), expected.sort())).to.be.true;
});

// Cloning before mutating
const draft = _.cloneDeep(body);
draft.user.email = "test@example.com";

Common entries with autocomplete in the editor: _.filter, _.map, _.forEach, _.find, _.groupBy, _.keyBy, _.countBy, _.sortBy, _.orderBy, _.uniq, _.uniqBy, _.flatten, _.flattenDeep, _.chunk, _.zip, _.range, _.pick, _.omit, _.merge, _.cloneDeep, _.isEqual, _.get, _.set, _.has, _.keys, _.values, _.entries, _.isEmpty, _.isArray, _.isObject, _.shuffle, _.sample, _.debounce, _.throttle.

6.18 setTimeout / retry loops

The sandbox exposes setTimeout, clearTimeout, setInterval, clearInterval. Use them for poll loops and async waits:

// Pre-request: refresh token if near expiry
const expiresAt = Number(pm.environment.get("tokenExpiresAt") || 0);
if (Date.now() > expiresAt - 30_000) {
  // ... refresh ...
}

// Tests: poll for eventual consistency
let attempts = 0;
let row = null;
while (attempts < 10) {
  const r = await pm.db.query({
    connectionId: "ordersPrimary",
    query: "SELECT status FROM orders WHERE id = '{{orderId}}'",
  });
  if (r.firstRow()?.status === "PAID") {
    row = r.firstRow();
    break;
  }
  await new Promise((resolve) => setTimeout(resolve, 500));
  attempts += 1;
}
pm.expect(row, "order eventually PAID").to.exist;

Two safety rails worth knowing:

6.19 Anti-patterns

Where to next: §7 Assertions.


7. Assertions

Assertions in Callman come in two flavours: scripted (you write the check) and declarative (you describe the shape, the engine checks it). Both run as part of the same test report. Use scripted when the invariant is business logic; use declarative when the invariant is "this field exists, is a string, and matches this regex."

7.1 Scripted assertions — pm.expect

Every operator was listed in §6.7 pm.expect. The mental model: pm.expect(actual).<chain>.<operator>(expected). The chain words are decoration; only the operator matters semantically.

Convert sloppy if-throws into clean assertions:

// Bad
if (pm.response.status !== 200) {
  throw new Error("not 200");
}

// Good
pm.expect(pm.response.status, "HTTP status").toBe(200);

The optional second argument to pm.expect (the message) shows up verbatim in the failure report.

7.2 Contract Validation — declarative response schema

The Contract Validation tab is path-based JSON-schema. Each row:

Contract rules run after the Tests script. Their failures show up as separate entries in the report — you can filter "contract failures only" in the analytics view.

When to use Contract vs Tests:

QuestionUse
Does the field exist?Contract
Is it the right type?Contract
Does it match a regex?Contract
Is it inside an allowed enum?Contract
Does business invariant X hold?Tests
Does field A equal computed value B?Tests
Does the response cross-reference DB row?Tests + DB

7.3 DB / Kafka / Redis declarative rules

Each integration tab supports the same five operators: equals, not_equals, exists, gt, lt. Field expressions support dot notation and array indexing. Value expressions support {{...}} substitution, so you can assert db_post.firstRow.userId equals {{response.userId}} without writing a line of JS.

This declarative style is the right choice when:

Switch to a script the moment you need anything conditional ("only check refundedAt if status is REFUNDED").

Where to next: §8 Database testing.


8. Database testing — Oracle, MongoDB, PostgreSQL, MySQL

Four database engines are wired into Callman: Oracle, MongoDB, PostgreSQL, and MySQL. All four are accessed the same way from the UI, from pm.db.*, and from scenario DB nodes — and the query syntax is identical whether the query runs on your desktop or inside a scheduled server-side scenario run. For a per-engine syntax guide with worked examples, see §24 Query cookbook.

8.1 Creating a connection

Sidebar → Connections → + Database. Two configuration modes:

  1. Detail mode — host, port, database (Oracle service name / Mongo database), user, password.
  2. Connection-string mode — paste a full URI:
    • mongodb://user:pass@host:27017/db
    • oracle://user:pass@host:1521/SERVICE
    • postgresql://user:pass@host:5432/db
    • mysql://user:pass@host:3306/db

Connections are saved per workspace. The connection ID is what you reference from scripts and scenario nodes.

Passwords are encrypted at rest (a separate workspace encryption key, not stored alongside the data). Connection definitions are pulled fresh each run — rotating a password doesn't require touching every request.

8.2 Per-request pre / post queries

In the DB Assertions tab of a request, pick a stage:

Write the query (parametrised with {{...}} substitutions), and define assertion rows against the returned rows.

-- Oracle
SELECT id, status, total
FROM orders
WHERE user_id = '{{userId}}'
  AND created_at > SYSTIMESTAMP - INTERVAL '5' MINUTE
{
  "collection": "orders",
  "operation": "find",
  "filter": { "userId": "{{userId}}" },
  "sort": { "createdAt": -1 },
  "limit": 10
}

MongoDB queries are not written in shell syntax (db.orders.find(...) is rejected). The query is a strict JSON object with collection and operation fields — see §24.6 for every operation and its envelope.

Sample rule rows:

Field expressionOpValue
db_post.rowCountequals1
db_post.firstRow.statusequalsPAID
db_post.firstRow.totalequals{{expected}}
db_post.rows.0.idexists

8.3 pm.db.query — ad-hoc queries

When you need a query you'd rather not declare on the tab — debug lookups, conditional checks — use the script API:

const res = await pm.db.query({
  connectionId: "ordersPrimary",
  query: `
    SELECT id, status, paid_at
    FROM orders
    WHERE user_id = '${pm.environment.get("userId")}'
    ORDER BY created_at DESC
    FETCH FIRST 1 ROWS ONLY
  `,
});

pm.test("most recent order is paid", () => {
  pm.expect(res.rowCount).toBe(1);
  pm.expect(res.firstRow().status).toBe("PAID");
  pm.expect(res.firstRow().paid_at).to.exist;
});

The returned object: { success, error, rowCount, rows, firstRow(), connectionId, connectionName, connectionType, durationMs, executedAt }.

8.4 Polling for async state

A common asynchronous pattern: the HTTP call schedules a job, the job writes to the DB seconds later. Poll inside a Tests script:

const target = pm.environment.get("orderId");
let row = null;
for (let i = 0; i < 10; i++) {
  const r = await pm.db.query({
    connectionId: "ordersPrimary",
    query: `SELECT status FROM orders WHERE id = '${target}'`,
  });
  if (r.firstRow()?.status === "PAID") {
    row = r.firstRow();
    break;
  }
  await new Promise((r) => setTimeout(r, 500));
}

pm.test("order eventually transitioned to PAID", () => {
  pm.expect(row).to.exist;
  pm.expect(row.status).toBe("PAID");
});

For longer waits, prefer a Wait node in a scenario rather than busy- looping in a script.

8.5 What is not supported

Where to next: §9 Redis testing.


9. Redis testing

Redis support has the same shape as DB support: saved connections, pre/post stages per request, declarative assertions, and a script API for ad-hoc reads.

9.1 Connection

Sidebar → Connections → + Redis. Host, port, password, database index (0 by default), TLS toggle. Saved per workspace.

9.2 Supported commands

Exactly five commands are supported — the same five on desktop and in server-side scenario runs. Anything else is rejected with "Supported Redis commands are GET, SET, DEL, EXISTS, and TTL".

CommandSyntaxReturns
GETGET keythe string value, or null if the key is absent
SETSET key valuethe value that was written
DELDEL keynumber of keys removed (0 or 1)
EXISTSEXISTS keytrue / false
TTLTTL keyseconds remaining; -1 no expiry, -2 no key

Values with spaces go in quotes: SET greeting "hello world". Single or double quotes both work, and \" escapes a quote inside a quoted value. GET/DEL/EXISTS/TTL take exactly one key — multi-key forms (DEL a b, MGET) are rejected. See §24.7 for worked examples.

9.3 Pre/post commands

In the Redis tab, one command per line. Each line gets a result row accessible as pm.redis.results.pre[i] or .post[i] in scripts.

SET test-trace-{{counter}} "{{random.uuid}}"
GET cart:{{userId}}
TTL session:{{userId}}

Assertion rows reference results by index or by key.

9.4 Worked example — cache validation

Scenario: HTTP POST /signup creates a user and the service writes a session entry to Redis. We want to assert the right key landed with an expiry attached.

Request configuration:

9.5 Scripted reads

const cached = pm.redis.get(`cart:${pm.environment.get("userId")}`);
pm.test("cart cache populated", () => {
  pm.expect(cached).to.exist;
});

const lastCommand = pm.redis.results.pre[0];
pm.log("GET returned", lastCommand);

pm.redis.get reads the result already captured in the pre/post stage. For ad-hoc commands not declared in the tab, prefer adding them to the pre/post list rather than synthesising a new connection in a script — the pre/post stages are part of the run report.

Where to next: §10 Kafka testing.


10. Kafka testing

Callman ships a Kafka consumer. The producer side is your service under test — you POST to it and assert the event came out. There is no pm.kafka.publish (and there shouldn't be — tests should not be the only producer of an event).

10.1 Connection

Sidebar → Connections → + Kafka. Broker list (comma-separated), client id, group id, security (PLAINTEXT, SASL_PLAINTEXT, SASL_SSL, SSL), username/password if SASL. Saved per workspace.

10.2 Listener mechanics

In the Kafka tab of a request:

When the request runs, the listener subscribes before the HTTP call goes out. Every message that arrives during the window is captured. The first one that matches becomes pm.kafka.event(). If the window ends with no match, pm.kafka.event() is null and the test fails with "no matching Kafka event."

There are no automatic retries. If you need to listen longer, raise the timeout. If you need to listen on multiple topics for the same request, use a scenario with a Kafka node per topic and an aggregator.

10.3 Regex match mode

The captured message is serialised as a string and matched against your regex. Useful when the message has a stable token you can spot quickly:

regex: /"orderId":"ord_[0-9a-f]{16}"/

This mode is fast and forgiving but doesn't let you assert against specific fields. Good for "did anything that looks like an order event come through."

10.4 Rules match mode

Add one or more field rules. Each rule is <path> <op> <value>.

Field expressionOpValue
kafkaEvent.typeequalsOrderCreated
kafkaEvent.orderIdequals{{orderId}}
kafkaEvent.amountgt0
kafkaEvent.metadata.envequals{{env.name}}

All rules must match for the message to count. Path expressions support dot notation and numeric array indices (kafkaEvent.items.0.sku).

10.5 Asserting against the matched event

pm.test("order event published", () => {
  const evt = pm.kafka.event();
  pm.expect(evt).to.exist;
  pm.expect(evt.type).to.equal("OrderCreated");
  pm.expect(evt.orderId).to.equal(pm.environment.get("orderId"));
});

pm.test("no errant duplicate events", () => {
  const all = pm.kafka.messages();
  const ours = all.filter((m) => m.value?.orderId === pm.environment.get("orderId"));
  pm.expect(ours.length).toBe(1);
});

10.6 Correlation-id pattern

The reliable way to match the right event in a busy topic: send a correlation id with the HTTP call, then match on it.

Pre-request script:

pm.environment.set("corr", crypto.randomUUID());

Request body:

{ "userId": "{{userId}}", "correlationId": "{{corr}}" }

Kafka tab rules:

FieldOpValue
kafkaEvent.correlationIdequals{{corr}}

This way two parallel test runs against the same service never collide.

10.7 Worked example — event-driven order pipeline

Goal: when we POST an order, three things should happen:

  1. orders.events.v1 should receive an OrderCreated.
  2. The orders table should have a row with status PENDING.
  3. The Redis hash inventory:reserved:{orderId} should exist.

Build it as one request:

When the request runs you get one report row showing all four checks. Any single failure flips the request red and pinpoints which side-effect broke.

Where to next: §11 Scenario builder.


11. Scenario builder — node by node

Scenarios are visual workflows. You drag typed nodes onto a canvas, connect them with edges, and execute the graph. Scenarios live in the Scenarios tab of the sidebar and run server-side when invoked from the desktop "Run" button, scheduled jobs, or the CLI.

Callman ships 11 node types. This chapter covers each one with purpose, configuration, outputs, and pitfalls.

11.1 Canvas mechanics

Every non-Condition node has two policy fields:

11.2 Edge types

EdgeEmitted byMeans
defaultAny node except ConditionSuccess path
yesCondition node, when condition trueTrue branch
noCondition node, when condition falseFalse branch

A node can have multiple outgoing default edges (a fan-out — every downstream is started after the source completes). A Condition node emits exactly one yes and one no.

11.3 Request node

Identical surface to a request tab: URL, method, params, headers, auth, body, scripts, contract rules, DB/Kafka/Redis. When a request node runs inside a scenario it sees the merged environment plus any prior node's outputs (see §12 Scenario data flow).

Outputs accessible by downstream nodes (referenced by the node's label as shown on the canvas):

{{<node-label>.response.status}}
{{<node-label>.response.headers.<name>}}
{{<node-label>.response.body.<json-path>}}
{{<node-label>.request.url}}

Common pitfall: forgetting that scripts in a scenario node run on the server, so they cannot reference the desktop machine. No localhost-style assumptions.

11.4 DB node

Executes a database query (Oracle, MongoDB, PostgreSQL, or MySQL) inside the workflow. Choose the connection, write the query — plus, for PostgreSQL/MySQL, optional positional bind params ($1 / ?), each template-resolved at run time — and either:

Best used for state checks between HTTP calls — confirm the previous step persisted before the next step depends on it.

11.5 Redis node

Run one or more Redis commands. Useful for:

Only GET, SET, DEL, EXISTS, and TTL are supported (see §9.2). If the node has a single command, the node output is that command's result — reference it as {{<node-label>}}. With multiple commands the output is an array: {{<node-label>[0]}}, {{<node-label>[1]}}, ...

11.6 Kafka node

Subscribe to a topic with a timeout and either regex or rules matching, exactly like the request Kafka tab. Place a Kafka node after the request that triggers the event, with edges configured so the listener starts before the request fires. (In practice you draw: previous-node → Kafka, and the engine opens the listener at "node about to start" time.)

11.7 Wait node

Pauses the workflow for the configured number of milliseconds.

Use cases:

There is no scheduling fairness — a 30s Wait blocks one node slot for 30s. For long sleeps, prefer a Condition-driven retry loop with short waits.

11.8 Script node

Runs arbitrary JavaScript with the same pm.* sandbox the request scripts use. Use when:

The script's return value is stored as the node output. Reference it directly: {{<node-label>}} for a scalar return, or {{<node-label>.orderId}} when the script returns an object.

const reqOut = pm.variables.resolve("{{create-order.response.body}}");
const dbOut = pm.variables.resolve("{{verify-order.rows[0].status}}");

if (dbOut !== "PENDING") {
  pm.expect.fail(`Expected PENDING, got ${dbOut}`);
}

return { orderId: JSON.parse(reqOut).id, verifiedStatus: dbOut };

11.9 Notification node

Sends a message to Slack. The notification provider in this build is Slack only — Teams, email, generic webhooks, and PagerDuty are not wired.

Config fields:

Use case: route a scenario's failure summary to an oncall channel.

:rotating_light: Payment scenario failed at {{failed-node-label}}
Order: {{create-order.response.body.id}}
Error: {{failed-node-error}}

11.10 Condition node

Branches the workflow into yes and no paths. Two configuration modes:

Example builder rule:

group OR
  ├ create-order.response.status == 201
  └ group AND
     ├ create-order.response.status == 200
     └ create-order.response.body.status == "PAID"

Conditions have no on-failure or retry policy. They either evaluate true (emit yes edge) or false (emit no edge). An evaluation error is treated as false.

11.11 End node

A terminal node. Two modes:

Loops are the only first-class repeat construct. There is no Loop node and no Parallel node. Fan-out is achieved by drawing multiple default edges out of one node.

11.12 Aggregator node

Merges prior node outputs into a single structured value. The merge is described by a template:

{
  "orderId": "{{create-order.response.body.id}}",
  "kafkaSeq": "{{event-check.sequence}}",
  "dbStatus": "{{verify-row.rows[0].status}}"
}

The merged object is the aggregator's output — downstream nodes (notifications, end summaries) reference its fields as {{<aggregator-label>.orderId}}.

Use case: build a structured payload to ship to Slack or attach to a report's metadata.

11.13 Sub-scenario node

Embeds a saved scenario as a single node. The contained graph runs the way a function call does — its inputs are the merged variables at the call site, its outputs are accessible at the call site.

Two uses:

11.14 Failure policy decision tree

Did the node fail?
├── retry.enabled and attempts < maxAttempts
│     └── wait delayMs, run again
├── onFailure = "continue"
│     └── mark node failed, follow outgoing edges anyway
└── onFailure = "stop" (default)
      └── halt the scenario; status = failed or completed_with_failures

completed_with_failures is the status when at least one node failed but onFailure = continue carried the run to the end.

Where to next: §12 Scenario data flow.


12. Scenario data flow

A scenario shares state through three channels: the merged environment, prior node outputs, and the __meta workflow state. Understanding which goes where is what separates a brittle scenario from a maintainable one.

12.1 The merged environment

When a scenario starts, the engine builds one variable scope:

workspace globals
     +
selected environment
     +
sub-scenario inputs (if this is a sub-scenario call)

Every node sees the same scope. A node writing pm.environment.set mutates the scope for every subsequent node in the same branch. The mutation does not leak back to the workspace environment after the run completes — it lives only for that run.

12.2 Node output references

Each node's output is addressable as {{<node-label>.<path>}} everywhere a {{...}} is supported. The key is the node's label exactly as it appears on the canvas. Array elements use bracket indexing: rows[0].id, not rows.0.id.

Node typePrimary outputs
Requestresponse.status, response.headers.<n>, response.body.<path>
DBrows[<i>].<column>, rowCount
Redissingle command: the result itself ({{label}}); multiple: [0], [1], ...
Kafka (publish)topic, partition, offset, timestamp
Kafka (consume)the matched event's JSON fields at top level, plus meta.key, meta.partition, meta.offset, meta.timestamp, meta.headers.<n>
Scriptthe return value itself ({{label}} or {{label.<field>}})
Aggregatorthe merged JSON's fields ({{label.<field>}})
Sub-scenariooutput.<exported-path>

Because references are label-based, renaming a node's label breaks every downstream {{...}} that mentions it — update them together. Labels with trailing spaces are trimmed before matching.

Besides label references, the engine also exposes the most recent result of each kind under fixed roots: {{response.*}} (last HTTP response), {{db.result[<i>].<column>}} (last DB rows), {{redis.result}} (last Redis result), {{kafkaEvent.*}} (last matched Kafka event).

12.3 __meta

__meta is the workflow state struct available inside scripts and template expressions. Useful keys:

Reach for __meta when you need to behave differently based on how the run was triggered — for example, only sending a Slack alert when the trigger is scheduled (during interactive runs, the operator is already watching).

12.4 Conditional branching deep dive

The Condition node accepts either a builder rule tree or a JS expression. The rule tree is recursive: groups (AND/OR) contain either leaf comparisons or nested groups. There is no depth limit in the UI, but past two levels readability suffers — switch to the Script node if you need more.

Three real branching shapes:

12.5 Aggregator merge patterns

Aggregators are syntactic sugar — anything an aggregator does, a Script node could do. But aggregators are declarative, so they show up cleanly in reports and don't require sandbox time.

Pattern: collect three parallel results into one Slack message.

{
  "trace": "{{__meta.runId}}",
  "kafka_seen": "{{kafka-check.type}}",
  "db_seen":    "{{db-check.rows[0].status}}",
  "cache_seen": "{{redis-check}}",
  "duration_ms": "{{__meta.previousNode.durationMs}}"
}

12.6 Sub-scenario reuse

When the same five-node check ("verify user, refresh token, ping health, log start, capture trace id") appears in three scenarios, extract it to its own scenario and reference it via a Sub-scenario node in each caller. Edits to the underlying scenario propagate to every caller on the next run.

Best practice: name sub-scenarios with verbs (refresh-auth, assert-user-active) and keep them under a dozen nodes each.

12.7 Retry / failure policy as a flow construct

Retries happen inside a node — you don't see them as separate graph entries. If you need visibility into "we retried three times and finally succeeded," put the call inside a Sub-scenario and read its run report.

If you need a fail-then-compensate flow ("if POST fails, send a DELETE to undo a partial side-effect"), use onFailure: continue on the failing node and route the default edge to the compensation request. The downstream nodes can read <failed-node-id>.error.

Where to next: §13 Real-world workflows.


13. Real-world workflows

Eight end-to-end scenarios that map to recognisable enterprise patterns. Every one is buildable today with the nodes documented above — no invented features.

13.1 Banking onboarding flow

Goal — Verify the new-customer flow: POST creates a user, Kafka emits a CustomerOnboarded, and the user lands in the customers Oracle table.

Graph:

[Request: POST /customers]
       │
       ▼
[DB: SELECT status FROM customers WHERE id = {{create.response.body.id}}]
       │
       ▼
[Kafka: orders.events.v1 / type=CustomerOnboarded / corr matches]
       │
       ▼
[Condition: all green?]
   yes ──▶ [Notification: success to #onboarding-tests]
   no  ──▶ [Notification: error to #oncall] ──▶ [End]

Why this scenario — exercising three systems in one workflow is exactly what a request tab struggles with at scale: you'd need three separate requests with chained envs. The scenario shows the full graph in one place and produces one report row covering all checks.

13.2 E-commerce checkout validation

Goal — A user adds an item, places an order, payment lands, and the inventory reservation appears in Redis.

Graph:

[Request: POST /cart/items]
   │
   ▼
[DB: SELECT cart_id FROM carts WHERE user = {{userId}}]
   │
   ▼
[Request: POST /checkout]
   │
   ▼
[Redis: EXISTS inventory:reserved:{{checkout.response.body.orderId}}]
   │
   ▼
[Kafka: payments.events / type=PaymentCaptured / corr matches]
   │
   ▼
[Aggregator: assemble checkout summary]
   │
   ▼
[End]

Why — checkout flows fail in interesting ways (HTTP succeeds but payment doesn't, or payment succeeds but the inventory lock didn't land). One scenario, four assertions, every failure pinpointed.

13.3 Loan approval system

Goal — Submit a loan application, branch based on credit-score range, run a sub-scenario for fraud checks if amount > 50k.

Graph:

[Request: POST /loans]
   │
   ▼
[Script: pull score from credit bureau via pm.sendRequest, return tier]
   │
   ▼
[Condition: tier in {gold, platinum}?]
   yes ──▶ [Request: PATCH /loans/{id} status=approved] ─▶ [End]
   no  ──▶ [Condition: amount > 50000?]
                yes ──▶ [Sub-scenario: full-fraud-check]
                            │
                            ▼
                       [Condition: passed?]
                          yes ──▶ [Request: PATCH /loans/{id} status=manual_review] ─▶ [End]
                          no  ──▶ [Notification: warning #risk] ──▶ [End]
                no  ──▶ [Request: PATCH /loans/{id} status=rejected] ─▶ [End]

Why — branching plus reuse. The fraud check is its own scenario, shared between loans and high-value transfers. Future edits to the fraud subgraph propagate everywhere.

13.4 Payment orchestration with rollback

Goal — Capture payment, write order, emit event. If the event publish fails, refund the payment.

Graph:

[Request: POST /payments/capture]   onFailure: stop
   │
   ▼
[Request: POST /orders]              onFailure: continue
   │ default
   ▼
[Kafka: orders.events / type=OrderCreated]   onFailure: continue
   │
   ▼
[Condition: orders OK and Kafka matched?]
   yes ──▶ [End]
   no  ──▶ [Request: POST /payments/{{captureId}}/refund]
              │
              ▼
           [Notification: warning to #payments-oncall]
              │
              ▼
           [End]

Why — distributed transactions don't exist in HTTP. Tests that care about side-effect rollback need to model the compensating step explicitly.

13.5 Notification pipeline validation

Goal — When a user updates their profile, an audit row should land in the DB, an event should hit Kafka, and a Slack notification should fire downstream. We verify all three.

Graph:

[Request: PUT /users/{{userId}}]
   │
   ▼
[Aggregator (fan-out by drawing 3 edges out of above)]
   ├ default ──▶ [DB: SELECT * FROM audit WHERE actor = {{userId}} ORDER BY ts DESC FETCH 1 ROW]
   ├ default ──▶ [Kafka: users.audit.v1 / corr matches]
   └ default ──▶ [Redis: EXISTS notif:dispatched:{{userId}}]
                    │  │  │
                    └──┴──┴──▶ [Aggregator: summarise]
                                  │
                                  ▼
                               [End]

Why — fan-out lets all three checks run in the same window. The final aggregator gives you one row in the report with the full state.

13.6 Microservice integration smoke

Goal — Hit three services in sequence (auth → catalog → cart), threading the same correlation id, and verify each step.

Graph:

[Script: generate corr, set pm.environment.set("corr", randomUuid)]
   │
   ▼
[Request: POST /auth/login   header: X-Corr: {{corr}}]
   │
   ▼
[Request: GET /catalog/items  header: X-Corr: {{corr}}]
   │
   ▼
[Request: POST /cart/items    header: X-Corr: {{corr}}]
   │
   ▼
[DB: SELECT count(*) FROM access_log WHERE correlation_id = '{{corr}}']
   │
   ▼
[Condition: count >= 3?]
   yes ──▶ [End]
   no  ──▶ [Notification: error #obs-gaps]

Why — observability gaps are usually invisible. Threading a correlation id and asserting the trail lands in your log table detects them before they hide a real incident.

13.7 Event-driven saga validation

Goal — A saga publishes three events in order: OrderCreated, InventoryReserved, PaymentInitiated. Validate all three within a single window.

Graph:

[Request: POST /orders]
   │
   ▼
[Kafka node A: orders.events.v1 type=OrderCreated]
[Kafka node B: inventory.events.v1 type=InventoryReserved]
[Kafka node C: payments.events.v1 type=PaymentInitiated]
   │
   ▼ (drawing three default edges from POST /orders into A, B, C; then A/B/C → Aggregator)
[Aggregator: shape result for stakeholders]
   │
   ▼
[Notification: info to #saga-tests]
   │
   ▼
[End]

Why — sagas are the hardest pattern to validate end-to-end with generic tooling. Three Kafka nodes in parallel give you one clear pass/fail per event without writing a custom consumer.

13.8 CI/CD smoke suite

Goal — Every morning at 02:00 the staging environment runs a five-request smoke test. On failure, Slack alerts. Reports are archived.

Schedule — daily 02:00 Europe/Baku, retry 2× at 5 min intervals, notify on failure to #staging-smoke.

Graph:

[Request: GET /health]
   │
   ▼
[Request: POST /auth/login]
   │
   ▼
[Request: GET /me]
   │
   ▼
[Request: GET /products]
   │
   ▼
[Request: GET /orders/recent]
   │
   ▼
[Condition: all 200?]
   yes ──▶ [Notification: info "smoke green"] ──▶ [End]
   no  ──▶ [Notification: error attach failed-node-label] ──▶ [End]

Pair with CI — same scenario kicked off from GitHub Actions on every deploy via callme scenario run, the report-id link posted to the PR.

Where to next: §14 Data-driven runs.


14. Data-driven runs

Some tests are the same request over and over with different inputs: "register these 5,000 users from this CSV and assert each one ended up in the right state." Callman's Run with Data feature handles that.

14.1 Opening Run with Data

In any request tab, open the 3-dot menu → Run with Data. A full-screen view replaces the editor with two columns: config on the left, live results on the right.

14.2 Data files

Three formats supported:

Upload via the Browse button. The preview shows row count and the first five rows so you can confirm the columns look right.

Edge cases handled:

14.3 Per-iteration variable scope

Each row becomes a temporary variable map. If your CSV has columns userId, email, name, then on iteration 7 those three values are available as {{userId}}, {{email}}, {{name}} anywhere the request uses {{...}}.

Precedence: row column → environment → globals. So a column named host will override the env variable named host for that iteration only.

14.4 Config options

OptionDefaultPurpose
Concurrency1Workers running in parallel (1 = sequential)
Inter-iteration delay0 msSleep between iterations (sequential mode)
Row range from / tofull fileIterate only a slice (debug a known bad row)

Use a high concurrency (10-50) for ingestion-style workloads where the server scales horizontally. Use sequential with delay for systems that throttle or that share a piece of mutable state.

14.5 Token refresh during long runs

For 5,000 requests with a 1-hour token: put a token-refresh check in the request's own pre-request script (see §6.10). The script runs once per iteration; the tokenExpiresAt env value persists across iterations within the same Run.

14.6 Live results table

The results column streams rows as iterations finish. Each row:

Filter chips switch between All, Success, Failed.

14.7 Pause / stop / resume

The Pause button freezes new iterations but lets in-flight ones finish. Stop is hard — running iterations complete, queue empties.

14.8 Exporting results

Export JSON writes the full state (config + parsed file metadata

14.9 When to use Run with Data vs a scenario

Use Run with Data when:

Use a scenario when:

Where to next: §15 Callme CLI.


15. Callme CLI

callme is the command-line entry point. It runs collections and scenarios in any environment that can install Node packages — CI runners, on-call laptops, bastion hosts.

15.1 Install

npm install -g @callman/cli
callme --version    # 1.1.0

15.2 Authentication

Two modes:

PATs are scoped. For scheduled scenario triggers, the PAT needs the pipeline:trigger scope. Token issuance and scope assignment happen in the desktop app under Settings → Tokens.

15.3 callme run — collections

File mode:

callme run smoke.json --env staging.json --report json --output smoke.json

Remote mode:

callme run \
  --collection-id col_01H6A... \
  --environment-id env_01H6B... \
  --workspace ws_01H6C... \
  --token "$CALLME_TOKEN" \
  --report html --output smoke.html

Flags:

FlagEffect
--env <file>Env JSON (file mode only)
--collection-id <id>Fetch the collection from the backend (remote mode)
--environment-id <id>Fetch the environment from the backend (remote mode)
--workspace <id>Workspace id (or CALLME_WORKSPACE_ID)
--token <token>Personal Access Token (or CALLME_TOKEN)
--api-url <url>Backend URL (default https://api.callman.io)
--report json|htmlReport format
--output <file>Where to write the report
--bailStop on first failure
--no-failForce exit code 0 even on failures
--verbosePrint resolved URLs, headers, bodies
--silentPrint only status lines and summary

15.4 callme scenario run

Triggers a scenario server-side. The CLI process polls until the scenario reaches a terminal status (or until the timeout) and exits accordingly.

callme scenario run \
  --scenario-id sc_01H6D... \
  --environment-id staging \
  --workspace ws_01H6C... \
  --token "$CALLME_TOKEN" \
  --report json --output scenario.json

Flags:

FlagEffect
--scenario-id <id>Single scenario to run (or CALLME_SCENARIO_ID). Mutually exclusive with --folder-id.
--folder-id <id>Run every enabled scenario in a folder. Sequential or parallel — configured on the folder in the desktop app. See §23.8.
--environment-id <id>Scenario's inline environment id (or CALLME_SCENARIO_ENV_ID). Falls back to environments[0]. Not allowed in folder mode.
--no-waitFire and forget — print the report id and exit 0
--timeout <ms>Max wait for terminal status (default 1800000 = 30 min)
--max-runtime <ms>Server-side runtime cap (advisory to the worker)
--fail-threshold <level>Lowest scenario priority that flips the exit code on failure: critical (default), high, medium, low. See §23.
--report jsonJSON output (HTML for scenarios falls back to JSON; not allowed in folder mode)
--output <file>Path for the JSON report
--no-failExit 0 even if scenario failed (priority gate too)
--bailNo-op for scenarios (per-node policies live in graph)
--verbose / --silentPolling output verbosity

Every invocation finishes with a colored Exit code: N line (green on 0, red on 1, white-on-red on 2). The line is printed even in --silent mode — the exit code is the machine-readable signal CI scripts depend on.

Folder mode example:

callme scenario run \
  --folder-id flr_01H6X... \
  --workspace ws_01H6C... \
  --token "$CALLME_TOKEN"

The CLI fetches the folder's enabled scenarios, triggers them in the mode the folder was configured for, prints a per-scenario summary, and finishes with an aggregated Folder summary: N passed, M failed line before the Exit code: footer.

15.5 Exit codes

CodeMeaning
0All passed; or --no-fail; or scenario failed but its priority was below --fail-threshold (default critical)
1A run failed and its priority is at or above --fail-threshold — for folder runs, any scenario that crosses the threshold flips the code
2Fatal — invalid args, network failure, bad PAT, or --timeout reached before a terminal status

The priority gate only applies to callme scenario run. callme run (collections) is binary: any failed request / script / contract test returns 1 unless --no-fail is set. See §23 for the priority model end to end.

15.6 CI recipes

GitHub Actions — run the smoke suite on every push to main and post the JSON report as an artifact.

name: smoke
on: { push: { branches: [main] } }
jobs:
  smoke:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm install -g @callman/cli
      - run: |
          callme scenario run \
            --scenario-id ${{ vars.CALLMAN_SCENARIO_ID }} \
            --workspace ${{ vars.CALLMAN_WORKSPACE_ID }} \
            --environment-id staging \
            --token ${{ secrets.CALLMAN_TOKEN }} \
            --report json --output smoke.json
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: smoke-report, path: smoke.json }

GitLab CI — same idea, with a cached node_modules and a JUnit- adjacent artifact for the pipeline UI.

smoke:
  image: node:20
  script:
    - npm install -g @callman/cli
    - callme run collection.json --env $CI_ENV --report json --output smoke.json
  artifacts:
    when: always
    paths: [smoke.json]

Jenkins — declarative pipeline. Treat callme like any other test runner; mark the build unstable on non-zero exit.

pipeline {
  agent any
  environment {
    CALLME_TOKEN = credentials('callman-token')
  }
  stages {
    stage('Smoke') {
      steps {
        sh '''
          npm install -g @callman/cli
          callme run collection.json --env ci.json --report json --output smoke.json
        '''
      }
      post {
        always { archiveArtifacts artifacts: 'smoke.json' }
      }
    }
  }
}

The Callman backend does not ship Jenkins/GitLab/Azure DevOps plug-ins — these recipes rely solely on the CLI.

Folder smoke suite — one CLI step runs every enabled scenario in a folder and gates the build only on critical failures.

callme scenario run \
  --folder-id "$CALLMAN_FOLDER_ID" \
  --workspace "$CALLMAN_WORKSPACE_ID" \
  --token "$CALLMAN_TOKEN" \
  --fail-threshold critical

Configure which scenarios participate and whether they run sequentially or in parallel in the desktop app (sidebar → folder menu → Pipeline config). A flaky low-priority probe in the folder no longer breaks the build; only the critical scenarios do.

Where to next: §16 Scheduling.


16. Scheduling

Scheduling turns a scenario into a recurring server-side job. The desktop app has the editor; the backend runs the cron loop.

16.1 Creating a schedule

In the scenario editor, Schedules → New schedule. Pick a trigger type:

TriggerInputs
DailyOne or more times of day
WeeklyDays of week + times
MonthlyDays of month + times
Custom cronStandard 5-field cron expression

Every schedule has an IANA timezone (UTC, Europe/Baku, America/New_York, etc.). The cron loop uses that timezone — DST is handled automatically.

16.2 Execution policy

OptionEffect
Allow parallel runsIf the previous run is still going, start anyway
Skip if runningIf the previous run is still going, skip this tick
Max runtime (minutes)Hard kill after this many minutes

The conservative default for new schedules: skip-if-running, 30 min max runtime.

16.3 Failure policy

OptionEffect
Retry countNumber of retries on failed run (0 = no retry)
Retry interval (m)Minutes between retries

A retry is a fresh scenario run, not a partial resume. Use retries for transient infra issues — they don't fix a broken assertion.

16.4 Notifications

Optional Slack notification on success and / or failure. The Slack channel and severity come from the connection configuration. Failure notifications include the report id, the failed-node label, and a direct link to the report in the desktop app.

16.5 Schedule state visible in the UI

For each schedule the editor shows:

16.6 Three patterns

Hourly health probe — Cron 0 * * * *, scenario hits 3 endpoints, 0 retries, Slack on failure only. Treat as a heartbeat.

Nightly regression — Daily 02:00 Europe/Baku, 30-step scenario, 2 retries 5 min apart, Slack on both success and failure, JSON report archived via webhook.

Weekly stakeholder report — Weekly Monday 09:00, scenario that runs a series of reads, produces an aggregator-summarised JSON, notifies a public channel with the metrics.

Where to next: §17 Reports and analytics.


17. Reports and analytics

Every scenario run produces a report. Reports are server-side and queryable by anyone in the workspace with viewer-or-better access.

17.1 Per-run metadata

Each report has:

17.2 Per-node detail

For each node executed:

17.3 Terminal states

StatusMeaning
successEvery assertion passed
failedA node failed with onFailure=stop, halting run
completed_with_failuresSome nodes failed with onFailure=continue
stoppedRun was cancelled (user or runtime cap)
queuedWaiting on a worker (rare; in-flight)
runningCurrently executing (in-flight)

17.4 Export formats

A dedicated server-side PDF endpoint is not exposed — print-to-PDF from the HTML report is the supported path.

17.5 Replay and diff

Inputs and outputs are stored per node. Opening a past report and clicking a node shows the inputs the node saw and the outputs it produced. Diff between two report runs is available from the report list view — pick two runs of the same scenario and click Diff.

17.6 Debugging a failed node

The drill-down sequence that solves 90% of failures:

  1. Open the report. Look at the failedNode label in the summary.
  2. Open the failed node's drawer. Read the error message.
  3. Inspect inputs — was the variable that fed this node correct? If not, the bug is upstream.
  4. Inspect outputs — did the node receive a response the assertion didn't expect? If yes, the assertion or the service is wrong.
  5. Open the previous run's report and diff. Did the input change? That points at config drift.

Where to next: §18 Tools.


18. Tools

The Tools page (left sidebar, Tools entry) collects standalone utilities that don't need a request to execute.

18.1 Base64 encoder / decoder

Encode text and binary to base64; decode base64 back to text or binary. Useful for inspecting Authorization: Basic headers, JWT payloads (the middle segment is base64url-encoded), or signing material.

18.2 UUID generator

Generate v4 UUIDs one at a time, batched, or in a specific format (with / without dashes, upper / lower case). Drop into env variables for static test users.

18.3 JWT decoder

Paste a token, see header, payload, and the expiry claim parsed as an ISO date. The signature is not verified — this is an introspection tool, not a verifier.

Common task: "the API is rejecting my token, is it expired?" → paste, read exp → if in the past, refresh.

18.4 Diff checker

Side-by-side comparison of two text blobs (auto-detected as JSON or plain). Useful for "the response shape changed between staging and prod" investigations.

18.5 File converter

Convert binary files to base64 (for stuffing into a JSON request body) and back. Image files render a preview so you can confirm you've encoded the right thing.

Where to next: §19 Workspace collaboration.


19. Workspace collaboration

Workspaces are the unit of collaboration. A user can be in many; a collection belongs to exactly one.

19.1 Roles

RoleCan readCan editCan runCan inviteCan manage workspace
Owner
Editor
Viewer

A read-only user can still trigger runs of scenarios and collections — viewing doesn't prevent execution, only modification. Edits to read-only collections in the desktop app are blocked at the UI layer.

19.2 Inviting members

Settings → Workspace → Members → Invite. Enter an email; an invitation email goes out. The invitee accepts and joins as the role you picked. Owners and Editors can invite Editors and Viewers; Editors cannot invite Owners.

19.3 Personal Access Tokens

Settings → Tokens → + New Token. The token string is shown once on creation (prefix cm_pat_) — copy it into your secrets manager immediately. Tokens are revocable from the same page and are workspace-scoped: a token issued for workspace A cannot read or trigger anything in workspace B.

Two pipeline scopes guard the CI endpoints:

ScopeGrants
pipeline:readDownload the collection + environment bundle, fetch folder manifests, poll run reports.
pipeline:triggerTrigger a scenario run (callme scenario run).

pipeline:read is a base scope — the backend force-adds it to every newly minted token so a pipeline:trigger-only token cannot fetch the bundle it needs to poll the run it just triggered. Each endpoint enforces its required scope independently: a missing scope surfaces as 403 Token is missing the '<scope>' scope, which the CLI prints verbatim to make CI logs self-explanatory.

19.4 Shared environments

Environments belong to a workspace. Every member with at least viewer access can read them. Editor and Owner can mutate them. There is no per-environment ACL — env-level access follows the workspace role.

A common pattern: an admin-only workspace holds production credentials, with three Owners. The day-to-day team workspace references those values via an env labelled prod whose contents are empty placeholders. The CI pipeline overlays the real values at run time via --env.

19.5 Audit trail

Workspace edits (member invites, role changes, scenario edits, env mutations) record an audit entry. The audit log is visible to Owners via Settings → Workspace → Audit. Entries: actor, action, target, timestamp, details.

Where to next: §20 Best practices.


20. Best practices

Patterns that have come up enough times across teams to be worth codifying.

20.1 Naming

20.2 Environment strategy

A workable three-env baseline:

Avoid one mega-env with host_staging, host_prod, host_local. Switching env should be one click, not a search-and-replace.

20.3 Reusable script patterns

20.4 Scenario sizing

20.5 Retry versus assert

20.6 Performance

20.7 Team workflows

Where to next: §21 Mocks.


21. Mocks

A mock is a short-lived fake HTTP API that lives at https://<api-host>/mock/<mockId>/<your-path>. It is workspace-scoped, expires automatically on its TTL, and is configured from Workspace → Mocks in the desktop app. Mocks let you unblock frontend / contract work before the real service exists, inject failure modes that would be hard to reproduce in production, or stand up disposable fixtures for a demo.

21.1 What mocks are (and are not)

A mock is a test double, not a deployment target. Each mock:

Mocks are not a persistence layer (state resets when the mock expires), not load-bearing infrastructure (rate limits cap them at small dozens of RPS), and not a Postman-style monitor (no scheduling — they're inert until called).

21.2 Creating a mock

Workspace → Mocks → Create mock. The create modal asks for:

On create you land on the mock detail page with three tabs: Routes, State, Logs, plus a Settings drawer.

21.3 Routes

A route binds an HTTP method + URL path inside the mock to a response. Routes use Express-style path syntax, so /users/:id/posts matches /users/42/posts and exposes id as a path parameter inside the response builders.

Pick a preset when creating a route — the preset wires up sane defaults so you don't start from a blank form:

PresetBehaviour
echoReflects whatever the caller sent: method, headers, query, body. Useful for sanity-checking the client.
listReturns a paginated, filterable, sortable list. Source: an inline static array, or a key from the mock's state bucket.
detailLooks one entity up by a path param (:id) inside the source array / state.
createAppends a row to a state key with auto-generated id (uuid or incremental), createdAt, updatedAt.
updatePatches or replaces a row in state (partial or full merge strategy).
deleteRemoves a row from state. Response can be 204, the deleted row, or a success message.
customYou write the status, headers, and body by hand. Fall-back when the presets don't fit.

Below the route list, the route editor lets you tweak:

21.4 Response types

Each route's response is one of three discriminated shapes:

  1. Static — fixed status + headers + body. The body ships verbatim, no interpolation.
  2. Template — same shape as static, but the body is interpolated with {{path.id}}, {{query.page}}, {{body.email}}, request helpers, and any state key. Use it for "return what they asked for" responses without writing code.
  3. Builder — the preset-driven editor. The form fields you fill out (source, filtering, pagination, lookup field, etc.) are translated into a handler at request time. Most CRUD endpoints should use builder responses; you almost never need a hand-rolled handler.

21.5 Variants

Each route can carry an ordered list of variants — alternative responses chosen by matching the incoming request against a condition tree.

Route GET /users/:id
  Variant 1 (priority 100):  path.id equals "0"  →  404 not found
  Variant 2 (priority 50):   header.x-flaky exists  →  500 simulated outage
  Variant 3 (priority 10):   query.locale equals "ja"  →  Japanese body
  Default response:                                   →  200 + standard body

Variants are evaluated highest-priority first. A condition is a rule set combined with all (every rule must match) or any (at least one). Each rule has a field (e.g. header.x-id, query.locale, body.email, path.id), an operator (exists, not-exists, equals, not-equals, contains, starts-with, matches), and an optional value.

Up to 20 rules per condition and 64 chars per variantId. Variants share the same status / headers / body forms as the route's default response.

21.6 State

The mock owns a state bucket — a single JSON object. State is seeded from initialState on create and mutated by create / update / delete builder routes at request time.

{
  "users": [
    { "id": "u1", "email": "ada@example.com" },
    { "id": "u2", "email": "lin@example.com" }
  ],
  "orders": []
}

The State tab shows the current snapshot and exposes a Reset to initial button. Reset is the right escape hatch when a test run left the mock in a dirty state and you want a clean fixture for the next iteration. State is capped at 1 MB by default; large fixtures should be split across multiple mocks.

21.7 Settings

The settings drawer exposes the per-mock guardrails:

SettingDefaultNotes
totalRequests20,000Hard cap. Once reached, the mock returns 429 Mock request budget exhausted.
rateLimitPerSecond30Sliding-window cap. Bursts above this return 429 Rate limit exceeded.
maxRequestBodyBytes1 MiBBackend-enforced; oversize bodies return 413.
maxResponseBodyBytes100 KiBTruncated with 413 if a builder generates a body bigger than this.
maxLogsPerMock500Ring buffer for the Logs tab — older entries get evicted as new ones arrive.

These caps exist to keep mocks "test fixture" sized; a per-workspace mock count cap (default 20) keeps the workspace from accumulating zombie fixtures.

21.8 Logs

The Logs tab is a reverse-chronological feed of every request that hit the mock: method, path, status code, latency, and the matched route / variant. Click any entry to see:

A TTL countdown in the mock header and a request-budget progress bar in the Settings drawer keep "how much is left" visible without you opening the logs.

21.9 Calling a mock from a request

Use the mock URL in any Callman request, scenario request node, or external client:

GET https://api.callman.io/mock/m_01H7A.../users/42

For token-auth mocks, add Authorization: Bearer <your-mock-token>. The mock surface lives at /mock/<mockId>/... — outside the /api namespace and outside workspace auth, because it has to be reachable by services that don't know about your Callman session.

21.10 Use cases

Frontend dev parallel to backend — the API team agrees the shape of POST /orders, you stand up a mock that returns the agreed-on body, and the frontend ships against the mock until the real endpoint lands. Once it does, only the base URL changes.

Failure injection — a variant on your payment endpoint returns 500 when the request body carries simulateOutage: true. Your scenario can call the live route, then call the same route with the simulator flag, and assert the error-handling path.

Demo seed — populate the mock's initialState with the data your stakeholder wants to see, link the demo UI at the mock URL, present without touching the real database.

21.11 What mocks are NOT

Where to next: §22 Performance testing.


22. Performance testing

Performance mode lets you turn a single saved request into a load generator: a number of virtual users (VUs), a curve that describes how many of them are active over time, and a data source that feeds per-iteration variables. The desktop app runs the load locally (against whichever target your request URL resolves to) and renders live metrics — latency percentiles, throughput, error rate — as the test progresses.

Use perf mode for capacity questions ("does our login endpoint hold 500 concurrent users?") and regression questions ("did p95 just double after the new release?"). For correctness questions, write a scenario (§11).

22.1 Opening perf mode

Open any request, then Tabs → Perf test. The first time you open the tab for a request the app snapshots the current state (URL, headers, body, auth, env, scripts) so the test runs against a frozen copy. Editing the request afterwards does not retroactively change the perf test config — open the perf tab again to re-snapshot.

The perf tab has three screens, swapped by state:

22.2 Load profile

Pick one of four curve shapes (each preview is a sparkline in the profile-picker cards):

ProfileInputsShape
Fixedvus, durationMsFlat line — constant VU count.
Ramp-uppeakVus, rampDurationMs, holdDurationMsLinear climb to peak, then hold.
SpikebaselineVus, spikeVus, spikeStartAtMs, spikeDurationMs, totalDurationMsBaseline → sudden burst → back to baseline.
Peak holdpeakVus, rampUpDurationMs, holdDurationMs, rampDownDurationMsRamp up, hold, ramp down. Classic "shape of a hill".

Defaults are conservative (50 VUs / 1 min for Fixed, up to 500 VUs for the dynamic profiles). A live preview chart shows the curve before you start the test so you can sanity-check the duration.

22.3 Data source

Pick how each VU iteration's variables are produced:

The data row is bound into the request as variables, so the same {{email}} token you wrote in the request body becomes the iteration-scoped value during perf runs.

22.4 Running screen

While the test runs the dashboard shows, updating every second:

Stop halts the test cleanly; you still get a partial report.

22.5 Report

When the test finishes (or you stop it) the report screen presents:

Reports stay in the desktop app's local store while the workspace is open. Export to JSON for archival or to share with a teammate who wasn't on the call.

22.6 Pre / post hooks during perf runs

The pre-request and post-request scripts you wrote on the original request do run on every iteration during perf mode. Be aware of:

22.7 When perf mode is the wrong tool

If you need any of these, use a scenario or a dedicated load tool:

22.8 Best practices

Where to next: §23 Pipeline gating & scenario priority.


23. Pipeline gating & scenario priority

Every scenario carries a priority: critical, high, medium, or low. The default for both new and legacy scenarios is medium. Priority is purely a CI concept — it drives one decision: should a failed run flip the pipeline build to red?

23.1 Why this exists

A team running 30 scenarios in CI was used to the first flaky non-critical scenario breaking deploys. Lowering reliability of the flaky scenario was not always possible, and disabling it lost coverage. The priority gate decouples signal (this scenario failed) from action (this build should fail).

After the rollout, only critical scenario failures break CI by default. Lower-priority failures still produce a red badge in the desktop UI and a Slack notification, but the pipeline keeps moving.

23.2 The four levels

PriorityUse case
critical"If this fails, stop the deploy." Smoke tests on payment, auth, regulatory paths.
high"If this fails, page someone, but the deploy proceeds unless threshold is high."
mediumDefault. Regression checks that matter but aren't release-blocking.
lowLong-tail / experimental scenarios. Failures are noise unless the threshold is low.

23.3 Setting a priority

The priority badge is everywhere the scenario shows up:

Color tokens — critical red, high amber, medium blue, low muted — track the theme. Light and dark mode both work without per-theme tweaks.

23.4 Snapshot at trigger time

Priority is snapshotted onto the report at trigger time. If you lower a scenario from critical to medium after a CI run already failed, the old report still reads critical and the CI build that recorded it still says "broke on critical". This is the right default: priority changes shouldn't rewrite history.

23.5 CLI gate — --fail-threshold

callme scenario run accepts a --fail-threshold <level> flag with the same four levels. The default is critical. A failed run flips the exit code to 1 only when the scenario's priority is at or above the threshold:

Scenario priority \ Thresholdcriticalhighmediumlow
critical1111
high0111
medium0011
low0001

0 means "passed or gated below threshold — pipeline keeps going". 1 means "failed loud — pipeline stops".

Examples:

# Default. Only critical scenario failures break the pipeline.
callme scenario run --scenario-id sc_... --token "$CALLME_TOKEN"

# Treat anything high or above as fatal.
callme scenario run --scenario-id sc_... --fail-threshold high \
  --token "$CALLME_TOKEN"

# Legacy behaviour — every failure breaks the pipeline.
callme scenario run --scenario-id sc_... --fail-threshold low \
  --token "$CALLME_TOKEN"

When a failure is gated out, the CLI prints a clear Pipeline not failed: scenario priority "low" is below threshold "critical" line so the operator knows the result wasn't silently ignored.

23.6 --no-fail escape hatch

--no-fail overrides everything — the CLI always exits 0. Use it when you want to collect evidence (the report, the HTML export, the Slack notification) without blocking the build under any circumstances. This is the "soak run" mode.

23.7 Folder pipeline runs

A single CLI invocation can fan out across every enabled scenario in a folder:

callme scenario run --folder-id flr_... --token "$CALLME_TOKEN"

The folder controls:

After all scenarios finish (or all polls return) the CLI prints a Folder summary: N passed, M failed line and aggregates the exit code: any failed scenario crossing --fail-threshold flips the folder run to 1. A timeout on any individual scenario also flips to 1, regardless of priority — the operator should know the run didn't actually complete.

Restrictions in folder mode:

23.8 Three patterns

Cautious main branch--fail-threshold critical on every CI build. Anything high and below shows up in Slack but doesn't gate the release.

Strict release branch--fail-threshold high on the release candidate pipeline. Tighten the gate when the stakes go up.

Coverage probe--no-fail on a nightly run of every scenario in the workspace. Goal isn't to block anything; goal is to find what broke overnight so the morning standup has a punch list.

Where to next: §24 Query cookbook.


24. Query cookbook — every connection type, every syntax

This chapter is the single reference for what you type into a query box — for all four databases (Oracle, MongoDB, PostgreSQL, MySQL), Redis, and Kafka. Every example here is copy-paste runnable and works in every place a query can live: the request DB / Redis / Kafka tabs, pm.db.query in scripts, and scenario DB / Redis / Kafka nodes — whether the run happens on your desktop or in a scheduled server-side scenario.

24.1 Where queries run

SurfaceExecutes on
Request tab Send, collection runs, pm.db.query, perf modeYour desktop
Scenario runs (manual, scheduled, CLI scenario run)Backend worker
callme run collection.jsonThe CLI machine

Desktop and server share the same query engine: the same SQL pass-through, the same MongoDB JSON parser, the same five Redis commands, the same Kafka publish/listen mechanics, and the same {{...}} substitution. A query you debug on your desktop behaves the same inside a 03:00 scheduled run. The one known exception is a MongoDB ObjectId nicety — see §24.6.

Two practical consequences:

24.2 Variables inside queries

Every query box supports {{...}} substitution before execution:

Token formResolves to
{{name}}environment → globals lookup
{{env.name}} / {{global.name}}forced scope lookup
{{response.body.items.0.id}}the current request's response (request tabs only)
{{<node-label>.rows[0].id}}a prior node's output (scenarios only — note [0], not .0)
{{random.uuid}}, {{$timestamp}}, {{counter}}dynamic generators (§5.4)

Substitution is plain text replacement. No quoting, no escaping, no type conversion. Three rules follow from that:

  1. Quote SQL strings yourself. WHERE id = '{{userId}}' — the quotes are yours. For numbers, leave them off: WHERE total > {{minTotal}}.
  2. A missing variable becomes an empty string, so WHERE id = '{{typoedName}}' silently becomes WHERE id = ''. If a query matches nothing unexpectedly, print the resolved query first (pm.log(pm.variables.resolve("...your query..."))).
  3. Values are not sanitised. Substitution is string splicing, so treat variable values as trusted test data — never feed user-supplied input into a query template.

In JSON queries (MongoDB), put tokens inside the JSON string values: "filter": { "userId": "{{userId}}" }. To splice a number, write "limit": {{pageSize}} without quotes — just make sure the variable really holds a number, or the JSON will fail to parse.

24.3 Oracle

Raw SQL, one statement per query box, no trailing semicolon. There are no bind parameters for Oracle — use {{...}} substitution. Rows come back as objects keyed by column name (uppercase unless you quote an alias).

SELECT id, status, total
FROM orders
WHERE user_id = '{{userId}}'
ORDER BY created_at DESC
FETCH FIRST 5 ROWS ONLY

Join + aggregate:

SELECT c.name, COUNT(o.id) AS order_count, SUM(o.total) AS revenue
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.created_at > SYSTIMESTAMP - INTERVAL '1' DAY
GROUP BY c.name
HAVING COUNT(o.id) > {{minOrders}}

Existence / freshness check (the classic post-response assertion):

SELECT COUNT(*) AS cnt
FROM audit_log
WHERE entity_id = '{{response.body.id}}'
  AND created_at > SYSTIMESTAMP - INTERVAL '5' MINUTE

Write operations:

INSERT INTO orders (id, user_id, status, total)
VALUES (orders_seq.NEXTVAL, '{{userId}}', 'PENDING', {{amount}})
UPDATE orders SET status = 'PAID' WHERE id = {{orderId}}
DELETE FROM orders WHERE user_id = '{{userId}}' AND status = 'TEST'

Every query auto-commits on success — same behaviour as PostgreSQL and MySQL. An INSERT in one node is immediately visible to a SELECT in a later node (or a later run). There is no rollback and no multi-statement transaction, so clean your test data up yourself — a DELETE node before the End node is the usual pattern.

What comes back, and how to reference it. A SELECT returns each row as an object keyed by column name — and Oracle uppercases unquoted identifiers, so the keys are ID, STATUS, TOTAL:

{
  "rows": [
    { "ID": 42, "STATUS": "PAID", "TOTAL": 99.5 },
    { "ID": 41, "STATUS": "PENDING", "TOTAL": 12 }
  ],
  "rowCount": 2
}

Downstream references from a node labelled check-orderuppercase column names, or quote an alias in the SQL (SELECT id AS "id") to keep it lowercase:

{{check-order.rows[0].ID}}        → 42
{{check-order.rows[0].STATUS}}    → PAID
{{check-order.rows[1].TOTAL}}     → 12
{{check-order.rowCount}}          → 2
Query typerowsrowCount
SELECTresult rows (objects)number of rows
INSERT/UPDATE/DELETE[]affected rows

24.4 PostgreSQL

Raw SQL, one statement per query box. In scenario DB nodes you also get positional bind params — $1, $2, ... — filled from the node's params list, where each param value is itself template-resolved. On the request DB tab there is no params list, so use {{...}} substitution directly in the SQL.

SELECT id, status, total
FROM orders
WHERE user_id = '{{userId}}'
  AND created_at > now() - interval '5 minutes'
ORDER BY created_at DESC
LIMIT 5

Scenario node with params (params: ["{{userId}}", "PAID"]):

SELECT id, status FROM orders WHERE user_id = $1 AND status = $2

Insert a fixture and capture the generated id in one statement:

INSERT INTO orders (user_id, status, total)
VALUES ('{{userId}}', 'PENDING', {{amount}})
RETURNING id, created_at

RETURNING rows land in rows like a SELECT — downstream nodes can read {{seed-order.rows[0].id}}.

Update, delete, join, CTE:

UPDATE orders SET status = 'CANCELLED'
WHERE id = '{{orderId}}' AND status = 'PENDING'
DELETE FROM orders WHERE user_id = '{{userId}}' AND status = 'TEST'
SELECT o.id, o.total, c.email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE c.email = '{{email}}'
WITH recent AS (
  SELECT * FROM orders WHERE created_at > now() - interval '1 hour'
)
SELECT status, COUNT(*) AS cnt FROM recent GROUP BY status

What comes back, and how to reference it. PostgreSQL keeps unquoted identifiers lowercase, so row keys are id, status, ...

Query typerowsrowCount
SELECTresult rows (objects)number of rows
INSERT ... RETURNING ...the returned columnsinserted rows
INSERT/UPDATE/DELETE[]affected rows
{ "rows": [ { "id": "ord_1", "status": "PAID" } ], "rowCount": 1 }

Downstream references, per query type (node labels in parentheses):

SELECT        (verify-order):  {{verify-order.rows[0].status}}   → PAID
                               {{verify-order.rowCount}}         → 1
INSERT…RETURNING (seed-order): {{seed-order.rows[0].id}}         → the generated id
                               {{seed-order.rows[0].created_at}} → 2026-06-11T09:30:00.000Z
UPDATE        (cancel-order):  {{cancel-order.rowCount}}         → rows actually updated
DELETE        (cleanup):       {{cleanup.rowCount}}              → rows actually deleted

{{cancel-order.rows[0].anything}} after a plain UPDATE resolves to an empty string — there are no rows unless you add RETURNING.

24.5 MySQL

Raw SQL, one statement per query box. Bind params use ? placeholders — like PostgreSQL, the params list exists only on scenario DB nodes.

SELECT id, status, total
FROM orders
WHERE user_id = '{{userId}}'
  AND created_at > NOW() - INTERVAL 5 MINUTE
ORDER BY created_at DESC
LIMIT 5

Scenario node with params (params: ["{{userId}}", "PAID"]):

SELECT id, status FROM orders WHERE user_id = ? AND status = ?

Write operations:

INSERT INTO orders (user_id, status, total)
VALUES ('{{userId}}', 'PENDING', {{amount}})
UPDATE orders SET status = 'PAID' WHERE id = '{{orderId}}'
DELETE FROM orders WHERE user_id = '{{userId}}' AND status = 'TEST'

For SELECTs, rows holds the result and rowCount the row count. For DML, rows is empty and rowCount is affectedRows.

Gotcha — generated ids. Each query runs on a fresh connection that closes immediately, so a follow-up SELECT LAST_INSERT_ID() runs on a different connection and returns 0. MySQL has no RETURNING; query the row back by a business key instead:

SELECT id FROM orders
WHERE user_id = '{{userId}}' AND status = 'PENDING'
ORDER BY id DESC LIMIT 1

What comes back, and how to reference it. Row keys match your column names as written:

Query typerowsrowCount
SELECTresult rows (objects)number of rows
INSERT/UPDATE/DELETE[]affectedRows
SELECT (verify-order):  {{verify-order.rows[0].id}}      → 42
                        {{verify-order.rows[0].status}}  → PAID
                        {{verify-order.rowCount}}        → 1
INSERT (seed-order):    {{seed-order.rowCount}}          → 1   (no rows — fetch the id with a follow-up SELECT)
UPDATE (mark-paid):     {{mark-paid.rowCount}}           → rows actually changed
DELETE (cleanup):       {{cleanup.rowCount}}             → rows actually deleted

Note: MySQL counts an UPDATE that changed nothing (values already equal) differently from one that matched nothing — rowCount is affected rows, so asserting rowCount == 1 after re-running the same UPDATE can fail even though the row exists.

24.6 MongoDB

A MongoDB query is a strict JSON object — not shell syntax. db.orders.find({...}) is rejected with a parse error. The envelope:

{
  "collection": "orders",
  "operation": "find",
  "filter": {},
  "projection": {},
  "sort": {},
  "limit": 10,
  "skip": 0,
  "pipeline": [],
  "document": {},
  "update": {},
  "options": {}
}

collection and operation are required; everything else is operation-specific. Eight operations are supported. Anything else (insertMany, updateMany, distinct, drop, ...) is rejected.

OperationUsesResult
findfilter, projection, sort, limit, skipmatching documents in rows
findOnefilter, projectionzero or one document in rows
aggregatepipelinepipeline output in rows
insertOnedocumentrows[0] = { acknowledged, insertedId }
updateOnefilter, update, optionsrows[0] = { acknowledged, matchedCount, modifiedCount, upsertedId }
deleteOnefilterrows[0] = { acknowledged, deletedCount }
deleteManyfilterrows[0] = { acknowledged, deletedCount }
countDocumentsfilterrows[0] = { count }

find with the works:

{
  "collection": "orders",
  "operation": "find",
  "filter": { "userId": "{{userId}}", "status": { "$in": ["PAID", "SHIPPED"] } },
  "projection": { "_id": 0, "orderNo": 1, "status": 1, "total": 1 },
  "sort": { "createdAt": -1 },
  "limit": 10
}

findOne:

{
  "collection": "users",
  "operation": "findOne",
  "filter": { "email": "{{email}}" },
  "projection": { "passwordHash": 0 }
}

aggregate — the Mongo equivalent of a JOIN is a $lookup stage:

{
  "collection": "orders",
  "operation": "aggregate",
  "pipeline": [
    { "$match": { "userId": "{{userId}}" } },
    { "$lookup": {
        "from": "customers",
        "localField": "customerId",
        "foreignField": "_id",
        "as": "customer"
    } },
    { "$unwind": "$customer" },
    { "$group": {
        "_id": "$customer.email",
        "orderCount": { "$sum": 1 },
        "revenue": { "$sum": "$total" }
    } },
    { "$sort": { "revenue": -1 } },
    { "$limit": 5 }
  ]
}

insertOne / updateOne / deleteMany / countDocuments:

{
  "collection": "orders",
  "operation": "insertOne",
  "document": { "userId": "{{userId}}", "status": "PENDING", "total": {{amount}} }
}
{
  "collection": "orders",
  "operation": "updateOne",
  "filter": { "orderNo": "{{orderNo}}" },
  "update": { "$set": { "status": "PAID", "paidAt": "{{$isoTimestamp}}" } },
  "options": { "upsert": false }
}
{
  "collection": "orders",
  "operation": "deleteMany",
  "filter": { "userId": "{{userId}}", "status": "TEST" }
}
{
  "collection": "orders",
  "operation": "countDocuments",
  "filter": { "userId": "{{userId}}" }
}

What each operation returns, and how to reference it. Every operation — reads and writes — lands in the same { rows, rowCount } shape. For writes, rows[0] is the driver's acknowledgement object, so counters like modifiedCount are addressable like any column. Examples below assume the node label in parentheses:

find (list-orders) — documents in rows:

{ "rows": [ { "orderNo": "A-1001", "status": "PAID", "total": 99.5 } ], "rowCount": 1 }
{{list-orders.rows[0].orderNo}}        → A-1001
{{list-orders.rows[0].status}}         → PAID
{{list-orders.rows[0].customer.email}} → nested fields chain with dots
{{list-orders.rowCount}}               → 1

findOne (get-user) — zero or one document, still in rows:

{{get-user.rows[0].email}}     → empty string when nothing matched
{{get-user.rowCount}}          → 0 or 1 (branch on this in a condition node)

aggregate (revenue-by-customer) — one row per pipeline output document; with a $group, the grouping key sits in _id:

{{revenue-by-customer.rows[0]._id}}       → customer@example.com
{{revenue-by-customer.rows[0].revenue}}   → 1240.5
{{revenue-by-customer.rows[0].orderCount}} → 7

insertOne (seed-order):

{ "rows": [ { "acknowledged": true, "insertedId": "665f1c2ab1e4d3a7c9f0e812" } ], "rowCount": 1 }
{{seed-order.rows[0].insertedId}}   → the new document's id (hex string)

updateOne (mark-paid):

{ "rows": [ { "acknowledged": true, "matchedCount": 1, "modifiedCount": 1, "upsertedId": null } ], "rowCount": 1 }
{{mark-paid.rows[0].matchedCount}}   → 1 means the filter found the doc
{{mark-paid.rows[0].modifiedCount}}  → 0 means it was already in that state
{{mark-paid.rows[0].upsertedId}}     → set only when options.upsert created a doc

deleteOne / deleteMany (cleanup):

{ "rows": [ { "acknowledged": true, "deletedCount": 3 } ], "rowCount": 1 }
{{cleanup.rows[0].deletedCount}}   → 3

countDocuments (order-count):

{ "rows": [ { "count": 12 } ], "rowCount": 1 }
{{order-count.rows[0].count}}   → 12

Mind the write-operation rowCount trap: in scenario nodes (server) it mirrors the meaningful counter — modifiedCount for updateOne, deletedCount for deletes — but on the request DB tab (desktop) a write always reports rowCount = 1 (one acknowledgement row). The counters inside rows[0] (modifiedCount, deletedCount, ...) mean the same thing on both surfaces, so assert against those.

Three caveats worth pinning to the wall:

24.7 Redis

One command per line; each line returns one result. The supported set is exactly GET, SET, DEL, EXISTS, TTL (§9.2).

SET fixture:{{userId}} "{{random.uuid}}"
GET session:{{userId}}
EXISTS cart:{{userId}}
TTL session:{{userId}}
DEL fixture:{{userId}}

Syntax rules:

Storing structured data — write JSON as a quoted string, parse it where you read it:

SET order:{{orderId}} "{\"status\":\"PAID\",\"total\":99.5}"
const raw = pm.redis.results.post[0];
const order = JSON.parse(raw);
pm.expect(order.status).toBe("PAID");

What comes back, and how to reference it. In a scenario, a single-command node's output is the result itself; a multi-command node's output is an array indexed in command order. A node labelled check-cache running:

GET session:{{userId}}
EXISTS cart:{{userId}}
TTL session:{{userId}}
DEL fixture:{{userId}}

produces ["a1b2c3-session-token", true, 1740, 1], referenced as:

{{check-cache[0]}}   → a1b2c3-session-token   (GET — null if missing)
{{check-cache[1]}}   → true                   (EXISTS)
{{check-cache[2]}}   → 1740                   (TTL seconds)
{{check-cache[3]}}   → 1                      (DEL — keys removed)

With a single GET, skip the index: {{check-cache}} is the value. A condition node can branch directly on these — check-cache[1] == true, or check-cache[2] > 0. The same values are also available globally as {{redis.result}} (or {{redis.result[1]}} and so on) until the next Redis node runs.

24.8 Kafka

Kafka has no query language in Callman — you either publish a message or listen for one (§10).

Publish (request Kafka tab or scenario Kafka node in publish mode). All fields are template-resolved, including the topic:

When the value format is JSON, the payload is validated after substitution — a token that resolved to nothing fails fast with "payload is not valid JSON" instead of publishing garbage. The node output is { topic, partition, offset, timestamp }.

Listen / capture (consume mode): subscribe to a topic for a timeout window and pick the matching message. With no rules at all, the first captured message wins. Otherwise two match modes exist — and while a message is being tested, that candidate message is exposed to your rules as kafkaEvent.

What kafkaEvent looks like. If the message value parses as a JSON object, its fields sit at the top level and the transport metadata moves under meta. For this published payload:

{ "type": "ORDER_CREATED", "orderId": "ord_42", "amount": 99.5,
  "items": [ { "sku": "ABC", "qty": 2 } ] }

the rule-visible structure is:

kafkaEvent.type                → ORDER_CREATED
kafkaEvent.orderId             → ord_42
kafkaEvent.amount              → 99.5
kafkaEvent.items[0].sku        → ABC          (bracket indices)
kafkaEvent.meta.key            → the message key
kafkaEvent.meta.partition      → 0
kafkaEvent.meta.offset         → 1547
kafkaEvent.meta.timestamp      → ISO timestamp
kafkaEvent.meta.headers.trace-id → header value, by header name

If the value is not a JSON object (plain text, a number, a JSON array), there is no spreading — the fields are kafkaEvent.value (parsed value or raw text), kafkaEvent.text (raw text), kafkaEvent.key, kafkaEvent.partition, kafkaEvent.offset, kafkaEvent.timestamp, kafkaEvent.headers.<name>.

Rules mode — what you can write. Each rule is one comparison:

<left> <operator> <right>

with exactly one operator per rule, chosen from:

OperatorMeaning
== !=loose equality — both sides compared as display strings
=== !==strict equality — type-aware ("5" === 5 is false)
> < >= <=numeric — the rule is false if either side isn't a number
containssubstring match; for arrays, "some element equals the value"

A message matches only when every rule in the list passes (the rules are AND-ed; for OR, listen loosely and branch with a condition node afterwards). Each side of a rule can be:

Realistic rule sets:

kafkaEvent.type == ORDER_CREATED
kafkaEvent.orderId == {{create-order.response.body.id}}
kafkaEvent.correlationId == {{corr}}
kafkaEvent.amount > 0
kafkaEvent.meta.headers.source contains payment

There are no exists / in / regex operators in Kafka rules (those belong to the condition node) — to express "field exists", write {{kafkaEvent.field}} != "". The braces matter for that trick: a brace-wrapped missing path resolves to an empty string, while a bare token that matches nothing is treated as a literal string (so kafkaEvent.missing != "" would always pass).

Regex mode — what it matches against. The first rule line is treated as a regular expression and tested against the entire serialised messageJSON.stringify of { key, valueText, valueJson, partition, offset, timestamp, headers } — so it can hit the payload, the key, or a header value alike. Write it bare or as a /pattern/flags literal:

ORDER_CREATED
/"orderId":\s*"ord_[0-9a-f]+"/
/order_created/i

Regex mode is a substring shotgun — quick, but it can't compare a field against a variable's value numerically or distinguish "amount": 99 in the payload from the same text inside a header. When you care about which field holds the value, use rules mode.

After the match — the matched event becomes the node's output (same shape as the kafkaEvent structure above, label-addressed), and stays globally available as {{kafkaEvent.*}} until the next Kafka node runs:

{{order-event.orderId}}            ← payload field
{{order-event.items[0].sku}}
{{order-event.meta.offset}}
{{kafkaEvent.orderId}}             ← same event, fixed root

If nothing matches within the timeout, the node fails with "timed out without a matching event" and downstream nodes follow the failure policy.

24.9 Using query results downstream

On a request (desktop) — results feed the declarative assertion rows and the script API. Assertion field expressions use dot-notation paths, including dot-indexed arrays:

db_post.rowCount
db_post.firstRow.status
db_post.rows.0.amount
redis.post.0

and in scripts:

const res = await pm.db.query({
  connectionId: "ordersPrimary",
  query: "SELECT status FROM orders WHERE id = '{{orderId}}'",
});
pm.expect(res.firstRow().status).toBe("PAID");

In a scenario (server) — downstream nodes reference a prior node's output by its label, and array indices use brackets:

{{verify-order.rows[0].status}}     ← DB node
{{verify-order.rowCount}}
{{check-cache}}                     ← single-command Redis node
{{order-event.orderId}}             ← Kafka consume node
{{seed-order.rows[0].id}}           ← Postgres INSERT ... RETURNING

The two index syntaxes are not interchangeable: request assertion fields use rows.0.amount, scenario references use rows[0].amount.

A typical chain: a DB node labelled seed-order inserts a fixture with RETURNING id, the next request node posts to /orders/{{seed-order.rows[0].id}}/pay, a condition node branches on the builder rule verify-order.rows[0].status == "PAID" (no braces in condition fields), and a notification node interpolates both into its Slack message.

24.10 Limits and gotchas

Where to next: Appendix A.


Appendix A — pm.* API index

Alphabetical, every public method. Section number points to where the API is explained in context. Top-level globals (CryptoJS, _, setTimeout, ...) are documented at the end of the table.

SymbolSection
pm.collectionVariables.clear()6.5.1
pm.collectionVariables.get(key)6.5.1
pm.collectionVariables.has(key)6.5.1
pm.collectionVariables.name6.5.1
pm.collectionVariables.replaceIn(template)6.5.1
pm.collectionVariables.set(key, value)6.5.1
pm.collectionVariables.toObject()6.5.1
pm.collectionVariables.unset(key)6.5.1
pm.counter.next()6.9
pm.counter.start(startAt?)6.9
pm.db.pre6.13
pm.db.post6.13
pm.db.query({ connectionId, query })6.13
pm.env (alias of pm.environment)6.3
pm.environment.clear()6.3
pm.environment.get(key)6.3
pm.environment.has(key)6.3
pm.environment.name6.3
pm.environment.replaceIn(template)6.3
pm.environment.set(key, value)6.3
pm.environment.toObject()6.3
pm.environment.unset(key)6.3
pm.environment.values6.3
pm.execution.location6.5.3
pm.execution.location.current6.5.3
pm.execution.setNextRequest(name)6.5.3
pm.execution.skipRequest()6.5.3
pm.expect(value)6.7
pm.expect.fail(message?)6.7
pm.globals.clear()6.4
pm.globals.get(key)6.4
pm.globals.has(key)6.4
pm.globals.name6.4
pm.globals.replaceIn(template)6.4
pm.globals.set(key, value)6.4
pm.globals.toObject()6.4
pm.globals.unset(key)6.4
pm.info.eventName6.5.2
pm.info.iteration6.5.2
pm.info.iterationCount6.5.2
pm.info.requestId6.5.2
pm.info.requestName6.5.2
pm.kafka.event()6.11
pm.kafka.messages()6.11
pm.log(...args)6.15
pm.node.nodeLabel6.14
pm.node.setNextRequest(name)6.14
pm.random.bigdecimal(min, max, scale)6.8
pm.random.boolean()6.8
pm.random.date(format?)6.8
pm.random.email()6.8
pm.random.int(min, max)6.8
pm.random.name()6.8
pm.random.phone()6.8
pm.random.string(length?)6.8
pm.random.uuid()6.8
pm.redis.get(key)6.12
pm.redis.results.pre6.12
pm.redis.results.post6.12
pm.request.body6.1
pm.request.getHeader(name)6.1
pm.request.getQueryParam(name)6.1
pm.request.headers (HeaderList)6.1
pm.request.headers.add(spec)6.1
pm.request.headers.all()6.1
pm.request.headers.get(name)6.1
pm.request.headers.has(name)6.1
pm.request.headers.idx(i)6.1
pm.request.headers.remove(name)6.1
pm.request.headers.upsert(spec)6.1
pm.request.method6.1
pm.request.query (HeaderList)6.1
pm.request.setHeader(name, value)6.1
pm.request.setMethod(method)6.1
pm.request.setQueryParam(name, value)6.1
pm.request.setRawBody(value)6.1
pm.request.setUrl(url)6.1
pm.request.unsetHeader(name)6.1
pm.request.unsetQueryParam(name)6.1
pm.request.url6.1
pm.response.body6.2
pm.response.code6.2
pm.response.contentType6.2
pm.response.data6.2
pm.response.duration6.2
pm.response.headers (HeaderList, read-only)6.2
pm.response.headers.get(name)6.2
pm.response.headers.has(name)6.2
pm.response.json()6.2
pm.response.rawBody6.2
pm.response.responseSize6.2
pm.response.responseTime6.2
pm.response.status6.2
pm.response.statusText6.2
pm.response.text()6.2
pm.response.to.be.accepted6.2.1
pm.response.to.be.badRequest6.2.1
pm.response.to.be.clientError6.2.1
pm.response.to.be.error6.2.1
pm.response.to.be.forbidden6.2.1
pm.response.to.be.html6.2.1
pm.response.to.be.info6.2.1
pm.response.to.be.json6.2.1
pm.response.to.be.notFound6.2.1
pm.response.to.be.ok6.2.1
pm.response.to.be.rateLimited6.2.1
pm.response.to.be.redirection6.2.1
pm.response.to.be.serverError6.2.1
pm.response.to.be.success6.2.1
pm.response.to.be.unauthorized6.2.1
pm.response.to.be.xml6.2.1
pm.response.to.have.body(text?)6.2.1
pm.response.to.have.header(name, value?)6.2.1
pm.response.to.have.jsonBody(path?, value?)6.2.1
pm.response.to.have.status(code)6.2.1
pm.response.to.not.<predicate>6.2.1
pm.sendRequest(options, callback?)6.10
pm.sendRequest(url, callback?)6.10
pm.test(name, fn)6.6
pm.variables.get(key)6.5
pm.variables.has(key)6.5
pm.variables.replaceIn(template)6.5
pm.variables.resolve(template)6.5
pm.variables.set(key, value)6.5
pm.variables.toObject()6.5
pm.variables.unset(key)6.5

Top-level globals (no pm. prefix)

SymbolSection
CryptoJS.AES.encrypt/decrypt6.16
CryptoJS.HmacSHA256(msg, key) (and SHA1/224/384/512, MD5)6.16
CryptoJS.SHA256(msg) (and SHA1/224/384/512, MD5)6.16
CryptoJS.PBKDF2(password, salt, options)6.16
CryptoJS.enc.{Utf8,Base64,Hex,Latin1}6.16
CryptoJS.lib.WordArray.create/random6.16
_.filter / .map / .groupBy / .get / .pick / ...6.17
setTimeout(fn, ms?) / clearTimeout(id)6.18
setInterval(fn, ms?) / clearInterval(id)6.18
atob(b64) / btoa(text)6
crypto.subtle / crypto.randomUUID()6
structuredClone(value)6
TextEncoder / TextDecoder6

Appendix B — Glossary


End of document.