Skip to content

Blog ·

The panel API said 200. Nothing happened.

Your integration reports success and nothing happened. Six hosting control panel APIs return HTTP 200 on failure. Measured, with the field to read instead.

By Provibr

Here is a failure mode that has no error in it anywhere.

You call the panel to suspend an account. You get back HTTP 200. Your code takes the 2xx as confirmation, writes suspended to your own database, and moves on. The account is still running. Nobody gets an alert, because from your side nothing went wrong.

We hit this while building integrations for eight different hosting and game panels. It is not a DirectAdmin quirk. It is the house style of an entire generation of control panel APIs, and each one puts the real answer in a different field.

The version that actually costs you money

Start with the worst shape of it, because it is the one that turns a silent no-op into data loss.

Plesk's XML API returns HTTP 200 for an authentication failure. A wrong or expired API key does not produce a 401; it produces a 200 OK whose body contains <errcode>11003</errcode>.

Now think about what a sync loop does with that. It asks for the list of subscriptions. It gets a 200. It parses the body, finds no <webspace> elements, and concludes: this server has zero accounts. If your reconciler is written the obvious way, it now marks every account that customer has as deleted.

> Measured against Plesk Obsidian 18.0.80 (build 2026-08-13) on 17 August 2026.

An authentication failure that reads as a successful empty result is the most expensive possible version of this bug, and there is no way to detect it from the status code alone.

What each panel actually does

Everything below is a real call against a real installation, not a reading of the documentation.

DirectAdmin 1.697. The legacy CMD_API_* endpoints answer in URL-encoded key/value pairs, not JSON, even when you send Accept: application/json. Failure looks like this, with HTTP 200:

text
error=1&text=Cannot+Create+User&details=Username+is+in+use

error=0 is success. The status code is 200 either way.

WHM / cPanel 11.136.0.33. JSON, with the verdict in metadata.result:

json
{"data": {}, "metadata": {"result": 0, "reason": "User does not exist", "command": "accountsummary", "version": 1}}

Every normal API failure we triggered (unknown user, duplicate account, unknown package) came back as HTTP 200 with result: 0.

There is a second shape. A bad or missing token returns a completely different envelope with no metadata at all, and this one does use a status code:

json
{"cpanelresult":{"apiversion":"2","error":"Access denied",
  "data":{"reason":"Access denied","result":"0"},"type":"text"}}

That arrives as 403. So a parser that only knows metadata cannot tell a rejected token from a broken server: it just fails to parse, twice, for two very different reasons.

Two more traps in the same responses: metadata.version came back as the number 1 in normal replies and the string "1" in the unknown-function error, and result is 1/0 as a number in one shape and "0" as a string in the other. Type them leniently or not at all. The response you need most in order to understand what broke is exactly the one that will blow up a strict deserializer.

One more thing worth knowing before you pipe responses into a log aggregator: createacct answers with roughly 12 kB of HTML creation log in metadata.output.raw, and that log echoes the account password you just set in plain text. Redact the response body before you store it.

CyberPanel 3.0.2. HTTP 200, and the success field is named differently per endpoint:

EndpointSuccess field
verifyConnverifyConn
createWebsitecreateWebSiteStatus (note the capital S in WebSite)
deleteWebsitewebsiteDeleteStatus
submitWebsiteStatuswebsiteStatus
changePackageAPIchangePackage
cyberPanelVersiongetVersion

An authorization failure on changePackageAPI returned HTTP 200 with "Could not authorize access to API." That is an authorization error delivered with a success status.

Virtualmin (Webmin remote.cgi, GPL installer 8.1.2). HTTP 200, and there are two failure shapes:

json
{ "status": "error",   "error": "Remote program xyz does not exist" }
{ "status": "failure", "error": "Virtual server x.example.com does not exist", "full_error": "…" }

error is wrapper-level (the program does not exist). failure is program-level (the program ran and refused). Testing for one lets the other through. The only safe rule is: anything that is not exactly "success" is a failure.

Note that full_error on the second shape contains the program's entire --help output, hundreds of characters. Do not put that in the message your customer sees.

The summary table

PanelVersionMeasuredFailure on the wireRead this
DirectAdmin1.6972026-08-01200error=1
WHM / cPanel11.136.0.332026-08-17200 (403 for a bad token)metadata.result
Plesk XML APIObsidian 18.0.802026-08-17200, including auth failure<errcode>
VirtualminGPL installer 8.1.22026-08-17200status != "success"
CyberPanel3.0.22026-08-18200varies per endpoint
HestiaCP1.10.32026-08-18status codes (see below)exit code header
PufferPanel3.0.92026-08-19status codesstatus code
GameAP4.4.22026-08-19status codesstatus code

Two panels get it right

PufferPanel 3.0.9 uses one envelope and means what the status code says:

json
{"error": {"msg": "UNIQUE constraint failed: servers.identifier", "code": "ErrGeneric"}}

The status code is the verdict; msg is a quote you show the operator, not something you branch on. GameAP 4.4.2 does the same thing, returning 422 for validation, 404 for a missing resource, and 500 where it has nothing better to say.

If you are picking a panel to integrate against and you get a vote, this matters more than it sounds like it should.

The inverse trap: HestiaCP

HestiaCP does use HTTP status codes, and that is precisely why it catches people out. It maps twenty internal exit codes onto four status codes, and the compression loses information in both directions:

  • A successful mutation returns 204, not 200. Every v-add-user, v-suspend-user, v-delete-user, v-add-web-domain and v-change-user-package we ran came back as 204 with an empty body. A client that accepts only 200 rejects every successful write.
  • 401 means four different things: wrong secret (exit 9), insufficient scope (10), IP not allowlisted (10), and the one that catches everybody, "user is already suspended" (exit 5). A client that reads 401 as "credentials are wrong" will tell you your key expired when all you did was suspend an account twice.

The exit code in the response header is the contract. The status code is derived from it, lossily.

> Measured against HestiaCP 1.10.3 on Ubuntu 24.04 LTS, 18 August 2026.

The rules we ended up with

  1. Never treat 2xx as success. Parse the body, find the panel's own verdict field, and branch on that. Write it once per provider, in one place.
  2. Make "I could not determine the outcome" a distinct state. It is not success and it is not failure. Anything else means a parse failure silently becomes one of the two, and you do not get to choose which.
  3. Never let an empty list drive deletion on its own. Require a positively-verified successful response before you reconcile anything downward. This single rule would have caught the Plesk auth case before it touched a row.
  4. Deserialize error responses leniently. They are the least-tested code path on the panel's side, and it shows: number-or-string fields, missing envelopes, different shapes for different failure classes.
  5. Read the body back after a mutation. DirectAdmin's unsuspend confirmation literally says "have been suspended", the same sentence it uses for suspend, in both directions. Confirmation text is not a state observation.

What we did not test

These measurements are single-version snapshots. We have not verified DirectAdmin below 1.697, Plesk below Obsidian 18.0.80, or CyberPanel below 3.0.2, and we have not re-measured any of them since the dates in the table. Panel vendors do change error conventions between minor releases. One of the panels above changed a variable's rules field from a string to an array in a point release, which is exactly the kind of change that breaks a strict parser. Re-measure before you trust a version you have not seen.

We also did not test rate limiting on any of them. That takes hundreds of requests against someone's production panel and we were not willing to do it.

All of this came out of building Provibr's panel integrations, where a command that silently does nothing is worse than one that fails loudly. If you want the same integrations without writing the parsers, that is what we sell.

Start with one host and one agent

Create an account, install the agent with a single command and connect your first Proxmox host. The trial runs for 14 days and asks for no payment details.