PRACTICAL GUIDE / Playwright form encoded requests
When your Playwright API test sends the wrong body
Learn to send URL-encoded bodies with Playwright, inspect the exact bytes on the wire, diagnose 400 and 415 responses, and migrate tests safely.
In this guide6 sections
What you will learn
- See what changes when Playwright serializes the request
- Capture the bytes with a runnable contract test
- Diagnose the rejection before changing the request
- Separate encoding bugs from convincing near-misses
Your browser signs in, but the API setup test receives 415 Unsupported Media Type from the same endpoint. The fields look right in the test, yet the server says grant_type is missing. The test sent JSON to a route that only parses HTML form data.
That mismatch appears often around OAuth token routes, legacy account services, payment gateways, and endpoints copied from an HTML form. It is easy to miss because both payloads contain familiar key-value pairs when printed as JavaScript objects. The server does not receive a JavaScript object. It receives a content type and a sequence of bytes, and those two details decide which parser runs.
See what changes when Playwright serializes the request
An HTTP handler normally chooses a body parser from the Content-Type header. A JSON parser expects bytes such as this:
{"grant_type":"client_credentials","scope":"read reports"}A URL-encoded form parser expects a different representation:
grant_type=client_credentials&scope=read+reportsThose bodies express similar data, but they are not interchangeable. Sending the first body with Content-Type: application/json is correct for a JSON endpoint. Sending the second with Content-Type: application/x-www-form-urlencoded is correct for a form endpoint. A strict service rejects the wrong media type with 415. A less careful service may run the wrong parser, produce an empty field map, and return 400 with a message such as grant_type is required. That second response tempts people to debug the field name even though the field never reached the intended parser.
Playwright makes the choice explicit. An object passed as data is serialized as JSON, and Playwright sets application/json when no content type was provided. An object passed as form is serialized using application/x-www-form-urlencoded, and Playwright sets that content type. The relevant call is small:
const response = await request.post('/oauth/token', {
form: {
grant_type: 'client_credentials',
scope: 'read reports',
},
});The convenience matters because form encoding has rules that are easy to reproduce incorrectly. Fields are separated by &. Names and values are separated by =. A space is normally represented as +. A literal plus sign must be percent-encoded as %2B, an ampersand inside a value becomes %26, and an equals sign inside a value becomes %3D. If a secret is qa+ci&blue=1, concatenating strings by hand changes one value into several pieces. The request can look plausible in a log while the service authenticates with corrupted credentials.
The form object accepts flat string, number, and boolean values. Playwright converts the non-string values for transport. That does not create a shared convention for nested objects or arrays. A service might expect roles=admin&roles=editor, roles[]=admin&roles[]=editor, a comma-separated value, or a JSON string inside one field. All four are seen in production APIs. Only the contract can tell you which representation is valid.
Duplicate names expose another limitation of ordinary object syntax. This value cannot hold two scope properties because the second property replaces the first before Playwright sees it:
const fields = {
scope: 'read',
scope: 'write',
};Use FormData when a form contract repeats a name. Current Playwright versions accept it through the form option, and append() preserves each occurrence. Do not switch to multipart merely because the type is called FormData. The option supplied to request.post() still determines the request encoding.
There is also a behavioral detail that affects diagnosis. request.post() returns an APIResponse for HTTP error statuses by default. A 400, 401, or 415 therefore does not automatically throw at the request line. If a test asserts only that a response object exists, it can pass after the service rejected the operation. Read the status and the response body, then assert the state change the endpoint promises.
Capture the bytes with a runnable contract test
The fastest way to settle an encoding argument is to capture the raw request before any framework parser touches it. The following helper starts a local HTTP server on an available port. It records the content type, raw UTF-8 body, and parsed form entries. It also behaves like a small token endpoint: wrong media types get 415, missing fields get 400, and a valid form gets 200.
Save this as tests/helpers/form-server.ts:
import { once } from 'node:events';
import { createServer, type ServerResponse } from 'node:http';
export type ReceivedRequest = {
contentType: string;
rawBody: string;
fields: Array<[string, string]>;
};
export type FormServer = {
url: string;
received: ReceivedRequest[];
close: () => Promise<void>;
};
function sendJson(
response: ServerResponse,
status: number,
value: unknown,
): void {
response.statusCode = status;
response.setHeader('content-type', 'application/json');
response.end(JSON.stringify(value));
}
export async function startFormServer(): Promise<FormServer> {
const received: ReceivedRequest[] = [];
const server = createServer(async (request, response) => {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.from(chunk));
}
const rawBody = Buffer.concat(chunks).toString('utf8');
const contentType = String(request.headers['content-type'] ?? '');
const fields = [...new URLSearchParams(rawBody).entries()];
received.push({ contentType, rawBody, fields });
if (request.method !== 'POST' || request.url !== '/token') {
sendJson(response, 404, { error: 'not_found' });
return;
}
if (!contentType.startsWith('application/x-www-form-urlencoded')) {
sendJson(response, 415, {
error: 'unsupported_media_type',
receivedContentType: contentType,
});
return;
}
const form = new URLSearchParams(rawBody);
if (form.get('grant_type') !== 'client_credentials') {
sendJson(response, 400, {
error: 'invalid_request',
receivedKeys: [...form.keys()],
});
return;
}
sendJson(response, 200, {
access_token: 'contract-test-token',
scope: form.getAll('scope'),
token_type: 'Bearer',
});
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Expected the test server to listen on a TCP port');
}
return {
url: `http://127.0.0.1:${address.port}`,
received,
close: async () => {
server.close();
await once(server, 'close');
},
};
}Create tests/api/form-encoding.spec.ts beside it. A custom fixture gives every test its own server, so the examples remain safe if the project enables full parallel execution.
import { test as base, expect } from '@playwright/test';
import {
startFormServer,
type FormServer,
} from '../helpers/form-server';
const test = base.extend<{ formServer: FormServer }>({
formServer: async ({}, use) => {
const server = await startFormServer();
await use(server);
await server.close();
},
});
test('encodes spaces and reserved characters', async ({
request,
formServer,
}) => {
const response = await request.post(`${formServer.url}/token`, {
form: {
grant_type: 'client_credentials',
scope: 'read reports',
client_secret: 'qa+ci&blue=1',
},
});
expect(response.status()).toBe(200);
expect(formServer.received).toHaveLength(1);
const captured = formServer.received[0];
expect(captured.contentType).toContain(
'application/x-www-form-urlencoded',
);
expect(captured.rawBody).toContain('scope=read+reports');
expect(captured.rawBody).toContain(
'client_secret=qa%2Bci%26blue%3D1',
);
expect(Object.fromEntries(captured.fields)).toMatchObject({
grant_type: 'client_credentials',
scope: 'read reports',
client_secret: 'qa+ci&blue=1',
});
});
test('exposes JSON disguised as form data', async ({
request,
formServer,
}) => {
const response = await request.post(`${formServer.url}/token`, {
data: {
grant_type: 'client_credentials',
scope: 'read',
},
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
});
expect(response.status()).toBe(400);
const error = await response.json();
expect(error).toMatchObject({
error: 'invalid_request',
});
expect(formServer.received[0].rawBody).toBe(
'{"grant_type":"client_credentials","scope":"read"}',
);
});
test('preserves repeated scope fields', async ({
request,
formServer,
}) => {
const fields = new FormData();
fields.append('grant_type', 'client_credentials');
fields.append('scope', 'read');
fields.append('scope', 'write');
const response = await request.post(`${formServer.url}/token`, {
form: fields,
});
expect(response.status()).toBe(200);
expect(
formServer.received[0].fields.filter(([name]) => name === 'scope'),
).toEqual([
['scope', 'read'],
['scope', 'write'],
]);
});These tests cover three different risks. The first proves percent-encoding, including characters that break naive string concatenation. The second demonstrates a dangerous half-fix: changing the header without changing the JSON bytes. The service now chooses its form parser, but that parser sees one malformed key instead of the intended fields. The third proves multiplicity, something a check based on Object.fromEntries() would hide because that conversion keeps only one value per name.
Run this one file while investigating:
npx playwright test tests/api/form-encoding.spec.ts --workers=1 --trace onOne worker makes console and server evidence easier to read. It is a diagnostic setting, not the production fix. The fixture already isolates state correctly, so the same file should also pass with the project's normal worker count.
Diagnose the rejection before changing the request
Start with four facts from the same attempt: final URL, status, response content type, and response body. Then obtain the outgoing request's content type and raw body from a test server, an application access log, a safe proxy capture, or the Playwright trace. Do not print credentials, session cookies, authorization headers, or full production payloads into CI logs. For secrets, record the field name, whether a value was present, and perhaps its length. The literal value is rarely required to prove serialization.
A useful failure attachment looks like this:
POST /oauth/token
request content-type: application/json
request body shape: JSON object
response status: 415
response content-type: application/json
response body: {"error":"unsupported_media_type","expected":"application/x-www-form-urlencoded"}That is strong evidence for an encoding mismatch. The server named the expected media type, and the captured request confirms Playwright sent JSON. Replacing data with form is justified.
This output points elsewhere:
POST /oauth/token
request content-type: application/x-www-form-urlencoded
request body keys: grant_type, client_id, client_secret
response status: 401
response body: {"error":"invalid_client"}The parser received the expected representation. A 401 invalid_client is now more likely to involve credentials, client authentication style, environment data, or policy. Rewriting the body again adds noise and may destroy the evidence you already have.
When a test failure only says Unexpected token '<', inspect the response before blaming JSON parsing. response.json() was probably called on HTML. Playwright follows redirects by default, so an unauthenticated token request can land on an HTML login page and finish with 200. Log response.url(), response.status(), response.headers()['content-type'], and a short redacted prefix from await response.text(). For a focused reproduction, set maxRedirects: 0 on the request so the first redirect remains visible. The cost is that this diagnostic request no longer behaves like the normal client, so remove the override or make the non-redirect contract explicit once the cause is known.
Playwright's trace can preserve the action around request.post(). Record one local attempt with --trace=on, open the generated report with npx playwright show-report, and select the failing test. Check the request action and its response rather than using the screenshot timeline, which has little value for an API-only test. If your Playwright version or proxy setup does not expose the raw body there, the receiving service remains the authority. A server-side capture answers what arrived after redirects and network middleware, not merely what the test intended to send.
Assertion failures can also reveal the serializer. A byte-level check against the wrong request may print something similar to:
Error: expect(received).toContain(expected)
Expected substring: "scope=read+reports"
Received string: "{\"grant_type\":\"client_credentials\",\"scope\":\"read reports\"}"That difference is more useful than increasing a timeout. Encoding is deterministic and happens before the network wait. A longer timeout cannot turn JSON punctuation into form delimiters.
Status progression matters during repair. Moving from 415 to 400 means the server probably selected a different parser, not that the endpoint works. Moving from 400 to 401 can mean the fields are finally readable and authentication is now being evaluated. Only the endpoint's success status plus its promised state or response contract counts as a passing result.
A known-good browser submission is useful as a control, but compare it at the HTTP boundary. Record the method, final URL, content type, ordered field names, decoded values, and status for one synthetic account. Do not compare every browser header with every API client header. Browsers add fetch metadata, origin, user-agent, and cookie headers that an API request may not need. The useful difference is the first one that explains parser or policy behavior. If the browser sends an empty body plus query parameters, or includes a CSRF token obtained from the page, copying only its content type will not reproduce the request. Likewise, copying its cookie header into test code turns a short-lived session into a secret fixture. Use the control to identify the supported contract, then establish authentication through the application's approved mechanism.
Separate encoding bugs from convincing near-misses
Several failures resemble bad form encoding in a short CI log. Treat each as a competing explanation and look for evidence that eliminates it.
A field went into the query string. The request URL contains ?grant_type=client_credentials, but the body is empty. Some frameworks expose query parameters and form fields through similarly named helpers, which can hide the mistake in a local stub. Inspect the raw URL and body separately. In Playwright, params controls URL query parameters while form controls the URL-encoded body.
The contract expects repeated names. A scope or tag is missing even though the content type is right and every visible value is valid. Capture the ordered list of entries, not an object made with Object.fromEntries(). If the server expects scope=read&scope=write, a plain object cannot express the request. Use FormData.append() for each value and assert getAll() or the entry list on the receiving side.
An omitted field became an empty string or a literal false. Form parsers distinguish absence from presence. nickname missing from the body is not the same input as nickname=, and consent=false is not the same input as no consent field. This is especially important when an API imitates HTML form behavior. An unchecked checkbox is normally omitted by a browser form, while a Playwright form object containing consent: false sends a value that the server can read as the string false. Some services treat any present value as selected, which turns an apparently explicit false into true behavior.
Model optional fields conditionally, as the token helper below does for scope. Do not silence TypeScript and pass undefined or null through a cast. Those values are outside the documented flat form value types, and relying on their accidental serialization couples the test to an implementation detail. At the receiving boundary, use URLSearchParams.has('nickname') to prove presence and get('nickname') to inspect the value. get() returns null when the name is absent and an empty string for nickname=, so one assertion can preserve the distinction.
Numbers have a similar trap. A numeric form value is converted for transport, but an identifier such as postal code 00107, employee code 00042, or a fixed-width one-time code is not a number. Store it as a string or the leading zeroes disappear before encoding. Booleans and numbers are convenient for contracts that genuinely define textual true, false, and decimal values. They do not make a form body typed after it reaches the server. The parser still produces strings, and application validation decides what those strings mean.
The endpoint expects HTTP Basic authentication. OAuth servers can accept client credentials in the Authorization header, the form body, or only one of those locations according to their configuration. A correctly encoded body can still return invalid_client when the service requires a header. Compare with the endpoint documentation and a known-good browser or command-line request. Do not duplicate secrets in both places merely to make the test green.
A gateway rewrites or rejects the request. The application log has no request, while an ingress log records 413, 415, or a policy block. Capturing only inside the application will misleadingly show no body at all. Correlate a request ID across the gateway and service. If the gateway changes a header, fix that configuration or the client-gateway contract rather than compensating in each test.
A CSRF check applies to browser sessions. The form reaches the handler, but the response says a token is missing or the origin is forbidden. This is not serialization. An API setup shortcut may be crossing a browser-only security boundary that requires a cookie and matching token. Either establish the supported browser session or use a documented service API intended for automation. Disabling CSRF in the test environment creates coverage that production does not share.
Character encoding corrupts non-ASCII data. ASCII fixtures pass, while names such as Málaga or 東京 fail downstream. Capture the raw bytes and the server's decoded value. URL encoding should carry the UTF-8 bytes as percent escapes, but an older service might decode using another character set. Do not remove international characters from the test data. Agree on the service contract and add one non-ASCII regression case.
A retry repeats a write. The first request succeeds but its response is lost, then a framework or test retry submits the form again. The second attempt receives 409, 400, or an application-specific duplicate error. Form encoding is identical on both attempts. Use a unique operation ID if the API supports idempotency, and retain evidence from attempt zero. Playwright's maxRetries request option only retries ECONNRESET, not arbitrary HTTP statuses, but test-runner retries can rerun the whole test and therefore the whole POST.
The response is not the business result. A token endpoint returning 200 with an empty token, or a waitlist route returning 200 without creating a record, is still broken. Assert the response schema and an observable postcondition when one exists. The transport check proves the server could parse the body. It does not prove that the server applied it correctly.
Roll the fix through an existing API suite
Do not replace every data option with form. Most modern APIs are intentionally JSON, and a mechanical rewrite would exchange one class of defect for another. Build an inventory from the API contract, known-good traffic, or server route configuration. Mark only endpoints that explicitly consume application/x-www-form-urlencoded.
For each marked call, record the current method, URL, content type, field names, duplicate-field behavior, authentication location, and expected status. This is enough to review the migration without copying secret values. If the suite has a request helper, change the narrow endpoint helper rather than every caller. A typed helper makes the encoding choice visible:
import type { APIRequestContext, APIResponse } from '@playwright/test';
type ClientCredentials = {
clientId: string;
clientSecret: string;
scope?: string;
};
export async function requestClientToken(
request: APIRequestContext,
credentials: ClientCredentials,
): Promise<APIResponse> {
const form: Record<string, string> = {
grant_type: 'client_credentials',
client_id: credentials.clientId,
client_secret: credentials.clientSecret,
};
if (credentials.scope !== undefined) {
form.scope = credentials.scope;
}
return request.post('/oauth/token', { form });
}The helper costs a layer of indirection, but it prevents callers from casually switching the body back to JSON. Keep endpoint-specific assertions in tests. A shared helper that asserts every token response is 200 can hide cases intended to test invalid credentials, missing scopes, or locked clients.
Add three contract cases before broad rollout. Use an ordinary valid value, a value containing spaces and reserved characters, and the contract's real multi-value shape if it has one. Keep one deliberate wrong-media-type case against a disposable local stub or a service contract environment. It proves the endpoint rejects a representation that production does not support. Do not send that negative case to a shared production-like identity that could trigger security alerts or lockouts.
Migrate low-risk setup calls first. Run them without test retries and retain the response body on failure after redaction. Once those calls are stable, move tests that create billable, stateful, or externally visible resources. Never compare JSON and form behavior by sending both payloads to a non-idempotent production endpoint. Two requests can create two accounts, payments, messages, or audit records.
During review, reject manual headers paired with object data unless the code deliberately serializes the body itself. This is the half-fix shown in the runnable example. The header tells the server which parser to choose, but it does not transform an existing JSON string.
Some protocols require a canonical field order because the exact body is signed. In that case, explicit serialization can be appropriate:
const body = new URLSearchParams();
body.append('grant_type', 'client_credentials');
body.append('scope', 'read reports');
const response = await request.post('/signed-token', {
data: body.toString(),
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
});This approach gives the test direct control over ordering and bytes. It also makes the team responsible for both serialization and the header, so it deserves a raw-body contract test. Prefer Playwright's form option when the server cares about decoded fields rather than byte-for-byte canonicalization.
Watch CI metrics during the rollout. Count statuses by endpoint and attempt number. A drop in 415 paired with a rise in 400 is unfinished work. A new cluster of 409 on retries points to repeated writes. A stable success status with schema assertion failures may reveal that the old tests stopped at transport success and never checked the response contract.
Know when form encoding is the wrong tool
Do not use URL-encoded forms for file uploads. Files need a contract designed for binary data, commonly multipart/form-data or a direct object-storage upload. Playwright provides the multipart option for multipart requests. Base64-encoding a file into a form field increases payload size and is only correct when the service explicitly defines that format.
Do not flatten a JSON document to satisfy an arbitrary test convention. Nested objects, arrays with meaningful structure, and typed values belong in JSON when the endpoint consumes JSON. Turning { customer: { id: 7 } } into fields such as customer.id=7 invents a protocol. Another client may encode the same structure differently, and the service contract becomes dependent on an undocumented convention.
Do not use an API request as the only proof for a browser form. APIRequestContext is excellent for service contracts and test setup, but it does not exercise the page's input names, disabled controls, submit event, browser validation, or client-side transformation. If the risk is that the checkout page sends the wrong body, submit through the page and inspect the browser request. Keep the API-level test beside it for faster diagnosis, not as a replacement.
Do not force a content type when the server supports content negotiation or chooses a format through a documented client library. Copying a header from one environment can bypass a boundary, omit a required version parameter, or disagree with a signature. Use the documented client behavior and assert what the service actually receives.
Do not log raw form bodies containing passwords, client secrets, reset tokens, personal information, or payment data. URL encoding is transport syntax, not encryption. Anyone who can read the log can decode it. Redaction adds implementation work and sometimes removes the exact character sequence needed for diagnosis. Solve that tension with synthetic credentials in contract tests and structural evidence in shared environments.
The form option also has a maintenance cost. It is concise, but the serializer hides the exact byte sequence until you capture it. Most endpoints benefit from that abstraction because they care about decoded fields. Signed requests, duplicate-name conventions, and legacy character handling may need explicit serialization and stronger byte-level assertions.
Finally, do not treat a successful status as proof that the migration is complete. Assert the response contract, verify any created state, and preserve the one negative case that distinguishes JSON from form data. That extra test costs a few milliseconds against a local server. It prevents a future cleanup from replacing form with the more familiar data option and silently reopening the original defect.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official developer.mozilla.org reference
developer.mozilla.org
Primary documentation selected and verified for the claims in this guide.
- 04Official developer.mozilla.org reference
developer.mozilla.org
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
How do I send application/x-www-form-urlencoded data in Playwright?
Pass a flat object through the `form` option of `request.post()`. Playwright serializes the fields and sets the content type to `application/x-www-form-urlencoded` unless you explicitly override that header.
Why does the browser request work while my Playwright API request gets 415?
The browser form probably sends URL-encoded fields, while an object passed through Playwright's `data` option is serialized as JSON. Compare the two requests' content type and raw body before changing authentication or retry settings.
Does Playwright set the content-type header when I use form?
Yes. The `form` option selects `application/x-www-form-urlencoded` automatically, provided your code has not supplied a different content-type header. Avoid setting the header yourself unless you also own the exact body serialization.
When should I use multipart instead of URL-encoded form data?
Choose `multipart` when the contract includes files or binary parts. Plain text fields can use either format only when the server accepts both, so copy the endpoint contract rather than choosing by convenience.
How can I send the same form field more than once?
Build a `FormData` value and call `append()` for each occurrence, then pass it through the `form` option. A JavaScript object cannot preserve duplicate property names, which matters for contracts such as repeated `scope` or `tag` fields.
RELATED GUIDES
Continue the learning route
GUIDE 01
Test localStorage Directly with the Playwright API
Learn Playwright localStorage API testing through practical setup, failure analysis, CI evidence, security boundaries, and measurable release gates for QA and SDET teams.
GUIDE 02
Test sessionStorage Directly with the Playwright API
A practical guide to Playwright sessionStorage API testing, with implementation examples, debugging workflows, CI evidence, security controls, and release gates.
GUIDE 03
Assert API TLS Security Details with Playwright
Learn Playwright API response TLS security details with working code, failure cases, debugging steps, and CI evidence for reliable QA automation.
GUIDE 04
Deterministic Timer and Date Tests with the Playwright Clock API
Control dates, timers, intervals, and inactivity flows with Playwright Clock API examples that keep time-dependent browser tests deterministic.
GUIDE 05
Use the Playwright Credentials API for Virtual Passkeys
Master Playwright Credentials API virtual authenticator with implementation examples, failure analysis, evidence design, CI controls, and release-ready QA checklists.