How-to › Operate integrations in production

How to debug a query string the API rejects#

Capture the query string your client sent, decode it, and compare it with the OpenAPI style and explode rules, so a 400 points at the one encoding fix it needs.

Audience
API consumer
Level
beginner
Topic
Debug an integration
Languages
TypeScript and Python
Verified

Your call returns 400 Bad Request, but the same call copied from the API’s documentation works. The difference sits in bytes you never look at. An array goes out as ids=1,2 where the server reads ids=1&ids=2, or a + in a timestamp offset arrives as a space. Sometimes your code encoded a value and the SDK encoded it again into %2520.

What you get

You will end up with the failing query string decoded, the encoder setting the API’s OpenAPI document calls for, and a test covering each broken form. This is for you if a call fails with 400 while its parameters look right.

Short answer

Decode the query string your client put on the wire with Python’s urllib.parse, then compare each parameter with the style and explode that the API’s OpenAPI document declares. Three mismatches recur: an array sent as ids=1,2 instead of repeated keys, a raw + read as a space, and a value encoded twice. Send a known-good request with curl --url-query and match your client’s string to it.

You will need

Node 22 or later, Python 3, curl 7.87 or later for its --url-query option, and the OpenAPI document for the API you call. Verified 2026-10-21 against Node 22.22.0, Python 3.13.16, curl 8.5.0 and qs 6.16.1. The API in the sample is a stand-in written with Python’s standard library. It decodes a query the way form-reading servers do and names the parameter it rejects, which a real API often declines to do. No request leaves the machine.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
curl --url-queryA reference request built outside the code under suspicionA second request you maintain by hand, written in form encoding, which matches your API only if it reads form encodingThe fix belongs inside the client, or the API reads %20 and rejects a plus
OpenAPI style and explodeThe API publishes an OpenAPI document and you want the format it declares, not a guessA document the server does not enforce, where an omitted style still means form with repeated keysThe document is missing, or it was written after the server and never tested against it
qsThe API reads brackets, indices, commas, or nested objectsA dependency and an arrayFormat you keep in step with the API, and its default index format matches no OpenAPI array styleThe API reads repeated keys, which URLSearchParams writes with append
URLSearchParamsRepeated keys, scalar values, and no room for a dependencyOne fixed convention, a space as a plus, and an array inside an object collapses into one comma-joined valueThe API wants brackets, commas, or %20 for a space

URLSearchParams is the standard encoder with one convention: repeated keys if you append per value, a plus for a space, and a comma join for an array inside an object. qs is configurable, so the array format becomes a setting you keep in step with the API, and its default writes indices. Read the format from the OpenAPI document rather than from either library’s default, and keep a curl request as an independent reference.

Decode the target the server received

The request target is the path and query as they crossed the wire, before any framework decoded a byte of them. Capture it with a proxy such as mitmproxy, from your SDK’s debug log, or from the provider’s request log if it shows one. The stand-in API in the sample echoes it in every reply.

Decode it twice, once as form data and once as RFC 3986 percent-encoding. Python’s urllib.parse has both: unquote_plus turns + into a space as form decoding does, and unquote leaves it a plus.1

def inspect(query: str) -> list[str]:
    lines = [f"query on the wire: {query}"]
    for pair in query.split("&"):
        name, _, raw = pair.partition("=")
        form = unquote_plus(raw)  # what a form-encoding server reads: + is a space
        strict = unquote(raw)  # what RFC 3986 says: + stays a plus sign
        lines.append(f"{unquote(name):6} form {form!r:32} rfc3986 {strict!r}")
        if form != strict:
            lines.append("       note: a raw + in the value; form decoding reads it as a space")
        if ESCAPE.search(strict):
            lines.append("       note: encoded twice; one decode still leaves a %XX escape")
        if "," in strict:
            lines.append("       note: one value with a comma; style form with explode true wants repeated keys")
        if "[" in unquote(name):
            lines.append("       note: a bracket in the name; that is qs or PHP array syntax")
    return lines
python3 decode.py 'https://api.example.com/items?ids=1%2C2&since=2026-01-01T10:00:00+01:00&q=a%2520b'
query on the wire: ids=1%2C2&since=2026-01-01T10:00:00+01:00&q=a%2520b
ids    form '1,2'                            rfc3986 '1,2'
       note: one value with a comma; style form with explode true wants repeated keys
