Connecting accounts
A profile holds accounts. There are two ways to attach one, and which you use is decided by the platform, not by you.
| Platforms | Browser involved | |
|---|---|---|
| OAuth | Mastodon, Discord, Slack, and most large networks | Yes — the user approves on the platform |
| Credentials | Bluesky, Telegram | No — the secret comes straight to us |
The credential path
Nothing is redirected anywhere, so there is no return address to constrain. You send the secret, we seal it, you get an account.
// Kredensiyel yolu allowlist'e HIC ugramaz: tarayici hicbir yere
// yonlendirilmez, dolayisiyla kisitlanacak bir donus adresi yoktur.
export async function connectWithCredentials(
zhyper: ZhyperClient,
input: { profileId: string; identifier: string; appPassword: string },
): Promise<{ accountId?: string }> {
return zhyper.request<{ accountId?: string }>('connectCredentials', {
body: {
profileId: input.profileId,
platform: 'bluesky',
credentials: {
identifier: input.identifier,
appPassword: input.appPassword,
},
},
});
}
Bluesky wants an app password, not the account password. Telegram wants a bot token and a chat id.
LinkedIn connects as a person or as an organization
One LinkedIn authorization exposes two kinds of destination: the member who approved it, and the organization pages that member administers. Which one an account publishes as is decided during the connection — and, unlike most of this page, it can be changed afterwards without reconnecting.
LinkedIn therefore takes the resource-selection path described above. Finish the
flow with completePendingConnection, and the resourceId you choose becomes
that account's platformAccountId: a member id for a person, or a
urn:li:organization:<id> for a page. There is also a LinkedIn-specific pair,
listLinkedInOrganizations and selectLinkedInOrganization, which carries the
same choice as an explicit accountType of personal or organization.
To change it later, PUT /v1/accounts/{accountId}/linkedin-organization
(updateLinkedInOrganization) switches an existing account between a person and
a verified organization. It needs accounts:write and it works from the stored
credential, so there is no second OAuth round trip — but only while that
credential still carries the organization grants below. Without them the switch
is refused with 403 linkedin_organizations_permission_missing.
The permission set
The authorization URL asks for fifteen scopes (measured 2026-08-31; this page said twelve until then, and the three it was missing are the last group). Five concern the member:
openid, profile, w_member_social, w_member_social_feed (used by first
comments) and r_member_postAnalytics.
Seven concern organizations:
w_organization_social, w_organization_social_feed, rw_organization_admin,
r_organization_social, r_organization_social_feed,
r_organization_followers and r_organization_admin.
Three concern advertising and conversions:
r_ads, rw_conversions and r_ads_reporting. They back the LinkedIn
conversion-destination and ad-forecasting endpoints. They are requested on the
same consent screen as the rest, so a user approving a LinkedIn connection sees
them whether or not you intend to use that surface.
Five of the fifteen are the required minimum, re-checked every time we list
what a credential can publish to: openid, profile, w_member_social,
w_organization_social and rw_organization_admin. A credential missing any of
them cannot enumerate its destinations at all.
That check is a subset test, not an equality test. An account consented with extra scopes keeps working and is never forced to reconnect for that reason alone. What does cost a reconnect is widening the required minimum — every account connected under the narrower set has to run OAuth again first. That cost lands on you rather than on us, which is why the list is written out here instead of being summarised.
The identity scopes are LinkedIn's current OpenID Connect ones; the connection
does not depend on the legacy Sign In flow's r_liteprofile permission.
Two consequences worth planning around
Post analytics exist only for organizations. Metrics (impressions,
clicks, likes, comments, shares) come from LinkedIn's Organization Share
Statistics API, so they are available for an account that selected an
organization and absent for one publishing as a person. Asking for them on a
personal account returns
422 linkedin_organization_analytics_requires_organization.
There is no token refresh. A LinkedIn credential cannot be refreshed in
place; when it stops working the account answers
401 linkedin_reconnect_required and has to go through OAuth again. Build the
reconnect prompt in from the start rather than treating it as a rare error path.
The OAuth path
Three steps, and the middle one happens on the platform:
- You call
authorizeConnectand get anauthorizationUrl. - You send the user there. They approve.
- The platform sends the browser back to your callback with
codeandstate. You hand both tocompleteConnect.
Register your callback first
Step 3 lands on an address you choose, and an address you choose is an address an attacker could choose if we let anyone name one. So we don't: the callback has to be registered in advance and it is matched exactly.
// Allowlist'e yazmak PANEL OTURUMU ister — bir API anahtari bunu yapamaz.
// Bilincli bir kisit: sizan bir anahtar once kendi adresini ekleyip sonra
// OAuth sonucunu oraya yollayabilseydi, allowlist'in bir anlami kalmazdi.
export async function registerRedirect(
panel: ZhyperClient,
redirectUri: string,
): Promise<string> {
const created = await panel.request<{ redirectUri: string }>(
'createAllowedRedirect',
{ body: { redirectUri } },
);
// Donen deger NORMALIZE edilmistir: sema ve host kucuk harfe iner, bos yol
// `/` olur. Eslesme tam URI uzerinden yapildigi icin OAuth baslatirken
// TAM OLARAK bu degeri gonderin.
return created.redirectUri;
}
Exactly means exactly. All of these are different addresses, and registering one does not register the others:
https://app.example.com/oauth/callback registered
https://app.example.com/oauth/callback/ trailing slash — different
https://app.example.com/oauth/cb different path
http://app.example.com/oauth/callback different scheme
https://api.example.com/oauth/callback different host
That strictness is the point. Hostname-level matching would accept any path on
your domain, so a single open redirect anywhere on your own site — a ?next=
parameter, a stale marketing redirect — would be enough to carry the
authorization code somewhere else.
To see what is currently registered:
export async function listRedirects(panel: ZhyperClient): Promise<string[]> {
const response = await panel.request<{ data: Array<{ redirectUri: string }> }>(
'listAllowedRedirects',
);
return response.data.map((row) => row.redirectUri);
}
This endpoint needs a panel session, not an API key. An API key with
accounts:writecan start OAuth flows but cannot touch this list. If it could, a leaked key would register its own address first and the allowlist would be decoration. Writing to an allowlist is a higher privilege than using one.
Start the flow
export async function startOAuth(
zhyper: ZhyperClient,
input: { profileId: string; platform: string; redirectUri: string; instanceUrl?: string },
): Promise<{ authorizationUrl: string; state: string }> {
return zhyper.request<{ authorizationUrl: string; state: string }>(
'authorizeConnect',
{
body: {
profileId: input.profileId,
platform: input.platform,
// Kayitli URI ile TAM ESLESMELI. Ayni host uzerinde farkli bir yol
// reddedilir — kasitli, cunku kendi sitenizdeki bir acik yonlendirme
// aksi halde authorization code'u oteye tasiyabilirdi.
redirectUrl: input.redirectUri,
// Yalnizca kendi sunucusunu barindiran platformlar (Mastodon) icin.
...(input.instanceUrl === undefined ? {} : { instanceUrl: input.instanceUrl }),
},
},
);
}
state is generated by us, single-use, and expires. Don't mint your own and
don't reuse ours.
Mastodon needs one extra field, instanceUrl, because every instance is a
separate server with separate app credentials. Platforms without instance-aware
OAuth reject the field rather than ignoring it.
Finish it
export async function completeOAuth(
zhyper: ZhyperClient,
callbackUrl: string,
): Promise<{ accountId?: string; pendingConnectionId?: string }> {
const url = new URL(callbackUrl);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
if (!code || !state) throw new Error('callback did not carry code and state');
// Cevap ya bir hesap ya da bekleyen bir baglanti dondurur: bazi platformlar
// once bir KAYNAK SECIMI ister (hangi sayfa, hangi kanal).
return zhyper.request<{ accountId?: string; pendingConnectionId?: string }>(
'completeConnect',
{ body: { state, code } },
);
}
The response comes back one of two ways. Usually you get an accountId and
you're done. Sometimes you get a pendingConnectionId and a list of resources —
that means the platform granted access to several destinations (pages, channels,
boards) and someone has to choose. Pass the choice to
completePendingConnection.
resources is the first page of the immutable selection snapshot, not
necessarily the whole list. When hasMore is true, call
GET /v1/connect/pending/{id}/resources — the {id} segment takes the
pendingConnectionId you were given — with nextCursor as
the cursor query parameter (and an optional limit from 1 to 100). Continue
until hasMore is false and nextCursor is null. Cursors are bound to this
endpoint and pending connection; do not reuse one for another connection. These
page reads use the stored snapshot and do not relist the provider.
Slack follows that resource-selection path: one workspace installation can expose several public or private channels, and each selected channel becomes a separate publishing account. Installing the app is not evidence that every channel is writable; keep channel membership and permission errors visible to the user.
Keep the selected Pinterest board explicit
For Pinterest, the selected resource id becomes the connected account's
platformAccountId. Keep that provider id separate from accountId, which is the
id of the zhyper account record. A Pinterest target must carry both identities:
{
"platform": "pinterest",
"accountId": "a1b2c3d4e5f6a7b8c9d0e1f2",
"options": {
"boardId": "200000000000000001"
}
}
Set options.boardId to the exact platformAccountId returned for that connected
Pinterest account. The field is required even though the board was selected during
OAuth. zhyper does not fall back to the first board, a credential default or a
different accessible board when it is missing or does not match. A valid-but-different
board id is rejected synchronously before a post target, outbox event or Pin is written.
Build the composer from capabilities
Read GET /v1/platforms before deciding which fields to show. The image, video,
carousel and text limits describe what the current production pipeline can
actually deliver, not the provider's theoretical maximum.
Some audited platforms also return publish.contentRequirements.alternatives.
Each item is one valid minimum; fields inside an item are combined with AND, and
the items themselves are combined with OR. A false field means “not required by
this alternative”, never “forbidden”. For example:
- Instagram has one alternative requiring media. A caption is optional.
- Facebook accepts either non-blank text or a non-blank
linkoption. - Threads accepts either non-blank text or media, including both together.
The field is additive and appears only after that platform's payload modes have been audited. If it is absent, do not infer that empty or text-only publishing is valid; keep provider validation errors visible to the user.
When a redirect is rejected
400 redirect_not_allowed means the URL you sent is not an exact match for
anything registered for that team. In order of likelihood:
- a trailing slash on one side and not the other
httpin development against anhttpsregistration- a query parameter appended to the callback before sending it
- registered on a different team than the key or session belongs to
Compare against listAllowedRedirects output rather than against what you
believe you registered — the stored value is normalized, so it may differ from
the string you sent.
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
Posts
A post is one piece of content plus the list of places it goes. Each of those places is a target: one account, on one platform, with its own status.