Skip to content
View as Markdown

Connect a social account

Connect is how a person grants your app read access to their YouTube, TikTok or Instagram account through SocialScope. Your backend creates a Connect attempt, your page auto-posts a one-use launch token to SocialScope's hosted Connect page, the person approves at the provider, and SocialScope sends the browser back to your registered return URL. Your backend then asks SocialScope what happened before showing anything. Provider passwords, codes and tokens never reach your app or the browser. This page covers the whole flow, every outcome, reconnecting and disconnecting.

Before you start

  • Your app is registered with its exact origins, return URLs, tenant keys, providers and capabilities. See Quickstart.
  • Your server has SOCIALSCOPE_GRAPHQL_URL and SOCIALSCOPE_CONSUMER_KEY. See Authentication.
  • The person is signed in to your app, so you know their tenant and subject.
  • integrationReadiness reports the provider as available. Otherwise show the action as unavailable.

The examples import socialscope, SocialScopeError and Scope from lib/socialscope.ts. Copy that file from Authentication. It keeps extensions.code and retryAfterSeconds on every error.

The flow

sequenceDiagram
  autonumber
  actor B as Person's browser
  participant App as Your backend
  participant SS as SocialScope (Connect host)
  participant P as Provider
  B->>App: Click "Connect Instagram" (your CSRF-protected form)
  App->>SS: createConnectSession(scope, provider, capabilities, returnUrl)
  SS-->>App: id, launchUrl, launchToken (10 min)
  App->>App: Save id with user, tenant, used=false
  App-->>B: no-store page that auto-posts launchToken
  B->>SS: POST /api/connect/redeem (Origin = your origin)
  SS-->>B: Hosted Connect page, then provider consent
  B->>P: Sign in, pick account, approve
  P-->>B: Redirect to SocialScope callback
  B->>SS: Callback (SocialScope exchanges the code server-side)
  SS-->>B: 303 to returnUrl?ss_session_id={id}
  B->>App: GET returnUrl?ss_session_id={id}
  App->>App: Match hint to saved unused attempt, mark used
  App->>SS: connectSessionResult(scope, id)
  SS-->>App: status, resultCode, connectionId, creatorAction
  App->>SS: connection(scope, connectionId)
  SS-->>App: status, syncStatus
  App-->>B: Connected, or a safe retry

1. Show the action

Next to the button, show your app's name, the workspace and what you will read, so the person knows what they are agreeing to. Disable the button when readiness for that provider is false or the readiness call fails.

Your app picks the provider. SocialScope's hosted page has no provider picker. The person only picks which account to use on the provider's consent screen. Expect that screen to list every permission SocialScope's provider app needs, even when you request fewer capabilities.

Account requirements differ per provider:

Provider Who can connect
YOUTUBE A Google account with exactly one YouTube channel. Otherwise the result is ACCOUNT_NOT_ELIGIBLE
TIKTOK Any TikTok user. Only public videos are listed later
INSTAGRAM A professional account (Business or Creator). Personal accounts get ACCOUNT_NOT_ELIGIBLE

On dev, provider apps can be in sandbox or testing mode, where only accounts added as testers can connect. Ask the SocialScope admin which accounts to use.

2. Create the attempt

Run this in an authenticated, CSRF-protected server action or route. Build the scope from the server session.

import { socialscope } from "./lib/socialscope";
import { launchPage } from "./lib/socialscope-launch";

const START_CONNECTION = `
  mutation StartConnection($input: ConnectInput!) {
    createConnectSession(input: $input) {
      id
      launchUrl
      launchToken
      expiresAt
      resultExpiresAt
    }
  }
`;

type ConnectSession = {
  id: string;
  launchUrl: string;
  launchToken: string;
  expiresAt: string; // authorization deadline, 10 minutes after creation
  resultExpiresAt: string; // result readable until this time, 24 hours after creation
};

type Provider = "YOUTUBE" | "TIKTOK" | "INSTAGRAM";

