Text Tools

JSON or Plain Text? Reading an API Response Correctly

You call an API expecting a tidy object, and what comes back is this:

OK|2122988149

No braces, no quotes, no keys. Just a word, a pipe, and a number — and an HTTP status of 200, which tells you nothing about whether anything actually worked. The key takeaway: the shape of a response and the success of a request are two separate questions, and most broken integrations come from answering the first one by assumption instead of by inspection. Decide the format explicitly, read the application-level status separately from the HTTP status, and write a parser that fails loudly instead of quietly returning nonsense.

Here's why bare strings still exist, what a status flag really promises, and how to parse both shapes so a change on the server side doesn't take your worker down at 3 a.m.

Two shapes for exactly the same answer

Take one operation — submitting a job and getting back its ID — and look at it in both dialects.

Plain text:

OK|2122988149

JSON:

{"status": 1, "request": "2122988149"}

Same information, three differences worth naming:

Plain text JSON
Structure positional — meaning comes from where a value sits named — meaning comes from the key
Parsing split on a delimiter you have to know in advance one json.loads(), structure guaranteed
Failure mode silent: a wrong split still returns something loud: malformed input raises immediately

That last row is the whole argument. A JSON parser refuses to guess. A split("|") happily hands you an empty string, a fragment, or an error message that your code then stores as if it were an ID.

Why older APIs still return bare strings

This isn't laziness, and it usually isn't age alone. Three real reasons keep the plain-text shape alive:

They predate JSON as a default. Many service APIs were designed in the CGI era, when a request was form-encoded and a response was whatever the script printed. print "OK|" + job_id was the entire serialisation layer. JSON only became the assumed web-API format later; by then these endpoints had users.

Bare strings are parseable from anywhere. A shell script with curl and cut, an embedded device, a macro in an old automation tool — none of them need a JSON library to read OK|2122988149. For an API whose callers are scripts rather than applications, that's a genuine feature.

The response format is part of the contract. Once thousands of integrations split on that pipe, changing the default output breaks all of them at once. So the sane move is what most of these services did: keep the legacy string as the default, and add a flag that opts you into JSON. The old callers keep working; new callers ask for structure.

You'll meet this pattern across long-lived infrastructure APIs. A current example is CaptchaAI, whose solving API deliberately speaks the widely-cloned legacy protocol so existing tooling works unchanged: you POST a task to /in.php on ocr.captchaai.com with your 32-character key, then poll /res.php?action=get&id=<taskId>. Send it as-is and you get plain text; add json=1 and you get an object instead. One parameter, two dialects — a clean specimen for everything below.

What a status flag actually tells you

There are up to three layers of "did it work?" in a single response, and they answer different questions.

Layer 1 — the HTTP status

This is about transport: did the request reach the application and come back? A 200 OK means the server received you and produced a body. It does not mean the operation succeeded. Plenty of APIs — especially the older ones — return 200 with an error string in the body, because from the web server's point of view, successfully printing "you have no balance" is a success. Treat a 2xx as "now go read the body", never as "done".

Layer 2 — the envelope status

This is the status field in the JSON shape, or the OK prefix in the text shape. It's about whether the request was understood, and it is usually binary — which is exactly where people over-read it.

In the legacy protocol above, status: 1 means "accepted / here's your value" and status: 0 means "not a value". But 0 covers two completely different situations:

{"status": 0, "request": "CAPCHA_NOT_READY"}
{"status": 0, "request": "ERROR_ZERO_BALANCE"}

The first is retry in a moment. The second is stop, a human must act. If your code branches on status alone, it treats a funding problem as a timing problem and spins forever. The flag tells you which field to read next — not what to do.

Layer 3 — the payload

