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 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. Store the connection ID either way, so you can read it again later. 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.

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. It then returns to CONNECTED with a fresh scan, 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.

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

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

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 };
  }>(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 always FAILED with errorCode: "UPSTREAM_REVOCATION_UNVERIFIED" and REMOVE_APP_AT_PROVIDER. Show that instruction every time an Instagram account is disconnected. 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, usually within minutes:
    • Other connected accounts become SUSPENDED, their stored posts are dropped, and reads fail with AUTHORIZATION_SUSPENDED. Afterwards each one either returns to CONNECTED and is rescanned, or ends RECONNECT_REQUIRED and needs a reconnect.
    • 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.
    • All of this is transient. Retry later and do not treat it as a configuration problem.
  • 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.