since  form '2026-01-01T10:00:00 01:00'      rfc3986 '2026-01-01T10:00:00+01:00'
       note: a raw + in the value; form decoding reads it as a space
q      form 'a%20b'                          rfc3986 'a%20b'
       note: encoded twice; one decode still leaves a %XX escape

Each note names one mismatch. The two decoded values of since differ, so the plus sign in its offset arrives as a space wherever the server reads form encoding. The q value still holds %20 after one decode, which means something encoded it twice. The ids value is one string with a comma in it, not two values.

Read the array format from the OpenAPI parameter

The OpenAPI parameter object says how an array goes into a query. Its style defaults to form for a query parameter, and explode defaults to true when the style is form. A parameter that sets neither field therefore means repeated keys, ids=1&ids=2. Setting explode to false means one comma-joined value instead, ids=1,2.

{
  "name": "ids",
  "in": "query",
  "style": "form",
  "explode": true,
  "schema": { "type": "array", "items": { "type": "integer" } }
},

Translate that into the encoder you use. arrayFormatFor maps the parameter object to a qs setting and refuses the styles qs cannot write.

export function arrayFormatFor(param) {
  const style = param.style ?? 'form'
  const explode = param.explode ?? style === 'form'
  if (style === 'form') return explode ? 'repeat' : 'comma'
  // spaceDelimited and pipeDelimited join values with %20 or |, which qs does
  // not write, and deepObject describes an object rather than an array.
  throw new Error(`style ${style} has no qs arrayFormat; build that parameter yourself`)
}

For explode: false, pass encodeValuesOnly: true beside the comma format. Without it qs writes the commas between values as %2C, the same bytes as a comma inside a value, so ids=1,2 goes out as ids=1%2C2.

URLSearchParams has no setting to choose. It writes repeated keys when you call append once per value, which is the only array format it has.

Send each encoder’s string to the same API

The demo builds one intent eight ways and sends each string through fetch to the stand-in API. The API reports the target it received and its verdict.

node demo.mjs
URLSearchParams from an object
  wire   /items?ids=1%2C2&since=2026-01-01T10%3A00%3A00%2B01%3A00&q=a+b
  reply  400 ids: one value containing a comma; send ids=1&ids=2
URLSearchParams, append per value
  wire   /items?ids=1&ids=2&since=2026-01-01T10%3A00%3A00%2B01%3A00&q=a+b
  reply  200
qs, arrayFormat indices (the default)
  wire   /items?ids%5B0%5D=1&ids%5B1%5D=2&since=2026-01-01T10%3A00%3A00%2B01%3A00&q=a%20b
  reply  400 ids[0]: unknown parameter ids[0]; the API reads ids, since and q
qs, arrayFormat brackets
  wire   /items?ids%5B%5D=1&ids%5B%5D=2&since=2026-01-01T10%3A00%3A00%2B01%3A00&q=a%20b
  reply  400 ids[]: unknown parameter ids[]; the API reads ids, since and q
qs, arrayFormat comma
  wire   /items?ids=1,2&since=2026-01-01T10%3A00%3A00%2B01%3A00&q=a%20b
  reply  400 ids: one value containing a comma; send ids=1&ids=2
qs, arrayFormat repeat (from the OpenAPI parameter)
  wire   /items?ids=1&ids=2&since=2026-01-01T10%3A00%3A00%2B01%3A00&q=a%20b
  reply  200
hand-built, plus sign left raw
  wire   /items?ids=1&ids=2&since=2026-01-01T10:00:00+01:00&q=a%20b
  reply  400 since: not an RFC 3339 timestamp; a plus sign decoded as a space, send %2B
encoded by the caller, then again at the boundary
  wire   /items?ids=1&ids=2&since=2026-01-01T10%3A00%3A00%2B01%3A00&q=a%2520b
  reply  400 q: encoded twice: one decode still leaves a %XX escape

The two variants that pass both write repeated keys. URLSearchParams built from an object sent the single value 1,2, because it reads each value as a string, and an array’s string form joins its items with commas. qs wrote ids[0] by default and ids[] in its bracket format, names this API has never heard of.2 The comma format is what explode: false would mean, which this API did not declare.

The hand-built string kept the plus sign raw, and the server read the offset as a space followed by 01:00. Both library encoders write %2B, which is the fix for a string assembled by hand.

Compare against a request curl encoded

