# 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.

Source: https://voxgig.com/howto/debug-a-query-string-the-api-rejects

- Audience: api-consumer
- Level: beginner
- Languages: typescript, python
- Verified: 2026-10-21
- Published: 2026-10-20

## 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`](https://curl.se/docs/manpage.html#--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

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [`curl --url-query`](https://curl.se/docs/manpage.html#--url-query) | A reference request built outside the code under suspicion | A second request you maintain by hand, written in form encoding, which matches your API only if it reads form encoding | The fix belongs inside the client, or the API reads `%20` and rejects a plus |
| [OpenAPI `style` and `explode`](https://spec.openapis.org/oas/v3.1.0#style-values) | The API publishes an OpenAPI document and you want the format it declares, not a guess | A document the server does not enforce, where an omitted `style` still means form with repeated keys | The document is missing, or it was written after the server and never tested against it |
| [`qs`](https://github.com/ljharb/qs) | The API reads brackets, indices, commas, or nested objects | A dependency and an `arrayFormat` you keep in step with the API, and its default index format matches no OpenAPI array style | The API reads repeated keys, which `URLSearchParams` writes with `append` |
| [`URLSearchParams`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) | Repeated keys, scalar values, and no room for a dependency | One fixed convention, a space as a plus, and an array inside an object collapses into one comma-joined value | The 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](https://docs.mitmproxy.org/stable/), 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`](https://docs.python.org/3/library/urllib.parse.html#urllib.parse.unquote_plus) has
both: `unquote_plus` turns `+` into a space as form decoding does, and `unquote` leaves it a
plus.[^1]

```py title="decode.py"
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
```

```bash
python3 decode.py 'https://api.example.com/items?ids=1%2C2&since=2026-01-01T10:00:00+01:00&q=a%2520b'
```

```text output
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](https://spec.openapis.org/oas/v3.1.0#parameter-object),
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`.

```json title="openapi.json"
{
  "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.

```js title="build.mjs"
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.

```bash
node demo.mjs
```

```text output
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.

```bash title="reference.sh"
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
bash reference.sh
```

```text output
{"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](https://nodejs.org/api/test.html).

```bash
node --test query.test.mjs
```

```text output
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.

```bash
node demo.mjs
```

```text output
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.

## Related how-tos

- [Reduce a failing SDK call to a minimal reproduction](/howto/minimal-reproduction-of-a-failing-sdk-call)

- [Map API parameters to CLI arguments and flags](/howto/map-api-parameters-to-cli-flags-and-arguments)

- [Check whether a vendor SDK lets you control the transport](/howto/check-transport-control-in-a-vendor-sdk)

## 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.

[^1]: In form encoding, a plus sign has meant a space since
[RFC 1866](https://www.rfc-editor.org/rfc/rfc1866.html#section-8.2.1), the HTML 2.0 specification
of November 1995, which replaces space characters with `+` before escaping the rest.
[RFC 3986](https://www.rfc-editor.org/rfc/rfc3986.html#section-2.2) lists `+` among its reserved
sub-delimiters, and a URI decoder following it leaves a plus as a plus. The
[URL Standard](https://url.spec.whatwg.org/#application/x-www-form-urlencoded) kept the form
convention, and describes the format as "an aberrant monstrosity, the result of many years of
implementation accidents and compromises."

[^2]: PHP reads the bracket form natively. Its manual page on
[variables from external sources](https://www.php.net/manual/en/language.variables.external.php)
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`.

[^3]: The reference request carries `%3a` and `%2b` where `URLSearchParams` wrote `%3A` and `%2B`.
[RFC 3986](https://www.rfc-editor.org/rfc/rfc3986.html#section-2.1) 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.