Errors
Every error response uses one envelope:
{
"error": "You do not have permission to perform this action.",
"type": "permission_error",
"code": "permission_denied",
"docUrl": "https://docs.zhyper.dev/errors/permission_denied",
"requestId": "req_0123456789abcdef"
}
Those five fields are always there. Two more appear only when they have
something to say, and they are absent, not null, when they do not:
{
"error": "state is required and must be a non-empty string",
"type": "invalid_request_error",
"code": "invalid_state",
"param": "state",
"docUrl": "https://docs.zhyper.dev/errors/invalid_state",
"requestId": "req_0123456789abcdef"
}
param names the offending field. details carries structured context when a
single param cannot say enough — a partially applied ad chain returns what it
already created there, for instance. Read both with a presence check
('param' in error), not by comparing against null.
Branch on code. It is the stable field. error is written for humans and
may be reworded; type is a coarse family.
Include requestId in any support request — it is how we find the exact call.
Types
type | Typical status | Meaning |
|---|---|---|
invalid_request_error | 400 | The request itself is malformed |
authentication_error | 401 | Missing or unusable credential |
permission_error | 403 | Authenticated, but not allowed |
not_found | 404, 410 | No such resource, or no longer valid |
conflict | 409 | State collision |
rate_limit_error | 429 | Too many requests |
platform_error | 502 | The social platform failed |
platform_rejected | 422 | The platform refused the content |
account_disconnected | 409 | Reconnect the account |
media_error | 422 | The media could not be used |
api_error | 500 | Our fault |
400 and 422 answer different questions
These are the two rows in the table above that are most often confused, because both of them mean "this did not go out". The line between them:
400 invalid_request_error— the request was malformed. Something you sent is wrong and the fix is to change it. Sending the identical request again fails the identical way.422 platform_rejected— the request was well formed, and the platform applied one of its own rules to it. Duplicate content, a playlist that is already full, a privacy level the account is not allowed to use. The fix is a decision rather than a correction, and it is often not yours to make.
This is a breaking change on three platforms. YouTube, TikTok and Snapchat
used to report a bare 400 from the platform as platform_rejected with status
422. They now report it as invalid_request_error with status 400. If your
code branches on 422, or on type === "platform_rejected", for any of those
three, the same input now takes the other branch.
The code field did not change. The platform event is the same event; what
changed is who the error says is at fault. Code that branches on code — the
field this page tells you to branch on — is unaffected.
422 did not go away, and reading this change as "422 is gone" would cost
you real error handling. It still carries every rejection the platform states as
a rule:
- A refusal the platform gives a reason for keeps
422. On TikTok that is the large majority of them: an unaudited client posting to a public account, a privacy-level mismatch, an unverified URL, spam risk, the active user cap. - YouTube keeps
422for the "the video went up, but ..." family — a playlist insert or a first comment that failed after the upload succeeded. A400there reads as "fix it and try again", and trying again uploads a second video. - WhatsApp keeps
422on the publishing path. - On Snapchat the only remaining route to
422is Snapchat's own422.
Platforms other than those three were not part of this change, and the split is
not yet uniform across all of them: Bluesky, Mastodon and Telegram still report
some bare platform 400s as 422. Branch on code, and treat the two families
above as the intent rather than as a guarantee that already holds everywhere.
A provider 404 now says not_found
Three media paths used to answer a provider "this asset is gone" with something
other than not_found, and all three were corrected on 2026-08-31. This one
does change a code, so it is spelled out rather than left to be discovered.
| Where | Was | Is |
|---|---|---|
LinkedIn image, document and video upload (initialize, upload, status) | platform_error, 502, code linkedin_<kind>_<operation>_unavailable | not_found, 404, code linkedin_<kind>_<operation>_not_found |
getWhatsAppMedia | not_found, but HTTP 400 | not_found, HTTP 404 |
<kind> is image, document or video; <operation> is initialize,
upload or status. The LinkedIn mappers run on the publishing path, so you
normally meet these on a failed target's errorMessage and in the webhook that
reports it rather than as the answer to a synchronous call. The code and the
status both moved, so code branching on linkedin_image_upload_unavailable will
no longer match a deleted or expired asset — it still matches the 409 and
5xx cases it always covered. On WhatsApp only the status moved;
whatsapp_media_not_found is the same code it always was, and
getWhatsAppMedia does answer it synchronously.
The reason is the same in both places and it is not cosmetic. platform_error
is a retryable family and not_found is not, so a permanently gone asset was
being retried with backoff until the attempt budget ran out. And on WhatsApp,
media expires after roughly a day, which makes this a routine outcome rather
than an edge case — answering 400 told the caller their request was malformed
when the truth was that the thing they asked for no longer exists.
Handling with the SDK
export interface FailureReport {
status: number;
code: string;
param: string | undefined;
reachedServer: boolean;
}
export async function reportFailure(
zhyper: ZhyperClient,
run: () => Promise<unknown>,
): Promise<FailureReport | null> {
try {
await run();
return null;
} catch (error) {
if (error instanceof ZhyperApiError) {
// Sunucu konustu ve istegi reddetti. `code` dallanmak icin kararli
// alandir; `error.message` insan icindir ve degisebilir.
return {
status: error.status,
code: error.code,
param: error.param,
reachedServer: true,
};
}
if (error instanceof ZhyperConnectionError) {
// Sunucuya HIC ulasilamadi. Istek gonderilmis olabilir de olmayabilir de;
// bu yuzden yazma islemlerinde ayni idempotency anahtariyla tekrarlayin.
return { status: 0, code: 'connection_error', param: undefined, reachedServer: false };
}
throw error;
}
}
ZhyperApiError means the server answered and refused. ZhyperConnectionError
means we never got an answer — the request may or may not have been carried out,
which is exactly when you retry using the same idempotency key
(Rate limits and idempotency).
Two behaviours worth knowing
Out-of-scope ids return 404. A key scoped to specific profiles gets the
same 404 for a profile that belongs to someone else as for one that does not
exist. There is deliberately no way to distinguish them.
Authentication failures are uniform. A wrong password and an unknown email
produce an identical 401 invalid_credentials, with the same amount of work
done server-side. Sign-in never reveals whether an account exists.
Detecting a read-only key, for instance, means looking at the code rather than the message:
export async function isReadOnlyKey(zhyper: ZhyperClient): Promise<boolean> {
try {
await zhyper.request('createProfile', {
body: { name: 'probe', timezone: 'UTC' },
});
return false;
} catch (error) {
// read_only bir anahtar yazma denemesinde 403 permission_denied alir.
return error instanceof ZhyperApiError && error.code === 'permission_denied';
}
}Webhooks
zhyper posts events to an HTTPS endpoint you register. Delivery is retried with backoff; your endpoint should be idempotent, because a retry can arrive after you have already proce
Rate limits and idempotency
Exceeding a limit returns 429 with type: "rate_limit_error" and a Retry-After header. Honour that header rather than a fixed sleep — it is the number of seconds until your usage ac