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:
- It refuses an empty body instead of returning an empty success.
- It lets
json.loadsraise. Don't catch a malformed JSON body and fall through to string-splitting — that turns a loud failure into a silent one. - It uses
partition, notsplit.partitionalways returns three parts, so there's no index error and no lost value containing a pipe. - It treats an undelimited token as failure.
ERROR_UNSOLVABLEhas 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.
0can mean "wait" or "stop", and only the message says which. - Set the format parameter explicitly (
json=1above) rather than depending on a default someone else controls. - Sniff the payload's first character; use
Content-Typeonly 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.