A reference request encoded by a tool outside your code settles whether the client or the API is wrong. curl --url-query URL-encodes the value of each name=value pair and appends the pair to the query, so the command states the intent and curl does the encoding.

curl -sS --url-query ids=1 --url-query ids=2 \
  --url-query 'since=2026-01-01T10:00:00+01:00' --url-query 'q=a b' \
  "http://127.0.0.1:$port/items"

The script starts the same stand-in API, sends that request, and prints the reply.

bash reference.sh
{"target": "/items?ids=1&ids=2&since=2026-01-01T10%3a00%3a00%2b01%3a00&q=a+b", "ids": [1, 2], "since": "2026-01-01T10:00:00+01:00", "q": "a b"}

The API accepted it, and its target matches the URLSearchParams string that passed, apart from the case of the hex digits.3 Once your client’s string decodes to the same pairs as curl’s, the query is no longer the problem. Send the corrected call through the SDK as well, because a fix in a handwritten test does not prove the SDK builds the same string.

Check it worked

The test suite sends every broken form and both correct ones to the stand-in API, and pins the parameter and value each rejection names. Run it under the Node test runner.

node --test query.test.mjs
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 247.637569

The first test is the one to keep: repeated keys from append and from the qs format repeat both get 200, with ids read as [1, 2]. The sixth test pins the mapping from the OpenAPI parameter object, including the defaults when style and explode are left out. The seventh holds the comma format to ids=1,2, with a comma inside a value still encoded.

When it goes wrong

The fix made it worse. The API rejected q, so you encoded the value yourself, and the SDK encoded it again: a b became a%20b in your code and a%2520b on the wire. One decode on the server leaves a%20b, which the API rejects.

node demo.mjs
encoded by the caller, then again at the boundary
  wire   /items?ids=1&ids=2&since=2026-01-01T10%3A00%3A00%2B01%3A00&q=a%2520b
  reply  400 q: encoded twice: one decode still leaves a %XX escape

Pass raw values to whatever builds the URL, and encode exactly once, at that boundary. encodedTwice in build.mjs reproduces the mistake in one line.

A timestamp with a positive offset fails while Z and negative offsets pass. Form decoding turns the raw + into a space and leaves - alone. Encode the value with a library encoder, which writes %2B, or send times in UTC with Z.

The API answers 200 with every item when you asked for two. A lenient API ignores a parameter name it does not know, so ids[]=1&ids[]=2 filters nothing. Check the decoded names against the parameter names in the OpenAPI document, and assert on the count you asked for rather than on the status.

When not to do this

Do not add qs to send repeated keys. URLSearchParams with one append per value writes them with no dependency, and Node and the browsers ship it as a global.

Do not encode a value before you hand it to an SDK, a URL object, or any builder that encodes. Encode once, at the boundary where the string is assembled, and let everything inside it handle raw values.

Do not fix the client when the OpenAPI document is wrong. If the server reads ids=1,2 but the document declares form with repeated keys, every client generated from that document inherits the 400. Fix the document, or ask the provider to.

Do not debug the query string before reading the error body. An API that names the parameter in its 400 response, as the stand-in here does, has already told you which parameter to look at.

Last verified

Verified 2026-10-21 against Node 22.22.0, Python 3.13.16, curl 8.5.0 and qs 6.16.1. Every output block is what the command preceding it printed. The API is a stand-in on the loopback interface, so no request reached a real API. The capture step names mitmproxy without running it.

Footnotes

  1. In form encoding, a plus sign has meant a space since RFC 1866, the HTML 2.0 specification of November 1995, which replaces space characters with + before escaping the rest. RFC 3986 lists + among its reserved sub-delimiters, and a URI decoder following it leaves a plus as a plus. The URL Standard kept the form convention, and describes the format as “an aberrant monstrosity, the result of many years of implementation accidents and compromises.” ↩︎ Back to text

  2. PHP reads the bracket form natively. Its manual page on variables from external sources shows a form field named beer[] arriving as an array. The same page records that dots and spaces in variable names are converted to underscores, so a parameter named a.b arrives in the script as a_b. ↩︎ Back to text

  3. The reference request carries %3a and %2b where URLSearchParams wrote %3A and %2B. RFC 3986 declares uppercase and lowercase hexadecimal digits equivalent, and asks producers to use uppercase for consistency. The lowercase from curl means the same octets, so a test comparing the two strings byte for byte fails on a difference the specification does not recognize. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.