zhyper docs

Accounts

An account is one connected destination: a Mastodon handle, a Discord channel, a Pinterest board, a LinkedIn page. It belongs to exactly one profile, and it is what a post target points at.

Getting one is covered in Connecting accounts. This page is about living with it afterwards — listing, renaming, moving, checking whether it still works, and letting it go.

Reads need accounts:read, writes need accounts:write. Note that this is a different permission from posts:*: a key that publishes does not automatically get to disconnect the thing it publishes to.

Listing

export async function listConnectedAccounts(
  zhyper: ZhyperClient,
  profileId: string,
): Promise<Array<Schemas['AccountDto']>> {
  const accounts: Array<Schemas['AccountDto']> = [];

  // `data` kanonik projeksiyondur. Ayni cevap bir de `accounts` alani tasir;
  // o Zernio uyumlulugu icindir ve daha az alan gosterir. Yenisini yazarken
  // `data`yi okuyun.
  for await (const account of zhyper.paginate<Schemas['AccountDto']>('listAccounts', {
    query: { profileId, limit: 50 },
  })) {
    accounts.push(account);
  }

  return accounts;
}
{
  "data": [
    {
      "id": "a1b2c3d4e5f6a7b8c9d0e1f2",
      "profileId": "c3d4e5f6a7b8c9d0e1f2a3b4",
      "platform": "mastodon",
      "platformAccountId": "109000000000000001",
      "displayName": "Acme Newsroom",
      "avatarUrl": null,
      "status": "active",
      "connectedAt": "2026-08-11T16:13:57.912Z"
    }
  ],
  "accounts": [],
  "hasMore": false,
  "nextCursor": null
}

Two fields carry the same rows. data is canonical; accounts is a narrower Zernio-compatible projection with different field names (_id, profilePicture, isActive, needsReconnection). Build against data.

A third, pagination, appears only when you pass page and limit together — cursor reads omit it, because totals cannot be derived safely from a keyset position. Filters are profileId, platform and status.

Offset pagination has a ceiling: page may not exceed 1000, and limit may not exceed 100, so the deepest offset this endpoint will serve is 100,000 rows. Past that, page is rejected with 400 invalid_page and the message states the maximum. The ceiling was 100,000 until 2026-09-02, which allowed a single request to ask for a ten-million-row offset; if you were walking pages beyond 1000, switch to cursor, which has no depth ceiling.

status is the connection's own state, and it is not a health verdict:

statusMeaning
activeConnected as far as we know
reconnect_requiredThe credential no longer works; run OAuth again
disconnectedDeliberately disconnected
errorLast operation failed in a way that needs looking at

Two identifiers on every account are easy to confuse and must not be: id is the zhyper account id — the one a post target carries — and platformAccountId is the provider's own id, which is what a Pinterest boardId or a Discord channel id refers to.

Health

status says what we decided. Health says what we measured.

export async function accountsNeedingAttention(
  zhyper: ZhyperClient,
): Promise<Array<{ id: string; state: string; issues: string[] }>> {
  const overview = await zhyper.request<Schemas['AccountHealthOverviewDto']>(
    'getAllAccountsHealth',
    { query: { limit: 50 } },
  );

  // `state` TEK ayirt edicidir ve dort deger alir. `unknown` SAGLIKLI DEGILDIR
  // — hicbir olcumun yapilmadigi anlamina gelir, o yuzden `healthy` ile ayni
  // kovaya konmaz.
  return overview.data
    .filter((entry) => entry.state !== 'healthy')
    .map((entry) => ({
      id: entry.id,
      state: entry.state,
      issues: [...entry.issues],
    }));
}

getAllAccountsHealth (and the single-account getAccountHealth) reads stored evidence and makes no provider call. That is why it can answer for every account at once, and why it is honest about age rather than pretending to be live:

  • state is the single discriminator: healthy, degraded, blocked or unknown. unknown is not healthy — it means nothing has ever been measured, so it is counted separately in the summary and must not be folded into healthy in yours either.
  • canPost, tokenValid and needsReconnect are nullable, and null means not measured. A true is only ever claimed with evidence behind it.
  • evidenceAgeSeconds and lastCheckedAt state how stale the answer is; needsRefresh is true once evidence is older than 24 hours or absent.
  • issues and recommendations come from fixed catalogues — never from customer input — so they are safe to switch on. Issues run account_disconnected, connection_error, token_revoked, token_expired, permissions_insufficient, reconnect_required, last_check_failed, health_unknown, health_stale; recommendations are reconnect_account, reauthorize_scopes and wait_for_health_check.

The summary block describes the scope, not the page: total counts every account the scope covers even when you are reading page one of many. The scope itself is spelled out in the response's scope field so the two can never be read against different populations.

A freshly connected account that no check has touched yet looks like this — and that is correct, not a bug:

