Zernio
APIFor you
Zernio
APIFor you
Overview

Guides

ProfilesConnecting accountsMedia uploadsQueue schedulingTimezones & schedulingIdempotency & safe retriesPost lifecycleError handlingRate limitsPixels and tracking tagsAutomated ad rulesCommerceManyChat-style automationsReport bugs & requestsPlatform settings
Dashboard
OverviewPlatformsAPI ReferenceResources
Guides

Connecting accounts

Connect an account to a profile with OAuth, a hosted or headless selection step, or credentials for Bluesky, Telegram, Shopify and self-hosted WordPress.


By the end of this page, an account is connected to a profile and you have its accountId.

Before you start

You need an API key, a profile id (create a profile) and a login on the platform.

Most platforms connect with OAuth. Bluesky, Telegram, Shopify and self-hosted WordPress have their own flows, covered at the end. Rules for a single platform are on its platform page.

OAuth flow (most platforms)

Call GET /v1/connect/{platform} with your profileId. Zernio returns an authUrl.

{platform} is one of these values:

PlatformAPI ValueGuide
XtwitterX Guide
InstagraminstagramInstagram Guide
FacebookfacebookFacebook Guide
LinkedInlinkedinLinkedIn Guide
TikToktiktokTikTok Guide
YouTubeyoutubeYouTube Guide
PinterestpinterestPinterest Guide
RedditredditReddit Guide
BlueskyblueskyBluesky Guide
ThreadsthreadsThreads Guide
Google Business ProfilegooglebusinessGoogle Business Profile Guide
SnapchatClosed betasnapchatSnapchat Guide
WhatsAppwhatsappWhatsApp Guide
TelegramtelegramTelegram Guide
DiscorddiscordDiscord Guide
SlackslackSlack Guide
ShopifyshopifyShopify Guide
WordPresswordpressWordPress Guide
WixwixWix Guide
WebflowwebflowWebflow Guide
HubSpothubspotHubSpot Guide
Google AnalyticsgoogleanalyticsGoogle Analytics Guide

Send the user's browser there. When they approve, the platform sends them back and the account is connected.

TypeScript
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.authUrl

Response (200):

JSON
{
  "authUrl": "https://www.linkedin.com/oauth/v2/authorization?client_id=..."
}

After approval, the user lands on your redirect_url with these params added:

  • connected, profileId, accountId and username
  • request_id and stage, the correlation params

Your own query string is kept, but a param with the same name as one of ours is overwritten.

redirect_url must be an absolute http(s) URL or an app scheme such as myapp://callback. A relative path fails with 400 INVALID_REDIRECT_URL, and a malformed profileId with 400. The Start OAuth endpoint lists every parameter.

Branded welcome screen (recommended)

Add welcome=true and authUrl opens a Zernio-hosted screen with your name and logo ("Acme wants to connect to your LinkedIn") before the platform's own consent screen. It forwards to the platform by itself after a second.

The pickers that follow and the screen that closes the flow carry the same brand.

Users who see whose app they are connecting finish the flow more often, so we recommend it for every connect link.

Pass brandName and logoUrl on the call, or save them once in the dashboard (Settings, White Labeling) or with PUT /v1/connect/welcome-screen. With "Show by default" saved, every link shows the screen without welcome=true. Pass welcome=false to skip it on one link.

Terminal
curl "https://zernio.com/api/v1/connect/linkedin?profileId=66a1f0c2a4b9d3e8f1a2b3c4&welcome=true&brandName=Acme&logoUrl=https://acme.com/logo.png&redirect_url=https://myapp.com/callback" \
  -H "Authorization: Bearer $ZERNIO_API_KEY"

It cannot be combined with headless=true or signup=hosted.

Finish on a phone

Some users start on a computer but log in to the platform more easily on their phone, where the app already has them signed in. Add handoff=true and the response carries a short link to show as a QR code.

Terminal
curl "https://zernio.com/api/v1/connect/instagram?profileId=66a1f0c2a4b9d3e8f1a2b3c4&handoff=true&brandName=Acme" \
  -H "Authorization: Bearer $ZERNIO_API_KEY"