// `session` and `attempts` stand in for your own signed-in session and storage.
export async function startConnect(session: { userId: string; tenantKey: string }, provider: Provider) {
  const scope = { tenantKey: session.tenantKey, externalSubjectId: session.userId };
  const { createConnectSession: attempt } = await socialscope<{ createConnectSession: ConnectSession }>(START_CONNECTION, {
    input: {
      scope,
      provider,
      capabilities: ["PROFILE", "CONTENT_LIST", "CONTENT_METRICS"],
      returnUrl: "https://app.example.com/social/return",
    },
  });

  // Save before any navigation. The return route needs this record.
  await attempts.insert({
    id: attempt.id,
    userId: session.userId,
    tenantKey: session.tenantKey,
    provider,
    resultExpiresAt: attempt.resultExpiresAt,
    used: false,
  });

  return launchPage(attempt.launchUrl, attempt.launchToken, "/api/connect/redeem");
}

Input rules:

  • scope.tenantKey must be registered for your app. scope.externalSubjectId is 1 to 128 characters.
  • capabilities must be non-empty, unique and all allowed for your app.
  • returnUrl must exactly match a registered return URL. It must not contain a fragment or any query parameter whose name starts with ss_, because SocialScope adds ss_session_id itself.
  • reconnectConnectionId is optional. See Reconnect.

Errors you can get here:

Code Meaning What to do
FORBIDDEN Tenant, provider or capability not allowed for your app, or the provider is not serving your app or a requested capability. In production it is also returned for a while after anyone disconnects a YouTube or TikTok account, until that revoke settles and every other account on the provider has been re-checked Show the provider as unavailable for now and retry later. If it persists, ask the admin. Do not treat it as misconfiguration on the first occurrence
BAD_USER_INPUT Malformed scope or capability list, or a return URL that is not exactly registered Fix the request. This is a bug in your code or registration
NOT_FOUND reconnectConnectionId does not belong to this scope and provider Refresh your list of connections
RECONNECT_REQUIRED The reconnect target changed state while the attempt was being created. Rare Read the connection again and retry
DISCONNECT_IN_PROGRESS SocialScope is still removing an earlier connection for this subject and provider Show "try again later". No attempt was created
RATE_LIMITED This subject already has 5 unfinished attempts for this provider Ask the person to finish or wait. Unfinished attempts expire after 10 minutes

A reconnect of a DISCONNECTED connection gets DISCONNECT_IN_PROGRESS, like a plain connect. DISCONNECT_IN_PROGRESS covers every account of that provider for the subject, because SocialScope cannot know which account the person would pick. Removal usually finishes within minutes. If the provider cannot confirm a revoke, it can last until a 7-day deletion deadline.

3. Hand the browser to SocialScope

The launchUrl is always on the Connect host, which is the same origin as the GraphQL endpoint. The sample derives the expected origin from SOCIALSCOPE_GRAPHQL_URL for that reason. If you ever call GraphQL through a different host, such as a proxy, configure the Connect origin as a separate setting instead.

Return a no-store HTML page, on the same origin as the returnUrl you sent, that auto-submits a top-level form posting launchToken to launchUrl. SocialScope checks the browser's Origin header against the origin of your return URL and refuses any other origin.

// lib/socialscope-launch.ts
// Server only. Returns a standard Response, usable as-is in a Next.js route handler.
type LaunchPath = "/api/connect/redeem" | "/api/identity/redeem";

const ESCAPES: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (char) => ESCAPES[char]);

export function launchPage(launchUrl: string, launchToken: string, expectedPath: LaunchPath): Response {
  const connectOrigin = new URL(process.env.SOCIALSCOPE_GRAPHQL_URL!).origin;
  const launch = new URL(launchUrl);
  if (launch.origin !== connectOrigin || launch.pathname !== expectedPath || launch.search || launch.hash) {
    throw new Error("Unexpected SocialScope launch URL");
  }
  if (!/^[0-9a-f]{64}$/.test(launchToken)) throw new Error("Unexpected SocialScope launch token");

  const html = `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Continuing</title></head>
<body>
<form method="post" action="${escapeHtml(launchUrl)}">
<input type="hidden" name="launchToken" value="${escapeHtml(launchToken)}">
<button type="submit">Continue</button>
</form>
<script>document.forms[0].submit()</script>
</body>
</html>`;

  return new Response(html, {
    headers: {
      "content-type": "text/html; charset=utf-8",
      "cache-control": "no-store",
      "referrer-policy": "strict-origin",
      "content-security-policy": `default-src 'none'; script-src 'unsafe-inline'; form-action ${connectOrigin}; base-uri 'none'`,
      "x-content-type-options": "nosniff",
    },
  });
}