{ "id": "a1b2c3d4e5f6a7b8c9d0e1f2", "state": "unknown", "issues": ["health_unknown"] }

Renaming and moving

export async function renameAccount(
  zhyper: ZhyperClient,
  accountId: string,
  displayName: string,
): Promise<string> {
  const result = await zhyper.request<Schemas['UpdateAccountResultDto']>(
    'updateAccount',
    { path: { accountId }, body: { displayName } },
  );

  // Yalnizca SAKLANAN gorunen ad degisir. Saglayicidaki kullanici adi ya da
  // profil bu uctan degistirilmez.
  if (result.displayName === null) {
    throw new Error('Account rename returned an empty display name');
  }
  return result.displayName;
}

updateAccount changes the stored displayName and nothing else. It is explicitly not a way to edit the provider profile: username and X capability toggles are rejected rather than silently ignored, because this service has no durable place to put them.

export async function moveAccount(
  zhyper: ZhyperClient,
  accountId: string,
  profileId: string,
): Promise<string> {
  const result = await zhyper.request<Schemas['MoveAccountToProfileResultDto']>(
    'moveAccountToProfile',
    { path: { accountId }, body: { profileId } },
  );

  return result.profileId;
}

moveAccountToProfile re-parents an account. Keep in mind that a post's targets must all share one profile (400 accounts_must_share_profile), so moving an account changes which posts it can be grouped into.

Disconnecting

export async function disconnectAccount(
  zhyper: ZhyperClient,
  accountId: string,
): Promise<Schemas['AccountDisconnectDto']> {
  // `revocationStatus` iki degerden birini alir. `complete` saglayicidaki
  // yetkinin de geri alindigini soyler; `pending` yalnizca BIZIM tarafimizin
  // kapandigini soyler ve saglayici tarafi arka planda denenmeye devam eder.
  return zhyper.request<Schemas['AccountDisconnectDto']>('disconnectAccount', {
    path: { id: accountId },
  });
}

There are two endpoints and they run the same operation: POST /v1/accounts/{id}/actions/disconnect and DELETE /v1/accounts/{accountId}. The difference is only in what they tell you back:

{
  "id": "a1b2c3d4e5f6a7b8c9d0e1f2",
  "platform": "mastodon",
  "status": "disconnected",
  "revocationStatus": "complete"
}

The POST returns the account with revocationStatus; the DELETE returns { "message": "Account disconnected successfully" }. Use the POST when you care about the distinction revocationStatus draws:

  • complete — the grant was revoked at the provider too.
  • pending — our side is closed and the provider revocation has not been confirmed. The credential may still exist upstream until it is.

Neither removes the account's posting history. Targets are records of what was attempted, and they outlive the connection.

The rest of the surface

The accounts tag carries 50 operations (measured 2026-08-31), and most of them are platform-specific: Discord settings, Pinterest boards and sections, Reddit flairs and subreddits, Slack members and settings, Telegram bot commands, Threads replies, TikTok creator constraints, YouTube playlists, Google Business posts and reviews, and Instagram publishing limits. Every one of them follows the same split as this page — 31 need accounts:read and 19 need accounts:write, with no exceptions — and all of them are listed in the API reference.

Two numbers on this page were stale until that measurement, and one sentence was simply wrong. It said 47 operations split 29/18, and it called accounts "by far the largest tag in the API". It is not, and has not been for a while: ads is the largest tag with 113 operations, more than twice accounts. If you sized your integration work off that sentence, size it again.

Do not read the permission off the URL. /v1/accounts/... is a path prefix, not a permission boundary: 127 operations hang off it and they carry four different permission pairs. That count is every operation whose path begins /v1/accounts/. If you narrow it to the sub-paths of a single account — /v1/accounts/{accountId}/... — it is 120. The other seven are the two collection reads (/v1/accounts/follower-stats and /v1/accounts/health) and the five that act on an account record itself rather than on something beneath it. Measured 2026-08-31 — 96 need accounts:*, 19 need ads:*, 11 need posts:* (the Shopify blog family and the Facebook post-reaction read), and one, registerWhatsAppNumber, needs whatsapp:write. The tag and the reference are the honest signal; the path prefix is not.

Two corrections to what this paragraph used to claim, both measured: no operation under /v1/accounts/ needs inbox:read or inbox:write — the inbox lives at /v1/inbox — and the Discord message controls are not there either; they sit under /v1/discord/.... What /v1/accounts/{accountId}/ does hold for Discord is a settings pair, getDiscordSettings and updateDiscordSettings.

Two of them are worth knowing exist before you need them: getFollowerStats returns stored follower counts over a date range (accountIds, fromDate, toDate, granularity), and getInstagramPublishingLimit reports how much of Instagram's own publishing allowance is left.

Next

On this page