zhyper docs

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

typeTypical statusMeaning
invalid_request_error400The request itself is malformed
authentication_error401Missing or unusable credential
permission_error403Authenticated, but not allowed
not_found404, 410No such resource, or no longer valid
conflict409State collision
rate_limit_error429Too many requests
platform_error502The social platform failed
platform_rejected422The platform refused the content
account_disconnected409Reconnect the account
media_error422The media could not be used
api_error500Our 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 422 for the "the video went up, but ..." family — a playlist insert or a first comment that failed after the upload succeeded. A 400 there reads as "fix it and try again", and trying again uploads a second video.
  • WhatsApp keeps 422 on the publishing path.
  • On Snapchat the only remaining route to 422 is Snapchat's own 422.

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.

WhereWasIs
LinkedIn image, document and video upload (initialize, upload, status)platform_error, 502, code linkedin_<kind>_<operation>_unavailablenot_found, 404, code linkedin_<kind>_<operation>_not_found
getWhatsAppMedianot_found, but HTTP 400not_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';
  }
}

On this page