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_URLandSOCIALSCOPE_CONSUMER_KEY. See Authentication. - The person is signed in to your app, so you know their tenant and subject.
integrationReadinessreports 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.tenantKeymust be registered for your app.scope.externalSubjectIdis 1 to 128 characters.capabilitiesmust be non-empty, unique and all allowed for your app.returnUrlmust exactly match a registered return URL. It must not contain a fragment or any query parameter whose name starts withss_, because SocialScope addsss_session_iditself.reconnectConnectionIdis 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> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
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
launchUrlis on the Connect host with path/api/connect/redeemand no query or fragment before using it. - The form body must contain only
launchToken. The button has noname, 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-originkeeps 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:
- 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=LaxorNone. ASameSite=Strictcookie is not sent on that request and the person looks signed out. - Find an unused saved attempt with that ID for that user. Reject missing, duplicate, malformed, foreign, expired or already used hints before calling SocialScope.
- Mark the attempt used.
- Call
connectSessionResultwith the saved scope and ID. - On
COMPLETEDwith aconnectionId, callconnectionand check its currentstatus. - Remove
ss_session_idfrom 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,SUSPENDEDorRECONNECT_REQUIREDwhen you create the attempt. - The person must pick the same provider account. A different account fails with
RECONNECT_REQUIREDand 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 withRECONNECT_REQUIRED, until erasure removes the row. Then they returnNOT_FOUND. - Calling
disconnectConnectionagain before erasure returns the same job IDs. After erasure it returnsNOT_FOUND. - A new Connect attempt for the same subject and provider fails with
DISCONNECT_IN_PROGRESSuntil 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 withAUTHORIZATION_SUSPENDED. Afterwards each one either returns toCONNECTEDand is rescanned, or endsRECONNECT_REQUIREDand needs a reconnect. - Connect attempts in progress for that provider end
CANCELEDwithresultCode: RECONNECT_REQUIRED. - New attempts for that provider get
FORBIDDENfromcreateConnectSession, and readiness reports the provider as unavailable. - All of this is transient. Retry later and do not treat it as a configuration problem.
- Other connected accounts become
- 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_idis 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.