Rules for the launch:

  • Check that launchUrl is on the Connect host with path /api/connect/redeem and no query or fragment before using it.
  • The form body must contain only launchToken. The button has no name, so it adds nothing.
  • Never put the token in a URL, never send it from your server to SocialScope, and never rebuild the form from a token supplied by the browser.
  • Referrer-Policy: strict-origin keeps your launch page's path out of the referrer.
  • The token works once and expires 10 minutes after the attempt was created.
  • The person must finish in the same browser. SocialScope binds the attempt to that browser with a cookie on its own host.

If the redeem fails (wrong origin, reused or expired token), SocialScope answers the browser with a 403 and a small JSON body. The attempt stays unfinished and later reads as EXPIRED. Offer a new attempt.

4. Verify the return

SocialScope redirects the browser to your exact return URL with ss_session_id=<attempt id> appended. The hint proves nothing. Your return route must:

  1. Require the same signed-in user and workspace that started the attempt. If your login expired, sign the same person in again, then resume. The browser reaches this route through a cross-site redirect from SocialScope, so the session cookie you check here must be SameSite=Lax or None. A SameSite=Strict cookie is not sent on that request and the person looks signed out.
  2. Find an unused saved attempt with that ID for that user. Reject missing, duplicate, malformed, foreign, expired or already used hints before calling SocialScope.
  3. Mark the attempt used.
  4. Call connectSessionResult with the saved scope and ID.
  5. On COMPLETED with a connectionId, call connection and check its current status.
  6. Remove ss_session_id from the URL before analytics or third-party scripts load, for example with a redirect to a clean URL.
import { socialscope, SocialScopeError } from "./lib/socialscope";

const VERIFY_CONNECTION = `
  query VerifyConnection($scope: SubjectScope!, $attemptId: ID!) {
    connectSessionResult(scope: $scope, id: $attemptId) {
      id
      provider
      status
      resultCode
      connectionId
      creatorAction
    }
  }
`;

const READ_CONNECTION = `
  query ReadConnection($scope: SubjectScope!, $connectionId: ID!) {
    connection(scope: $scope, id: $connectionId) {
      id
      provider
      status
      syncStatus
      profile { displayName handle avatarUrl observedAt }
    }
  }
`;

type ConnectResult = {
  id: string;
  provider: string;
  status: "CREATED" | "AUTHORIZING" | "EXCHANGING" | "COMPLETED" | "DENIED" | "FAILED" | "EXPIRED" | "CANCELED";
  resultCode: string | null;
  connectionId: string | null;
  creatorAction: string | null;
};

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

