For four modules we have been building the Aroma Store API: we designed it in module 2, built it in module 3 and hardened it in module 4. And all that time we have tested it in two ways: with curl by hand, typing endless headers into the terminal, and with the automated Supertest tests from 03-08, which are excellent for the code but useless for exploring.

A third way of working is missing, the one that fills the real working day of anyone who develops or consumes an API: open a client, fire a request, look at the response, change a parameter, fire it again. And, when something works, save it so you never have to type it again or rely on someone remembering the exact syntax.

This lesson opens module 5 with that tool. We are going to build the "Aroma Store v1" collection: a versionable file, with folders per resource, environments for local, test and production, authentication that renews itself, assertions that check the contract and chained requests. And we will finish by running the whole thing from the terminal with Newman, which is exactly what 05-05 will wire into continuous integration.

All the data, domains and credentials in this lesson are fictional. No string that looks like a token is a real secret, and no example should be copied with real values inside it.

Contents

  1. Why you need an HTTP client as well as automated tests
  2. What Postman is and what alternatives exist
  3. Installation and first contact: GET /v1/coffees
  4. Reading the response: body, headers and timings
  5. Collections and folders: the structure of "Aroma Store v1"
  6. The real requests from the contract
  7. Variables: collection, environment and global
  8. Environments: local, test and production
  9. Secrets: what never gets exported
  10. Authentication: Bearer Token and folder inheritance
  11. Pre-request scripts: what happens before sending
  12. Post-response scripts: the assertions
  13. Chaining requests: saving the id and using it later
  14. Generating a different Idempotency-Key on every send
  15. Assertions against the JSON schema
  16. Running the whole collection with the Collection Runner
  17. CSV and JSON data files
  18. Newman: the collection from the terminal
  19. Importing openapi.yaml and exporting the collection
  20. Documentation and sharing with the team
  21. Postman's mock server
  22. Best practices and what goes into the repository

  1. Why you need an HTTP client as well as automated tests

The tests from 03-08 and a client like Postman answer different questions, and confusing them leads to teams that have one and sorely miss the other.

Supertest tests (03-08) HTTP client (Postman)
Question it answers Does what used to work still work? What happens if I do this?
When it is used On every git push, with no humans While you develop, debug or explore
What it runs against The in-memory app object, no network A real server, with network, TLS and proxies
Who writes it Whoever develops the API Whoever consumes it too
What it detects well Logic regressions Network, CORS, header and deployment problems
What it does not detect That the deployment is misconfigured Regressions, because nobody runs it by hand

The key sentence is the last row. Supertest would never see that the production load balancer is stripping the Aroma-Trace-Id header, because there is never a load balancer: it calls app directly. And Postman would not detect a regression if nobody presses the button. That is why the goal of this lesson is not "learn to press Send", but to turn manual exploration into a repeatable, executable artefact that, in 05-05, will press itself.

  1. What Postman is and what alternatives exist

Postman is an HTTP client with a graphical interface that stores requests in collections: JSON files with the URLs, headers, bodies, variables and scripts. That detail —that a collection is a file— is what makes it more than a personal tool: it is versioned in Git, reviewed in a pull request and run in continuous integration.

These are the serious alternatives you will come across in real teams:

Tool Model Strength Weakness Collection format
Postman Desktop app, cloud account Complete ecosystem: runner, mocks, monitors, documentation Pushes you towards the cloud; heavy; key features on the paid plan Its own JSON (v2.1)
Insomnia Desktop app Lightweight, good for GraphQL and gRPC Smaller ecosystem Its own JSON/YAML
Bruno Desktop app, offline first Stores each request as a plain-text file (.bru) in your repository; readable diffs Young, fewer integrations .bru files in folders
Hoppscotch Web (and self-hostable) Zero installation, opens in the browser Depends on the browser for CORS and certificates Its own JSON
REST Client (VS Code) Editor extension Requests in a .http file next to the code; never leave the editor No runner or reports .http file
curl Terminal It is everywhere; it is the lingua franca for sharing a bug Verbose; no state between calls A single command
HTTPie Terminal Far more readable syntax than curl; colours JSON You have to install it A single command

A practical comparison of the same request in the three terminal forms:

# curl: universal, verbose. This is how you paste a bug into a ticket.
curl -i -X POST http://localhost:3000/v1/orders \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7f3c1a90-2d64-4e11-9c88-1b2f4a6d0e55" \
  -d '{"customerId":"cus_842","items":[{"coffeeId":"cof_001","quantity":2}]}'

# HTTPie: the same thing, far shorter to read
http POST localhost:3000/v1/orders \
  "Authorization:Bearer $TOKEN" \
  "Idempotency-Key:7f3c1a90-2d64-4e11-9c88-1b2f4a6d0e55" \
  customerId=cus_842 \
  items:='[{"coffeeId":"cof_001","quantity":2}]'

And the same case as a .http file for the REST Client extension, which has the advantage of living inside the repository next to the code it tests:

### Login for customer Marta
# @name login
POST http://localhost:3000/v1/sessions
Content-Type: application/json

{ "email": "[email protected]", "password": "{{testPassword}}" }

### Create an order reusing the token from the previous response
POST http://localhost:3000/v1/orders
Authorization: Bearer {{login.response.body.token}}
Content-Type: application/json
Idempotency-Key: 7f3c1a90-2d64-4e11-9c88-1b2f4a6d0e55

{ "customerId": "cus_842", "items": [{ "coffeeId": "cof_001", "quantity": 2 }] }

How to choose. If your team already lives in Postman, stay in Postman: the gain from switching rarely pays for itself. If what matters most to you is that requests get reviewed as code in pull requests, Bruno or REST Client are better because they store readable plain text. Everything conceptual in this lesson —variables, environments, chaining, assertions, running in CI— exists in all four graphical tools under another name; what changes is the syntax.

We will use Postman because it is the de facto standard and because Newman gives us the CI execution that 05-05 needs.

  1. Installation and first contact: GET /v1/coffees

Postman is downloaded from its official site for Windows, macOS and Linux; there is also a web version, although to call localhost it needs the Postman Agent installed, so for our case the desktop application is the better option.

First of all, start up the project from modules 3 and 4:

cd aroma-store-api
npm run db:reset   # migrates and seeds: cof_001, cof_002, cus_842, ord_5001...
npm run dev        # node --watch src/server.js → http://localhost:3000

In Postman, Ctrl/Cmd + NHTTP Request. Type the GET method and the URL:

http://localhost:3000/v1/coffees?roast=light&limit=2&sort=-priceEuros

Press Send. Notice that Postman has understood the query string and filled the Params tab in by itself with one row per parameter: you can enable and disable them with the checkbox, which is the comfortable way to try filter combinations without editing text.

The response our API returns:

{
  "data": [
    {
      "id": "cof_001",
      "name": "Ethiopia Yirgacheffe",
      "origin": "Ethiopia",
      "roast": "light",
      "priceEuros": 14.50,
      "stock": 120,
      "tastingNotes": ["citrus", "floral", "black tea"],
      "createdAt": "2026-01-15T08:30:00Z",
      "_links": {
        "self": { "href": "/v1/coffees/cof_001" },
        "reviews": { "href": "/v1/coffees/cof_001/reviews" }
      }
    }
  ],
  "total": 1
}

  1. Reading the response: body, headers and timings

The body is the first thing anyone looks at and the least interesting for what we have built. At the bottom of Postman there are three pieces of data that sum up half of module 4:

  • Status: 200 OK.
  • Time: the total time of the request. Careful: it includes DNS resolution, the connection and TLS, so it will always be higher than the latency prom-client measures at /metrics (04-07). Hover over it to see the breakdown by phase, which is the quickest way to discover that "the API is slow" is really "the TLS handshake takes 300 ms".
  • Size: the size. If you enabled compression in 04-06, you will see the compressed size.

The response Headers tab is where module 4's work gets verified:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
ETag: W/"a1b2c3d4e5f6"
Cache-Control: public, max-age=60
Vary: Accept-Encoding, Origin
Link: </v1/coffees?limit=2&offset=2&roast=light>; rel="next",
      </v1/coffees?limit=2&offset=0&roast=light>; rel="first"
Aroma-Trace-Id: 3f9a2c1e-8b47-4d2a-9e01-77c6b5d3a812
Aroma-RateLimit-Limit: 600
Aroma-RateLimit-Remaining: 597
Aroma-RateLimit-Reset: 1771065600
X-Content-Type-Options: nosniff

Four checks worth doing by hand the first time:

  1. ETag and 304. Copy the ETag value, create an identical request with the If-None-Match header set to that value and send it. It must answer 304 Not Modified with no body. Postman shows Size: 0 B for the body: that is where you really see the saving from 04-06.
  2. Aroma-RateLimit-Remaining. Press Send ten times in a row and watch it go down. It is the simplest check that the limiter from 04-04 is active in this environment.
  3. Link. Copy the rel="next" URL into a new request: it must bring you the next page without you having to build the offset yourself. If you have to work it out by hand, HATEOAS is not working.
  4. Aroma-Trace-Id. Copy it and search for it in the pino output in your terminal. It must appear on every line for that request. That is the correlation from 04-07 seen from the outside.

Tip. Try an error on purpose too: GET /v1/coffees?roast=purple. It must answer 400 with {"error":{"code":"invalid_parameter",...}} and without a traceId in the body, because only 5xx responses carry one. Seeing the error contract being honoured is as important as seeing the happy path.

  1. Collections and folders: the structure of "Aroma Store v1"

A loose request gets lost. The next step is to create the collection. In the left-hand panel: Collections → + and name it Aroma Store v1.

The structure we are going to build reflects the resources of the contract from 02-02, not the implementation:

Aroma Store v1/
├── 00 Sessions/
│   ├── POST Log in (customer)
│   ├── POST Log in (administrator)
│   └── DELETE Log out
├── 01 Coffees/
│   ├── GET List coffees
│   ├── GET List filtered coffees
│   ├── GET Get coffee by id
│   ├── GET Get coffee (If-None-Match → 304)
│   ├── POST Create coffee
│   ├── PATCH Update coffee (merge-patch)
│   ├── DELETE Delete coffee
│   └── GET Reviews of the coffee
├── 02 Orders/
│   ├── POST Create order (with Idempotency-Key)
│   ├── GET List my orders
│   ├── GET Get order
│   ├── POST Pay order
│   └── POST Cancel order
├── 03 Customers/
├── 04 Reviews/
└── 99 Expected errors/
    ├── GET Non-existent coffee → 404
    ├── GET Invalid parameter → 400
    ├── POST Create coffee without token → 401
    ├── POST Create coffee as customer → 403
    └── PUT /v1/coffees → 405 with Allow

Three decisions in that structure deserve an explanation:

  • The numeric prefix (00, 01, …) is not decoration: the Collection Runner runs requests in the order they appear, and the 00 Sessions folder must run first because it is the one that obtains the token everything else uses.
  • One folder per resource, not per use case. Use cases change every quarter; resources are the stable part of the contract.
  • The 99 Expected errors folder is what separates a professional collection from a list of requests. It documents the behaviour of the error catalogue from 02-04 and catches the most common regression: someone touches authorisation and a 403 turns into a 500.

  1. The real requests from the contract

These are the central requests exactly as they end up configured. We start with the login, on which everything else depends:

POST {{baseUrl}}/sessions
Content-Type: application/json

{
  "email": "[email protected]",
  "password": "{{customerPassword}}"
}

Expected response, 200, with the JWT we issued in 03-06:

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.EXAMPLE.FICTIONAL",
  "expiresIn": 3600,
  "customer": { "id": "cus_842", "name": "Marta García", "role": "customer" }
}

Listing with filters, using the Params tab so you can disable them one by one:

GET {{baseUrl}}/coffees?origin=Colombia&roast=medium&priceMax=15&sort=-priceEuros&limit=20
Authorization: Bearer {{token}}

Creating a coffee, which requires the administrator role according to the permission matrix from 03-06:

POST {{baseUrl}}/coffees
Authorization: Bearer {{adminToken}}
Content-Type: application/json

{
  "name": "Kenya Nyeri AA",
  "origin": "Kenya",
  "roast": "light",
  "priceEuros": 16.90,
  "stock": 40,
  "tastingNotes": ["blackcurrant", "tomato", "citrus"]
}