The actual value, or the actual reason. Documented reasons in this protocol include CAPCHA_NOT_READY (still working — note the historical spelling, which you must match exactly), ERROR_UNSOLVABLE (this task can't be completed) and ERROR_ZERO_BALANCE (account out of funds). Only after reading this layer do you know whether to wait, stop, or alert.

The general rule: a status flag routes you; the payload decides you.

Content-Type is a hint, not a promise

The obvious idea is to branch on the Content-Type header. Do use it — but don't trust it alone. Legacy endpoints frequently return text/html for a JSON body, or text/plain for everything regardless of what you asked for, because the header was set once in a config file years ago and nobody revisited it.

A more reliable check is cheap: strip the body, and if the first character is { or [, try JSON. Use the header as a tiebreaker. Better still, when the API offers an explicit format parameter, set it every time rather than relying on a default someone else controls.

A parser that reads both shapes

Here's the whole idea in about fifteen lines of Python. It normalises either dialect into the same (ok, value) pair, so the rest of your code never has to care which one arrived.

import json

def read_response(body, content_type=""):
    """Return (ok, value) from either a JSON or legacy plain-text body."""
    body = (body or "").strip()
    if not body:
        return False, "ERROR_EMPTY_BODY"

    # Sniff the payload itself; the header is only a tiebreaker.
    if body[:1] in "{[" or "json" in content_type.lower():
        data = json.loads(body)          # raises on malformed input — good
        return data.get("status") == 1, str(data.get("request", ""))

    if "|" in body:
        head, _, rest = body.partition("|")
        return head.strip() == "OK", rest.strip()

    # A bare token with no delimiter is an error code, not a value.
    return False, body

Four things that code does on purpose:

  1. It refuses an empty body instead of returning an empty success.
  2. It lets json.loads raise. Don't catch a malformed JSON body and fall through to string-splitting — that turns a loud failure into a silent one.
  3. It uses partition, not split. partition always returns three parts, so there's no index error and no lost value containing a pipe.
  4. It treats an undelimited token as failure. ERROR_UNSOLVABLE has no pipe; defaulting that to "success" is exactly the bug this article exists to prevent.

Then classify — with an explicit unknown

The parser answers what came back. A separate, tiny table answers what to do:

PENDING = {"CAPCHA_NOT_READY"}
FATAL   = {"ERROR_ZERO_BALANCE", "ERROR_UNSOLVABLE"}

def decide(ok, value):
    if ok:
        return "done"
    if value in PENDING:
        return "wait"
    if value in FATAL:
        return "stop"
    return "unknown"          # never silently equals "wait"

The unknown branch is the one that saves you. APIs gain new error codes; if unknown ones fall into your retry bucket, a brand-new fatal error becomes an infinite poll loop. Give unknown a bounded retry count, then a real alert with the raw body attached.

And poll on the documented cadence

import time

deadline = time.monotonic() + 120          # give up eventually
while time.monotonic() < deadline:
    ok, value = read_response(*fetch_result(task_id))
    action = decide(ok, value)
    if action != "wait":
        break
    time.sleep(5)                          # documented ~5s poll interval

Honour the interval the vendor documents — CaptchaAI's docs describe polling roughly every 5 seconds until CAPCHA_NOT_READY stops — and always set a deadline, because "wait forever" is not an error-handling strategy. The same courtesy applies to whatever site your automation touches: respect its robots.txt, its rate limits and its terms of service. Solving a CAPTCHA is a legitimate step in QA automation, accessibility testing, uptime monitoring or lawful data collection you're permitted to run — never a licence to hammer someone's server.

Log the raw body, always

One habit outranks every parsing trick: store the first few hundred characters of the raw response whenever parsing fails — not the exception, not your summary, the actual bytes. When a provider flips a default, adds a field, or starts wrapping the payload, the raw body tells you in ten seconds what a stack trace won't tell you in an hour. It's also the fastest way to spot the classic impostors: an HTML error page from a proxy, a rate-limit notice, or a maintenance banner — all of which arrive with a 200 and none of which are your API speaking.

Pretty-printing that captured string is usually the first move when you're staring at one long unbroken line. For more on the formats themselves, our text tools guide walks through JSON, CSV and YAML side by side, including the trailing-comma and quoting rules that break the most copied snippets.

The short version

  • HTTP 200 means delivered, not succeeded. Read the body.
  • A status flag routes you; the payload decides you. 0 can mean "wait" or "stop", and only the message says which.
  • Set the format parameter explicitly (json=1 above) rather than depending on a default someone else controls.
  • Sniff the payload's first character; use Content-Type only as a tiebreaker.
  • Let malformed JSON raise. Never fall back to string-splitting.
  • Give unknown codes their own branch, bounded retries and an alert.
  • Log the raw body on every parse failure.

Do that, and the day the format changes you get one clean alert with the evidence attached — instead of a queue full of jobs marked "complete" that contain the string ERROR_UNSOLVABLE.

FAQ

Is a plain-text API response a sign of a badly built API? No. It usually means the API is old enough to predate JSON as the default and has kept backwards compatibility for its existing callers. A bare string is harder to parse safely, but the format itself says nothing about the reliability of the service behind it. Check whether a JSON mode is available and opt into it.

If the response is JSON, do I still need to check a status field? Yes. JSON guarantees the structure of the reply, not its meaning. A perfectly valid object can still say {"status": 0, "request": "ERROR_ZERO_BALANCE"}. Parsing succeeds; the operation failed. They're independent checks.

What should my code do with an error code it doesn't recognise? Treat it as its own state — not as "retry" and not as "success". Retry a small fixed number of times in case it's transient, then stop and raise an alert that includes the raw response. Folding unknown codes into the retry path is how workers end up looping forever on a brand-new fatal error.

Why do some error strings look misspelled, like CAPCHA_NOT_READY? Because a typo that ships becomes part of the contract. Once callers compare against that exact string, correcting the spelling would break every one of them. Match the documented string character for character; don't "fix" it in your comparison.

Try it on your own responses

Grab the next raw response your integration logs, paste it in, and see the structure — format and inspect JSON free at medley-web.com, alongside the encoding, case and slug tools Medley Web is building for exactly this kind of five-second check.

And if the API you're wiring up is a solving endpoint, the two-dialect protocol described here is the one CaptchaAI speaks — plain text by default, structured JSON with json=1, documented error codes for both. Pin the format, read the payload, and your worker will keep its footing when the shape changes.

Comments are disabled for this article.