# Quickstart This page takes a server-rendered web app from nothing to a verified social connection. Your backend calls one GraphQL endpoint with an application key, the person's browser visits SocialScope's hosted Connect page to approve access at YouTube, TikTok or Instagram, and your backend then reads their profile, posts and metrics. You keep your own users, login and database. No SDK or npm package is needed. The code is server-side TypeScript using the standard `fetch`, `Request` and `Response`. It runs as-is in Next.js route handlers. In Express or NestJS on Node, call the same functions and copy a returned `Response`'s status, headers and body onto your framework's response object. ## Hosts | What | Dev value | | ---------------------- | ---------------------------------------------------------- | | GraphQL endpoint | `https://connect.socialscope.hardscope.incdev.dev/graphql` | | Connect host (browser) | `https://connect.socialscope.hardscope.incdev.dev` | | These docs | `https://docs.socialscope.hardscope.incdev.dev` | Production hosts will be announced. Read the GraphQL URL from configuration so you can switch environments without a code change. ## 1. Get your app registered A SocialScope admin creates a client for your app. Send them: - Your app's name and a short slug, for example `example-app`. - Every exact browser origin your app serves pages from, for example `https://app.example.com`. Origins are HTTPS, with no path, no port and no wildcard. `https://app.example.com` does not cover `https://www.app.example.com`. Up to 10. - Every return URL the browser should land on after Connect, for example `https://app.example.com/social/return`. Each must sit on one of your origins. Up to 20. - The tenant keys you will use. A tenant key names one workspace in your app, for example `example-workspace`. An app with no workspaces uses one fixed tenant key. - The providers you want (`YOUTUBE`, `TIKTOK`, `INSTAGRAM`) and the capabilities you need (`PROFILE`, `CONTENT_LIST`, `CONTENT_LOOKUP`, `CONTENT_METRICS`). - Whether you also want [Google sign-in](/google-identity.md), and its return URL. The admin then issues an application key of the form `.` and sends it to you through a password manager, never chat or email. ## 2. Store the key on your server ```dotenv # Server-side secret store only. SOCIALSCOPE_GRAPHQL_URL=https://connect.socialscope.hardscope.incdev.dev/graphql SOCIALSCOPE_CONSUMER_KEY=. ``` Never put the key in `NEXT_PUBLIC_*` or `VITE_*` variables, a mobile bundle, browser storage, source control or logs. It speaks for your whole app. See [Authentication](/authentication.md). Add one helper that every SocialScope call goes through: ```ts // lib/socialscope.ts // Server only. Never import this file from browser code. const TIMEOUT_MS = 10_000; // 10 s export class SocialScopeError extends Error { constructor( readonly code: string, readonly correlationId?: string, readonly retryAfterSeconds?: number, ) { super(code); this.name = "SocialScopeError"; } } type GraphQLResponse = { data?: T; errors?: { message: string; extensions?: { code?: string; correlationId?: string; retryAfterSeconds?: number }; }[]; }; export async function socialscope(query: string, variables: Record = {}): Promise { const url = process.env.SOCIALSCOPE_GRAPHQL_URL; const key = process.env.SOCIALSCOPE_CONSUMER_KEY; if (!url || !key) throw new SocialScopeError("SOCIALSCOPE_NOT_CONFIGURED"); let response: Response; try { response = await fetch(url, { method: "POST", cache: "no-store", // never let a framework cache a SocialScope response signal: AbortSignal.timeout(TIMEOUT_MS), headers: { "content-type": "application/json", authorization: `Bearer ${key}` }, body: JSON.stringify({ query, variables }), }); } catch { // Network failure or timeout. Never log the request: it carries the key. throw new SocialScopeError("SOCIALSCOPE_UNAVAILABLE"); } // A body over 64 KiB is refused with HTTP 413 before GraphQL runs, so there is no GraphQL error body. if (response.status === 413) throw new SocialScopeError("SOCIALSCOPE_REQUEST_TOO_LARGE"); // Errors arrive with HTTP 200, or 400 for validation, limit and batching errors. Read the body either way. const payload = (await response.json().catch(() => null)) as GraphQLResponse | null; const first = payload?.errors?.[0]; if (first) { throw new SocialScopeError(first.extensions?.code ?? "UPSTREAM_UNAVAILABLE", first.extensions?.correlationId, first.extensions?.retryAfterSeconds); } if (!response.ok || !payload?.data) throw new SocialScopeError("SOCIALSCOPE_UNAVAILABLE"); return payload.data; } export type Scope = { tenantKey: string; externalSubjectId: string }; ``` `SOCIALSCOPE_NOT_CONFIGURED` and `SOCIALSCOPE_UNAVAILABLE` are codes this helper invents for local failures. Every other code comes from SocialScope and is listed in [Errors](/errors.md). ## 3. Check readiness Before you show a Connect button, ask which providers your app can use right now. ```ts import { socialscope } from "./lib/socialscope"; const READINESS = ` query IntegrationReadiness { integrationReadiness { providers { provider available } googleIdentityAvailable } } `; type Readiness = { integrationReadiness: { providers: { provider: string; available: boolean }[]; googleIdentityAvailable: boolean; }; }; export async function canConnect(provider: "YOUTUBE" | "TIKTOK" | "INSTAGRAM"): Promise { try { const { integrationReadiness } = await socialscope(READINESS); return integrationReadiness.providers.some((item) => item.provider === provider && item.available); } catch { return false; // A failed check means unavailable, never available. } } ``` If a provider is `false`, show the button as unavailable. A `false` usually means the admin still has setup to do for your app. A `true` means the configuration checks pass. It does not prove a person can finish consent, so still connect one real account per provider before launch. ## 4. Start Connect and hand the browser over In an authenticated, CSRF-protected server action, build the scope from your own session, never from browser input. Create the attempt, save its ID against the signed-in user, then return a page that auto-posts the one-use launch token to SocialScope. ```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; resultExpiresAt: string; }; // `user`, `workspace` and `saveAttempt` stand in for your own session and storage. export async function startInstagramConnect(user: { id: string }, workspace: { tenantKey: string }) { const scope = { tenantKey: workspace.tenantKey, externalSubjectId: user.id }; const { createConnectSession: attempt } = await socialscope<{ createConnectSession: ConnectSession }>(START_CONNECTION, { input: { scope, provider: "INSTAGRAM", capabilities: ["PROFILE", "CONTENT_LIST", "CONTENT_METRICS"], returnUrl: "https://app.example.com/social/return", }, }); await saveAttempt({ id: attempt.id, userId: user.id, tenantKey: scope.tenantKey, used: false }); return launchPage(attempt.launchUrl, attempt.launchToken, "/api/connect/redeem"); } ``` The launch page must be served from the same origin as the `returnUrl` you sent, because SocialScope checks the browser's `Origin` header on the POST. ```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 = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; 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 = ` Continuing
`; 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", }, }); } ``` ## 5. Verify the return before showing success SocialScope sends the browser back to your return URL with `?ss_session_id=`. That value is a lookup hint, not proof. Match it to an unused attempt you saved for the same signed-in user, mark it used, then ask SocialScope what happened. ```ts import { socialscope, SocialScopeError } from "./lib/socialscope"; const VERIFY_CONNECTION = ` query VerifyConnection($scope: SubjectScope!, $attemptId: ID!) { connectSessionResult(scope: $scope, id: $attemptId) { id status resultCode connectionId creatorAction } } `; const READ_CONNECTION = ` query ReadConnection($scope: SubjectScope!, $connectionId: ID!) { connection(scope: $scope, id: $connectionId) { id status syncStatus } } `; type Result = { id: string; status: string; resultCode: string | null; connectionId: string | null; creatorAction: string | null; }; // Your return route, after confirming the same signed-in user and workspace. // findUnusedAttempt, markAttemptUsed, saveConnectionId, showRetry, showOutcome and showConnected stand in for your own code. // The session cookie checked here must be SameSite=Lax: SocialScope's cross-site redirect does not carry a Strict cookie. export async function handleReturn(url: URL, user: { id: string }) { const hint = url.searchParams.get("ss_session_id"); const saved = await findUnusedAttempt(user.id, hint); // null for missing, foreign, malformed or used hints if (!saved) return showRetry(); await markAttemptUsed(saved.id); const scope = { tenantKey: saved.tenantKey, externalSubjectId: user.id }; const { connectSessionResult: result } = await socialscope<{ connectSessionResult: Result }>(VERIFY_CONNECTION, { scope, attemptId: saved.id, }); if (result.id !== saved.id || result.status !== "COMPLETED" || !result.connectionId) { return showOutcome(result); // See the result table in /connect.md } // Keep the connection ID even if the read below fails, so you can read it again later. await saveConnectionId(user.id, saved.tenantKey, result.connectionId); try { const { connection } = await socialscope<{ connection: { id: string; status: string; syncStatus: string } }>(READ_CONNECTION, { scope, connectionId: result.connectionId }); return showConnected({ connected: connection.status === "CONNECTED", // PENDING and RUNNING: loading. READY: loaded. PARTIAL: possibly incomplete. STALE: show with its age. FAILED: retry. syncStatus: connection.syncStatus, }); } catch (error) { // A connected account can still fail its read, for example AUTHORIZATION_SUSPENDED or RECONNECT_REQUIRED. return showOutcome(result, error instanceof SocialScopeError ? error.code : "SOCIALSCOPE_UNAVAILABLE"); } } ``` Remove `ss_session_id` from the browser URL before analytics or third-party scripts load, for example by redirecting to a clean URL. ## 6. Read posts and metrics ```ts import { socialscope } from "./lib/socialscope"; const READ_CONTENT = ` query ReadContent($scope: SubjectScope!, $connectionId: ID!, $window: ContentWindow!) { content(scope: $scope, connectionId: $connectionId, window: $window) { items { providerContentId title publishedAt availability observedAt metrics { name value availability reason sourceField observedAt } } pageInfo { endCursor hasNextPage completeForWindow incompleteReason } } } `; type Metric = { name: string; value: string | null; availability: string; observedAt: string }; // `session` stands in for your signed-in session. Build the scope from it on every read. export async function readSeptember(session: { userId: string; tenantKey: string }, connectionId: string) { const scope = { tenantKey: session.tenantKey, externalSubjectId: session.userId }; const { content } = await socialscope<{ content: { items: { providerContentId: string; title: string | null; observedAt: string; metrics: Metric[] }[]; pageInfo: { completeForWindow: boolean }; }; }>(READ_CONTENT, { scope, connectionId, window: { publishedAfter: "2026-09-01T00:00:00Z", publishedBefore: "2026-10-01T00:00:00Z", first: 50 }, }); return content; } // Never turn a missing count into zero. export function formatMetric(metric: Metric | undefined): string { if (!metric || metric.availability !== "AVAILABLE" || metric.value === null) return "Not available"; return BigInt(metric.value).toLocaleString("en-US"); } ``` A metric is a number only when its `availability` is `AVAILABLE`. Show anything else as "not available", never as `0`. Values are integer strings such as `"10482"`. Show `observedAt` so nobody mistakes a stored count for a live one. See [Reading data](/reading-data.md) for refresh, completeness and URL lookup. ## Next - [Guide for coding agents](/agent-guide.md) has the full rule list and checklists. - [Connect](/connect.md) covers every result, reconnect and disconnect. - [API reference](/api-reference.md) lists every operation. The raw schema is at [/schema.graphql](/schema.graphql). --- # Guide for coding agents This page is written to you, the coding agent asked to integrate SocialScope into an existing application. It tells you what to read, what to inspect in the app first, the rules you must not break, the order to build things in, what to test and what to report. SocialScope gives the app hosted OAuth for YouTube, TikTok and Instagram plus observed account data over one server-to-server GraphQL API, and optionally Google sign-in. The app keeps its own backend, users, sessions and database. No SDK or npm package is needed. ## 1. Load the contract Read these before writing code. They are plain Markdown. | Need | URL | | ------------------------- | ------------------------------------------------------------------ | | Index of every page | `https://docs.socialscope.hardscope.incdev.dev/llms.txt` | | The 5-minute path | `https://docs.socialscope.hardscope.incdev.dev/quickstart.md` | | Connect flow and outcomes | `https://docs.socialscope.hardscope.incdev.dev/connect.md` | | Reading data | `https://docs.socialscope.hardscope.incdev.dev/reading-data.md` | | Google sign-in | `https://docs.socialscope.hardscope.incdev.dev/google-identity.md` | | Every error code | `https://docs.socialscope.hardscope.incdev.dev/errors.md` | | Every operation | `https://docs.socialscope.hardscope.incdev.dev/api-reference.md` | | GraphQL schema | `https://docs.socialscope.hardscope.incdev.dev/schema.graphql` | Use only operations and fields that exist in the schema. If you cannot fetch the docs or the schema, finish the inventory below and report that blocker. Do not invent operations or fields. Validate every GraphQL document you write against `/schema.graphql` locally, for example with `graphql-js` `buildSchema` and `validate` in a unit test. SocialScope answers an invalid query with a bare `BAD_USER_INPUT` that does not name the bad field. ## 2. Inventory the app before designing Find and write down, with file paths: - Server routes or server actions, and how the app protects them against CSRF. - How a signed-in user is identified on the server, and the stable user ID to use as `externalSubjectId`. - How the current workspace is selected and authorized, to map to `tenantKey`. If the app has no workspaces, it uses one fixed tenant key from server configuration. - The secret store and how server environment variables are read. - The existing HTTP or API client conventions, logging and error reporting. - The database and migration tool, for the attempt records you need to store. - The test framework and how routes are tested. Reuse all of these. Do not add a new auth system, database, queue or framework for this integration. Then pick the flows the task asks for: social account connection, Google sign-in, or both. They are separate. Google sign-in grants no YouTube access, and connecting YouTube signs nobody in. ## 3. Rules You must follow every rule in this list. ### Secrets 1. Keep the application key server-side only, in the app's secret store, as `SOCIALSCOPE_CONSUMER_KEY`, next to `SOCIALSCOPE_GRAPHQL_URL`. 2. Never put the key in `NEXT_PUBLIC_*`, `VITE_*` or any client-bundled variable, browser storage, a mobile bundle, source control, test fixtures or logs. 3. Never log the `Authorization` header, a `launchToken`, a `resultProof` or raw SocialScope responses. 4. Use only the key issued to your app. ### Ownership 5. Derive `tenantKey` and `externalSubjectId` from the server session on every call. Never accept them from a form field, query string, header or request body the browser controls. 6. The key identifies the app. There is no app ID argument. Never pass or trust an app ID from the browser. 7. Connections are separate per app, tenant and subject. Do not try to share them between apps. ### Connect 8. Call `integrationReadiness` before offering a provider. Show the action as unavailable when the provider is `false` or the call fails. Never bypass readiness. 9. Create the attempt with `createConnectSession` in an authenticated, CSRF-protected server action, and save its `id` with the user, tenant and provider before sending the browser anywhere. 10. Check `launchUrl` is on the Connect host (the origin of `SOCIALSCOPE_GRAPHQL_URL`) with path exactly `/api/connect/redeem` and no query or fragment. 11. Serve a no-store HTML page from the origin of the return URL that auto-submits a top-level form posting only `launchToken`. Escape both values. Send `Referrer-Policy: strict-origin`. Never put the token in a URL and never send it from the server. 12. On return, treat `ss_session_id` as a hint only. Require the same signed-in user and tenant, match an unused saved attempt, mark it used, then call `connectSessionResult` with the saved scope and ID. 13. Show success only when the result is `COMPLETED`, its IDs match, and `connection.status` is `CONNECTED`. Present posts by `syncStatus`: `PENDING` or `RUNNING` is loading, `READY` is loaded, `PARTIAL` is loaded but possibly incomplete, `STALE` shows the stored data with its age, `FAILED` is an error with a retry. 14. Show the "remove the app in the provider's settings" instruction whenever `creatorAction` is set, including values you do not recognize. 15. On denial, expiry, failure or SocialScope downtime, offer a retry. Never infer success and never fall back to the app's own provider OAuth. 16. Remove `ss_session_id` and `ss_identity_id` from the URL before analytics or third-party scripts load. 17. The session cookie checked on the return routes must be `SameSite=Lax` or `None`, because SocialScope reaches them with a cross-site redirect. 18. In production, `FORBIDDEN` from `createConnectSession` and `false` readiness can be transient while a YouTube or TikTok disconnect settles. Retry later before treating it as misconfiguration. ### Data 19. Never show an unavailable metric as `0`. Only `availability: AVAILABLE` carries a value. 20. Treat `DecimalCount` as an integer string. Do not parse it into a float. 21. Show `observedAt` next to counts. Keep `sourceField` when you interpret them. 22. Do not claim a list is complete unless `pageInfo.completeForWindow` is `true` for that exact window. 23. Refresh and lookup are asynchronous and there are no webhooks. Poll `syncJob` before reading again. After a lookup succeeds, read `contentItem` and check its `availability`. 24. Store the connection IDs you receive in your own database and read each with `connection`. A `CONNECTED` account can still fail its read with `AUTHORIZATION_SUSPENDED`, `RECONNECT_REQUIRED`, `CAPABILITY_UNAVAILABLE` or `FORBIDDEN`, and one such account fails the whole `connections` list. Map each code to that account's state. 25. Do not tell a person a disconnect revoked access at the provider unless the revoke receipt is `SUCCEEDED` with a null `creatorAction`. Instagram never confirms a revoke. ### Google identity 26. Bind `createGoogleIdentitySession` to the browser session. Do not require an existing local user. 27. Keep `resultProof` on the server. Redeem once, with the saved tenant and proof, and only for the browser session that started the attempt. 28. Create or find the local user only on `COMPLETED` with non-empty `issuer` and `googleSub`, keyed by that pair. Never link accounts by email. Check `emailVerified`. 29. Create the app's session only after the user write succeeds. ### Scope of your work 30. Registering the app, issuing keys and enabling providers or Google identity are done by a SocialScope admin. List what the admin needs. Do not attempt it yourself. 31. A disabled readiness value is not permission to widen access or work around it. ## 4. Build order 1. Add the server-only helper from [Authentication](/authentication.md) and the two environment variables. 2. Add the readiness check and render provider actions from it. 3. Add the attempt store: a table or existing session store holding attempt ID, user ID, tenant key, provider, `resultExpiresAt` and a used flag. 4. Add the start action and the launch page from [Connect](/connect.md). 5. Add the return route with the full verification from [Connect](/connect.md#4-verify-the-return). 6. Add the reads the feature needs from [Reading data](/reading-data.md), with the availability and completeness rules. 7. Add reconnect and disconnect if the feature needs them. 8. Add Google identity only if asked, following [Google identity](/google-identity.md). 9. Write the tests below as you go, one behavior at a time. ## 5. Tests to write Use the app's own test tools. Stub SocialScope at the HTTP boundary with responses shaped like the schema. See [Testing](/testing.md) for an example. - Two users: neither can read the other's connections. - Two app keys: the second app cannot read the first app's connections. - A foreign, duplicate, malformed or used `ss_session_id` is rejected without showing success. - An unavailable provider in readiness renders as unavailable. A failed readiness call does too. - Provider denial and expiry produce a retry. - `COMPLETED` with a connection that is not `CONNECTED` is not shown as connected. - A metric that is not `AVAILABLE` renders as "not available", not `0`. - For Google identity: a new user, a returning user, and a rejected second redemption. - The key does not appear in client bundles. A stubbed suite and SocialScope's local fixture prove wiring, not provider approval or a live consent. ## 6. Report back When you finish, report: - The routes and files you added or changed. - The admin registration the app needs, without secret values: slug, exact origins, return URLs, tenant keys, providers, capabilities, and the Google identity return URL if used. - The server settings to add: `SOCIALSCOPE_GRAPHQL_URL` and `SOCIALSCOPE_CONSUMER_KEY`, with no values. - The checks you ran and their results. - What still needs a live check: one real account connected per provider on the target environment, and a real Google sign-in if used. - Any part of the contract you could not read, and the date you read the docs. --- # Concepts SocialScope is a shared service that lets people connect a YouTube, TikTok or Instagram account to your app without your app ever seeing their password or provider tokens. Your backend then reads the account's profile, posts and metrics through one GraphQL API. This page defines the words the rest of the docs use and explains how ownership works, so you can map SocialScope onto your app's existing users and workspaces. ## Who owns what | Party | Owns | | ------------------- | -------------------------------------------------------------------------------------------------------------------- | | Your app backend | Its application key, its users, sessions and database, the mapping to tenant and subject, every SocialScope call | | Your app browser | Submitting the launch form from your registered origin, landing on your return URL | | SocialScope | The hosted Connect page, the OAuth round trip, the encrypted provider grant, token refresh, stored posts and metrics | | The provider | The person's consent and the account itself | | A SocialScope admin | Your client registration: origins, return URLs, tenants, providers, capabilities and keys | ## Application (client) Your app as SocialScope knows it. An admin registers it with a slug such as `example-app`, its exact browser origins, its return URLs, its allowed tenant keys, the providers it may use and the capabilities it may read. The admin UI calls this a client. The application is identified by its key on every request. There is no `applicationId` argument anywhere in the API. Each application is isolated: a connection made through one app gives no other app access, even for the same person and the same provider account. ## Tenant (`tenantKey`) A workspace inside your app, such as a team, club or brand. It must be one of the tenant keys registered for your application, 1 to 128 characters. An app with no workspaces registers one tenant key and always uses it. ## Subject (`externalSubjectId`) Your user, as your app identifies them. It is opaque to SocialScope, 1 to 128 characters. Use your stable internal user ID, not an email address. SocialScope does not authenticate your users. Your backend asserts which tenant and subject it is acting for, so your backend must derive both from its own authenticated session on every call and never accept them from a browser form or query string. ## Scope (`SubjectScope`) The pair `{ tenantKey, externalSubjectId }`. Every connection-related operation takes a scope. SocialScope matches rows on the full tuple of application, tenant, subject and ID. A connection that belongs to another subject, tenant or application is indistinguishable from one that does not exist: you get `NOT_FOUND`. ## Connect attempt (session) One try at connecting one provider account. Your backend creates it with `createConnectSession`, the browser carries its one-use launch token to SocialScope, and the person approves at the provider. Your backend reads the outcome with `connectSessionResult`. An attempt has 10 minutes to finish authorization, and its result stays readable for 24 hours. Statuses: `CREATED`, `AUTHORIZING`, `EXCHANGING` (in progress), then one terminal status: `COMPLETED`, `DENIED`, `FAILED`, `EXPIRED` or `CANCELED`. See [Connect](/connect.md). ## Connection A provider account that a subject connected through your app. It keeps its ID across reconnects. A subject can hold several connections, including several accounts of the same provider. | `Connection.status` | Meaning | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CONNECTED` | Active. Reads work when the capability is available, though a read can still fail if the provider registration or your app's policy changed. See [Errors](/errors.md#reads-by-connection-status) | | `SUSPENDED` | Paused by SocialScope. In production this happens to other accounts on YouTube or TikTok while a disconnect's revoke at that provider settles. Its stored posts are dropped. Afterwards it returns to `CONNECTED` with a fresh scan, or moves to `RECONNECT_REQUIRED` | | `RECONNECT_REQUIRED` | The provider grant no longer works. Start a reconnect | | `DISCONNECTED` | Disconnected and being erased. It disappears once erasure finishes | | `DELETING` | Reserved. Treat it like `DISCONNECTED` | `syncStatus` is separate from `status`. It describes the last data sync and how to present the posts: | `syncStatus` | Meaning | Show | | ------------ | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | | `PENDING` | Never synced yet. Normal right after connecting | Loading | | `RUNNING` | A sync job is running | Loading | | `READY` | The last scan finished and was complete | Loaded | | `PARTIAL` | The last scan finished but did not cover everything, for example it hit a page or item limit or could not read an item | Loaded but possibly incomplete | | `STALE` | Data exists but is old, or the last job failed after an earlier success | The stored data with its age, and a refresh action | | `FAILED` | The first sync failed and no data has been read yet | An error with a retry | A `COMPLETED` Connect result means the account is linked. It does not mean posts are loaded. ## Provider `YOUTUBE`, `TIKTOK` or `INSTAGRAM`. Your app chooses the provider when it creates the attempt. The person only chooses which account to use on the provider's own consent screen. ## Capability What your app may read from a connection. | Capability | Allows | | ----------------- | --------------------------------------------------------- | | `PROFILE` | Display name, handle, avatar, profile URL and description | | `CONTENT_LIST` | Listing posts in a time window, scans and `contentItem` | | `CONTENT_LOOKUP` | Looking up one post by its URL, and `contentItem` | | `CONTENT_METRICS` | Views, likes, comments and shares on each post | You request capabilities per attempt, and they must be a subset of what your application is allowed. The provider's consent screen asks for every permission SocialScope's provider app needs, even if you request fewer capabilities. ## Observed data SocialScope does not proxy live provider calls on each read. A background worker reads the provider and stores what it saw, stamped with `observedAt`. Your reads return those stored observations. - There are no webhooks. Your app learns about changes only by polling. - Nothing refreshes on its own after the first sync. The first sync covers the last 7 days, up to 100 posts. Call `requestRefresh` when you need newer data. - Stored posts older than 30 days since their last observation are no longer returned by `content`. A profile older than 30 days is no longer returned. - There is no metric history. Each observation replaces the previous one. ## Availability Every post, metric and capability carries an `availability`: | `Availability` | Meaning | | -------------- | -------------------------------------------------------------------------------- | | `AVAILABLE` | The value was observed and is present | | `UNSUPPORTED` | The provider does not offer this value for this item, for example YouTube shares | | `NOT_GRANTED` | Reserved for a value the grant does not cover | | `UNAVAILABLE` | The provider omitted the value, returned something invalid, or it expired | Only `AVAILABLE` carries a value. Show everything else as "not available", never as `0`. A zero view count tells a creator their post flopped when SocialScope simply does not know. ## Metric (`MetricObservation`) One count on one post: `name` (`VIEWS`, `LIKES`, `COMMENTS`, `SHARES`), `value` (a `DecimalCount`), `availability`, `reason`, `sourceField` and `observedAt`. `DecimalCount` is a non-negative integer sent as a string, such as `"10482"`, so large counts never lose precision. `sourceField` names the provider field the value came from, because the same metric name means slightly different things per provider. | Metric | YouTube | TikTok | Instagram | | ---------- | -------------- | --------------- | ------------------------------------------------------------------------------ | | `VIEWS` | `viewCount` | `view_count` | Insight `views`, videos and reels only. `UNSUPPORTED` for images and carousels | | `LIKES` | `likeCount` | `like_count` | `like_count` | | `COMMENTS` | `commentCount` | `comment_count` | `comments_count` | | `SHARES` | `UNSUPPORTED` | `share_count` | Insight `shares` | ## Provider differences Normalized fields do not mean identical capabilities. What a connection can return depends on the provider and the account type. | Topic | YouTube | TikTok | Instagram | | ----------------- | ----------------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------- | | Eligible accounts | A Google account with exactly one channel | Any TikTok user | Professional accounts only (Business or Creator). Personal accounts are refused with `ACCOUNT_NOT_ELIGIBLE` | | Posts listed | Uploads | Public videos only | Media, including images and carousels | | `kind` values | `VIDEO` | `VIDEO` | `VIDEO`, `IMAGE`, `CAROUSEL`, `UNKNOWN` | | `title` | Video title | Video title | Always null. The caption is in `description` | | `thumbnailUrl` | Always null | Cover image, which expires at TikTok | Thumbnail URL. Can be null for images | | URL lookup | Direct | Direct | Searches the 100 most recent posts only | | Disconnect revoke | Confirmed by Google | Confirmed by TikTok | Not possible. Instagram has no revoke API, so the person must remove the app in Instagram settings | ## Sync job Asynchronous work: a scan of a time window, a metrics refresh of one post, a URL lookup, or the revoke and erase steps of a disconnect. Mutations that start work return a `SyncJob`, and you poll `syncJob` until it reaches `SUCCEEDED`, `FAILED` or `CANCELED`. See [Reading data](/reading-data.md). ## Readiness `integrationReadiness` tells your app which providers it can offer right now, and whether Google sign-in is available. It reflects your app's policy and SocialScope's configuration. It does not prove a live consent will succeed. ## Google identity A separate flow that lets your app sign people up or in with Google through SocialScope. It returns verified Google identity claims once to your backend. It creates no connection, no SocialScope user and no YouTube access. See [Google identity](/google-identity.md). --- # Authentication Your backend authenticates to SocialScope with an application key sent as a bearer token on every GraphQL request. The key identifies your application, not a user or a tenant. SocialScope is server-to-server only: the endpoint does not enable CORS, so a browser on your origin cannot call it, and the key must never reach browser code anyway. This page covers the key's format, where to store it, how to call the endpoint, rotation and what each authentication failure means. ## The endpoint | Environment | GraphQL URL | | ----------- | ---------------------------------------------------------- | | Dev | `https://connect.socialscope.hardscope.incdev.dev/graphql` | | Production | To be announced | The endpoint accepts only `POST /graphql` with a JSON body. A `GET` returns `404`. The other paths on that host serve the browser side of Connect and provider callbacks, and your server never calls them. ## The key An admin issues the key when your app is registered and prints it once. It looks like this: ```text . ``` The key ID is a UUID and the secret is 64 hexadecimal characters. Send it as: ```http Authorization: Bearer . ``` Anything that does not match `Bearer .<64 hex>` exactly is rejected with `UNAUTHENTICATED`. ## Store it on the server only ```dotenv SOCIALSCOPE_GRAPHQL_URL=https://connect.socialscope.hardscope.incdev.dev/graphql SOCIALSCOPE_CONSUMER_KEY=. ``` Rules: - Keep it in your server's secret store or environment, next to the GraphQL URL. - Never put it in `NEXT_PUBLIC_*`, `VITE_*` or any variable that is bundled into browser code, in a mobile app, in browser storage, in source control or in client logs. - Never log the `Authorization` header, request bodies that contain launch tokens or result proofs, or raw response bodies. - Use a separate key for each environment. Use only the key issued to your app. The same rule covers the other secrets in the flows: the Connect `launchToken` goes only into the auto-submitted form on your page, and the Google identity `resultProof` never leaves your server. ## Call it ```ts // lib/socialscope.ts // Server only. Never import this file from browser code. const TIMEOUT_MS = 10_000; // 10 s export class SocialScopeError extends Error { constructor( readonly code: string, readonly correlationId?: string, readonly retryAfterSeconds?: number, ) { super(code); this.name = "SocialScopeError"; } } type GraphQLResponse = { data?: T; errors?: { message: string; extensions?: { code?: string; correlationId?: string; retryAfterSeconds?: number }; }[]; }; export async function socialscope(query: string, variables: Record = {}): Promise { const url = process.env.SOCIALSCOPE_GRAPHQL_URL; const key = process.env.SOCIALSCOPE_CONSUMER_KEY; if (!url || !key) throw new SocialScopeError("SOCIALSCOPE_NOT_CONFIGURED"); let response: Response; try { response = await fetch(url, { method: "POST", cache: "no-store", // never let a framework cache a SocialScope response signal: AbortSignal.timeout(TIMEOUT_MS), headers: { "content-type": "application/json", authorization: `Bearer ${key}` }, body: JSON.stringify({ query, variables }), }); } catch { // Network failure or timeout. Never log the request: it carries the key. throw new SocialScopeError("SOCIALSCOPE_UNAVAILABLE"); } // A body over 64 KiB is refused with HTTP 413 before GraphQL runs, so there is no GraphQL error body. if (response.status === 413) throw new SocialScopeError("SOCIALSCOPE_REQUEST_TOO_LARGE"); // Errors arrive with HTTP 200, or 400 for validation, limit and batching errors. Read the body either way. const payload = (await response.json().catch(() => null)) as GraphQLResponse | null; const first = payload?.errors?.[0]; if (first) { throw new SocialScopeError(first.extensions?.code ?? "UPSTREAM_UNAVAILABLE", first.extensions?.correlationId, first.extensions?.retryAfterSeconds); } if (!response.ok || !payload?.data) throw new SocialScopeError("SOCIALSCOPE_UNAVAILABLE"); return payload.data; } export type Scope = { tenantKey: string; externalSubjectId: string }; ``` In Next.js, call this only from route handlers, server actions or server components. The `cache: "no-store"` option stops the framework from serving a cached SocialScope response. `SOCIALSCOPE_NOT_CONFIGURED`, `SOCIALSCOPE_REQUEST_TOO_LARGE` and `SOCIALSCOPE_UNAVAILABLE` are local codes this helper invents. Every other code comes from SocialScope. Do not rely on the HTTP status alone. A failed operation usually arrives with HTTP 200 and an `errors` array. Invalid queries, request-limit errors and batched requests arrive with HTTP 400 and the same `errors` shape. A body over 64 KiB gets HTTP 413 before GraphQL runs, with no GraphQL body. Always read the body when there is one. ### Smoke test with curl `status` is the only operation that needs no key. Use it to confirm the route works: ```sh curl -sS -X POST https://connect.socialscope.hardscope.incdev.dev/graphql \ -H 'content-type: application/json' \ -d '{"query":"query Status { status { ok message } }"}' ``` Then check the key with `integrationReadiness`. Read the key from your environment rather than typing it, so it does not land in shell history: ```sh curl -sS -X POST "$SOCIALSCOPE_GRAPHQL_URL" \ -H 'content-type: application/json' \ -H "authorization: Bearer $SOCIALSCOPE_CONSUMER_KEY" \ -d '{"query":"query Readiness { integrationReadiness { providers { provider available } googleIdentityAvailable } }"}' ``` ## What the key does and does not prove The key proves which application is calling. It does not prove which of your users is calling. Every connection operation takes a `scope` of `{ tenantKey, externalSubjectId }`, and SocialScope trusts your backend to send the right one. - Derive `tenantKey` from the workspace your signed-in user selected, checked against your own authorization rules. - Derive `externalSubjectId` from your signed-in user's stable ID. - Never read either value from a browser form, URL or header that the user controls. Doing so would let one user connect or read accounts in another user's name. `tenantKey` must be one of your registered tenant keys. An unregistered tenant gets `FORBIDDEN` or `NOT_FOUND`, depending on the operation. ## Rotation and expiry - A key can have an expiry chosen by the admin, or no expiry. - Your application can have at most two active keys at once, so you can rotate without downtime: ask for a new key, deploy it, confirm traffic works, then ask the admin to revoke the old one. - A key with no expiry stays valid until revoked. Rotate it on your own schedule. - If a key may have leaked, ask the admin to revoke it at once and issue a replacement. ## Authentication failures | Code | Meaning | What to do | | ----------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | `UNAUTHENTICATED` | The key is missing, malformed, unknown, revoked or expired | Check the configured value and expiry. Ask the admin for a new key | | `FORBIDDEN` | The key is valid but your application is disabled, or this call is outside your app's policy | Ask the admin to check your client | Each error carries `extensions.correlationId`, a UUID. Log it, without the key, and quote it when you ask the SocialScope team for help. ## Request limits Every request is checked against these limits before it runs: | Limit | Value | | ---------------------- | ----------------------------------------------------------- | | Operations per request | 1. Batched requests are rejected | | Aliases | 8 | | Field depth | 8 | | Cost | 100, where each root field costs 10 and each nested field 1 | | JSON body | 64 KiB | A request over the alias, depth, cost or operation limit fails with HTTP 400 and `BAD_USER_INPUT`, with a message such as `GraphQL cost limit exceeded`. A body over 64 KiB fails with HTTP 413 before GraphQL runs. In practice this means one or two root fields per request. Send separate requests rather than combining many operations with aliases. --- # 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 = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; 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 = ` Continuing
`; 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=` 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. --- # Read profiles, posts and metrics Once a person has connected an account, your backend reads its profile, posts and per-post metrics from SocialScope. Reads return data that SocialScope's background worker observed and stored, never a live provider call, so every value carries an `observedAt` time and an `availability`. Getting new data is asynchronous: you request a refresh or a URL lookup, poll the returned job, then read again. This page covers listing connections, paging posts, completeness, refreshing, URL lookup and polling. ## Rules that apply to every read - Build `scope` from your signed-in session on every call. Never take `tenantKey` or `externalSubjectId` from the browser. - A metric is a number only when its `availability` is `AVAILABLE`. Show `UNSUPPORTED`, `UNAVAILABLE` and `NOT_GRANTED` as "not available", never as `0`. - `DecimalCount` values are non-negative integer strings such as `"10482"`. Keep them as strings or convert with `BigInt`, or with `Number` only when you know they fit. - Show `observedAt` next to counts, so nobody mistakes a stored count for a live one. - Keep `sourceField` when you interpret a count, because the same metric name comes from different provider fields. - Do not call a list complete unless `pageInfo.completeForWindow` is `true` for the window you asked about. - Nothing refreshes automatically after the first sync. If you need fresh data, call `requestRefresh` yourself. - There are no webhooks. Every change, from a finished sync to a revoke receipt, is learned by polling. 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, which the retry logic below relies on. ## List connections `connections` lists the subject's accounts in this tenant, in every status until a disconnected account is erased. `first` is 1 to 100 and defaults to 20. SocialScope rechecks every `CONNECTED` account on each read. If one account's provider registration is paused, its grant stopped working or your app's policy changed, reading that account throws (`AUTHORIZATION_SUSPENDED`, `RECONNECT_REQUIRED`, `CAPABILITY_UNAVAILABLE` or `FORBIDDEN`), and `connections` fails as a whole with that code. So treat the list as a convenience: - Store the connection IDs you receive from Connect in your own database, per user and tenant. - Read each one with `connection(scope, id)` and map each error code to that account's state, as in the per-account example below. - If `connections` fails, fall back to the per-ID reads. Never show "no accounts" because the list call failed. ```ts import { socialscope, type Scope } from "./lib/socialscope"; const LIST_CONNECTIONS = ` query ListConnections($scope: SubjectScope!, $first: Int, $after: String) { connections(scope: $scope, first: $first, after: $after) { items { id provider providerAccountId status syncStatus lastSuccessfulSyncAt nextRetryAt capabilities { capability availability reason } profile { displayName handle avatarUrl profileUrl observedAt } } pageInfo { endCursor hasNextPage } } } `; type ConnectionSummary = { id: string; provider: "YOUTUBE" | "TIKTOK" | "INSTAGRAM"; providerAccountId: string; status: "CONNECTED" | "SUSPENDED" | "RECONNECT_REQUIRED" | "DISCONNECTED" | "DELETING"; syncStatus: "PENDING" | "RUNNING" | "READY" | "PARTIAL" | "STALE" | "FAILED"; lastSuccessfulSyncAt: string | null; nextRetryAt: string | null; capabilities: { capability: string; availability: string; reason: string | null }[]; profile: { displayName: string | null; handle: string | null; avatarUrl: string | null; profileUrl: string | null; observedAt: string; } | null; }; type ConnectionsPage = { connections: { items: ConnectionSummary[]; pageInfo: { endCursor: string | null; hasNextPage: boolean } }; }; export async function listConnections(scope: Scope): Promise { const all: ConnectionSummary[] = []; let after: string | null = null; do { const page: ConnectionsPage = await socialscope(LIST_CONNECTIONS, { scope, first: 100, after }); const { connections } = page; all.push(...connections.items); after = connections.pageInfo.hasNextPage ? connections.pageInfo.endCursor : null; } while (after); return all; } ``` `connection(scope, id)` returns one connection with the same fields. Read each stored ID on its own so one account's error does not hide the others: ```ts import { socialscope, SocialScopeError, type Scope } from "./lib/socialscope"; const READ_CONNECTION = ` query ReadConnection($scope: SubjectScope!, $id: ID!) { connection(scope: $scope, id: $id) { id provider status syncStatus lastSuccessfulSyncAt profile { displayName handle avatarUrl observedAt } } } `; type ProblemState = "suspended" | "reconnect" | "unavailable" | "gone" | "error"; type AccountState = { state: "ok"; connection: ConnectionSummary } | { state: ProblemState; code: string }; const STATE_BY_CODE: Record = { AUTHORIZATION_SUSPENDED: "suspended", RECONNECT_REQUIRED: "reconnect", CAPABILITY_UNAVAILABLE: "unavailable", FORBIDDEN: "unavailable", NOT_FOUND: "gone", }; // `storedIds` are the connection IDs your app saved for this user and tenant. export async function readAccounts(scope: Scope, storedIds: string[]): Promise { return Promise.all( storedIds.map(async (id): Promise => { try { const { connection } = await socialscope<{ connection: ConnectionSummary }>(READ_CONNECTION, { scope, id }); return { state: "ok", connection }; } catch (error) { const code = error instanceof SocialScopeError ? error.code : "SOCIALSCOPE_UNAVAILABLE"; return { state: STATE_BY_CODE[code] ?? "error", code }; } }), ); } ``` A `gone` account was erased after a disconnect. Remove it from your list. What each field means: - `profile` is returned only while the connection is `CONNECTED`, has the `PROFILE` capability and the stored profile is less than 30 days old. Otherwise it is null. - `lastSuccessfulSyncAt` and `nextRetryAt` are null unless the connection is `CONNECTED`. `nextRetryAt` is set while a failed sync waits to retry. - `capabilities` lists what this connection was granted. Per-metric gaps, such as YouTube having no share count, are reported on each metric, not here. - An account with `RECONNECT_REQUIRED` needs a [reconnect](/connect.md#reconnect). `DISCONNECTED` accounts are on their way out. `syncStatus` tells you how to present the account's posts: | `syncStatus` | Show | | -------------------- | -------------------------------------------------------------------------------------------------- | | `PENDING`, `RUNNING` | Loading | | `READY` | Loaded. Still check `completeForWindow` for the window you display | | `PARTIAL` | Loaded but possibly incomplete. Some partial scans cannot be continued, so do not promise the rest | | `STALE` | Show the stored data with its age (`observedAt`, `lastSuccessfulSyncAt`) and offer a refresh | | `FAILED` | An error with a retry. No data was read yet | ## Read posts in a window `content` pages through stored posts published inside a window, newest first. It needs the `CONTENT_LIST` capability. | Argument | Rule | | ------------------------ | ---------------------------------------------------------------------------------------- | | `window.publishedAfter` | ISO 8601 date-time, inclusive start | | `window.publishedBefore` | ISO 8601 date-time, exclusive end, after `publishedAfter`. The window is at most 90 days | | `window.first` | 1 to 50 items per page | | `window.after` | `pageInfo.endCursor` from the previous page, with the same window | ```ts import { socialscope, type Scope } from "./lib/socialscope"; const READ_CONTENT = ` query ReadContent($scope: SubjectScope!, $connectionId: ID!, $window: ContentWindow!) { content(scope: $scope, connectionId: $connectionId, window: $window) { items { id providerContentId provider kind canonicalUrl thumbnailUrl title description publishedAt availability reason observedAt metrics { name value availability reason sourceField observedAt } } pageInfo { endCursor hasNextPage completeForWindow incompleteReason } lastSuccessfulSyncAt } } `; type Metric = { name: "VIEWS" | "LIKES" | "COMMENTS" | "SHARES"; value: string | null; availability: "AVAILABLE" | "UNSUPPORTED" | "NOT_GRANTED" | "UNAVAILABLE"; reason: string | null; sourceField: string | null; observedAt: string; }; type Post = { id: string; providerContentId: string; provider: string; kind: "VIDEO" | "IMAGE" | "CAROUSEL" | "UNKNOWN"; canonicalUrl: string | null; thumbnailUrl: string | null; title: string | null; description: string | null; publishedAt: string | null; availability: string; reason: string | null; observedAt: string; metrics: Metric[]; }; type PageInfo = { endCursor: string | null; hasNextPage: boolean; completeForWindow: boolean; incompleteReason: string | null }; export async function readWindow(scope: Scope, connectionId: string, publishedAfter: string, publishedBefore: string) { const posts: Post[] = []; let after: string | undefined; let pageInfo: PageInfo; do { const { content } = await socialscope<{ content: { items: Post[]; pageInfo: PageInfo } }>(READ_CONTENT, { scope, connectionId, window: { publishedAfter, publishedBefore, first: 50, after }, }); posts.push(...content.items); pageInfo = content.pageInfo; after = pageInfo.hasNextPage && pageInfo.endCursor ? pageInfo.endCursor : undefined; } while (after); // Only claim "all posts" when SocialScope proved the window was fully scanned. return { posts, complete: pageInfo.completeForWindow, incompleteReason: pageInfo.incompleteReason }; } // Display helper: never turn a missing count into zero. export function formatMetric(metric: Metric | undefined): string { if (!metric || metric.availability !== "AVAILABLE" || metric.value === null) return "Not available"; return BigInt(metric.value).toLocaleString("en-US"); } ``` Notes: - A cursor is bound to your scope, the connection and the exact window. Changing the window, reconnecting or tampering with it gives `STALE_CURSOR`. Start again from the first page. - `content` returns only posts observed in the last 30 days. - `content` never calls the provider. To load posts outside what was already scanned, [refresh](#refresh-a-window) first. ## Completeness `pageInfo.completeForWindow` is answered for the exact window you queried. It is `true` only when a successful scan of the current connection covered that whole window. That scan must have finished in the last 7 days, or be the connection's latest scan, and in either case within the last 30 days. A scan only proves posts published before it started. So a window that ends after the scan began, including one that ends "now", stays incomplete until a later scan covers it. A reconnect clears completeness. An unscanned window reports `incompleteReason: "NOT_FULLY_SCANNED"`. | `incompleteReason` | Meaning | | -------------------------- | ---------------------------------------------------------------------------- | | `NOT_FULLY_SCANNED` | No qualifying scan covers this window | | `SCAN_BOUNDED` | The scan stopped at its page or item limit. Continue it or narrow the window | | `MISSING_PUBLICATION_DATE` | The provider returned an item without a publication date | | `CONTENT_UNAVAILABLE` | The provider returned an item SocialScope could not read | Treat any other value as incomplete too. `syncStatus` is a separate, coarser signal. `READY` means the last scan finished and was complete. A connection with only `PROFILE` can also be `READY` after a profile-only refresh, without any posts being read. ## Refresh a window `requestRefresh` without `providerContentId` queues a scan of the profile and posts in a window. It needs `CONTENT_LIST`. | Argument | Rule | | -------------------- | -------------------------------------------------------------------------------- | | `window` | Optional `SyncWindow`. Defaults to the 7 days ending now. At most 90 days | | `window.maxItems` | 1 to 100, default 100 | | `continuationCursor` | From a finished, incomplete scan's `syncJob.continuationCursor`, within 24 hours | To show a refreshed window as complete, read back exactly the window you sent. Fix `publishedBefore` once, at or before the moment you request the refresh, and reuse that value for the reads. ```ts import { socialscope, SocialScopeError, type Scope } from "./lib/socialscope"; const REFRESH_WINDOW = ` mutation RefreshWindow($scope: SubjectScope!, $connectionId: ID!, $window: SyncWindow, $continuationCursor: String) { requestRefresh(scope: $scope, connectionId: $connectionId, window: $window, continuationCursor: $continuationCursor) { id status } } `; const BUSY_RETRY_MS = 10_000; // 10 s between tries while another scan holds the connection const BUSY_ATTEMPTS = 30; // about 5 min in total type Job = { id: string; status: string }; // Right after Connect, the initial 7-day scan holds the connection, so any other window is busy until it finishes. async function requestRefreshWhenFree(variables: Record): Promise { for (let attempt = 1; ; attempt++) { try { const { requestRefresh } = await socialscope<{ requestRefresh: Job }>(REFRESH_WINDOW, variables); return requestRefresh; } catch (error) { if (!(error instanceof SocialScopeError) || attempt >= BUSY_ATTEMPTS) throw error; if (error.code === "SYNC_WINDOW_BUSY") { await new Promise((resolve) => setTimeout(resolve, BUSY_RETRY_MS)); } else if (error.code === "RATE_LIMITED") { await new Promise((resolve) => setTimeout(resolve, (error.retryAfterSeconds ?? 60) * 1000)); } else { throw error; } } } } export async function refreshLast30Days(scope: Scope, connectionId: string) { const publishedBefore = new Date().toISOString(); const publishedAfter = new Date(Date.now() - 30 * 24 * 60 * 60_000).toISOString(); // 30 d const window = { publishedAfter, publishedBefore, maxItems: 100 }; let job = await requestRefreshWhenFree({ scope, connectionId, window }); let result = await waitForJob(scope, connectionId, job.id); // Follow continuation cursors while the scan stopped at its limit. for (let rounds = 0; result.status === "SUCCEEDED" && !result.completeForWindow && result.continuationCursor && rounds < 5; rounds++) { job = await requestRefreshWhenFree({ scope, connectionId, continuationCursor: result.continuationCursor }); result = await waitForJob(scope, connectionId, job.id); } // Read back the exact same window. return readWindow(scope, connectionId, publishedAfter, publishedBefore); } ``` `waitForJob` and `readWindow` are defined on this page. A continuation inherits the original window. If you also pass `window` with a continuation, it must equal the original or you get `STALE_CURSOR`. Coalescing and limits: - The same window while a scan for it is queued or running returns the existing job. - A different window while another scan is queued or running fails with `SYNC_WINDOW_BUSY`. Wait for the running job, then retry. This includes the initial 7-day scan that starts right after Connect, and the same dates with a different `maxItems`. - Your application can have at most 20 queued or running read jobs across all connections. Beyond that you get `RATE_LIMITED` with `extensions.retryAfterSeconds` (60). Your own refresh of one window does not reset completeness for another window. ## Refresh one post's metrics `requestRefresh` with `providerContentId` queues a metrics refresh for one post SocialScope has already stored for this connection. It needs `CONTENT_METRICS`. Do not pass `window` or `continuationCursor` with it. An unknown post returns `NOT_FOUND`. A second request for the same post while one is queued or running returns the existing job. ```ts const REFRESH_POST = ` mutation RefreshPost($scope: SubjectScope!, $connectionId: ID!, $providerContentId: String!) { requestRefresh(scope: $scope, connectionId: $connectionId, providerContentId: $providerContentId) { id status } } `; ``` After the job succeeds, read the post with `contentItem`. ## Read one post `contentItem` returns one stored post by its provider ID. It needs `CONTENT_LIST` or `CONTENT_LOOKUP`. ```ts const READ_POST = ` query ReadPost($scope: SubjectScope!, $connectionId: ID!, $providerContentId: String!) { contentItem(scope: $scope, connectionId: $connectionId, providerContentId: $providerContentId) { providerContentId kind canonicalUrl title description publishedAt availability reason observedAt metrics { name value availability reason sourceField observedAt } } } `; ``` - It returns `null` when SocialScope never observed that post. - A post last observed more than 30 days ago comes back with its fields null, `availability: UNAVAILABLE`, `reason: EXPIRED` and no metrics. Refresh it to read it again. - A post a lookup found is not owned by this account comes back `UNAVAILABLE` with `reason: CONTENT_NOT_OWNED`. ## Look up a post by URL `requestContentLookup` resolves a post URL to a post owned by the connected account and observes it. It needs `CONTENT_LOOKUP`. SocialScope parses the URL locally and never fetches it. Accepted URLs are HTTPS only, with no credentials, port or fragment, up to 2,048 characters: | Provider | Accepted shapes | | --------- | --------------------------------------------------------------------------- | | YouTube | `youtube.com/watch?v=`, `www.youtube.com/watch?v=`, `youtu.be/` | | TikTok | `tiktok.com/@/video/` or `www.tiktok.com/@/video/` | | Instagram | `instagram.com/p/`, `instagram.com/reel/`, also with `www.` | Short links such as `vm.tiktok.com` and YouTube Shorts URLs are rejected with `BAD_USER_INPUT`. Ask for the full URL. ```ts import { socialscope, type Scope } from "./lib/socialscope"; const LOOKUP = ` mutation Lookup($scope: SubjectScope!, $connectionId: ID!, $url: String!) { requestContentLookup(scope: $scope, connectionId: $connectionId, url: $url) { id status } } `; const READ_LOOKED_UP_POST = ` query ReadLookedUpPost($scope: SubjectScope!, $connectionId: ID!, $providerContentId: String!) { contentItem(scope: $scope, connectionId: $connectionId, providerContentId: $providerContentId) { providerContentId canonicalUrl availability reason observedAt metrics { name value availability reason sourceField observedAt } } } `; export async function lookupPost(scope: Scope, connectionId: string, url: string) { const { requestContentLookup: job } = await socialscope<{ requestContentLookup: { id: string } }>(LOOKUP, { scope, connectionId, url, }); const result = await waitForJob(scope, connectionId, job.id); if (result.status !== "SUCCEEDED" || !result.resultProviderContentId) { return { found: false as const, reason: result.errorCode }; } // A succeeded lookup can still leave the post unavailable, so check the item itself. const { contentItem } = await socialscope<{ contentItem: { availability: string; reason: string | null } | null }>(READ_LOOKED_UP_POST, { scope, connectionId, providerContentId: result.resultProviderContentId, }); if (!contentItem || contentItem.availability !== "AVAILABLE") { return { found: false as const, reason: contentItem?.reason ?? result.incompleteReason }; } return { found: true as const, post: contentItem }; } ``` A lookup of a post SocialScope already stored can end `SUCCEEDED` with the post marked unavailable. The job then has `completeForWindow: false` and `incompleteReason` set to one of the codes below, and the item's `reason` and every metric's `reason` carry the same code. That is why the example reads `contentItem` and checks `availability` before showing the post. A failed lookup of a new post sets `errorCode`: | `errorCode` | Meaning | | --------------------- | ---------------------------------------------------------------------------------------------------------------- | | `CONTENT_NOT_OWNED` | The post exists but belongs to another account | | `CONTENT_UNAVAILABLE` | The provider did not return the post, for example it is private or deleted, or belongs to another TikTok account | | `LOOKUP_INCOMPLETE` | Instagram only. The post is not among the account's 100 most recent posts | | `BAD_PROVIDER_ID` | The ID in the URL is not a valid ID for that provider | ## Poll a job Refresh, lookup and disconnect return a `SyncJob`. Read it with `syncJob(scope, connectionId, id)` until its `status` is `SUCCEEDED`, `FAILED` or `CANCELED`. ```ts import { socialscope, type Scope } from "./lib/socialscope"; const READ_JOB = ` query ReadJob($scope: SubjectScope!, $connectionId: ID!, $jobId: ID!) { syncJob(scope: $scope, connectionId: $connectionId, id: $jobId) { id status nextRetryAt errorCode creatorAction resultProviderContentId completeForWindow incompleteReason continuationCursor } } `; export type SyncJob = { id: string; status: "QUEUED" | "RUNNING" | "SUCCEEDED" | "FAILED" | "CANCELED"; nextRetryAt: string | null; errorCode: string | null; creatorAction: string | null; resultProviderContentId: string | null; completeForWindow: boolean | null; incompleteReason: string | null; continuationCursor: string | null; }; const POLL_START_MS = 2_000; // 2 s const POLL_MAX_MS = 30_000; // 30 s const POLL_DEADLINE_MS = 5 * 60_000; // 5 min export async function waitForJob(scope: Scope, connectionId: string, jobId: string): Promise { const deadline = Date.now() + POLL_DEADLINE_MS; let delay = POLL_START_MS; for (;;) { const { syncJob } = await socialscope<{ syncJob: SyncJob }>(READ_JOB, { scope, connectionId, jobId }); if (syncJob.status === "SUCCEEDED" || syncJob.status === "FAILED" || syncJob.status === "CANCELED") return syncJob; if (Date.now() + delay > deadline) throw new Error("SOCIALSCOPE_JOB_TIMEOUT"); await new Promise((resolve) => setTimeout(resolve, delay)); delay = Math.min(delay * 2, POLL_MAX_MS); } } ``` These intervals are a sensible default, not a SocialScope requirement. A provider's `Retry-After` can delay a retry beyond the 5-minute polling deadline. A `SOCIALSCOPE_JOB_TIMEOUT` therefore means "not finished yet", not "failed", so read the job again later. Jobs usually start within seconds, and a failed provider call is retried a few times with short delays, shown in `nextRetryAt`. Polling inside a web request ties up that request, so for long scans prefer a background job in your app or let the page check the job again later. Job notes: - `connectionId` is the connection the job belongs to. For disconnect receipts use the original connection ID, even after erasure. - Terminal jobs are readable for 7 days after they finish. Revoke and erase receipts are readable for 7 days after creation. - `CANCELED` usually means the connection changed under the job, for example a reconnect or disconnect. Read the connection again. - `FAILED` with `RECONNECT_REQUIRED` means the provider grant stopped working. Read the connection, and offer a [reconnect](/connect.md#reconnect) when its status is `RECONNECT_REQUIRED`. - `FAILED` with `UPSTREAM_UNAVAILABLE` or `RATE_LIMITED` means the provider kept failing after retries. Try again later. A provider rate limit pauses reads for every app on that provider for a while. ## Data lifetime | Data | How long SocialScope returns it | | ----------------------- | ---------------------------------------- | | Posts and their metrics | 30 days after the post was last observed | | Profile | 30 days after it was last observed | | Connect attempt result | 24 hours after the attempt was created | | Finished jobs | 7 days | | A disconnected account | Until erasure finishes, usually minutes | These are SocialScope's current limits. Keep your own copy only within your app's data policy, and refresh rather than trusting an old observation. --- # Sign people in with Google SocialScope can run "Continue with Google" for your app, so you can sign people up or in without operating your own Google OAuth client. Your backend starts an identity attempt, your page auto-posts a one-use launch token to SocialScope, the person approves at Google, and your backend redeems the verified result exactly once. You then create or find your own user and start your own session. This flow is separate from connecting a YouTube channel: it creates no SocialScope user, no connection and no YouTube access. ## How it differs from Connect | | Google identity | Social Connect | | -------------------------- | ---------------------------------------------- | ------------------------------------- | | Needs an existing app user | No. Only a tenant key and a return URL | Yes, a scope with `externalSubjectId` | | Google permissions | `openid email profile` | Provider read permissions | | Result | Verified claims, returned once to your backend | A durable connection | | Return hint | `ss_identity_id` | `ss_session_id` | | Launch path | `/api/identity/redeem` | `/api/connect/redeem` | A person can run both flows at the same time in different tabs. ## Before you start - An admin enables Google identity for your client and registers your identity return URL, for example `https://app.example.com/auth/google/return`. The URL must be on one of your registered origins and must not contain any query parameter starting with `ss_`. - `integrationReadiness.googleIdentityAvailable` is `true`. If it is `false` or the readiness call fails, hide or disable the Google button. - Your server has `SOCIALSCOPE_GRAPHQL_URL` and `SOCIALSCOPE_CONSUMER_KEY`. See [Authentication](/authentication.md). - Pull request preview deployments cannot use Google identity. SocialScope serves per-PR preview hosts through one shared preview client registered with a host template instead of exact origins, and that client is limited to Connect. See [Testing](/testing.md#preview-deployments). The examples import `socialscope` from `lib/socialscope.ts`. Copy that file from [Authentication](/authentication.md#call-it). It keeps `extensions.code` and `retryAfterSeconds` on every error, which you need for `RATE_LIMITED`. ## The flow ```mermaid sequenceDiagram autonumber actor B as Browser participant App as Your backend participant SS as SocialScope participant G as Google B->>App: Continue with Google App->>SS: createGoogleIdentitySession(tenantKey, returnUrl) SS-->>App: id, launchUrl, launchToken, resultProof App->>App: Save id, tenantKey, resultProof with the browser session App-->>B: no-store page that auto-posts launchToken B->>SS: POST /api/identity/redeem (Origin = your origin) SS-->>B: SocialScope page names your app, then Google B->>G: Sign in and approve G-->>B: Redirect to SocialScope callback B->>SS: Callback (SocialScope verifies the ID token) SS-->>B: 303 to returnUrl?ss_identity_id={id} B->>App: GET returnUrl?ss_identity_id={id} App->>SS: redeemGoogleIdentityResult(id, tenantKey, resultProof) SS-->>App: status, issuer, googleSub, email, emailVerified, name, pictureUrl App->>App: Find or create user by issuer + googleSub, then start your session ``` ## 1. Start the attempt There may be no user yet, so bind the attempt to the browser session instead, for example a short-lived, signed, HttpOnly pre-login cookie. Your server picks the tenant and return URL from its own configuration. ```ts import { socialscope } from "./lib/socialscope"; import { launchPage } from "./lib/socialscope-launch"; const START_GOOGLE_IDENTITY = ` mutation StartGoogleIdentity($input: GoogleIdentityInput!) { createGoogleIdentitySession(input: $input) { id launchUrl launchToken resultProof expiresAt resultExpiresAt } } `; type IdentitySession = { id: string; launchUrl: string; launchToken: string; resultProof: string; expiresAt: string; // authorization deadline, 10 minutes after creation resultExpiresAt: string; // redeem deadline, 20 minutes after creation }; // `browserSessionId` identifies the pre-login browser session. `identityAttempts` is your server-side store. export async function startGoogleSignIn(browserSessionId: string) { const tenantKey = "example-workspace"; const returnUrl = "https://app.example.com/auth/google/return"; const { createGoogleIdentitySession: attempt } = await socialscope<{ createGoogleIdentitySession: IdentitySession }>(START_GOOGLE_IDENTITY, { input: { tenantKey, returnUrl } }); // resultProof never leaves the server: not to the browser, a URL or a log. await identityAttempts.insert({ id: attempt.id, browserSessionId, tenantKey, resultProof: attempt.resultProof, resultExpiresAt: attempt.resultExpiresAt, used: false, }); return launchPage(attempt.launchUrl, attempt.launchToken, "/api/identity/redeem"); } ``` Errors you can get here: | Code | Meaning | What to do | | ------------------------ | -------------------------------------------------------------------------- | -------------------------------------------------------------- | | `CAPABILITY_UNAVAILABLE` | Google identity is not available in this SocialScope environment right now | Hide the button. Readiness will say `false` too | | `FORBIDDEN` | Google identity is not enabled for your app, or the tenant is not yours | Ask the admin | | `BAD_USER_INPUT` | The return URL is not exactly registered | Fix the configured return URL | | `RATE_LIMITED` | Your app already has 20 unfinished attempts | Wait `extensions.retryAfterSeconds` (1 to 600) before retrying | ## 2. Send the browser Use the same launch page as Connect, with the identity path. Serve it from the origin of your identity return URL. ```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 = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; 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 = ` Continuing
`; 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", }, }); } ``` SocialScope shows a page naming your app and asks the person to continue to Google. After Google, it redirects to your return URL with only `ss_identity_id`. No claims, code or token ever appear in the URL. ## 3. Redeem the result At your return route, require the same browser session that started the attempt and match the hint to its saved unused attempt. Redeem once from your server with the saved tenant and proof, then mark the attempt used unless the status is `PENDING`, which does not consume the proof. The browser reaches this route through a cross-site redirect from SocialScope, so the pre-login cookie you check must be `SameSite=Lax` or `None`. A `SameSite=Strict` cookie is not sent on that request. ```ts import { socialscope } from "./lib/socialscope"; const REDEEM_GOOGLE_IDENTITY = ` mutation RedeemGoogleIdentity($id: ID!, $tenantKey: String!, $resultProof: String!) { redeemGoogleIdentityResult(id: $id, tenantKey: $tenantKey, resultProof: $resultProof) { status issuer googleSub email emailVerified name pictureUrl } } `; type IdentityResult = { status: "PENDING" | "COMPLETED" | "DENIED" | "FAILED" | "EXPIRED"; issuer: string | null; googleSub: string | null; email: string | null; emailVerified: boolean | null; name: string | null; pictureUrl: 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; export async function googleReturn(url: URL, browserSessionId: string) { const hints = url.searchParams.getAll("ss_identity_id"); if (hints.length !== 1 || !UUID.test(hints[0])) return { outcome: "retry" as const }; const saved = await identityAttempts.findUnused({ id: hints[0], browserSessionId }); if (!saved || new Date(saved.resultExpiresAt) <= new Date()) return { outcome: "retry" as const }; const { redeemGoogleIdentityResult: person } = await socialscope<{ redeemGoogleIdentityResult: IdentityResult }>(REDEEM_GOOGLE_IDENTITY, { id: saved.id, tenantKey: saved.tenantKey, resultProof: saved.resultProof }); // PENDING does not consume the proof. Every other status does. if (person.status === "PENDING") return { outcome: "pending" as const }; await identityAttempts.markUsed(saved.id); if (person.status !== "COMPLETED" || !person.issuer || !person.googleSub) { return { outcome: "not-signed-in" as const, status: person.status }; } // Key the user by issuer + googleSub. Email can change and is never a linking key. const user = await users.findOrCreateByGoogle({ issuer: person.issuer, googleSub: person.googleSub, email: person.email, emailVerified: person.emailVerified === true, name: person.name, pictureUrl: person.pictureUrl, }); // Start your session only after the user write succeeded. return { outcome: "signed-in" as const, userId: user.id }; } ``` Rules: - Only `COMPLETED` with a non-empty `issuer` and `googleSub` can create or sign in a user. `issuer` is `https://accounts.google.com`. - Key your user by issuer plus Google subject in your own database. Email may change, so never link accounts by email alone. - Check `emailVerified` before treating the email as verified. - Create your app's session only after your own user write succeeds. - `DENIED`, `FAILED` and `EXPIRED` create no user. Offer a retry. - `PENDING` means the authorization is still running. Redeem again shortly, until the 20-minute result deadline. - The proof redeems once. A second redeem returns `NOT_FOUND` and must never sign anyone in. - Strip `ss_identity_id` from the URL before analytics or third-party scripts load. - If the browser session that started the attempt has expired, have the person start again rather than accepting the hint. ## Statuses | Status | Meaning | Proof consumed | | ----------- | --------------------------------------------------------------------- | -------------- | | `PENDING` | Still running and the 10-minute authorization deadline has not passed | No | | `COMPLETED` | Verified. Claims are returned once | Yes | | `DENIED` | The person cancelled at Google | Yes | | `FAILED` | The Google exchange or verification failed | Yes | | `EXPIRED` | The authorization deadline passed | Yes | `redeemGoogleIdentityResult` returns `NOT_FOUND` for a second redeem, a wrong proof, a wrong tenant, another app's attempt, a result past its 20-minute deadline, or when identity was turned off for your app. These are deliberately indistinguishable. ## Failure and recovery | What happened | What your app sees | What to do | | ----------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | The launch form was blocked or the tab closed | No return. A redeem gives `PENDING`, then `EXPIRED` after 10 minutes | Start a new attempt | | The person cancelled at Google | Return with the hint. Redeem gives `DENIED` | Show cancelled. Offer a retry | | Google's code exchange failed | The browser may stay on SocialScope's error page and never return | Redeem from your server with the saved attempt. You get `FAILED` or `EXPIRED`. Offer a retry | | The person reloaded SocialScope's page after starting | SocialScope says the request could not be verified | Start again from your app | | The network dropped during your redeem call | The proof may already be consumed and the claims gone | Start a new attempt | | Identity was turned off mid-flow | Later steps fail. Redeem gives `NOT_FOUND` | Start again once it is re-enabled | ## What Google identity does not do - It does not create a SocialScope user or session. Your app owns both. - It does not grant YouTube access. Use [Connect](/connect.md) with `YOUTUBE` for that. - It does not store any Google access or refresh token. --- # Errors and result codes SocialScope reports problems in four places: GraphQL errors on a request, `resultCode` on a Connect attempt, `errorCode` on a sync job, and `reason` or `incompleteReason` on data. This page lists every value a consumer can see and what your app should do about it. Treat any code you do not recognize as a retryable failure that shows no success, and log its `correlationId`. ## GraphQL error shape A failed request returns an `errors` array. Each error looks like this: ```json { "errors": [ { "message": "RATE_LIMITED", "path": ["requestRefresh"], "extensions": { "code": "RATE_LIMITED", "correlationId": "4f1c2a9e-0b7d-4e7a-9a51-2a3f0c1d9e88", "retryAfterSeconds": 60 } } ], "data": null } ``` - `extensions.code` is one of the codes below. Branch on it, not on `message`. - `message` is the code itself, except for the request-limit messages `GraphQL alias limit exceeded`, `GraphQL depth limit exceeded`, `GraphQL cost limit exceeded`, `GraphQL operation limit exceeded` and `Operation batching disabled`. Any other detail, such as a parse or validation error naming a bad field, is replaced by the bare code. - `extensions.correlationId` is a UUID on every error. Log it and quote it when you ask for help. - `extensions.retryAfterSeconds` appears only on some `RATE_LIMITED` errors. - Do not rely on the HTTP status. Most failed operations arrive with HTTP 200. Parse, validation, limit and batching errors arrive with HTTP 400 and the same `errors` shape. A body over 64 KiB gets HTTP 413 before GraphQL runs, with no GraphQL body. ## GraphQL error codes | Code | Where | Meaning | What to do | | ------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `UNAUTHENTICATED` | Any authenticated operation | The key is missing, malformed, unknown, revoked or expired | Check configuration. Ask the admin for a new key. Do not retry in a loop | | `FORBIDDEN` | Any | Your app is disabled, or the tenant, provider, capability or registration is outside your app's policy. Also a connection whose policy changed. On `createConnectSession` it can also be transient, while a disconnect at the same provider settles in production | Treat the feature as unavailable for now and retry later. If it persists, ask the admin | | `NOT_FOUND` | Reads and mutations | The attempt, connection, post or job does not exist, belongs to another scope or app, or has expired. Also an unregistered tenant on most reads | Refresh your local state. Never reveal whether another user's item exists | | `BAD_USER_INPUT` | Any | Invalid window, `first`, URL, capability list, scope length or return URL. Also a query that does not parse or does not match the schema, such as a misspelled field, and a request over a [limit](/authentication.md#request-limits). The message does not say which field is wrong | Fix the request. This is a bug, not a transient error. Validate your queries against [/schema.graphql](/schema.graphql) locally, for example with `graphql-js` `validate()`, because the error will not name the field | | `STALE_CURSOR` | `connections`, `content`, `requestRefresh` | A cursor is tampered with, from another owner or window, expired, or from before a reconnect | Restart from the first page or a new refresh | | `SYNC_WINDOW_BUSY` | `requestRefresh` | A scan for a different window is already queued or running on this connection | Wait for that job, then retry | | `RATE_LIMITED` | `createConnectSession`, `createGoogleIdentitySession`, `requestRefresh`, `requestContentLookup` | A cap was reached. See [Rate limits](#rate-limits) | Wait `retryAfterSeconds` when present. Otherwise back off and retry later | | `RECONNECT_REQUIRED` | Reads and refreshes | The connection is not `CONNECTED`, its grant stopped working, or a reconnect target cannot be reconnected | Show a reconnect action. See [Connect](/connect.md#reconnect) | | `AUTHORIZATION_SUSPENDED` | Reads and refreshes | The connection is `SUSPENDED`, or SocialScope paused the provider registration | Show "temporarily unavailable". Retry later. If it persists, ask the SocialScope team | | `CAPABILITY_UNAVAILABLE` | Reads, refreshes, identity start | The connection lacks the capability, the provider no longer serves your app, or Google identity is unavailable | Hide the feature for this connection | | `DISCONNECT_IN_PROGRESS` | `createConnectSession` | SocialScope is still removing an earlier connection for this subject and provider | Show "try again later". No attempt was created | | `UPSTREAM_UNAVAILABLE` | Any | An unexpected failure inside SocialScope or at a provider | Retry with backoff. Show a safe error, never inferred success | ### Reads by connection status | `Connection.status` | `connection`, `connections` | `content`, `contentItem`, `requestRefresh`, `requestContentLookup` | | -------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------- | | `CONNECTED` | Full row, or an error when the row is no longer readable (below) | Allowed when the capability is available, otherwise the same errors | | `SUSPENDED` | Row with no profile | `AUTHORIZATION_SUSPENDED` | | `RECONNECT_REQUIRED` | Row with no profile | `RECONNECT_REQUIRED` | | `DISCONNECTED` | Row with no profile, until erased | `RECONNECT_REQUIRED` | | Erased | `NOT_FOUND` | `NOT_FOUND` | A `CONNECTED` connection is checked again on every read. `connection` then throws instead of returning the row when: | Code | Why | Per-account state to show | | ------------------------- | ---------------------------------------------------------------------- | ------------------------- | | `AUTHORIZATION_SUSPENDED` | SocialScope paused the provider registration | Temporarily unavailable | | `RECONNECT_REQUIRED` | The grant is no longer active, or its refresh credential expired | Needs reconnect | | `CAPABILITY_UNAVAILABLE` | The provider registration no longer serves your app | Unavailable | | `FORBIDDEN` | Your app's policy no longer allows this tenant, provider or capability | Unavailable | `connections` reads every row on the page the same way, so one such account makes the whole list call fail with that code. Keep the connection IDs you received from Connect in your own database, read each one with `connection`, and map each error code to that account's state. Treat `connections` as a convenience. If it fails, fall back to per-ID reads rather than showing "no accounts". ### Local codes from the sample helper The sample helper in [Authentication](/authentication.md) adds codes of its own. SocialScope never sends them. | Code | Meaning | | ------------------------------- | -------------------------------------------------------------- | | `SOCIALSCOPE_NOT_CONFIGURED` | The GraphQL URL or key is missing from your server environment | | `SOCIALSCOPE_REQUEST_TOO_LARGE` | The request body was over 64 KiB and got HTTP 413 | | `SOCIALSCOPE_UNAVAILABLE` | Network failure, timeout, or a response that was not GraphQL | | `SOCIALSCOPE_JOB_TIMEOUT` | The polling helper gave up waiting for a job | ## Rate limits | Limit | Error | | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | | 5 unfinished Connect attempts per subject and provider | `RATE_LIMITED` from `createConnectSession`, no retry hint. Attempts stop counting once they finish or pass their 10-minute deadline | | 20 unfinished Google identity attempts per app | `RATE_LIMITED` with `retryAfterSeconds` from 1 to 600 | | 20 queued or running read jobs per app | `RATE_LIMITED` with `retryAfterSeconds: 60` | | One scan window at a time per connection | `SYNC_WINDOW_BUSY` | There is no minimum interval between refreshes, but repeated requests for the same window or post while one is in flight return the existing job. ## Connect result codes `connectSessionResult` returns `status`, `resultCode` and `creatorAction`. | `status` | `resultCode` | Meaning | | ----------- | ----------------------------- | -------------------------------------------------------------------------------------------------------- | | `COMPLETED` | `CONNECTED` | The attempt committed a connection. Read `connection` for its current status | | `DENIED` | `PROVIDER_DENIED` | The person declined at the provider | | `EXPIRED` | `SESSION_EXPIRED` | The 10-minute authorization deadline passed | | `CANCELED` | `USER_CANCELED` | The person cancelled on SocialScope's page | | `CANCELED` | `RECONNECT_REQUIRED` | SocialScope stopped the attempt while a disconnect at the same provider settled | | `FAILED` | `POLICY_WITHDRAWN` | Your app's access changed during the attempt | | `FAILED` | `DISCONNECT_IN_PROGRESS` | The chosen account is still being removed for this subject | | `FAILED` | `ACCOUNT_NOT_ELIGIBLE` | Wrong account type, such as a personal Instagram account or a Google account without exactly one channel | | `FAILED` | `ACCOUNT_MISMATCH` | The provider returned inconsistent account details | | `FAILED` | `MISSING_REQUIRED_PERMISSION` | The grant lacks a permission SocialScope needs | | `FAILED` | `OFFLINE_ACCESS_REQUIRED` | The provider did not issue a refresh token | | `FAILED` | `RECONNECT_REQUIRED` | A reconnect picked a different account, or the target changed during the attempt | | `FAILED` | `RATE_LIMITED` | The provider or SocialScope was busy with another token operation | | `FAILED` | `UPSTREAM_UNAVAILABLE` | The provider did not answer the code exchange, or another failure | | `FAILED` | `CAPABILITY_UNAVAILABLE` | The provider registration stopped serving your app during the attempt | For every non-`COMPLETED` result, offer a retry in your app. Never fall back to your own provider OAuth. `creatorAction` is null or `REMOVE_APP_AT_PROVIDER`. When set, ask the person to remove the app in their provider account settings, because a grant may still be active there. Treat any unknown value the same way. It can change from null to `REMOVE_APP_AT_PROVIDER` on a later read of the same attempt. ## Sync job error codes `syncJob.errorCode` explains a `FAILED` job, and in one case a `SUCCEEDED` one. A `QUEUED` job that is waiting to retry also carries the last error in `errorCode`, with `nextRetryAt` set. That is not a final failure. | `errorCode` | Job kind | Meaning and action | | -------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------- | | `CONTENT_NOT_OWNED` | Lookup | The post belongs to another account. Tell the person | | `CONTENT_UNAVAILABLE` | Lookup | The provider did not return the post. It may be private, deleted or not theirs | | `LOOKUP_INCOMPLETE` | Lookup | Instagram only. The post is not among the 100 most recent | | `BAD_PROVIDER_ID` | Lookup | The URL's ID is not valid for that provider | | `RECONNECT_REQUIRED` | Any read | The grant stopped working. Offer a reconnect | | `CAPABILITY_UNAVAILABLE` | Any read | The capability or provider is no longer available for this connection | | `ACCOUNT_MISMATCH` | Any read | The provider now reports a different account for this grant. Offer a reconnect | | `MISSING_REQUIRED_PERMISSION` | Any read | The grant lost a permission. Offer a reconnect | | `UPSTREAM_UNAVAILABLE` | Any | The provider kept failing after retries. Try again later | | `RATE_LIMITED` | Any | The provider kept rate limiting after retries. Try again later | | `UPSTREAM_REVOCATION_UNVERIFIED` | Revoke | SocialScope could not confirm the revoke. Always the case for Instagram. `creatorAction` is set | | `ALREADY_INVALID` | Revoke, on `SUCCEEDED` | The provider said the token was already invalid. The grant may still exist, so `creatorAction` is set | ## Availability reasons On a `MetricObservation`: | `availability` | `reason` | Meaning | | -------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | `AVAILABLE` | null | `value` holds the count | | `UNSUPPORTED` | `PROVIDER_UNSUPPORTED` | The provider does not offer this metric for this item | | `UNAVAILABLE` | `PROVIDER_OMITTED` | The provider did not return the value | | `UNAVAILABLE` | `PROVIDER_INVALID` | The provider returned something that was not a count | | `UNAVAILABLE` | `CONTENT_NOT_OWNED`, `CONTENT_UNAVAILABLE`, `LOOKUP_INCOMPLETE`, `BAD_PROVIDER_ID` | A later metrics refresh or lookup could not read the post. `value` is null | On a `ContentItem`: | `availability` | `reason` | Meaning | | -------------- | ------------------------------------------------------------- | ---------------------------------------------------------------- | | `AVAILABLE` | null | Normal | | `UNAVAILABLE` | `EXPIRED` | Last observed more than 30 days ago. Fields are null. Refresh it | | `UNAVAILABLE` | `CONTENT_NOT_OWNED` | A lookup found it belongs to another account | | `UNAVAILABLE` | `CONTENT_UNAVAILABLE`, `LOOKUP_INCOMPLETE`, `BAD_PROVIDER_ID` | A metrics refresh or lookup could not read it | In every case except `AVAILABLE`, show "not available", never `0`. ## Completeness reasons `pageInfo.incompleteReason` and `syncJob.incompleteReason`: | Value | Meaning | | -------------------------- | ------------------------------------ | | `NOT_FULLY_SCANNED` | No qualifying scan covers the window | | `SCAN_BOUNDED` | The scan hit its page or item limit | | `MISSING_PUBLICATION_DATE` | An item had no publication date | | `CONTENT_UNAVAILABLE` | An item could not be read | A `syncJob` for a metrics refresh or lookup that could not read its post also sets `completeForWindow: false` and `incompleteReason` to `CONTENT_NOT_OWNED`, `CONTENT_UNAVAILABLE`, `LOOKUP_INCOMPLETE` or `BAD_PROVIDER_ID`. Treat any other value as incomplete too. See [Reading data](/reading-data.md#completeness). ## Google identity outcomes `redeemGoogleIdentityResult.status` is `PENDING`, `COMPLETED`, `DENIED`, `FAILED` or `EXPIRED`. A second redeem, a wrong proof or tenant, another app's attempt, an expired result, or identity turned off for your app all return the same `NOT_FOUND`. See [Google identity](/google-identity.md#statuses). ## Browser-side failures These happen in the person's browser on SocialScope's host, not in your GraphQL calls. You learn about them by reading the attempt from your server. | Failure | What the person sees | What your server reads | | ----------------------------------------------------------------- | -------------------------------------------------- | -------------------------------------------- | | Launch posted from the wrong origin, or a reused or expired token | A `403` with a small JSON body | The attempt stays unfinished, then `EXPIRED` | | Consent opened in a different browser or profile | A `403` at the callback | Unfinished, then `EXPIRED` | | Provider returned an unexpected error | A `403` at the callback | Unfinished, then `EXPIRED` | | Your return URL was removed during the attempt | A static "Return to the app you started from" page | The result is still readable | In each case, let the person start again from your app. An unfinished attempt reads as `EXPIRED` once its 10-minute deadline passes. --- # Test your integration A SocialScope integration is mostly about refusing the right things: another user's return, another app's connection, a missing metric presented as zero, or success inferred from a redirect. This page explains where you can test, which cases to cover before launch, how to stub SocialScope in your own test suite, and what a passing test does and does not prove. ## Where to test | Where | What it proves | | ---------------------------------------- | ------------------------------------------------------------------- | | Your own test suite, SocialScope stubbed | Your routes, ownership checks, result handling and display rules | | The dev deployment | The real handoff, origins, return URLs and a live provider consent | | SocialScope's local fixture | The whole Connect journey with synthetic accounts, no live provider | ### The dev deployment - GraphQL: `https://connect.socialscope.hardscope.incdev.dev/graphql` - Connect host: `https://connect.socialscope.hardscope.incdev.dev` Your test environment needs its own client registration and key. Origins on the dev deployment must be exact HTTPS origins on a public DNS name, with no port. `http://localhost:3000` is not accepted, so test the browser flow from a deployed HTTPS environment of your app, registered as its own origin and return URL. Disconnects behave differently on dev. On dev a disconnect affects only the one connection. In production, a YouTube or TikTok disconnect also suspends other accounts on that provider, cancels Connect attempts in progress and briefly makes the provider unavailable to new attempts, until its revoke settles. Dev testing cannot show those effects, so cover them with stubbed tests. See [Connect](/connect.md#disconnect). Provider apps on dev can run in sandbox or testing mode, where only accounts added as testers can connect. Ask the SocialScope admin which test accounts to use for each provider. Readiness reports a provider as available only after the admin has enabled it for your app. ### SocialScope's local fixture SocialScope can run locally with a fake provider that returns synthetic profiles, posts and metrics. It runs the real hosted handoff on `consumer.localhost` and `socialscope.localhost` in Chromium. It needs access to the SocialScope source, so ask the SocialScope team if you want to use it. The fixture proves wiring. It does not prove provider approval or a live consent. ## Cases to cover before launch ### Connect - [ ] Two different users in the same tenant each connect an account. Neither can list or read the other's connection. - [ ] A second app key, registered as a separate client, cannot read the first app's connections. The read returns `NOT_FOUND`. - [ ] The return route rejects a missing, malformed, duplicate, foreign or already used `ss_session_id` without showing success. - [ ] A return for user A opened while signed in as user B is rejected. - [ ] Opening the return URL twice with the same hint does not show success twice. - [ ] With a provider unavailable in readiness, its button shows as unavailable. A failed readiness call also disables it. - [ ] Declining at the provider (`DENIED`) and letting an attempt expire (`EXPIRED`) both lead to a retry, not success. - [ ] `COMPLETED` with a connection that is not `CONNECTED` does not show as connected. - [ ] Each `syncStatus` renders correctly: `PENDING` and `RUNNING` as loading, `READY` as loaded, `PARTIAL` as possibly incomplete, `STALE` with its age, `FAILED` as an error with a retry. - [ ] A stored connection whose read fails with `AUTHORIZATION_SUSPENDED`, `RECONNECT_REQUIRED`, `CAPABILITY_UNAVAILABLE` or `FORBIDDEN` shows that per-account state, and the other accounts still render. - [ ] Any `creatorAction` shows the "remove the app at the provider" instruction. - [ ] `ss_session_id` is gone from the URL before analytics or third-party scripts load. ### Data - [ ] A metric with `availability` other than `AVAILABLE` renders as "not available", never `0`. - [ ] `observedAt` is shown next to counts. - [ ] A window with `completeForWindow: false` is not presented as the full list. - [ ] `SYNC_WINDOW_BUSY` and `RATE_LIMITED` lead to a later retry, not an error page loop. ### Disconnect - [ ] Disconnecting removes the account from your UI immediately. - [ ] An Instagram disconnect always shows the "remove the app in Instagram settings" instruction. - [ ] Your UI never claims a provider revoke unless the receipt is `SUCCEEDED` with a null `creatorAction`. ### Google identity, if used - [ ] A new Google user creates one local user keyed by `issuer` and `googleSub`. - [ ] A returning Google user signs in to the same local user, even if their email changed. - [ ] A second redeem of the same result is rejected and signs nobody in. - [ ] `DENIED`, `FAILED` and `EXPIRED` create no user. - [ ] A hint from another browser session is rejected. ### Secrets - [ ] The key is absent from every browser bundle. Search the built client assets for the key ID. - [ ] Logs contain no `Authorization` header, launch token or result proof. ## Stub SocialScope in your own tests Test your routes through their public interface and stub SocialScope at the HTTP boundary, the `fetch` to `SOCIALSCOPE_GRAPHQL_URL`. Answer with responses shaped like the [schema](/schema.graphql), including error responses with `extensions.code`. This Vitest example checks that a return route refuses a hint that does not belong to the signed-in user, without calling SocialScope at all. `handleConnectReturn` and `seedAttempt` stand in for your own route and test fixture. ```ts import { afterEach, describe, expect, it, vi } from "vitest"; import { handleConnectReturn } from "../src/social/return-route"; import { seedAttempt } from "./fixtures"; const GRAPHQL_URL = "https://connect.socialscope.hardscope.incdev.dev/graphql"; function stubSocialScope(answer: (operation: string) => unknown) { const calls: string[] = []; vi.stubGlobal( "fetch", vi.fn(async (url: string, init: RequestInit) => { expect(url).toBe(GRAPHQL_URL); const { query } = JSON.parse(String(init.body)) as { query: string }; const operation = /(?:query|mutation)\s+(\w+)/.exec(query)?.[1] ?? "anonymous"; calls.push(operation); return new Response(JSON.stringify(answer(operation)), { headers: { "content-type": "application/json" } }); }), ); return calls; } afterEach(() => { vi.unstubAllGlobals(); }); describe("connect return route", () => { it("rejects another user's attempt without asking SocialScope", async () => { // Arrange process.env.SOCIALSCOPE_GRAPHQL_URL = GRAPHQL_URL; process.env.SOCIALSCOPE_CONSUMER_KEY = "test-key-id.test-secret"; const attempt = await seedAttempt({ userId: "user-a", tenantKey: "example-workspace" }); const calls = stubSocialScope(() => ({ data: null })); const url = new URL(`https://app.example.com/social/return?ss_session_id=${attempt.id}`); // Act const result = await handleConnectReturn(url, { userId: "user-b", tenantKey: "example-workspace" }); // Assert expect(result.outcome).toBe("retry"); expect(calls).toEqual([]); }); it("does not show success when the connection is no longer connected", async () => { // Arrange process.env.SOCIALSCOPE_GRAPHQL_URL = GRAPHQL_URL; process.env.SOCIALSCOPE_CONSUMER_KEY = "test-key-id.test-secret"; const attempt = await seedAttempt({ userId: "user-a", tenantKey: "example-workspace" }); stubSocialScope((operation) => operation === "VerifyConnection" ? { data: { connectSessionResult: { id: attempt.id, provider: "INSTAGRAM", status: "COMPLETED", resultCode: "CONNECTED", connectionId: "c0ffee00-0000-4000-8000-000000000001", creatorAction: null, }, }, } : { data: { connection: { id: "c0ffee00-0000-4000-8000-000000000001", provider: "INSTAGRAM", status: "RECONNECT_REQUIRED", syncStatus: "STALE", }, }, }, ); const url = new URL(`https://app.example.com/social/return?ss_session_id=${attempt.id}`); // Act const result = await handleConnectReturn(url, { userId: "user-a", tenantKey: "example-workspace" }); // Assert expect(result.outcome).toBe("not-connected"); }); }); ``` Use real keys only in the dev environment's secret store, never in test files. ## Preview deployments If your app deploys a preview per pull request on hosts that are only known at deploy time, SocialScope can serve one shared preview client per deployment. It matches a host template such as `https://pr-*-example-preview.example.com`, where `*` is the pull request number. Ask the SocialScope admin whether a preview client is configured for your app and which template and return paths it uses. Rules for preview clients: - The `*` is a pull request number of 1 to 6 digits with no leading zero. A port, `http`, a different suffix or a shared hosting domain never matches. - Render the launch form on the concrete preview origin, the same origin as the return URL. - Only Connect works. Google identity is never available to a preview client. - Only provider apps in sandbox or testing mode serve preview clients, so only their test accounts can connect. Readiness can still report `true` for a provider that then refuses the attempt with `FORBIDDEN`. - All previews share one key and one tenant. Prefix `externalSubjectId` with the pull request number, for example `pr-123:`, so previews that clone the same users never share connections. SocialScope cannot enforce this for you. ## What a passing test does not prove - A stubbed suite proves your handling, not SocialScope's behavior or the provider's. - The local fixture proves wiring, not provider approval. - `integrationReadiness: true` proves configuration, not that a live consent will succeed. - Before launch, connect one real account per provider on the target environment, read its posts, and disconnect it. --- # API reference This page lists every query and mutation in SocialScope's consumer GraphQL API, with arguments, return fields, rules, errors and a server-side example. The machine-readable schema is at [/schema.graphql](/schema.graphql). All operations except `status` need your application key. Every call is a `POST` with a JSON body holding one operation. ## Endpoint | Environment | URL | | ----------- | ---------------------------------------------------------- | | Dev | `https://connect.socialscope.hardscope.incdev.dev/graphql` | | Production | To be announced | ```http POST /graphql Content-Type: application/json Authorization: Bearer . {"query": "...", "variables": {...}} ``` Limits per request: one operation, no batching, 8 aliases, depth 8, cost 100 (each root field 10, each nested field 1), 64 KiB body. Limit, validation and batching errors return HTTP 400 with `BAD_USER_INPUT`. An oversized body returns HTTP 413 with no GraphQL body. See [Authentication](/authentication.md#request-limits). ## Calling from TypeScript Every example below uses this server-only helper. ```ts // lib/socialscope.ts // Server only. Never import this file from browser code. const TIMEOUT_MS = 10_000; // 10 s export class SocialScopeError extends Error { constructor( readonly code: string, readonly correlationId?: string, readonly retryAfterSeconds?: number, ) { super(code); this.name = "SocialScopeError"; } } type GraphQLResponse = { data?: T; errors?: { message: string; extensions?: { code?: string; correlationId?: string; retryAfterSeconds?: number }; }[]; }; export async function socialscope(query: string, variables: Record = {}): Promise { const url = process.env.SOCIALSCOPE_GRAPHQL_URL; const key = process.env.SOCIALSCOPE_CONSUMER_KEY; if (!url || !key) throw new SocialScopeError("SOCIALSCOPE_NOT_CONFIGURED"); let response: Response; try { response = await fetch(url, { method: "POST", cache: "no-store", // never let a framework cache a SocialScope response signal: AbortSignal.timeout(TIMEOUT_MS), headers: { "content-type": "application/json", authorization: `Bearer ${key}` }, body: JSON.stringify({ query, variables }), }); } catch { // Network failure or timeout. Never log the request: it carries the key. throw new SocialScopeError("SOCIALSCOPE_UNAVAILABLE"); } // A body over 64 KiB is refused with HTTP 413 before GraphQL runs, so there is no GraphQL error body. if (response.status === 413) throw new SocialScopeError("SOCIALSCOPE_REQUEST_TOO_LARGE"); // Errors arrive with HTTP 200, or 400 for validation, limit and batching errors. Read the body either way. const payload = (await response.json().catch(() => null)) as GraphQLResponse | null; const first = payload?.errors?.[0]; if (first) { throw new SocialScopeError(first.extensions?.code ?? "UPSTREAM_UNAVAILABLE", first.extensions?.correlationId, first.extensions?.retryAfterSeconds); } if (!response.ok || !payload?.data) throw new SocialScopeError("SOCIALSCOPE_UNAVAILABLE"); return payload.data; } export type Scope = { tenantKey: string; externalSubjectId: string }; ``` In the examples, `scope` is always built on the server from the signed-in session: ```ts const scope = { tenantKey: session.tenantKey, externalSubjectId: session.userId }; ``` ## Queries ### `status` Unauthenticated liveness check. ```graphql query Status { status { ok message } } ``` Returns `ApiStatus { ok: Boolean!, message: String! }`. ```sh curl -sS -X POST https://connect.socialscope.hardscope.incdev.dev/graphql \ -H 'content-type: application/json' \ -d '{"query":"query Status { status { ok message } }"}' ``` ### `integrationReadiness` What your app can offer right now. ```graphql query IntegrationReadiness { integrationReadiness { providers { provider available } googleIdentityAvailable } } ``` | Field | Type | Meaning | | ------------------------- | ----------------------- | ------------------------------------------------------------------------------------- | | `providers` | `[ProviderReadiness!]!` | One entry each for `INSTAGRAM`, `TIKTOK` and `YOUTUBE` | | `providers[].provider` | `String!` | The provider name | | `providers[].available` | `Boolean!` | `true` when your app's policy and SocialScope's configuration allow a Connect attempt | | `googleIdentityAvailable` | `Boolean!` | `true` when your app can start Google identity | `true` is a configuration check, not proof a live consent will succeed. `false` or an error means show the action as unavailable. ```sh curl -sS -X POST "$SOCIALSCOPE_GRAPHQL_URL" \ -H 'content-type: application/json' \ -H "authorization: Bearer $SOCIALSCOPE_CONSUMER_KEY" \ -d '{"query":"query IntegrationReadiness { integrationReadiness { providers { provider available } googleIdentityAvailable } }"}' ``` ### `connectSessionResult(scope, id)` The outcome of one Connect attempt. | Argument | Type | Rule | | -------- | --------------- | -------------------------------------- | | `scope` | `SubjectScope!` | The scope the attempt was created with | | `id` | `ID!` | The attempt ID you saved | Returns `ConnectSessionResult`: | Field | Type | Meaning | | ----------------- | ----------------------- | ---------------------------------------------------------------------------------------------- | | `id` | `ID!` | Attempt ID. Compare with your saved ID | | `provider` | `SocialProvider!` | The provider of the attempt | | `status` | `ConnectSessionStatus!` | `CREATED`, `AUTHORIZING`, `EXCHANGING`, `COMPLETED`, `DENIED`, `FAILED`, `EXPIRED`, `CANCELED` | | `resultCode` | `String` | See [Connect result codes](/errors.md#connect-result-codes) | | `connectionId` | `ID` | Set on `COMPLETED` | | `creatorAction` | `ConnectCreatorAction` | `REMOVE_APP_AT_PROVIDER` or null | | `expiresAt` | `DateTime!` | Authorization deadline, 10 minutes after creation | | `resultExpiresAt` | `DateTime!` | The result is readable until then, 24 hours after creation | An unfinished attempt read after its 10-minute deadline returns `EXPIRED`. After `resultExpiresAt` the read returns `NOT_FOUND`. A tenant that is not registered for your app returns `FORBIDDEN`. ```ts const { connectSessionResult } = await socialscope<{ connectSessionResult: { status: string; connectionId: string | null } }>( `query VerifyConnection($scope: SubjectScope!, $id: ID!) { connectSessionResult(scope: $scope, id: $id) { id provider status resultCode connectionId creatorAction expiresAt resultExpiresAt } }`, { scope, id: savedAttemptId }, ); ``` ### `connection(scope, id)` One connection's current state. | Argument | Type | | -------- | --------------- | | `scope` | `SubjectScope!` | | `id` | `ID!` | Returns `Connection`: | Field | Type | Meaning | | ---------------------- | --------------------- | ------------------------------------------------------------------------------------ | | `id` | `ID!` | Stable across reconnects | | `provider` | `SocialProvider!` | | | `providerAccountId` | `String!` | The provider's account ID: YouTube channel ID, TikTok `open_id`, Instagram user ID | | `status` | `ConnectionStatus!` | `CONNECTED`, `SUSPENDED`, `RECONNECT_REQUIRED`, `DISCONNECTED`, `DELETING` | | `syncStatus` | `SyncStatus!` | `PENDING`, `RUNNING`, `READY`, `PARTIAL`, `STALE`, `FAILED` | | `capabilities` | `[CapabilityState!]!` | `{ capability, availability, reason }` for each granted capability | | `profile` | `Profile` | Null unless `CONNECTED`, `PROFILE` is available and the profile is under 30 days old | | `lastSuccessfulSyncAt` | `DateTime` | Null unless `CONNECTED` | | `nextRetryAt` | `DateTime` | Set while a failed sync waits to retry. Null unless `CONNECTED` | `Profile` fields: `providerAccountId`, `displayName`, `handle`, `avatarUrl`, `profileUrl`, `description`, `observedAt`. SocialScope rechecks a `CONNECTED` connection on every read. Instead of returning the row, `connection` throws `AUTHORIZATION_SUSPENDED` when the provider registration is paused, `RECONNECT_REQUIRED` when the grant is no longer active or its refresh credential expired, `CAPABILITY_UNAVAILABLE` when the registration no longer serves your app, and `FORBIDDEN` when your app's policy no longer allows the tenant, provider or capability. Map each code to that account's state. Other statuses return the row with a null profile. An erased or foreign connection returns `NOT_FOUND`. ```ts const { connection } = await socialscope<{ connection: { id: string; status: string; syncStatus: string } }>( `query ReadConnection($scope: SubjectScope!, $id: ID!) { connection(scope: $scope, id: $id) { id provider providerAccountId status syncStatus lastSuccessfulSyncAt nextRetryAt capabilities { capability availability reason } profile { displayName handle avatarUrl profileUrl description observedAt } } }`, { scope, id: connectionId }, ); ``` ### `connections(scope, first, after)` The subject's connections in this tenant. | Argument | Type | Rule | | -------- | --------------- | --------------------------------------- | | `scope` | `SubjectScope!` | | | `first` | `Int` | 1 to 100, default 20 | | `after` | `String` | `pageInfo.endCursor` from the last page | Returns `ConnectionPage { items: [Connection!]!, pageInfo: ConnectionPageInfo! }`, where `ConnectionPageInfo` is `{ endCursor: String, hasNextPage: Boolean! }`. Includes connections in every status until a disconnected one is erased. Each row goes through the same check as `connection`, so one account that fails it makes the whole call fail with that account's code. Keep connection IDs in your own database and read them with `connection`, using this list as a convenience. ```ts const { connections } = await socialscope<{ connections: { items: { id: string; provider: string; status: string }[]; pageInfo: { endCursor: string | null; hasNextPage: boolean } }; }>( `query ListConnections($scope: SubjectScope!, $first: Int, $after: String) { connections(scope: $scope, first: $first, after: $after) { items { id provider status syncStatus profile { displayName handle avatarUrl observedAt } } pageInfo { endCursor hasNextPage } } }`, { scope, first: 100 }, ); ``` ### `content(scope, connectionId, window)` Stored posts published in a window, newest first. Needs `CONTENT_LIST`. | Argument | Type | Rule | | ------------------------ | --------------- | ------------------------------------------ | | `scope` | `SubjectScope!` | | | `connectionId` | `ID!` | | | `window.publishedAfter` | `DateTime!` | Start | | `window.publishedBefore` | `DateTime!` | After the start, at most 90 days later | | `window.first` | `Int!` | 1 to 50 | | `window.after` | `String` | `pageInfo.endCursor`, with the same window | Returns `ContentPage`: | Field | Type | Meaning | | ---------------------------- | ----------------- | ------------------------------------------------------------------ | | `items` | `[ContentItem!]!` | Posts observed in the last 30 days | | `pageInfo.endCursor` | `String` | Cursor for the next page | | `pageInfo.hasNextPage` | `Boolean!` | | | `pageInfo.completeForWindow` | `Boolean!` | `true` only when a recent successful scan covered the whole window | | `pageInfo.incompleteReason` | `String` | Why it is incomplete | | `lastSuccessfulSyncAt` | `DateTime` | The connection's last successful sync | `ContentItem` fields: | Field | Type | Meaning | | ------------------- | ----------------------- | ------------------------------------------------------------------------------------- | | `id` | `ID!` | SocialScope's ID for the stored item | | `providerContentId` | `String!` | The provider's post ID. Use it with `contentItem` and `requestRefresh` | | `provider` | `SocialProvider!` | | | `kind` | `ContentKind!` | `VIDEO`, `IMAGE`, `CAROUSEL`, `UNKNOWN` | | `canonicalUrl` | `String` | Public URL of the post | | `thumbnailUrl` | `String` | May expire at the provider. Always null for YouTube. Can be null for Instagram images | | `title` | `String` | Always null for Instagram | | `description` | `String` | Description or caption | | `publishedAt` | `DateTime` | | | `availability` | `Availability!` | | | `reason` | `String` | Why it is not available | | `observedAt` | `DateTime!` | When SocialScope last read it | | `metrics` | `[MetricObservation!]!` | Empty unless the connection has `CONTENT_METRICS` | `MetricObservation` fields: `name` (`VIEWS`, `LIKES`, `COMMENTS`, `SHARES`), `value` (`DecimalCount`, an integer string or null), `availability`, `reason`, `sourceField`, `observedAt`. ```ts const { content } = await socialscope<{ content: { items: unknown[]; pageInfo: { completeForWindow: boolean } } }>( `query ReadContent($scope: SubjectScope!, $connectionId: ID!, $window: ContentWindow!) { content(scope: $scope, connectionId: $connectionId, window: $window) { items { id providerContentId provider kind canonicalUrl thumbnailUrl title description publishedAt availability reason observedAt metrics { name value availability reason sourceField observedAt } } pageInfo { endCursor hasNextPage completeForWindow incompleteReason } lastSuccessfulSyncAt } }`, { scope, connectionId, window: { publishedAfter: "2026-09-01T00:00:00Z", publishedBefore: "2026-10-01T00:00:00Z", first: 50 }, }, ); ``` ### `contentItem(scope, connectionId, providerContentId)` One stored post. Needs `CONTENT_LIST` or `CONTENT_LOOKUP`. | Argument | Type | | ------------------- | --------------- | | `scope` | `SubjectScope!` | | `connectionId` | `ID!` | | `providerContentId` | `String!` | Returns `ContentItem` or null when never observed. A post last observed more than 30 days ago returns with fields null, `availability: UNAVAILABLE`, `reason: EXPIRED` and no metrics. ```ts const { contentItem } = await socialscope<{ contentItem: { providerContentId: string } | null }>( `query ReadPost($scope: SubjectScope!, $connectionId: ID!, $providerContentId: String!) { contentItem(scope: $scope, connectionId: $connectionId, providerContentId: $providerContentId) { providerContentId kind canonicalUrl title description publishedAt availability reason observedAt metrics { name value availability reason sourceField observedAt } } }`, { scope, connectionId, providerContentId }, ); ``` ### `syncJob(scope, connectionId, id)` The state of a refresh, metrics, lookup, revoke or erase job. | Argument | Type | Rule | | -------------- | --------------- | -------------------------------------------------------------- | | `scope` | `SubjectScope!` | | | `connectionId` | `ID!` | The job's connection. For disconnect receipts, the original ID | | `id` | `ID!` | The job ID | Returns `SyncJob`: | Field | Type | Meaning | | ------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------- | | `id` | `ID!` | | | `status` | `JobStatus!` | `QUEUED`, `RUNNING`, `SUCCEEDED`, `FAILED`, `CANCELED` | | `nextRetryAt` | `DateTime` | Set while waiting to retry | | `errorCode` | `String` | See [Sync job error codes](/errors.md#sync-job-error-codes) | | `creatorAction` | `ConnectCreatorAction` | On revoke receipts. `REMOVE_APP_AT_PROVIDER` means the grant may still exist | | `resultProviderContentId` | `String` | On a successful lookup, the post's provider ID. Check the item's `availability` before showing it | | `completeForWindow` | `Boolean` | On scans, whether the scan covered its window | | `incompleteReason` | `String` | On scans, why not | | `continuationCursor` | `String` | On a successful incomplete scan less than 24 hours old | Finished jobs are readable for 7 days. Revoke and erase receipts are readable for 7 days after creation, even after the connection is erased. ```ts const { syncJob } = await socialscope<{ syncJob: { status: string; errorCode: string | null } }>( `query ReadJob($scope: SubjectScope!, $connectionId: ID!, $id: ID!) { syncJob(scope: $scope, connectionId: $connectionId, id: $id) { id status nextRetryAt errorCode creatorAction resultProviderContentId completeForWindow incompleteReason continuationCursor } }`, { scope, connectionId, id: jobId }, ); ``` ## Mutations ### `createConnectSession(input)` Start a Connect attempt. See [Connect](/connect.md). `ConnectInput`: | Field | Type | Rule | | ----------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `scope` | `SubjectScope!` | Registered tenant, subject 1 to 128 characters | | `provider` | `SocialProvider!` | Allowed for your app | | `capabilities` | `[Capability!]!` | Non-empty, unique, all allowed for your app | | `returnUrl` | `String!` | Exactly a registered return URL. No fragment, no `ss_*` query parameter | | `reconnectConnectionId` | `ID` | Optional. A `CONNECTED`, `SUSPENDED` or `RECONNECT_REQUIRED` connection in this scope and provider. A `DISCONNECTED` target gets `DISCONNECT_IN_PROGRESS` | Returns `ConnectSession`: | Field | Type | Meaning | | ----------------- | ----------- | -------------------------------------------------------------- | | `id` | `ID!` | Save it against the signed-in user before navigation | | `launchUrl` | `String!` | `/api/connect/redeem` | | `launchToken` | `String!` | One-use, 64 hex characters. Post it from the browser form only | | `expiresAt` | `DateTime!` | 10 minutes after creation | | `resultExpiresAt` | `DateTime!` | 24 hours after creation | Errors: `FORBIDDEN`, `BAD_USER_INPUT`, `NOT_FOUND` (reconnect target), `RECONNECT_REQUIRED` (the target changed state during creation, rare), `DISCONNECT_IN_PROGRESS`, `RATE_LIMITED` (5 unfinished attempts per subject and provider). In production `FORBIDDEN` is also returned for a while after a YouTube or TikTok disconnect, until its revoke settles. Retry later. ```ts const { createConnectSession } = await socialscope<{ createConnectSession: { id: string; launchUrl: string; launchToken: string; expiresAt: string; resultExpiresAt: string }; }>( `mutation StartConnection($input: ConnectInput!) { createConnectSession(input: $input) { id launchUrl launchToken expiresAt resultExpiresAt } }`, { input: { scope, provider: "INSTAGRAM", capabilities: ["PROFILE", "CONTENT_LIST", "CONTENT_METRICS"], returnUrl: "https://app.example.com/social/return", }, }, ); ``` ### `disconnectConnection(scope, connectionId)` Stop access at once and start the upstream revoke and the erase. Idempotent until erasure. In production, a YouTube or TikTok disconnect also suspends other accounts on that provider until the revoke settles. See [Connect](/connect.md#disconnect). | Argument | Type | Rule | | -------------- | --------------- | ---------------------------- | | `scope` | `SubjectScope!` | The connection's scope | | `connectionId` | `ID!` | The connection to disconnect | Returns `DisconnectResult`: | Field | Type | Meaning | | --------------------------- | ---------- | ------------------------------------------ | | `accepted` | `Boolean!` | | | `connectionId` | `ID!` | Keep it to read the receipts | | `upstreamRevocationPending` | `Boolean!` | `true` until the revoke job succeeds | | `revokeJobId` | `ID!` | Read with `syncJob` for the revoke outcome | | `eraseJobId` | `ID!` | Read with `syncJob` for the erase outcome | A second call before erasure returns the same job IDs. After erasure it returns `NOT_FOUND`. ```ts const { disconnectConnection } = await socialscope<{ disconnectConnection: { accepted: boolean; connectionId: string; revokeJobId: string; eraseJobId: string }; }>( `mutation Disconnect($scope: SubjectScope!, $connectionId: ID!) { disconnectConnection(scope: $scope, connectionId: $connectionId) { accepted connectionId upstreamRevocationPending revokeJobId eraseJobId } }`, { scope, connectionId }, ); ``` ### `requestRefresh(scope, connectionId, providerContentId, window, continuationCursor)` Queue a scan of a window, or a metrics refresh of one stored post. Returns a `SyncJob`. | Argument | Type | Rule | | -------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `scope` | `SubjectScope!` | | | `connectionId` | `ID!` | | | `providerContentId` | `String` | Set for a metrics refresh of a stored post. Needs `CONTENT_METRICS`. No `window` or cursor | | `window` | `SyncWindow` | For a scan. `publishedAfter`, `publishedBefore` (at most 90 days apart), `maxItems` 1 to 100, default 100. Defaults to the last 7 days | | `continuationCursor` | `String` | From an incomplete scan's `syncJob.continuationCursor`. A `window`, if also passed, must equal the original | A scan needs `CONTENT_LIST`. The same window or post while one is in flight returns the existing job. A different window while a scan is in flight fails with `SYNC_WINDOW_BUSY`. More than 20 in-flight read jobs for your app fail with `RATE_LIMITED` (`retryAfterSeconds: 60`). ```ts const { requestRefresh } = await socialscope<{ requestRefresh: { id: string; status: string } }>( `mutation RefreshWindow($scope: SubjectScope!, $connectionId: ID!, $window: SyncWindow) { requestRefresh(scope: $scope, connectionId: $connectionId, window: $window) { id status } }`, { scope, connectionId, window: { publishedAfter: "2026-09-05T00:00:00Z", publishedBefore: "2026-10-05T00:00:00Z", maxItems: 100 }, }, ); ``` ### `requestContentLookup(scope, connectionId, url)` Resolve a post URL to an owned post and observe it. Needs `CONTENT_LOOKUP`. Returns a `SyncJob`. Poll it and read `resultProviderContentId`, then read `contentItem` and check its `availability`. A lookup can succeed and still leave the post `UNAVAILABLE`, with the reason in `incompleteReason`. | Argument | Type | Rule | | -------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------- | | `scope` | `SubjectScope!` | The connection's scope | | `connectionId` | `ID!` | A connection with `CONTENT_LOOKUP` | | `url` | `String!` | HTTPS, no credentials, port or fragment, up to 2,048 characters. See [URL lookup](/reading-data.md#look-up-a-post-by-url) | ```ts const { requestContentLookup } = await socialscope<{ requestContentLookup: { id: string } }>( `mutation Lookup($scope: SubjectScope!, $connectionId: ID!, $url: String!) { requestContentLookup(scope: $scope, connectionId: $connectionId, url: $url) { id status } }`, { scope, connectionId, url: "https://www.youtube.com/watch?v=abcdefghijk" }, ); ``` ### `createGoogleIdentitySession(input)` Start a Google sign-in handoff. See [Google identity](/google-identity.md). `GoogleIdentityInput`: `tenantKey: String!` (registered for your app), `returnUrl: String!` (your exactly registered identity return URL). Returns `GoogleIdentitySession`: | Field | Type | Meaning | | ----------------- | ----------- | ------------------------------------------- | | `id` | `ID!` | Save with the browser session | | `launchUrl` | `String!` | `/api/identity/redeem` | | `launchToken` | `String!` | One-use. Post it from the browser form only | | `resultProof` | `String!` | Keep on the server. Needed to redeem | | `expiresAt` | `DateTime!` | 10 minutes after creation | | `resultExpiresAt` | `DateTime!` | 20 minutes after creation | Errors: `CAPABILITY_UNAVAILABLE`, `FORBIDDEN`, `BAD_USER_INPUT`, `RATE_LIMITED` with `retryAfterSeconds` (20 unfinished attempts per app). ```ts const { createGoogleIdentitySession } = await socialscope<{ createGoogleIdentitySession: { id: string; launchUrl: string; launchToken: string; resultProof: string }; }>( `mutation StartGoogleIdentity($input: GoogleIdentityInput!) { createGoogleIdentitySession(input: $input) { id launchUrl launchToken resultProof expiresAt resultExpiresAt } }`, { input: { tenantKey: "example-workspace", returnUrl: "https://app.example.com/auth/google/return" } }, ); ``` ### `redeemGoogleIdentityResult(id, tenantKey, resultProof)` Redeem the identity result once. Returns `GoogleIdentityResult`: | Field | Type | Meaning | | --------------- | ----------------------- | ---------------------------------------------------------- | | `status` | `GoogleIdentityStatus!` | `PENDING`, `COMPLETED`, `DENIED`, `FAILED`, `EXPIRED` | | `issuer` | `String` | `https://accounts.google.com` on `COMPLETED` | | `googleSub` | `String` | Google's stable user ID. Key your user on issuer plus this | | `email` | `String` | | | `emailVerified` | `Boolean` | | | `name` | `String` | | | `pictureUrl` | `String` | | `PENDING` does not consume the proof. Every other status does. A second redeem returns `NOT_FOUND`. ```ts const { redeemGoogleIdentityResult } = await socialscope<{ redeemGoogleIdentityResult: { status: string; issuer: string | null; googleSub: string | null }; }>( `mutation RedeemGoogleIdentity($id: ID!, $tenantKey: String!, $resultProof: String!) { redeemGoogleIdentityResult(id: $id, tenantKey: $tenantKey, resultProof: $resultProof) { status issuer googleSub email emailVerified name pictureUrl } }`, { id: saved.id, tenantKey: saved.tenantKey, resultProof: saved.resultProof }, ); ``` ## Shared types | Type | Definition | | ---------------------- | --------------------------------------------------------------------------------------- | | `SubjectScope` | `{ tenantKey: String!, externalSubjectId: String! }`, each 1 to 128 characters | | `DateTime` | ISO 8601 date-time string, for example `2026-10-05T12:00:00.000Z` | | `DecimalCount` | Non-negative integer as a string, for example `"10482"` | | `SocialProvider` | `YOUTUBE`, `TIKTOK`, `INSTAGRAM` | | `Capability` | `PROFILE`, `CONTENT_LIST`, `CONTENT_LOOKUP`, `CONTENT_METRICS` | | `Availability` | `AVAILABLE`, `UNSUPPORTED`, `NOT_GRANTED`, `UNAVAILABLE` | | `MetricName` | `VIEWS`, `LIKES`, `COMMENTS`, `SHARES` | | `ContentKind` | `VIDEO`, `IMAGE`, `CAROUSEL`, `UNKNOWN` | | `ConnectCreatorAction` | `REMOVE_APP_AT_PROVIDER` | | `SyncWindow` | `{ publishedAfter: DateTime!, publishedBefore: DateTime!, maxItems: Int = 100 }` | | `ContentWindow` | `{ publishedAfter: DateTime!, publishedBefore: DateTime!, first: Int!, after: String }` | Every code a consumer can see is listed in [Errors](/errors.md).