JSON
{
  "authUrl": "https://zernio.com/connect/welcome?ticket=...",
  "state": "...",
  "handoff": {
    "id": "79c6dbf2-97ba-4f34-b615-5bf9435526db",
    "url": "https://zernio.com/connect/m/79c6dbf2-97ba-4f34-b615-5bf9435526db",
    "expiresAt": "2026-10-22T18:30:00.000Z"
  }
}
  1. Draw handoff.url as a QR code in your app with any QR library. It works for 30 minutes.
  2. The user scans it, sees your welcome screen on the phone and logs in to the platform there. The phone ends on a screen telling them to go back to their computer.
  3. Poll GET /v1/connect/handoffs/{handoffId} every few seconds. status goes from waiting to opened when the code is scanned, then to connected with the new accounts, or failed with the error code. It turns expired if nobody scans the code within 30 minutes. The account.connected webhook fires too.

handoff=true always shows the welcome screen, so it cannot be combined with welcome=false, headless=true, signup, redirect=true or redirect_url.

Scopes

By default, the flow asks for every scope Zernio supports on the platform.

To ask for less, pass scopes with the areas you use: posting, analytics, comments, messaging, ads.

TypeScript
const { data: connect } = await zernio.connect.getConnectUrl({
  path: { platform: 'linkedin' },
  query: { profileId, scopes: 'posting,analytics', redirect_url: 'https://myapp.com/callback' }
});

The consent screen then lists only the scopes behind those areas. It also keeps the ones every connection needs: identity, token refresh, and listing the Pages, organizations or channels to pick from.

Each platform page shows which area asks for each scope, and the Start OAuth endpoint has the full table.

A posting-only account cannot read analytics or the inbox, and it skips first comments (they need comments). To add areas, the user connects again.

Scopes the user was never asked for are missing from the account's permissions. Account health reports them as not granted, through canPost and canFetchAnalytics.

scopes works on Facebook, Instagram (both login methods), LinkedIn, X, TikTok, YouTube, Threads, Reddit, Pinterest, Google Business Profile and Slack (GET /v1/connect/slack).

Bluesky, Telegram, Discord, Snapchat and WhatsApp reject it with 400 INVALID_FIELD_VALUE, because their consent screen cannot be reduced. An empty list or an unknown area gets the same error.

Platforms requiring secondary selection

Six platforms need one more step after OAuth: the user picks which Page, organization, board, location or public profile to connect.

PlatformWhat to selectEndpoints
FacebookPageList Pages → Select Page
LinkedInOrganization or personal profileList orgs → Select org
PinterestBoardList boards → Select board
Google Business ProfileLocationList locations → Select location
SnapchatPublic profileList profiles → Select profile
Instagram (loginMethod=facebook_login only)Page with a linked Instagram accountList Pages → Select account

Instagram needs it only with loginMethod=facebook_login, to pick the Page. The default Instagram Login has no selection step. Both modes below work for it, and the Instagram page explains the two login methods.

Standard vs headless mode

Standard mode (default). Zernio hosts the selection screen. The user picks there, then lands on your redirect_url.

Headless mode. You build the selection screen. Pass headless=true when you start the flow.

In headless mode, the user lands on your redirect_url after OAuth with four params: tempToken, userProfile (JSON, encoded twice), step and connect_token. Your backend passes them to the list and select endpoints.

step names the selection endpoint to call next:

stepPlatform
select_pageFacebook
select_organizationLinkedIn
select_boardPinterest
select_locationGoogle Business Profile
select_public_profileSnapchat
select_phone_numberWhatsApp
select_accountInstagram via Facebook Login

Instagram sends no userProfile, because its select-account endpoint does not take one.

Here is the headless flow for a Facebook Page. Start it like any OAuth flow, with headless: true:

TypeScript
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.authUrl

Response (200):

JSON
{
  "authUrl": "https://www.facebook.com/v21.0/dialog/oauth?client_id=..."
}

After consent, Meta sends the user to your callback:

Text
https://your-app.com/cb?profileId=...&tempToken=...&userProfile=...&platform=facebook&step=select_page&connect_token=...

In that handler, read tempToken and userProfile from the URL. userProfile is encoded twice: after your framework decodes the query string, decode it once more, then parse the JSON. tempToken and connect_token are plain values.

Then list the Pages the user manages:

TypeScript
// requestUrl: the URL your handler received; searchParams decodes the values once
const { tempToken, userProfile: encodedProfile } = Object.fromEntries(new URL(requestUrl).searchParams);
const userProfile = JSON.parse(decodeURIComponent(encodedProfile));

const { data: pages } = await zernio.connect.facebook.listFacebookPages({
  query: { profileId, tempToken },
});

Response (200):

JSON
{
  "pages": [
    { "id": "123456789", "name": "My Brand Page", "username": "mybrand", "category": "Brand" }
  ]
}

Connect the chosen Page with POST /v1/connect/facebook/select-page. Pass userProfile as the decoded object, not the encoded string:

TypeScript
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_url

Response (200):

JSON
{
  "message": "Facebook page connected successfully",
  "redirect_url": "https://your-app.com/final-success?connected=facebook&profileId=66a1f0c2a4b9d3e8f1a2b3c4&accountId=66b2e19d8c3f5a7e9d0b1c2d&username=My+Brand+Page&request_id=...&stage=select_page",
  "account": {
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "platform": "facebook",
    "username": "mybrand",
    "isActive": true,
    "selectedPageName": "My Brand Page"
  }
}

account.accountId is the new account's id.

Connect Meta Ads only (skip the Page picker)

If your user only needs ads, start at GET /v1/connect/facebook/ads instead. Ads covers audience uploads, the Conversions API, analytics, and listing ads and campaigns.

Your backend picks the first Page, so there is no picker. The user goes from Meta's consent screen straight to your "Connected" screen.

This is the classic login (loginMode=classic, the default). To connect ads without a posting account, use Facebook Login for Business.

TypeScript
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):

JSON
{
  "authUrl": "https://www.facebook.com/v21.0/dialog/oauth?client_id=..."
}

The callback, list and select calls are the same as in the headless flow above.

The select response adds adsAccountId, in the body and on redirect_url. It is the metaads account, created alongside the Facebook account: use it with the /v1/ads endpoints.

It is absent when the ads account could not be created, and account.accountId is still the Facebook posting account. POST /v1/connect/instagram/select-account works the same way on an Instagram ads connect (loginMethod=facebook_login).

Does the first Page matter? For roughly 70% of the metaads API, no: everything that does not send object_story_spec.page_id works with whichever Page is bound.

The other 30% is Page-specific: boosting Page posts, Click-to-WhatsApp ads and Lead Gen Forms. When your user needs those for a particular Page, show a picker then and POST the new pageId to /v1/connect/facebook/select-page. Zernio updates the Facebook account in place, with no second OAuth.

If the profile had no Facebook account for that Page, the one created here has enabled: false: hidden, not postable and not billed. See Connections and billing.

Facebook Login for Business

Call GET /v1/connect/{platform}/ads with platform set to facebook or instagram, plus profileId and loginMode=business. Without loginMode, the flow is classic.

Text
GET /v1/connect/facebook/ads?profileId=...&loginMode=business

Business login always returns an authUrl to open in the user's browser. The callback creates or reconnects a standalone metaads account with a Business Integration System User token. No posting account is created or needed.

GET /v1/accounts marks it with metadata.tokenType: "system-user". When Meta returns no expires_in, tokenExpiresAt is absent, and Zernio does not re-exchange it as a personal token.

Pick the granted Facebook Page used for ad creatives and lead forms:

  • Pass pageId on the connect request to choose a specific granted Page. A pageId outside the grant fails with page_not_granted.
  • Without pageId, a reconnect reuses the previous Page; a new connection binds the first granted Page. Change the default later with PUT /v1/accounts/{accountId}/facebook-page.
  • With zero granted Pages, the connection is refused with no_pages_granted.

