# 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](/quickstart.md).
- Your server has `SOCIALSCOPE_GRAPHQL_URL` and `SOCIALSCOPE_CONSUMER_KEY`. See [Authentication](/authentication.md).
- 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](/authentication.md#call-it). It keeps `extensions.code` and `retryAfterSeconds` on every error.

## The flow

```mermaid
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.

```ts
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](#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.

```ts
// 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.

```ts
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](/reading-data.md#list-connections).

### 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](/errors.md#connect-result-codes).

### `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.

```ts
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.

```ts
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.
