Module 2: The K6 Script And Virtual Users
3. `http.get` and `http.post` with body and headers
Overview
A VU that makes no requests tests nothing. The default script comes into its own when inside it the virtual user talks to the API: it asks for the list of rooms, quotes a booking, confirms a purchase. In k6, that conversation happens through the k6/http module —http.get to read, http.post to send—. In this lesson we fill the script's lap with real requests to the Reservo API, and learn the two pieces every POST to a JSON API needs: the body (the JSON you send) and the headers (the Content-Type that tells the server how to read that body).
It's the same mechanics as any HTTP client, but in the exact form k6 expects. A GET is simple: a URL and done. A POST to a JSON API has three parts that must be assembled right —the URL, the body serialized with JSON.stringify, and the headers in a params object—; getting the order or the header wrong is the most common beginner mistake, and we'll see it in detail. And once the request comes back, you have to read the response: its status (200?) and its JSON body (res.json()), which in lesson 4 will feed the check().
Connection to the module: lesson 2 gave you the empty loop; this one fills it with content. Now the VU doesn't just "go around": in each lap it requests /rooms or quotes at /quote with its {room, tier, hours} body and receives {price_cents}. The k6 code is shown as content; the Python equivalent with urllib.request is actually run against the canonical API, and you'll see the anchor numbers —7500 and 6000— come out of the real server. Everything labeled as Python output was measured in this environment with Python 3.14.0. The response we here only read, in lesson 4 we'll verify with check().
Ordering the kitchen by phone
Think of it this way. You call a restaurant that also does deliveries. There are two kinds of call. The first is a query: "what rooms... sorry, what dishes do you have today?" —you just ask, you send nothing, and they read you the menu—. The second is an order: "I want dish so-and-so, for this many people, at this time" —here you do send information, and you have to give it in a format the kitchen understands; if you speak a language they don't handle, they can't take the order no matter how loud you shout—.
http.get is the query: you ask for a URL and they return what's there (the room list from /rooms). You send no data, you just ask. http.post is the order: you send a body (your booking data) and, crucially, a header saying what language that body is in —Content-Type: application/json, "this is JSON, read it as JSON"—. If you forget the header, it's like ordering in a language the kitchen doesn't recognize: the server receives the bytes but doesn't know they're JSON, and your order can fail or be misread. The body is what you order; the header is how it's written.
http.get(url)is a query: you ask and read, without sending data.http.post(url, body, params)is an order: you send a body (the JSON withJSON.stringify) and some headers (theContent-Type: application/jsonthat tells the server the body is JSON). Forgetting the header is like ordering in a language the kitchen doesn't understand.
http.get: reading the room list (content)
The simplest case: request GET /rooms to see what rooms there are and at what rate. In k6:
// rooms.js — reading Reservo's room list.
// SHOWN AS CONTENT: k6 is not installed in this environment.
import http from 'k6/http';
const BASE_URL = 'http://localhost:8000';
export default function () {
// GET: a query. Only the URL, no body.
const res = http.get(`${BASE_URL}/rooms`);
// Read the response:
// res.status -> the HTTP code (200, 404, ...)
// res.json() -> the body parsed as a JavaScript object
console.log(res.status); // 200
console.log(res.json('rooms')); // the list of rooms
}
Three things about http.get:
- It only needs the URL. A
GETsends no body; that's whyhttp.getreceives only the address. It's the way to read a resource. resis the response.http.getreturns a response object with everything the server replied:res.status(the HTTP code),res.body(the raw body), andres.json()(the body already parsed as an object, if it's JSON).res.json('rooms')reads theroomsfield of the response JSON. You can writeres.json()for the full object and then.rooms, or pass the path directly as here. In lesson 4 we'll useres.json('price_cents')the same way.
http.post: quoting with body and headers (content)
Now the case that matters: quoting at POST /quote. Here we do send data —{room, tier, hours}— and the three parts must be assembled carefully:
// quote.js — quoting a room in Reservo.
// SHOWN AS CONTENT: k6 is not installed in this environment.
import http from 'k6/http';
const BASE_URL = 'http://localhost:8000';
export default function () {
// 1) The body: the JS object converted to JSON text with JSON.stringify.
const payload = JSON.stringify({ room: 'Focus', tier: 'basic', hours: 3 });
// 2) The headers: we tell the server the body is JSON.
const params = { headers: { 'Content-Type': 'application/json' } };
// 3) The request: url, body, params (in that order).
const res = http.post(`${BASE_URL}/quote`, payload, params);
console.log(res.status); // 200
console.log(res.json('price_cents')); // 7500
}
Take it apart, because these three parts are the mold of every POST to a JSON API:
- The body (
payload). The API expects JSON, buthttp.postsends text. That's why you wrap your object inJSON.stringify(...): it converts{ room: 'Focus', tier: 'basic', hours: 3 }into the string'{"room":"Focus","tier":"basic","hours":3}'. If you passed it the object withoutstringify, k6 would send it as a form (application/x-www-form-urlencoded), not as JSON —a classic mistake we'll see in "Common mistakes"—. - The headers (
params). The third argument ofhttp.postis an options object; inside,headerscarries theContent-Type: application/json. This header is the label that tells the server "the body that follows is JSON, parse it as JSON." Without it, many APIs reject or misread the body. - The order:
http.post(url, body, params). The URL first, the body second, the params third. Swapping body and params is a silent bug: the server doesn't receive your JSON where it expects it.
The response is read the same as with GET: res.status (we expect 200) and res.json('price_cents') (we expect 7500). That 7500 is the anchor number —Focus at 2500 cents per hour × 3 hours— you already know from module 1.
The same requests, run in Python
Now the face that does run. In Python, the standard-library urllib.request does exactly the same as k6/http, with the same three parts in the POST. Here's the equivalent:
# The same requests as in k6, with standard-library urllib.
import json, urllib.request
BASE_URL = "http://127.0.0.1:PORT" # the real port is assigned by the OS
def get_rooms():
# GET /rooms: a query, just the URL.
with urllib.request.urlopen(f"{BASE_URL}/rooms") as res:
return res.status, json.loads(res.read())
def post_quote(room, tier, hours):
# POST /quote: JSON body + Content-Type header, as in k6.
payload = json.dumps({"room": room, "tier": tier, "hours": hours}).encode()
req = urllib.request.Request(
f"{BASE_URL}/quote",
data=payload, # the body (2)
headers={"Content-Type": "application/json"}, # the headers (3)
method="POST",
)
with urllib.request.urlopen(req) as res:
return res.status, json.loads(res.read())
The mapping with k6 is direct: json.dumps(...) is the JSON.stringify(...); the headers={...} argument is the params.headers; urllib.request.urlopen is the request. Let's run the two queries and the two anchor quotes against the canonical API.
What to expect. GET /rooms must return the three rooms with their rate; POST /quote Focus/basic/3h must give 7500, and Focus/pro/3h must give 6000 (the same price with the integer 20% pro discount). Real output in this environment:
# GET /rooms
status = 200
{'rooms': [{'room': 'Focus', 'rate_cents': 2500}, {'room': 'Studio', 'rate_cents': 4000}, {'room': 'Boardroom', 'rate_cents': 8000}]}
# POST /quote {"room":"Focus","tier":"basic","hours":3}
status = 200
{'price_cents': 7500}
# POST /quote {"room":"Focus","tier":"pro","hours":3}
status = 200
{'price_cents': 6000}
Read it calmly:
GET /rooms→ 200 + the list — the server returned the three rooms (Focus2500,Studio4000,Boardroom8000 cents per hour). It's the query: you ask and they read you the menu.POST /quoteFocus/basic/3h → 7500 — the order with a body worked. 2500 cents/hour × 3 hours = 7500 cents ($75.00). TheContent-Type: application/jsonheader is what let the server read the body as JSON.POST /quoteFocus/pro/3h → 6000 — the same body but withtier: 'pro'. The server applies the integer pro discount: 7500 × 80 // 100 = 6000 cents ($60.00). It's the guide's second anchor number.
These are the same values the k6 script would receive —res.json('price_cents') would give 7500 and 6000— because they hit the same canonical API. The only difference is who sends the request: k6 (content) or urllib (executed). The server doesn't notice the difference.
Reading the response: status and the JSON body
Sending the request is half; the other half is reading what comes back, because lesson 4's check() will depend on it. Two fields matter:
res.status— the HTTP code.200means "OK, here's your response";400would be "your request is wrong" (for example, invalid hours);404, "that endpoint doesn't exist." Verifyingstatus === 200is the first check of every load test: did the API even respond well?res.json('field')— the parsed body. The Reservo API responds JSON ({"price_cents": 7500}), andres.json('price_cents')extracts that7500as a number. Mind the type: the API sends cents as an integer (7500, not75.0or"7500"), so the comparison in thecheckwill be with an integer (=== 7500). That detail —money inintcents— avoids the rounding errors of floats and is a convention of the whole guide.
With these two fields in hand, the VU no longer just talks to the API: it can understand what it replies. In lesson 4 we turn that understanding into a formal verification with check().
Common mistakes
Forgetting JSON.stringify in the POST body. What happens: someone writes http.post(url, { room: 'Focus', ... }, params) passing the object directly. Why it happens: it seems natural to send the object as-is. How to detect it: k6 serializes the object as a form (room=Focus&tier=basic&hours=3, with a form Content-Type), the API expects JSON and responds 400 or misreads the body. How to fix it: always wrap the object in JSON.stringify(...) for a JSON body, and accompany it with the Content-Type: application/json header. In Python, the equivalent is json.dumps(...).encode().
Forgetting the Content-Type header. What happens: you send the JSON with JSON.stringify but without the params headers object. Why it happens: the body is already JSON, so one assumes the server will notice. How to detect it: some APIs tolerate it, but many respond 400 or 415 ("Unsupported Media Type") because, without the header, they don't know to parse the body as JSON. How to fix it: always include const params = { headers: { 'Content-Type': 'application/json' } } and pass it as the third argument. The header is the language label; don't omit it.
Swapping the body/params order in http.post. What happens: someone writes http.post(url, params, payload) —the headers where the body goes—. Why it happens: they're two objects and it's easy to confuse which goes first. How to detect it: the API doesn't receive your JSON as the body (it receives the headers object), and responds with a validation error even though your payload is perfect. How to fix it: memorize the signature http.post(url, body, params) —URL, body, params, in that order—. The body is the second argument, always.
Exercises
Exercise 1 — Build the POST. Write (in k6, as content) the request to quote room Studio, tier pro, 2 hours. Include the body with JSON.stringify, the headers, and the http.post call in the correct order.
See solution
const payload = JSON.stringify({ room: 'Studio', tier: 'pro', hours: 2 });
const params = { headers: { 'Content-Type': 'application/json' } };
const res = http.post(`${BASE_URL}/quote`, payload, params);
- The body goes with
JSON.stringifyso it comes out as JSON text. - The
Content-Type: application/jsonheader accompanies the body. - The order is
http.post(url, payload, params): URL, body, params.
The expected price would be Studio (4000 cents/hour) × 2 hours = 8000, with the pro discount: 8000 × 80 // 100 = 6400 cents.
Exercise 2 — get or post? For each action, say whether you'd use http.get or http.post, and why: (a) get the list of available rooms; (b) quote a specific booking; (c) confirm a booking by sending the customer's data.
See solution
- (a)
http.get(BASE_URL + '/rooms')— it's a query: you just read a resource, you send no data. - (b)
http.post(BASE_URL + '/quote', payload, params)— it's an order: you send{room, tier, hours}in the body so the API computes the price. - (c)
http.post(BASE_URL + '/book', payload, params)— another order: you send the booking data and the server creates and confirms it. Sending data that changes or creates something goes viaPOST.
Exercise 3 — Diagnose the 400. A colleague quotes at /quote and always gets status = 400 even though their object { room: 'Focus', tier: 'basic', hours: 3 } looks correct. Their call is: http.post(url, { room: 'Focus', tier: 'basic', hours: 3 }). What two things are wrong and how do you fix it?
See solution
Two of the three parts of a JSON POST are missing:
- They didn't serialize the body with
JSON.stringify. They passed the object directly, so k6 sends it as a form, not as JSON. The API expects JSON and doesn't find the fields where it looks. - They didn't include the
Content-Type: application/jsonheader. Without it, the server doesn't know to parse the body as JSON.
Fix:
const payload = JSON.stringify({ room: 'Focus', tier: 'basic', hours: 3 });
const params = { headers: { 'Content-Type': 'application/json' } };
const res = http.post(url, payload, params);
With the body serialized and the header present, the API receives the JSON as it expects and responds 200 with {"price_cents": 7500}.
Summary and next step
In this lesson the VU learned to talk to the API. You saw http.get(url) to read a resource (the room list from /rooms) and http.post(url, body, params) to send data —the three parts every JSON POST needs: the body with JSON.stringify, the headers with Content-Type: application/json, and the correct order—. And you learned to read the response: res.status (200?) and res.json('field') (the JSON body, with money in int cents). You ran it for real with Python's urllib.request against the canonical API and saw the anchor numbers come out: /rooms with the three rooms, /quote Focus/basic/3h = 7500 and Focus/pro/3h = 6000. The same requests, the same API; only who sends them changes.
Before moving on you should be able to: write an http.get and an http.post with JSON body and headers in the correct order; explain why JSON.stringify and the Content-Type are mandatory in a JSON POST; and read res.status and res.json('field') from the response.
So far we've only read the response. Lesson 4 verifies it: with check() we'll check that the API not only responded, but responded well —status 200 and correct price_cents— even under load, and we'll see what happens (with a real bug) when the price doesn't add up.
Resources
- HTTP requests in k6 —
http.get/http.post— the reference on how to sendGETandPOSTwith body and headers, with thehttp.post(url, body, params)signature. The exact source of this lesson. - The
k6/httpmodule — Response object — everything the response carries:status,body,json(). How to read what the API replies. urllib.request— Python documentation — the standard-library HTTP client we run the same requests with.Request(url, data, headers, method)is the mold of thePOST.json— Python documentation —json.dumps(Python'sJSON.stringify) andjson.loadsto serialize and parse the body. The piece that builds and reads the JSON.