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:
status | Meaning |
|---|---|
active | Connected as far as we know |
reconnect_required | The credential no longer works; run OAuth again |
disconnected | Deliberately disconnected |
error | Last 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:
stateis the single discriminator:healthy,degraded,blockedorunknown.unknownis not healthy — it means nothing has ever been measured, so it is counted separately in the summary and must not be folded intohealthyin yours either.canPost,tokenValidandneedsReconnectare nullable, andnullmeans not measured. Atrueis only ever claimed with evidence behind it.evidenceAgeSecondsandlastCheckedAtstate how stale the answer is;needsRefreshistrueonce evidence is older than 24 hours or absent.issuesandrecommendationscome from fixed catalogues — never from customer input — so they are safe to switch on. Issues runaccount_disconnected,connection_error,token_revoked,token_expired,permissions_insufficient,reconnect_required,last_check_failed,health_unknown,health_stale; recommendations arereconnect_account,reauthorize_scopesandwait_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
- Connecting accounts — how one gets here
- Posts — what points at an account
- API reference — the platform-specific endpoints
Queues
A queue is a weekly pattern of publishing times. Instead of picking a timestamp for every post, you describe the rhythm once — "Monday and Wednesday at 09:30, Friday at 16:00" — an
Contacts
A contact is a person your team talks to. It belongs to exactly one profile, carries the details you keep about them (name, email, company, notes, tags, custom fields), and links t