Partial update with merge-patch, the format we settled on in 02-05. It is easy to get this wrong: the Content-Type is not application/json, and our API answers 415 with Accept-Patch if you get it wrong.

PATCH {{baseUrl}}/coffees/{{coffeeId}}
Authorization: Bearer {{adminToken}}
Content-Type: application/merge-patch+json
If-Match: {{coffeeEtag}}

{ "priceEuros": 15.90, "stock": 55 }

And order creation, the only one —along with payment— that requires an Idempotency-Key:

POST {{baseUrl}}/orders
Authorization: Bearer {{token}}
Content-Type: application/json
Idempotency-Key: {{idempotencyKey}}

{
  "customerId": "cus_842",
  "items": [
    { "coffeeId": "cof_001", "quantity": 2 },
    { "coffeeId": "cof_002", "quantity": 1 }
  ]
}

Notice that not a single value is hard-coded: {{baseUrl}}, {{token}}, {{coffeeId}}, {{idempotencyKey}}. That is what the next section is about.

  1. Variables: collection, environment and global

Postman resolves {{name}} by looking through several scopes, from the most specific to the most general. Understanding this hierarchy avoids 80 % of all "but I typed the URL correctly" moments.

Scope Where it lives Reach Correct use in Aroma Store
Local (run-time) Only during a Runner/Newman run The current run Data from the CSV file
Data Runner's CSV/JSON file The current iteration coffeeName, price from each row
Environment The selected environment file Everything that runs with that environment baseUrl, token, customerPassword
Collection Inside the collection's .json The whole collection, in any environment version: "v1", currency: "EUR"
Global The Postman installation Everything, every collection Almost nothing: avoid them

Rule of thumb: if the value changes depending on where you point, it belongs to the environment; if it is always the same, it belongs to the collection; if you think it is global, you are almost always wrong. Global variables are the usual cause of "it works on my machine": someone has a value in their installation that is not in the file they shared.

Collection variables for "Aroma Store v1":

{
  "variable": [
    { "key": "version", "value": "v1" },
    { "key": "customerEmail", "value": "[email protected]" },
    { "key": "adminEmail", "value": "[email protected]" },
    { "key": "seedCoffeeId", "value": "cof_001" },
    { "key": "seedOrderId", "value": "ord_5001" }
  ]
}

The seeded identifiers (cof_001, ord_5001) go in the collection because npm run seed from 03-05 guarantees them in every environment.

  1. Environments: local, test and production

An environment is a set of values for the same keys. You switch with the dropdown in the top right corner, and the whole collection points somewhere else without touching a single request.

Variable local test production
baseUrl http://localhost:3000/v1 https://api-test.aromastore.example/v1 https://api.aromastore.example/v1
customerPassword local-test-password (secret) (not defined)
token (empty, filled in by the script) (empty) (empty)
allowWrites true true false

The local environment file, exportable and safe to version because it contains nothing sensitive:

{
  "name": "Aroma Store — local",
  "values": [
    { "key": "baseUrl", "value": "http://localhost:3000/v1", "type": "default", "enabled": true },
    { "key": "customerPassword", "value": "local-test-password", "type": "default", "enabled": true },
    { "key": "adminPassword", "value": "local-admin-password", "type": "default", "enabled": true },
    { "key": "token", "value": "", "type": "secret", "enabled": true },
    { "key": "adminToken", "value": "", "type": "secret", "enabled": true },
    { "key": "allowWrites", "value": "true", "type": "default", "enabled": true }
  ]
}

allowWrites is not a whim. It is the handbrake that prevents the worst possible story with an HTTP client: running the whole collection against production and creating forty test coffees in the real catalogue. In the pre-request script of the write folders:

// Pre-request for the "01 Coffees" and "02 Orders" folders
// Aborts any write method if the environment does not allow it.
const method = pm.request.method;
const isWrite = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
const allowed = pm.environment.get('allowWrites') === 'true';

if (isWrite && !allowed) {
  throw new Error(
    `Blocked: ${method} is not allowed in the "${pm.environment.name}" environment.`
  );
}

  1. Secrets: what never gets exported

Postman distinguishes the type of a variable: default (plain text) or secret (shown masked and left out when you export or share the environment).

Rules for Aroma Store:

  • The real password of any account, the OAuth client_secret from 04-03 and any token: always secret.
  • No token is ever typed by hand. The token is a derived value: the login produces it and a script stores it. If you paste it by hand, it expires in an hour and you paste it again, and so on until someone commits it.
  • The production environment's passwords are not stored in the file: they are filled in on the spot, or better still, there is no production environment with write permissions in the shared collection.
  • The exported file gets reviewed before it goes into the repository. A quick grep saves you from unpleasant surprises:
# Before committing any Postman file
grep -iE '"value": "(eyJ|sk_|ghp_|AKIA)' postman/*.json && echo "STOP! there is a secret" || echo "clean"

  1. Authentication: Bearer Token and folder inheritance

Typing Authorization: Bearer {{token}} by hand into thirty requests guarantees that three will end up without it. The Authorization tab exists for exactly that and works by inheritance:

  1. On the collection: Auth Type → Bearer Token, Token → {{token}}.
  2. On each request: Auth Type → Inherit auth from parent (the default).
  3. On the 00 Sessions folder and on the POST /v1/customers sign-up: Auth Type → No Auth, because they are public and sending an expired token to POST /v1/sessions is unnecessary noise.
  4. On the administration requests: Bearer Token → {{adminToken}}, overriding the inheritance.

The 99 Expected errors folder deserves attention: the "Create coffee without token → 401" request must be on explicit No Auth, and "Create coffee as customer → 403" on Bearer with {{token}} (Marta's, role customer). If both inherit the same thing, one of the two is not testing what it says it tests.

Postman also supports the full OAuth 2.0 flow from 04-03: under Auth Type → OAuth 2.0 you can configure Authorization Code with PKCE, and Postman opens the browser, performs the exchange and stores the token. It is the correct way to test the CataBox integration, and to really check that a token holding only the coffees.read scope receives 403 insufficient_permissions when it tries POST /v1/orders.

  1. Pre-request scripts: what happens before sending

Every request, folder and collection has two JavaScript scripts: Pre-request (before sending) and Post-response (on receipt; in earlier Postman versions it was called "Tests"). They run in cascade: first the collection's, then the folder's and finally the request's.

The most useful pre-request use in our API is renewing the token if it has expired, so the collection works even if you have not touched it for two hours. In the collection's pre-request script:

// Pre-request for the "Aroma Store v1" collection
// If there is no token, or it is about to expire, it logs in before continuing.

const now = Date.now();
const expiresAt = Number(pm.environment.get('tokenExpiresAt') || 0);
const marginMs = 60 * 1000; // renew a minute early: avoids the 401 race

if (expiresAt - marginMs > now) {
  return; // the token is still valid, nothing to do
}

pm.sendRequest({
  url: `${pm.environment.get('baseUrl')}/sessions`,
  method: 'POST',
  header: { 'Content-Type': 'application/json' },
  body: {
    mode: 'raw',
    raw: JSON.stringify({
      email: pm.collectionVariables.get('customerEmail'),
      password: pm.environment.get('customerPassword'),
    }),
  },
}, (error, response) => {
  if (error) {
    throw new Error(`Could not renew the token: ${error}`);
  }
  if (response.code !== 200) {
    throw new Error(`Login failed (${response.code}): ${response.text()}`);
  }
  const body = response.json();
  pm.environment.set('token', body.token);
  // expiresIn comes in seconds (03-06); we convert it to an absolute timestamp
  pm.environment.set('tokenExpiresAt', Date.now() + body.expiresIn * 1000);
  console.log('Token renewed automatically.');
});

Points worth understanding about this script:

  • pm.sendRequest is asynchronous with a callback. Postman waits for it to finish before sending the main request, but any pm.environment.set you do outside the callback will run too early.
  • The one-minute margin avoids the classic race: the token is valid when the script checks and has expired by the time it reaches the server.
  • Throwing an Error stops execution with a clear message instead of leaving you investigating why everything returns 401.
  • console.log writes to the Postman Console (Ctrl/Cmd + Alt + C), which also shows the exact HTTP request that was sent, headers included. It is the number one debugging tool and almost nobody opens it.

  1. Post-response scripts: the assertions

This is where the collection stops being documentation and becomes a test. The API is pm.test(name, fn), and inside you use pm.expect, which is Chai.

Post-response for POST /v1/coffees:

// --- Status and headers --------------------------------------------------
pm.test('Answers 201 Created', () => {
  pm.response.to.have.status(201);
});

pm.test('Returns Location pointing at the created resource', () => {
  const location = pm.response.headers.get('Location');
  pm.expect(location, 'the Location header is missing').to.be.a('string');
  // The contract from 02-04: /v1/coffees/{id} with an opaque id prefixed cof_
  pm.expect(location).to.match(/^\/v1\/coffees\/cof_[A-Za-z0-9]+$/);
});

pm.test('Returns an ETag for optimistic concurrency', () => {
  pm.expect(pm.response.headers.get('ETag')).to.be.a('string');
});

pm.test('The Content-Type is JSON', () => {
  pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json');
});

// --- Body ----------------------------------------------------------------
const body = pm.response.json();

pm.test('The body returns the created resource, not a wrapper', () => {
  pm.expect(body).to.have.property('id');
  pm.expect(body).to.not.have.property('data'); // that is for collections
});

pm.test('The price is serialised in euros with two decimals', () => {
  pm.expect(body.priceEuros).to.be.a('number');
  // The rule from 02-05: euros outside, cents inside. It must never come out as 1690.
  pm.expect(body.priceEuros).to.equal(16.90);
});

pm.test('The date is ISO-8601 in UTC with a Z', () => {
  pm.expect(body.createdAt).to.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/);
});

pm.test('Includes _links with self', () => {
  pm.expect(body._links.self.href).to.equal(`/v1/coffees/${body.id}`);
});

pm.test('Answers in under 500 ms', () => {
  pm.expect(pm.response.responseTime).to.be.below(500);
});

Post-response for GET /v1/coffees, where what gets checked is the shape of the collection and the pagination from 02-06:

const body = pm.response.json();

pm.test('Answers 200', () => pm.response.to.have.status(200));

pm.test('The collection has the shape {data, total}', () => {
  pm.expect(body).to.have.all.keys('data', 'total');
  pm.expect(body.data).to.be.an('array');
  pm.expect(body.total).to.be.a('number');
});

pm.test('Respects the requested limit', () => {
  const limit = Number(pm.request.url.query.get('limit') || 20);
  pm.expect(body.data.length).to.be.at.most(limit);
});

pm.test('The roast=light filter really is applied', () => {
  // A status assertion without checking the filter lets the worst bug through:
  // the filter being silently ignored and the whole catalogue returned.
  body.data.forEach((coffee) => pm.expect(coffee.roast).to.equal('light'));
});

pm.test('The descending price ordering is respected', () => {
  const prices = body.data.map((c) => c.priceEuros);
  const sorted = [...prices].sort((a, b) => b - a);
  pm.expect(prices).to.eql(sorted);
});

pm.test('Exposes the rate limit headers', () => {
  pm.expect(pm.response.headers.has('Aroma-RateLimit-Remaining')).to.be.true;
});

pm.test('Emits Link with rel=next when there are more pages', () => {
  if (body.total > body.data.length) {
    pm.expect(pm.response.headers.get('Link')).to.include('rel="next"');
  }
});

And the expected error from folder 99, every bit as important as the happy path:

// Post-response for "GET Non-existent coffee → 404"
pm.test('Answers 404', () => pm.response.to.have.status(404));

pm.test('Complies with the catalogue error format', () => {
  const { error } = pm.response.json();
  pm.expect(error.code).to.equal('coffee_not_found');
  pm.expect(error.message).to.be.a('string').and.not.empty;
  pm.expect(error.details).to.be.an('array');
});

pm.test('Does not leak traceId on a 4xx', () => {
  // Only 5xx responses carry traceId (03-07). If it shows up here, the convention has leaked.
  pm.expect(pm.response.json().error).to.not.have.property('traceId');
});

  1. Chaining requests: saving the id and using it later

Chaining is what turns a list of requests into a journey. The technique is always the same: one request stores a value in a variable, the next one consumes it.

In the post-response of POST /v1/coffees:

// We store the id and the ETag for the following requests in the folder.
if (pm.response.code === 201) {
  const body = pm.response.json();
  pm.collectionVariables.set('coffeeId', body.id);
  pm.collectionVariables.set('coffeeEtag', pm.response.headers.get('ETag'));
  pm.collectionVariables.set('coffeeVersion', body.version);
}

Now PATCH {{baseUrl}}/coffees/{{coffeeId}} with If-Match: {{coffeeEtag}} works without touching anything, and the later DELETE does too. An important detail: after the PATCH the ETag changes (the version has gone up), so the PATCH post-response must store it again, or the DELETE will get the 412 precondition_failed from 04-06. It is exactly the same mistake a real client would make, and discovering it here is cheap.

This is the full journey the collection runs:

sequenceDiagram
    participant P as Postman
    participant A as Aroma Store API
    P->>A: POST /v1/sessions with email and password
    A-->>P: 200 with the token
    Note over P: stores the token and tokenExpiresAt variables
    P->>A: POST /v1/coffees as administrator
    A-->>P: 201 with Location and ETag
    Note over P: stores the coffeeId and coffeeEtag variables
    P->>A: PATCH /v1/coffees/coffeeId with If-Match
    A-->>P: 200 with a new ETag
    Note over P: updates the coffeeEtag variable
    P->>A: POST /v1/orders with Idempotency-Key
    A-->>P: 201 with the order id
    Note over P: stores the orderId variable
    P->>A: POST /v1/orders/orderId/payment
    A-->>P: 200 with status paid
    P->>A: DELETE /v1/coffees/coffeeId to clean up
    A-->>P: 204

The last request is the one almost everybody forgets: clean up what you created. Without it, every run leaves a new coffee in the database and the collection stops being repeatable.

There is an alternative to the fixed order worth knowing about: postman.setNextRequest('name of the request') lets you jump around in the runner's execution, for example to skip the rest of a folder if the login failed. Use it sparingly: collections with jumps all over the place are unreadable.

  1. Generating a different Idempotency-Key on every send

POST /v1/orders requires an Idempotency-Key (02-03 and 04-01). If you hard-code it, the second send will return the same response as the first instead of creating a new order, which is precisely what the header promises. And if you change the body while keeping the key, you will get 409 idempotency_key_reused.

Postman has built-in dynamic variables: it is enough to put {{$guid}} as the header value. But it is better to generate it in the pre-request so you can reuse the same value in the retry test:

// Pre-request for "POST Create order"
const key = pm.variables.replaceIn('{{$guid}}'); // UUID v4 generated by Postman
pm.collectionVariables.set('idempotencyKey', key);
console.log('Idempotency-Key for this run:', key);

And a second identical request, "Create order (retry)", which does not regenerate the key and checks the idempotency contract:

// Post-response for "POST Create order (retry)"
pm.test('The retry with the same key does not create a new order', () => {
  pm.response.to.have.status(201); // the original response is replayed
  pm.expect(pm.response.json().id).to.equal(pm.collectionVariables.get('orderId'));
});

Other useful dynamic variables: {{$timestamp}} (Unix seconds), {{$randomInt}}, {{$isoTimestamp}}, {{$randomEmail}}, {{$randomFullName}}. They are handy for creating test customers without email collisions.

  1. Assertions against the JSON schema

Checking property by property soon becomes unmanageable. Postman bundles AJV, so you can validate the whole response against a JSON Schema:

const coffeeSchema = {
  type: 'object',
  required: ['id', 'name', 'origin', 'roast', 'priceEuros', 'stock', 'createdAt'],
  additionalProperties: true, // we tolerate new fields: 02-07, forwards compatibility
  properties: {
    id: { type: 'string', pattern: '^cof_[A-Za-z0-9]+$' },
    name: { type: 'string', minLength: 1, maxLength: 120 },
    origin: { type: 'string' },
    roast: { type: 'string', enum: ['light', 'medium', 'dark'] },
    priceEuros: { type: 'number', minimum: 0 },
    stock: { type: 'integer', minimum: 0 },
    tastingNotes: { type: 'array', items: { type: 'string' } },
    createdAt: { type: 'string', format: 'date-time' },
    version: { type: 'integer', minimum: 1 },
  },
};

const collectionSchema = {
  type: 'object',
  required: ['data', 'total'],
  properties: {
    data: { type: 'array', items: coffeeSchema },
    total: { type: 'integer', minimum: 0 },
  },
};

pm.test('The response complies with the coffee collection schema', () => {
  pm.response.to.have.jsonSchema(collectionSchema);
});

additionalProperties: true is deliberate: if tomorrow we add roastingCountry to the resource, the collection must not break. That is the forwards compatibility rule from 02-07 applied to tests.

Duplicating these schemas by hand is tedious and they drift apart. The right thing is for them to come out of openapi.yaml, and that is exactly what 05-04 will do with contract tests; here it stands as the quick solution, good enough for exploration.

  1. Running the whole collection with the Collection Runner

Right-click on the collection → Run collection. The runner executes every request in order and shows a report with the assertions that pass and the ones that do not.

Options that matter:

  • Iterations: how many times the whole collection is repeated (with a data file, one per row).
  • Delay: milliseconds between requests. With the rate limiting from 04-04 active, a runner with no delay can eat through the quota and cause 429s halfway through the run. 50-100 ms is usually enough.
  • Keep variable values: whether the values written by the scripts are persisted. Turn it on while you debug, turn it off to check the collection works from scratch.
  • Run manually / Automatically: step-by-step execution is extremely useful for debugging a broken chain.

  1. CSV and JSON data files

To test several coffees without duplicating requests, the runner accepts a data file; each row is an iteration and its columns become variables.

postman/coffees-data.csv:

name,origin,roast,priceEuros,stock,expected
Kenya Nyeri AA,Kenya,light,16.90,40,201
Brazil Cerrado,Brazil,dark,9.50,200,201
Coffee with no origin,,medium,11.00,10,400
Negative price,Peru,medium,-3.00,10,400
Invalid roast,Peru,purple,11.00,10,400

The request body uses the columns as variables, and note the detail about the quotes: {{priceEuros}} goes without quotes because it is a number, and {{name}} with them because it is text.

{
  "name": "{{name}}",
  "origin": "{{origin}}",
  "roast": "{{roast}}",
  "priceEuros": {{priceEuros}},
  "stock": {{stock}}
}

The expected column lets a single request validate both valid and invalid cases:

const expected = Number(pm.iterationData.get('expected'));

pm.test(`Answers ${expected} for "${pm.iterationData.get('name')}"`, () => {
  pm.response.to.have.status(expected);
});

if (expected === 400) {
  pm.test('The 400 uses the invalid_data code with details', () => {
    const { error } = pm.response.json();
    pm.expect(error.code).to.equal('invalid_data');
    pm.expect(error.details).to.be.an('array').that.is.not.empty;
    pm.expect(error.details[0]).to.have.property('field');
  });
}

This tests the Zod validation from 03-04 and the error format from 03-07 in one go, with five lines of CSV instead of five requests.

  1. Newman: the collection from the terminal

Newman is the command-line collection runner. It is the piece that makes everything above useful to somebody other than you.

Export the files first (right-click → Export, format Collection v2.1) into the repository's postman/ folder:

aroma-store-api/
└── postman/
    ├── aroma-store-v1.postman_collection.json
    ├── local.postman_environment.json
    ├── test.postman_environment.json
    └── coffees-data.csv

And run it:

# Basic run with the local environment
npx newman run postman/aroma-store-v1.postman_collection.json \
  -e postman/local.postman_environment.json

# With a data file, a delay so we do not collide with rate limiting,
# and an HTML report as well as the on-screen summary
npx newman run postman/aroma-store-v1.postman_collection.json \
  -e postman/test.postman_environment.json \
  -d postman/coffees-data.csv \
  --delay-request 100 \
  --reporters cli,junit,htmlextra \
  --reporter-junit-export reports/newman.xml \
  --reporter-htmlextra-export reports/newman.html \
  --bail

# One folder only: useful for a quick smoke test after deploying
npx newman run postman/aroma-store-v1.postman_collection.json \
  -e postman/test.postman_environment.json \
  --folder "99 Expected errors"
Option What it is for
-e Environment file
-d CSV/JSON data file
--folder Runs one folder only
--env-var key=value Injects a variable without writing it into the file: this is how secrets get into CI
--delay-request Pause between requests
--bail Stops at the first failure
--reporters Report formats; junit is the one CI systems understand
--insecure Accepts self-signed certificates (internal staging only)

The key point for 05-05: Newman returns a non-zero exit code if any assertion fails. That is all a CI pipeline needs in order to block a deployment.

Add it as a script in package.json:

{
  "scripts": {
    "test:api": "newman run postman/aroma-store-v1.postman_collection.json -e postman/local.postman_environment.json --delay-request 50",
    "test:smoke": "newman run postman/aroma-store-v1.postman_collection.json -e postman/test.postman_environment.json --folder \"00 Sessions\" --bail"
  }
}

And newman plus newman-reporter-htmlextra go into devDependencies.

  1. Importing openapi.yaml and exporting the collection

There is no need to create the requests by hand if you already have the contract. Import → File → openapi.yaml generates a complete collection with every route, parameter, example body and folders per tag.

Pros and cons, because it is not magic:

  • In favour: instant coverage of every endpoint, bodies pre-filled with the specification's example values, and an indirect check that openapi.yaml describes what you think it does.
  • Against: it brings no scripts, no chaining and no assertions —the very things that give the collection its value— and re-importing goes badly: Postman creates a new collection instead of merging, and you lose your scripts.

A practical strategy: import once to get the skeleton, add scripts and chaining on top, and maintain the collection by hand from then on. When the contract adds a new endpoint, import it into a temporary collection and copy across the missing request. Real synchronisation between contract and implementation is not solved here: it is solved with the contract tests in 05-04.

  1. Documentation and sharing with the team

Postman generates browsable documentation from the collection: Markdown descriptions of each request and folder, parameters, and automatically generated code samples in curl, JavaScript, Python or Go.

Two habits that multiply its value:

  1. Save response examples. On each request, the "Save as Example" button after a good response. Save at least the success case and one error per endpoint. The examples are what shows up in the documentation and, on top of that, what feeds the mock server in the next section.
  2. Write a description for each folder explaining which role is needed, what preconditions it has and what errors to expect. It is documentation that lives where it is used.

Ways of sharing, from the least to the most committing: export the JSON and put it in the repository (the one we recommend, because it gets reviewed in pull requests and does not depend on accounts); publish the documentation as a public link; or use team workspaces with cloud synchronisation, which are convenient but mean the contents of your collections live on a third-party server —have security review that before you put an internal API in there.

For external consumers like CataBox, Postman documentation is one option, but the natural destination is the developer portal from 05-06, fed by the openapi.yaml from 05-02.

  1. Postman's mock server

Postman can spin up a public URL that answers with the saved examples from your collection. It lets the SPA team start laying out the catalogue screen before the endpoint even exists.

https://a1b2c3d4-1111-2222-3333-444455556666.mock.pstmn.io/v1/coffees

It takes three clicks to create and has two important limits: it answers with fixed examples and no logic —the ?roast=light filter does not filter anything— and it depends on Postman's cloud.

It is one option among several, and not the best one if you already have a contract: Prism generates the mock straight from openapi.yaml, with no separate examples to maintain. We will look at it in depth in 05-04, along with msw and nock.

Common Mistakes and Tips

  • Writing the full URL in every request. The day a staging environment appears you have to edit forty requests. {{baseUrl}} from the very first one, always.
  • Pasting the token by hand. It expires in an hour and you end up with tokens in the exported collection, which is to say in the repository. The token is obtained with the login and stored by a script.
  • Exporting the environment with secrets inside. Mark sensitive variables as secret, review the file before committing and add postman/*.local.json to .gitignore.
  • Assertions that only check the status. pm.response.to.have.status(200) passes even if the API returns an empty array because the filter was ignored. Check the shape and the content too.
  • Forgetting the clean-up. Every run leaves data behind. Finish each folder with a DELETE of whatever it created, or seed the database beforehand with npm run db:reset.
  • Confusing environment variables with collection variables. If baseUrl lives in the collection, the environment dropdown does nothing and you always end up pointing at the same place.
  • Running the runner with no delay against an environment with rate limiting. Random 429 failures that look like API bugs and are not. --delay-request 100, or a specific quota for the CI client (04-04).
  • Content-Type: application/json on the PATCH. Our API answers 415 unsupported_format with Accept-Patch. The correct value is application/merge-patch+json.
  • Not opening the Postman Console. That is where you see the literal request that was sent, with the variables already substituted. Most "I don't understand what's happening" moments are solved in ten seconds by looking at it.
  • Tip: name requests after what they test, not after the method. "Create coffee as customer → 403" says far more than "POST coffees 2".
  • Tip: put the collection in the same repository as the API. That way the pull request that changes an endpoint also changes its request, and review catches the inconsistencies.

Exercises

Exercise 1: the expected errors folder

Build the 99 Expected errors folder of the "Aroma Store v1" collection with five requests that verify the catalogue from 02-04, and write their assertions. The cases: non-existent coffee (404 coffee_not_found), invalid query parameter (400 invalid_parameter), creation without a token (401 not_authenticated with WWW-Authenticate), creation with the customer role (403 insufficient_permissions) and PUT /v1/coffees (405 method_not_allowed with Allow).

For each one, state which Authorization configuration it needs and write the post-response script for at least three of them.

Exercise 2: chained purchase journey

Create a 10 Purchase journey folder that runs, in order and with no manual intervention: log in as Marta → list coffees (saving the id of the first one with stock available) → create an order with a generated Idempotency-Key → pay the order → fetch the order and check that its status is paid.

Write the scripts you need and explain which variables travel between requests and in which scope you would store them.

Exercise 3: Newman with data and a quality gate

Prepare the terminal run of the collection so that it works as a quality gate before a deployment: a data file with at least two valid and two invalid coffee-creation cases, the Newman command that runs it against the test environment injecting the password without writing it into any file, and the package.json script. Explain how the CI system will know whether it should block the deployment.

Solutions

Solution 1

Authentication configuration per request:

Request Authorization Reason
Non-existent coffee → 404 Inherit ({{token}}) You need to be authenticated to reach the 404 instead of stopping at the 401
Invalid parameter → 400 Inherit ({{token}}) Same thing: validation happens after authentication
No token → 401 Explicit No Auth If it inherits the Bearer, the test tests nothing
As customer → 403 Bearer {{token}} (role customer) Must be authenticated but without permission
PUT /v1/coffees → 405 Inherit The router.all answers before the logic

Request 3 — POST {{baseUrl}}/coffees without a token:

pm.test('Answers 401', () => pm.response.to.have.status(401));

pm.test('Includes WWW-Authenticate', () => {
  const header = pm.response.headers.get('WWW-Authenticate');
  pm.expect(header).to.include('Bearer');
});

pm.test('The code is not_authenticated', () => {
  pm.expect(pm.response.json().error.code).to.equal('not_authenticated');
});

pm.test('The message does not reveal whether the resource exists', () => {
  // 04-02: a 401 must not leak information about the state of the system
  pm.expect(pm.response.json().error.message.toLowerCase()).to.not.include('coffee');
});

Request 4 — POST {{baseUrl}}/coffees with Marta's token (role customer):

pm.test('Answers 403, not 401', () => {
  // Key distinction from 02-04: 401 is "I don't know who you are", 403 is "I know who you are and you can't"
  pm.response.to.have.status(403);
});

pm.test('The code is insufficient_permissions', () => {
  pm.expect(pm.response.json().error.code).to.equal('insufficient_permissions');
});

pm.test('Nothing has been created', () => {
  pm.expect(pm.response.headers.has('Location')).to.be.false;
});

Request 5 — PUT {{baseUrl}}/coffees:

pm.test('Answers 405', () => pm.response.to.have.status(405));

pm.test('Declares the allowed methods in Allow', () => {
  const allow = pm.response.headers.get('Allow');
  pm.expect(allow, 'the Allow header is missing, and it is mandatory on a 405').to.be.a('string');
  ['GET', 'POST', 'HEAD', 'OPTIONS'].forEach((method) => {
    pm.expect(allow).to.include(method);
  });
  pm.expect(allow).to.not.include('PUT');
});

pm.test('The code is method_not_allowed', () => {
  pm.expect(pm.response.json().error.code).to.equal('method_not_allowed');
});

Solution 2

Scopes chosen: every variable in the journey goes to pm.collectionVariables, not to pm.environment. The reason: they are ephemeral values from one particular run and must not pollute the environment file that gets shared. The token is the exception: it goes to the environment because every folder shares it and the collection's pre-request manages it.

Request 1 — POST {{baseUrl}}/sessions (No Auth). Post-response:

pm.test('Login successful', () => pm.response.to.have.status(200));
const body = pm.response.json();
pm.environment.set('token', body.token);
pm.environment.set('tokenExpiresAt', Date.now() + body.expiresIn * 1000);
pm.collectionVariables.set('customerId', body.customer.id);

pm.test('The role is customer', () => pm.expect(body.customer.role).to.equal('customer'));

Request 2 — GET {{baseUrl}}/coffees?available=true&limit=5:

const body = pm.response.json();
pm.test('There is at least one coffee available', () => {
  pm.expect(body.data.length).to.be.above(0);
});

const coffee = body.data.find((c) => c.stock >= 2);
if (!coffee) {
  throw new Error('No coffee has enough stock: seed the database.');
}
pm.collectionVariables.set('coffeeId', coffee.id);
pm.collectionVariables.set('coffeePrice', coffee.priceEuros);

Request 3 — POST {{baseUrl}}/orders. Pre-request:

pm.collectionVariables.set('idempotencyKey', pm.variables.replaceIn('{{$guid}}'));

Body and post-response:

{ "customerId": "{{customerId}}", "items": [{ "coffeeId": "{{coffeeId}}", "quantity": 2 }] }
pm.test('Order created', () => pm.response.to.have.status(201));
const order = pm.response.json();
pm.collectionVariables.set('orderId', order.id);

pm.test('The initial status is pending_payment', () => {
  pm.expect(order.status).to.equal('pending_payment');
});

pm.test('The total matches price × quantity', () => {
  // Also checks the cents→euros conversion of the mapper from 03-03
  const expected = Number((pm.collectionVariables.get('coffeePrice') * 2).toFixed(2));
  pm.expect(order.totalEuros).to.equal(expected);
});

pm.test('Location points at the created order', () => {
  pm.expect(pm.response.headers.get('Location')).to.equal(`/v1/orders/${order.id}`);
});

Request 4 — POST {{baseUrl}}/orders/{{orderId}}/payment with Idempotency-Key: {{$guid}}:

pm.test('Payment accepted', () => pm.response.to.have.status(200));
pm.test('The order becomes paid', () => {
  pm.expect(pm.response.json().status).to.equal('paid');
});

Request 5 — GET {{baseUrl}}/orders/{{orderId}}:

pm.test('The persisted status is paid', () => {
  pm.expect(pm.response.json().status).to.equal('paid');
});

pm.test('The coffee stock has gone down', () => {
  pm.sendRequest({
    url: `${pm.environment.get('baseUrl')}/coffees/${pm.collectionVariables.get('coffeeId')}`,
    method: 'GET',
    header: { Authorization: `Bearer ${pm.environment.get('token')}` },
  }, (err, res) => {
    pm.expect(res.json().stock).to.be.at.most(120 - 2);
  });
});

Variables that travel: token (environment) → all of them; customerId and coffeeId (collection) → request 3; orderId (collection) → requests 4 and 5; idempotencyKey (collection) → request 3.

Solution 3

File postman/coffees-data.csv:

name,origin,roast,priceEuros,stock,expected,errorCode
Kenya Nyeri AA,Kenya,light,16.90,40,201,
Brazil Cerrado,Brazil,dark,9.50,200,201,
No name,,medium,11.00,10,400,invalid_data
Invalid roast,Peru,purple,11.00,10,400,invalid_data

Post-response covering both cases:

const expected = Number(pm.iterationData.get('expected'));
const errorCode = pm.iterationData.get('errorCode');

pm.test(`"${pm.iterationData.get('name')}" answers ${expected}`, () => {
  pm.response.to.have.status(expected);
});

if (expected === 201) {
  // We store the id so we can clean up at the end of the journey
  const created = JSON.parse(pm.collectionVariables.get('createdCoffees') || '[]');
  created.push(pm.response.json().id);
  pm.collectionVariables.set('createdCoffees', JSON.stringify(created));
} else {
  pm.test(`The error code is ${errorCode}`, () => {
    pm.expect(pm.response.json().error.code).to.equal(errorCode);
  });
}

Command with the secret injected from outside:

npx newman run postman/aroma-store-v1.postman_collection.json \
  -e postman/test.postman_environment.json \
  -d postman/coffees-data.csv \
  --env-var "customerPassword=$TEST_PASSWORD" \
  --env-var "adminPassword=$TEST_ADMIN_PASSWORD" \
  --delay-request 100 \
  --reporters cli,junit \
  --reporter-junit-export reports/newman.xml

--env-var overrides the value from the environment file. That way the versioned file can have an empty customerPassword and the real value arrives from an environment variable that in CI comes from the secret manager (05-05), never from the repository.

Script in package.json:

{
  "scripts": {
    "test:api:test-env": "newman run postman/aroma-store-v1.postman_collection.json -e postman/test.postman_environment.json -d postman/coffees-data.csv --delay-request 100 --reporters cli,junit --reporter-junit-export reports/newman.xml"
  }
}

How it blocks the deployment. Newman exits with code 0 if every assertion passes and with a non-zero code if any fails or there is a network error. Any CI system reads a non-zero exit code as a failed step and stops the pipeline. On top of that, the junit report lets the interface show exactly which assertion failed, without having to read the log. If you want the failure to be immediate rather than running the whole collection, add --bail.

Conclusion

You have turned manual exploration into an artefact. The "Aroma Store v1" collection is no longer a list of URLs: it has folders per resource that mirror the contract from 02-02, environments that let you point at local, test or production without editing a single request, a handbrake that stops you writing to production by accident, and a pre-request that renews the token from the 03-06 login before it expires, without anyone pasting credentials by hand.

On top of that base you have added what turns it into a test: assertions that verify the 201 and its Location, the {data, total} shape of collections, that the filters from 02-06 really filter, that the price comes out in euros and not in cents, that the errors from the 02-04 catalogue arrive with their exact code and without a traceId on 4xx responses, and JSON schemas validated with AJV that tolerate new fields. You have chained a complete purchase journey passing the id and the ETag from one request to the next, generated an Idempotency-Key per run to test the retry behaviour from 02-03, and multiplied the cases with a CSV file in which each row declares the code it expects. And with Newman all of that runs from the terminal, with an exit code that is enough to block a deployment: the project's new files are postman/aroma-store-v1.postman_collection.json, postman/local.postman_environment.json, postman/test.postman_environment.json and postman/coffees-data.csv, with newman in devDependencies and the test:api and test:smoke scripts in package.json.

One loose end remains, and it is the important one: the collection is a second description of the API, written by hand, which can drift from the real one without anyone noticing. We already have a better description —the openapi.yaml we have been dragging along since 02-08— but it is half-finished: a single endpoint, no complete schemas, no declared security and unpublished. In 05-02, Swagger and OpenAPI for documentation, we finish it: the full anatomy of the document section by section, components with reusable schemas, parameters and responses, the securitySchemes with the OAuth scopes from 04-03, the difference between writing it by hand and generating it from the code, Swagger UI served at /docs inside the project itself, validation with swagger-cli and the Spectral rules from 04-01, and generating TypeScript clients for the SPA and for Aroma Mobile from the contract.

REST API Course: Principles of Designing and Developing RESTful APIs

Module 1: Introduction to RESTful APIs

Module 2: Designing RESTful APIs

Module 3: Building RESTful APIs

Module 4: Best Practices and Security

Module 5: Tools and Frameworks

Module 6: Case Studies and Projects

© Copyright 2026. All rights reserved