// `session` and `attempts` stand in for your own signed-in session and storage.
export async function connectReturn(url: URL, session: { userId: string; tenantKey: string }) {
  const hints = url.searchParams.getAll("ss_session_id");
  if (hints.length !== 1 || !UUID.test(hints[0])) return { outcome: "retry" as const };

  const saved = await attempts.findUnused({ id: hints[0], userId: session.userId, tenantKey: session.tenantKey });
  if (!saved) return { outcome: "retry" as const };
  await attempts.markUsed(saved.id);

  const scope = { tenantKey: saved.tenantKey, externalSubjectId: session.userId };
  const { connectSessionResult: result } = await socialscope<{ connectSessionResult: ConnectResult }>(VERIFY_CONNECTION, { scope, attemptId: saved.id });
  if (result.id !== saved.id) return { outcome: "retry" as const };

  // Any creatorAction means a grant may still exist at the provider. Unknown values count too.
  const removeAppAtProvider = result.creatorAction !== null;

  if (result.status !== "COMPLETED" || !result.connectionId) {
    return { outcome: "not-connected" as const, result, removeAppAtProvider };
  }

  let connection: { id: string; provider: string; status: string; syncStatus: string };
  try {
    ({ connection } = await socialscope<{
      connection: { id: string; provider: string; status: string; syncStatus: string };
    }>(READ_CONNECTION, { scope, connectionId: result.connectionId }));
  } catch (error) {
    // A connected row can still fail its read check, for example AUTHORIZATION_SUSPENDED or RECONNECT_REQUIRED.
    const code = error instanceof SocialScopeError ? error.code : "SOCIALSCOPE_UNAVAILABLE";
    return { outcome: "not-connected" as const, result, removeAppAtProvider, code };
  }

  if (connection.id !== result.connectionId || connection.status !== "CONNECTED") {
    return { outcome: "not-connected" as const, result, removeAppAtProvider };
  }
  // PENDING and RUNNING mean loading. PARTIAL, STALE and FAILED are handled on the account page.
  return { outcome: "connected" as const, connection, postsLoading: connection.syncStatus === "PENDING" || connection.syncStatus === "RUNNING" };
}

Show the account as connected only when the current connection status is CONNECTED. A COMPLETED result is history: it proves the attempt committed, not that the connection is still healthy. Posts arrive later through a background sync. Present them by syncStatus: PENDING or RUNNING is loading, READY is loaded, PARTIAL is loaded but possibly incomplete, STALE shows the stored data with its age, and FAILED is an error with a retry.

If the connection read itself fails, map the code to the account's state: AUTHORIZATION_SUSPENDED is temporarily unavailable, RECONNECT_REQUIRED needs a reconnect, and CAPABILITY_UNAVAILABLE or FORBIDDEN is unavailable. The account also appears in connections with that state, so you can show it later without storing the ID. See Reading data.

What each result means

status resultCode What to show
COMPLETED CONNECTED Connected, after checking connection.status. Present posts by syncStatus
DENIED PROVIDER_DENIED They declined at the provider. Nothing is connected. Offer to try again
CANCELED USER_CANCELED They backed out on SocialScope's page. Offer to try again
CANCELED RECONNECT_REQUIRED SocialScope stopped the attempt while a disconnect at the same provider settled. Offer to try again later
EXPIRED SESSION_EXPIRED They took longer than 10 minutes, or never came back. Offer to try again
FAILED POLICY_WITHDRAWN Your app's access changed during the attempt. Nothing is connected. Offer a retry
FAILED DISCONNECT_IN_PROGRESS SocialScope is still removing an earlier connection of this account. Offer a retry later
FAILED ACCOUNT_NOT_ELIGIBLE Wrong account type, for example a personal Instagram account or a Google account with no single channel
FAILED MISSING_REQUIRED_PERMISSION The grant lacks a permission SocialScope needs, for example one left unticked on the consent screen. Ask them to approve every permission
FAILED RECONNECT_REQUIRED A reconnect picked a different account, or the target changed. Ask them to pick the original account
FAILED Any other code Something failed at the provider or in SocialScope. Offer a retry. Never fall back to your own provider OAuth
CREATED, AUTHORIZING, EXCHANGING null Still in progress. Read again shortly. After 10 minutes the read returns EXPIRED

The full list of resultCode values is in Errors.

creatorAction

creatorAction is either null or REMOVE_APP_AT_PROVIDER. When it is set, SocialScope discarded a grant it could not confirm was revoked at the provider. Ask the person to remove the app in their provider account settings. Treat any value you do not recognize the same way.

Check it on every non-COMPLETED result, not just a few codes. It can change from null to REMOVE_APP_AT_PROVIDER on a later read of the same result, once a background revoke fails. On FAILED with DISCONNECT_IN_PROGRESS, it is null when another app has the same account connected, because removing the app at the provider would end that app's connection too.

If the person never comes back

The person may close the tab, or a provider error may leave them on SocialScope's page. Your saved attempt is still there. Read connectSessionResult from your server with the saved scope and ID at any time until resultExpiresAt. After the 10-minute authorization deadline, an unfinished attempt reads as EXPIRED. After resultExpiresAt (24 hours), the read returns NOT_FOUND.

