Connecting accounts
Connect an account to a profile with OAuth, a hosted or headless selection step, or credentials for Bluesky, Telegram and Shopify.
When you finish this page an account is connected to a profile and you have its accountId. You need an API key, a profile id (Step 2 of the quickstart) and a login on the platform. The 16 posting platforms and Shopify each connect one of the ways below; the rules specific to a platform are on its platform page.
OAuth flow (most platforms)
Call GET /v1/connect/{platform} with profileId. Zernio returns an authUrl; send the user's browser there, and when they approve, the platform sends them back and the account is connected.
import Zernio from '@zernio/node';
const zernio = new Zernio();
const profileId = '66a1f0c2a4b9d3e8f1a2b3c4';
const { data: connect } = await zernio.connect.getConnectUrl({
path: { platform: 'linkedin' },
query: { profileId, redirect_url: 'https://myapp.com/callback' }
});
// Send the user's browser to connect.authUrlResponse (200):
{
"authUrl": "https://www.linkedin.com/oauth/v2/authorization?client_id=..."
}After approval the user lands on redirect_url with connected=linkedin&profileId=...&accountId=...&username=... appended; an existing query string is kept. redirect_url must be an absolute http(s) URL or an app scheme such as myapp://callback; a relative path is rejected with 400 INVALID_REDIRECT_URL, and a malformed profileId with 400. The Start OAuth endpoint lists every parameter.
Scopes
Zernio requests every scope a platform needs in this single OAuth flow; scopes cannot be requested one at a time. Each platform page lists what its consent screen asks for. To see what a connected account can do with the scopes the user granted, call Account health: it returns canPost and canFetchAnalytics per account.
Platforms requiring secondary selection
Six platforms, one of them conditionally, need the user to pick which Page, organization, board, location or public profile to connect after OAuth:
| Platform | What to select | Endpoints |
|---|---|---|
| Page | List Pages → Select Page | |
| Organization or personal profile | List orgs → Select org | |
| Board | List boards → Select board | |
| Google Business Profile | Location | List locations → Select location |
| Snapchat | Public profile | List profiles → Select profile |
Instagram (loginMethod=facebook_login only) | Page with a linked Instagram account | List Pages → Select account |
Instagram is conditional: the default Instagram Login (loginMethod omitted) creates the account with no selection step. Only loginMethod=facebook_login adds one, because the user has to say which Page to connect. The Instagram page explains the two methods; both modes below work for it.
Standard vs headless mode
Standard mode (default): Zernio hosts the selection screen. The user picks their Page or organization there, then lands on your redirect_url.
Headless mode: you build the selection screen. Pass headless=true when starting the flow. After OAuth, the user lands on your redirect_url with tempToken, userProfile (URL-encoded JSON), step=select_page and connect_token query params. Your backend passes them to the list and select endpoints to connect the account.
step names the selection endpoint to call next: select_page (Facebook), select_organization (LinkedIn), select_board (Pinterest), select_location (Google Business Profile), select_public_profile (Snapchat), select_phone_number (WhatsApp), select_account (Instagram via Facebook Login). Instagram sends no userProfile, because its select-account endpoint does not take one.
The headless flow for a Facebook Page starts like any OAuth flow, with headless: true:
const { data: start } = await zernio.connect.getConnectUrl({
path: { platform: 'facebook' },
query: { profileId, headless: true, redirect_url: 'https://your-app.com/cb' },
});
// Send the user's browser to start.authUrlResponse (200):
{
"authUrl": "https://www.facebook.com/v21.0/dialog/oauth?client_id=..."
}After consent, Meta sends the user to https://your-app.com/cb?profileId=...&tempToken=...&userProfile=...&platform=facebook&step=select_page&connect_token=.... In that handler, read tempToken and userProfile from the request URL, then list the Pages the user manages:
// requestUrl: the URL your handler received; searchParams decodes the values
const { tempToken, userProfile: encodedProfile } = Object.fromEntries(new URL(requestUrl).searchParams);
const userProfile = JSON.parse(encodedProfile);
const { data: pages } = await zernio.connect.facebook.listFacebookPages({
query: { profileId, tempToken },
});Response (200):
{
"pages": [
{ "id": "123456789", "name": "My Brand Page", "username": "mybrand", "category": "Brand" }
]
}Connect the chosen Page with POST /v1/connect/facebook/select-page. userProfile is the decoded object, not the encoded string:
const { data: result } = await zernio.connect.facebook.selectFacebookPage({
body: {
profileId,
pageId: pages.pages[0].id,
tempToken,
userProfile,
redirect_url: 'https://your-app.com/final-success',
},
});
// Send the browser to result.redirect_urlResponse (200):
{
"message": "Facebook page connected successfully",
"redirect_url": "https://your-app.com/final-success?connected=facebook&profileId=66a1f0c2a4b9d3e8f1a2b3c4&accountId=66b2e19d8c3f5a7e9d0b1c2d&username=My+Brand+Page",
"account": {
"accountId": "66b2e19d8c3f5a7e9d0b1c2d",
"platform": "facebook",
"username": "mybrand",
"isActive": true,
"selectedPageName": "My Brand Page"
}
}account.accountId is the new accountId.
Connect Meta Ads only (skip the Page picker)
For the classic login (loginMode=classic, the default), if your user only needs ads (audience uploads, the Conversions API, analytics, listing ads and campaigns), start at GET /v1/connect/facebook/ads instead and let your backend pick the first Page without showing a picker. The user goes from Meta's consent screen straight to your "Connected" screen. To connect ads independently of a posting account, use Facebook Login for Business.
const { data: adsStart } = await zernio.connect.connectAds({
path: { platform: 'facebook' },
query: { profileId, headless: true, redirect_url: 'https://your-app.com/cb' },
});
// Send the user's browser to adsStart.authUrl, then list and select
// as above with pages.pages[0].id and no picker.Response (200):
{
"authUrl": "https://www.facebook.com/v21.0/dialog/oauth?client_id=..."
}The callback, list and select calls are the headless flow above. The metaads account is created alongside the Facebook account. Roughly 70% of the metaads surface (everything that does not emit object_story_spec.page_id) works whichever Page is bound, so the first Page is fine for ads-only callers. The remaining 30% (boosting Page posts, Click-to-WhatsApp ads, Lead Gen Forms) is Page-specific: when the user later needs those for a specific Page, show a picker then and POST the new pageId to /v1/connect/facebook/select-page. Zernio updates the existing Facebook account in place, with no second OAuth.
Facebook Login for Business
Call GET /v1/connect/{platform}/ads with platform set to facebook or instagram, profileId and loginMode=business. For example, the Facebook route is GET /v1/connect/facebook/ads?profileId=...&loginMode=business. Omitting loginMode uses classic.
Business login always returns an authUrl to open in the user's browser. The callback creates or reconnects an independent metaads account using a Business Integration System User token, without creating or requiring a posting account. GET /v1/accounts identifies it with metadata.tokenType: "system-user". When Meta returns no expires_in, tokenExpiresAt is absent; Zernio does not re-exchange it as a personal token.
Select a granted Facebook Page for ad creatives and lead forms:
- Pass
pageIdon the connect request to choose a specific granted Page. - Without
pageId, Zernio reuses the previous Page or chooses the sole granted Page automatically. - With several granted Pages and no selection, API callers receive a
400listing the available Page IDs. Restart the connect request withpageId. - With zero granted Pages, the connection can manage campaigns and sync insights, but cannot create Page-based creatives or list Page forms. Grant a Page when reconnecting to use those features.
A business reconnect preserves the connection's ID, history and ad-account scope. It must grant every previously scoped ad account, or every previous grant on an unscoped connection; missing or unverifiable grants return 409 before changing the account.
Scoping sync to specific ad accounts
By default, sync covers every act_* ad account the connected Meta token can see. That is fine for one person's account but leaks for agencies and multi-Business-Manager setups, where the token sees every account in every Business Manager the user has a role on. To restrict sync to an allowlist, pass adAccountId (one) or adAccountIds (several) on GET /v1/connect/facebook/ads:
?adAccountId=act_1330190928038136
?adAccountIds=act_1330190928038136,act_3686966528111132Zernio validates each id against the token's /me/adaccounts and stores the list. The account.ads.initial_sync_completed webhook then carries account.platformAdAccountId (when the scope is exactly one account) and account.platformAdAccountIds (always), so you can confirm what was synced. Omit both params to keep the "sync everything visible" behaviour. The latest call wins: a new connect with new ids replaces the earlier allowlist.
Platforms without OAuth
Bluesky
Bluesky connects with an app password. state is {userId}-{profileId}: userId is currentUserId from GET /v1/users, profileId from GET /v1/profiles.
const { data: bluesky } = await zernio.connect.bluesky.connectBlueskyCredentials({
body: {
identifier: 'yourhandle.bsky.social',
appPassword: 'your-app-password',
state: '66a0e8b1c2d3e4f5a6b7c8d9-66a1f0c2a4b9d3e8f1a2b3c4',
},
});
console.log(`Connected ${bluesky.account.username}`);Response (200):
{
"message": "Bluesky connected successfully",
"account": {
"platform": "bluesky",
"username": "yourhandle.bsky.social",
"displayName": "Your Name",
"isActive": true
}
}The Connect Bluesky endpoint lists every field.
Telegram
Telegram connects with an access code:
- Call
GET /v1/connect/telegramto generate an access code, valid for 15 minutes. - The user adds the Zernio Telegram bot as an admin of the channel or group and sends it the code.
- Poll
PATCH /v1/connect/telegramto check the status until it reports connected.
If the bot is already an admin of the channel or group, skip the code and connect directly with POST /v1/connect/telegram and the chat id.
Shopify
Shopify is OAuth, but the authorization URL is built per store, so you need the merchant's myshopify.com domain before you start. Shopify offers no store picker and no lookup from a merchant to their shops.
- Collect the store domain from the merchant (
your-store.myshopify.com; the bareyour-storeprefix is accepted). - Call
GET /v1/connect/shopifywithprofileIdandshopto get the authorization URL. - Redirect the merchant to the returned
authUrl; after approval they land on yourredirect_url.
A merchant who installs from the Shopify App Store never types the domain, because Shopify supplies it to Zernio directly.
To skip the browser flow, the merchant can create a custom app in their Shopify admin with the read_content and write_content scopes and hand you its Admin API access token, which you exchange for a connected store with POST /v1/connect/shopify/token. shop is required there too: an Admin token does not identify its own store.
A connected store publishes no social posts. It powers the Blogs API; see the Shopify page.
Change a selection without reconnecting
Change the selected Page, organization, board, location or subreddit on an existing account without a second OAuth:
- Update Facebook Page
- Update LinkedIn organization
- Update Pinterest board
- Update Google Business Profile location
- Update Reddit subreddit
If it fails
When the user denies consent or the platform rejects the callback, the browser lands on your redirect_url with error and platform appended, for example ?error=oauth_denied&platform=linkedin. Treat an unknown error value as a generic failure; new values are added without notice.
The connect call itself returns 402 when a billing gate blocks it:
{
"error": "X (Twitter) requires a payment method due to API pass-through costs. Add a payment method to connect an X account.",
"code": "PAYMENT_REQUIRED",
"reason": "twitter_passthrough",
"dashboard_url": "https://zernio.com/dashboard?tab=billing"
}reason is free_tier_exceeded (more than the free connected accounts and no card on file), twitter_passthrough (any X account without a card) or enterprise_required (a contract cap). Send the user to dashboard_url to fix it.
Related
- List accounts: every connected account and its
isActivestate. - Update an account: change settings such as default Pages or boards.
- Account health: verify tokens and permissions.
- Disconnect an account: remove it from the profile.
- Instagram: Instagram Login versus Facebook Login.