The Pages live on the metaads account; no Facebook account is created. Connections and billing covers how this relates to organic Facebook and Instagram accounts, and which accounts bill.

Reconnecting keeps the connection's id and history. Re-run the dialog to add, drop or swap ad accounts.

The new grant must share at least one ad account with the connection. A grant with none in common is refused before anything changes, with error=invalid_field_value and error_reason=reconnect_mismatch. To move to unrelated ad accounts, disconnect and connect again.

On an accepted reconnect, the ad-account scope is rebuilt from the new grant:

  • adAccountIds passed on the re-auth become the scope.
  • Otherwise, a narrowed scope keeps its still-granted ad accounts, plus any granted for the first time.
  • An unscoped connection follows the new grant.

Errors. Every failure after Meta's dialog redirects to redirect_url with error, platform=metaads, error_message, request_id and stage. is_user_fixable and error_reason are added when known. error is the API error code:

errorerror_reasonMeaning
invalid_field_valueno_pages_grantedNo Page was ticked in Meta's dialog.
invalid_field_valuepage_not_grantedThe pageId you passed is not in the grant.
invalid_field_valuead_accounts_not_grantedNo ad account was granted.
invalid_field_valuereconnect_mismatchThe grant shares no ad account with the existing connection.
invalid_statestate_expiredThe user came back after the 30-minute window. Start again.
connection_failedMeta refused the code, for example a replayed callback.

The gates of GET /v1/connect/{platform} arrive the same way: reconnect_required, ads_addon_required, payment_required and the profile gates. A denial in the dialog is meta_ads_authorization_denied, with the platform_error* params from If it fails.

Only a state that cannot be decrypted at all still answers with 400 JSON, because it names no redirect_url.

Scoping sync to specific ad accounts

By default, sync covers every act_* ad account the Meta token can see.

That is fine for one person's account. For agencies and setups with several Business Managers it leaks: the token sees every account in every Business Manager the user has a role on.

To sync an allowlist instead, pass adAccountId (one) or adAccountIds (several) on GET /v1/connect/facebook/ads:

Text
?adAccountId=act_1330190928038136
?adAccountIds=act_1330190928038136,act_3686966528111132

Zernio checks each id against the token's /me/adaccounts and stores the list. The latest connect wins: new ids replace the earlier list. Omit both params to sync everything the token can see.

To confirm what was synced, read the account.ads.initial_sync_completed webhook. It always carries account.platformAdAccountIds, and account.platformAdAccountId when the scope is exactly one account.

Credential and store-specific connections

Bluesky

Bluesky connects with an app password.

Pass state as {userId}-{profileId}. userId is currentUserId from GET /v1/users, and profileId comes from GET /v1/profiles.

TypeScript
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):