If an admin removes your return URL or its origin during the attempt, SocialScope shows the person a static "Return to the app you started from" page instead of redirecting. The result is still readable from your server.

Reconnect

When a connection's status is RECONNECT_REQUIRED, start a normal attempt with reconnectConnectionId set to that connection's ID.

SocialScope refreshes tokens in the background, so this mostly happens when the person removed your app at the provider or a token expired anyway. See Token lifetime and reconnects.

const input = {
  scope, // same tenant and subject as the connection
  provider: "YOUTUBE",
  capabilities: ["PROFILE", "CONTENT_LIST", "CONTENT_METRICS"],
  returnUrl: "https://app.example.com/social/return",
  reconnectConnectionId: connection.id,
};

Rules:

  • The target must belong to the same scope and provider, and be CONNECTED, SUSPENDED or RECONNECT_REQUIRED when you create the attempt.
  • The person must pick the same provider account. A different account fails with RECONNECT_REQUIRED and connects nothing.
  • On success the connection keeps its ID, its stored posts are cleared and a fresh 7-day sync starts. Pagination cursors from before the reconnect return STALE_CURSOR.
  • A plain connect, without reconnectConnectionId, for an account the subject already has connected updates that same connection rather than creating a second one.

A SUSPENDED connection is usually paused while a disconnect's revoke at the same provider settles and SocialScope re-checks it. It then returns to CONNECTED with its stored posts, plus a fresh scan when your app's policy still allows reads, or becomes RECONNECT_REQUIRED. Offer a reconnect once it is RECONNECT_REQUIRED.

Disconnect

disconnectConnection stops your app's access at once and starts two background jobs: an upstream revoke at the provider and an erase of the stored data.

In rare cases, while other accounts on the same provider are connecting, it returns RATE_LIMITED with retryAfterSeconds: 1 and changes nothing. Retry after that delay.

import { socialscope } from "./lib/socialscope";

const DISCONNECT = `
  mutation Disconnect($scope: SubjectScope!, $connectionId: ID!) {
    disconnectConnection(scope: $scope, connectionId: $connectionId) {
      accepted
      connectionId
      upstreamRevocationPending
      revokeJobId
      eraseJobId
      creatorAction
    }
  }
`;

const REVOKE_RECEIPT = `
  query RevokeReceipt($scope: SubjectScope!, $connectionId: ID!, $jobId: ID!) {
    syncJob(scope: $scope, connectionId: $connectionId, id: $jobId) {
      id
      status
      errorCode
      creatorAction
      nextRetryAt
    }
  }
`;

type Receipt = {
  id: string;
  status: "QUEUED" | "RUNNING" | "SUCCEEDED" | "FAILED" | "CANCELED";
  errorCode: string | null;
  creatorAction: string | null;
  nextRetryAt: string | null;
};

export async function disconnect(scope: { tenantKey: string; externalSubjectId: string }, connectionId: string) {
  const { disconnectConnection: result } = await socialscope<{
    disconnectConnection: { accepted: boolean; revokeJobId: string; eraseJobId: string; creatorAction: string | null };
  }>(DISCONNECT, { scope, connectionId });
  // Keep connectionId and revokeJobId. The receipt stays readable for 7 days, even after erasure.
  await disconnects.insert({ connectionId, revokeJobId: result.revokeJobId, eraseJobId: result.eraseJobId });
  return result;
}

export async function revokeOutcome(scope: { tenantKey: string; externalSubjectId: string }, connectionId: string, revokeJobId: string) {
  const { syncJob } = await socialscope<{ syncJob: Receipt }>(REVOKE_RECEIPT, { scope, connectionId, jobId: revokeJobId });
  if (syncJob.status === "QUEUED" || syncJob.status === "RUNNING") return "pending" as const;
  if (syncJob.status === "SUCCEEDED" && syncJob.creatorAction === null) return "revoked" as const;
  return "remove-app-at-provider" as const;
}

