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
- Why you need an HTTP client as well as automated tests
- What Postman is and what alternatives exist
- Installation and first contact:
GET /v1/coffees - Reading the response: body, headers and timings
- Collections and folders: the structure of "Aroma Store v1"
- The real requests from the contract
- Variables: collection, environment and global
- Environments: local, test and production
- Secrets: what never gets exported
- Authentication: Bearer Token and folder inheritance
- Pre-request scripts: what happens before sending
- Post-response scripts: the assertions
- Chaining requests: saving the
idand using it later - Generating a different
Idempotency-Keyon every send - Assertions against the JSON schema
- Running the whole collection with the Collection Runner
- CSV and JSON data files
- Newman: the collection from the terminal
- Importing
openapi.yamland exporting the collection - Documentation and sharing with the team
- Postman's mock server
- Best practices and what goes into the repository
- 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.
- 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.
- Installation and first contact:
GET /v1/coffees
GET /v1/coffeesPostman 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:3000In Postman, Ctrl/Cmd + N → HTTP Request. Type the GET method and the URL:
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
}
- 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-clientmeasures 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
compressionin 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: nosniffFour checks worth doing by hand the first time:
ETagand304. Copy theETagvalue, create an identical request with theIf-None-Matchheader set to that value and send it. It must answer304 Not Modifiedwith no body. Postman showsSize: 0 Bfor the body: that is where you really see the saving from 04-06.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.Link. Copy therel="next"URL into a new request: it must bring you the next page without you having to build theoffsetyourself. If you have to work it out by hand, HATEOAS is not working.Aroma-Trace-Id. Copy it and search for it in thepinooutput 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 answer400with{"error":{"code":"invalid_parameter",...}}and without atraceIdin the body, because only5xxresponses carry one. Seeing the error contract being honoured is as important as seeing the happy path.
- 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 AllowThree 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 the00 Sessionsfolder 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 errorsfolder 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 a403turns into a500.
- 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.
- 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.
- 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.`
);
}
- 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_secretfrom 04-03 and any token: alwayssecret. - 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
grepsaves 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"
- 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:
- On the collection: Auth Type → Bearer Token, Token →
{{token}}. - On each request: Auth Type → Inherit auth from parent (the default).
- On the
00 Sessionsfolder and on thePOST /v1/customerssign-up: Auth Type → No Auth, because they are public and sending an expired token toPOST /v1/sessionsis unnecessary noise. - 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.
- 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.sendRequestis asynchronous with a callback. Postman waits for it to finish before sending the main request, but anypm.environment.setyou 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
Errorstops execution with a clear message instead of leaving you investigating why everything returns401. console.logwrites 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.
- 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');
});
- Chaining requests: saving the
id and using it later
id and using it laterChaining 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.
- Generating a different
Idempotency-Key on every send
Idempotency-Key on every sendPOST /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.
- 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.
- 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.
- 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.
- 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.csvAnd 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.
- Importing
openapi.yaml and exporting the collection
openapi.yaml and exporting the collectionThere 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
examplevalues, and an indirect check thatopenapi.yamldescribes 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.
- 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:
- 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.
- 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.
- 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.
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 addpostman/*.local.jsonto.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
DELETEof whatever it created, or seed the database beforehand withnpm run db:reset. - Confusing environment variables with collection variables. If
baseUrllives 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
429failures that look like API bugs and are not.--delay-request 100, or a specific quota for the CI client (04-04). Content-Type: application/jsonon the PATCH. Our API answers415 unsupported_formatwithAccept-Patch. The correct value isapplication/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:
Body and post-response:
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
- What Is an API?
- History and Evolution of APIs
- HTTP Fundamentals for APIs
- Basic Principles of REST
- The Richardson Maturity Model and HATEOAS
- REST vs. SOAP
- REST Compared with GraphQL, gRPC and Webhooks
Module 2: Designing RESTful APIs
- RESTful API Design Principles
- Resources and URIs
- HTTP Methods
- HTTP Status Codes
- Representations, Headers and Content Negotiation
- Filtering, Sorting, Pagination and Search
- API Versioning
- API Documentation
Module 3: Building RESTful APIs
- Setting Up the Development Environment
- Building a Basic Server
- Handling Requests and Responses
- Input Data Validation
- Persistence and the Data Access Layer
- Authentication and Authorisation
- Error Handling
- Testing and Validation
Module 4: Best Practices and Security
- API Design Best Practices
- Security in RESTful APIs
- OAuth 2.0 and OpenID Connect in Practice
- Rate Limiting and Throttling
- CORS and Security Policies
- HTTP Caching and Performance
- Observability: Logs, Metrics and Traces
Module 5: Tools and Frameworks
- Postman for API Testing
- Swagger and OpenAPI for Documentation
- Popular Frameworks for RESTful APIs
- Contracts, Mocks and Automated API Testing
- Continuous Integration and Deployment
- API Gateways and Developer Portals