JSON
{
  "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:

  1. Call GET /v1/connect/telegram to generate an access code, valid for 15 minutes.
  2. The user adds the Zernio Telegram bot as an admin of the channel or group and sends it the code.
  3. Poll PATCH /v1/connect/telegram to 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 uses OAuth, but the authorization URL is built per store. You need the merchant's myshopify.com domain first, because Shopify has no store picker and no way to look up a merchant's shops.

  1. Collect the store domain from the merchant (your-store.myshopify.com; the bare your-store prefix is accepted).
  2. Call GET /v1/connect/shopify with profileId and shop to get the authorization URL.
  3. Redirect the merchant to the returned authUrl; after approval they land on your redirect_url with connected, profileId, accountId and username, like every OAuth platform.

username is the store's real myshopify.com domain. Merchants often type their brand name instead, and they can approve a different store than the one in shop: the account is the store they approved.

Keep the accountId from the redirect (or the account.connected webhook) rather than matching on the domain you sent.

If the merchant installs the app from the Shopify App Store instead, it links to your profile only when it is the store you passed in shop, within an hour of your call. Any other store is not linked to your profile.

After a linked install they return to your redirect_url with the same params, and account.connected fires. A store another profile already holds is not linked, and the merchant stays in their Shopify admin.

Without the browser flow. The merchant creates a custom app in their Shopify admin with the read_content, write_content, read_products and write_products scopes, and gives you its Admin API access token. You exchange it for a connected store with POST /v1/connect/shopify/token.

shop is required there too, because an Admin token does not identify its own store.

A connected store publishes no social posts. It powers the Blogs API and the Commerce API; see the Shopify page.

WordPress

WordPress has two ways to connect.

WordPress.com. Call GET /v1/connect/wordpress with profileId and redirect_url, then send the user to the returned authUrl. WordPress.com shows its own consent screen and returns the chosen site in the callback. See Get WordPress.com authorization URL.

Self-hosted. Create an Application Password in the site's WordPress admin. Send the site's HTTPS URL, the login username and the Application Password to POST /v1/connect/wordpress/token.

Zernio finds the site's REST API and checks that the user can publish posts and upload media before saving the account. Do not put the username or the Application Password in siteUrl.

Each WordPress account is one site with one blog. It publishes no social posts: use the Blogs API and the WordPress guide. A self-hosted site running WooCommerce is also a store for the Commerce API.

Change a selection without reconnecting

Change the Page, organization, board, location or subreddit of 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 added:

Text
?error=oauth_denied&platform=linkedin&request_id=...&stage=authorize

Check for error first, before reading any success param. Treat an unknown value as a generic failure, because new values are added without notice. The Start OAuth endpoint lists the known ones.

error and platform are always present. The rest are optional:

ParamMeaning
error_messageHuman-readable explanation you can show the user.
is_user_fixabletrue when the user can fix it by connecting again (for example they cancelled, declined a permission or ticked no Page).
error_reasonFiner reason for some error values, listed below.
platform_error, platform_error_reason, platform_error_descriptionOn oauth_denied (and meta_ads_authorization_denied), the platform's own values when it sent them, for example access_denied and user_denied. The description is truncated to 500 characters.

The internal headless, adsConnect and adsScope markers used during the flow are removed from error redirects.

no_facebook_pages comes with is_user_fixable=true and an error_message. Ask the user to connect again, click "Edit previous settings" in Meta's dialog and tick the Page, or create a Page first.

When Meta's token debug answered, error_reason says which case it is:

error_reasonMeaning
pages_permission_declinedThe user declined the pages_show_list permission.
no_pages_grantedThe permission was granted with no Page ticked. A user who manages no Page looks the same to Meta, so this covers both.
granted_pages_not_listedPages were ticked, but Meta listed none the user can manage.

Request ids

Every redirect from an OAuth callback carries request_id and stage, on success and on failure. So does the redirect_url returned by the selection endpoints: select-page, select-account, select-organization, select-board, select-location, select-profile and select-phone-number.

Every API response also carries an X-Request-Id header. Quote request_id or X-Request-Id when you contact support: it is the id we log that request under.

stage says where the flow ended:

  • authorize: the platform's consent dialog returned an error or denial instead of a code.
  • callback: Zernio processed the returned code (success, a selection step or a failure).
  • select_page: a selection endpoint completed the flow.

Billing gates

The connect call itself returns 402 when billing blocks it:

JSON
{
  "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 one of:

  • free_tier_exceeded: more than the free connected accounts, and no card on file.
  • twitter_passthrough: any X account without a card.
  • enterprise_required: a contract cap.

Send the user to dashboard_url to fix it.

Related

  • List accounts: every connected account and its isActive state.
  • 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.
Was this page helpful?

Profiles

Create a profile, connect accounts into it (several of the same platform if you like), move accounts between profiles, and rename or delete it later.

Media uploads

Upload an image, video or document with a presigned URL and attach it to a post with mediaItems.

On this page

Before you startOAuth flow (most platforms)Branded welcome screen (recommended)Finish on a phoneScopesPlatforms requiring secondary selectionStandard vs headless modeConnect Meta Ads only (skip the Page picker)Credential and store-specific connectionsBlueskyTelegramShopifyWordPressChange a selection without reconnectingIf it failsRequest idsBilling gatesRelated