How to read the revoke receipt:

Receipt What it means
QUEUED or RUNNING Still working. nextRetryAt is set while it waits to retry. Check again later
SUCCEEDED, creatorAction null The provider confirmed the revoke
SUCCEEDED, errorCode: "ALREADY_INVALID", creatorAction: "REMOVE_APP_AT_PROVIDER" The provider said the stored token was already expired or revoked. An expired token can leave the grant itself active, so ask the person to remove the app
FAILED, creatorAction: "REMOVE_APP_AT_PROVIDER" SocialScope could not confirm the revoke. Ask the person to remove the app in the provider's settings

Instagram has no revoke API, so an Instagram revoke receipt is FAILED with errorCode: "UPSTREAM_REVOCATION_UNVERIFIED" and REMOVE_APP_AT_PROVIDER. The exception is a disconnect that happened because the person removed the app at Meta: that receipt is SUCCEEDED with errorCode: "PROVIDER_REMOVED" and a null creatorAction. The disconnect result already says so: its creatorAction is REMOVE_APP_AT_PROVIDER for an Instagram disconnect, so your server can show the instruction straight away. It is null when the person already removed the app at Meta and SocialScope disconnected the account for that reason. For YouTube and TikTok it is null at disconnect time, and the revoke receipt gives the outcome later. A repeated disconnect call reports what the receipt says at that moment. Never tell a person their access was revoked at the provider unless the receipt says so.

After disconnecting:

  • Reads of the connection return status: DISCONNECTED, and content reads fail with RECONNECT_REQUIRED, until erasure removes the row. Then they return NOT_FOUND.
  • Calling disconnectConnection again before erasure returns the same job IDs. After erasure it returns NOT_FOUND.
  • A new Connect attempt for the same subject and provider fails with DISCONNECT_IN_PROGRESS until removal finishes, usually within minutes.
  • In production, a YouTube or TikTok disconnect affects every account on that provider, in every app, until its revoke settles and the last other account has been decided. That is usually minutes, but while one account's provider keeps failing it can take up to about 24 hours. A TikTok revoke whose outcome is unknown can leave the provider quarantined until an operator acts, and production has no clearing command yet:
    • Other connected accounts become SUSPENDED and reads fail with AUTHORIZATION_SUSPENDED. Their stored posts are kept but not readable.
    • Once the revoke settles, SocialScope re-checks every suspended account at the provider, with a fresh token each time. A confirmed account returns to CONNECTED with its stored posts and profile. Its reads come back once the last account on that provider has been decided, and a fresh scan then updates them when your app's policy still allows reads.
    • An account ends RECONNECT_REQUIRED with its stored posts deleted only when the provider refuses its grant (invalid_grant), it answers for another account or one that no longer qualifies, the grant lost a scope it needs, the provider refuses its fresh token on the check read (401), its grant expired or is no longer active, or provider errors have continued for 24 hours with at least 3 of them counted. Any other provider error counts toward those 24 hours. Problems on SocialScope's side and quota waits never delete posts: the account stays SUSPENDED and is retried. One case can still end in a reconnect: if a provider replaced the account's token and SocialScope could not save the new one, the provider then refuses the old token.
    • Connect attempts in progress for that provider end CANCELED with resultCode: RECONNECT_REQUIRED.
    • New attempts for that provider get FORBIDDEN from createConnectSession, and readiness reports the provider as unavailable.
    • Show these accounts and the provider as temporarily unavailable and retry later. Do not treat it as a configuration problem in your app.
  • On the dev deployment, disconnects stay local to the one connection and have none of these wider effects. Instagram disconnects never have them, because Instagram has no revoke API.

Security checklist

  • Scope comes from your server session, never from the browser.
  • The attempt ID is saved against the user before navigation and can be used once.
  • The launch page is served from the return URL's origin, as a no-store top-level form POST.
  • The return route verifies the result from your server before showing success.
  • ss_session_id is stripped before third-party scripts run.
  • Denial, expiry, provider failure or SocialScope downtime produce a retry, never inferred success and never a direct provider OAuth fallback.