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.comdoes not coverhttps://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, and its return URL.
The admin then issues an application key of the form <key-id>.<secret> and sends it to you through a password manager, never chat or email.
2. Store the key on your server
# Server-side secret store only.
SOCIALSCOPE_GRAPHQL_URL=https://connect.socialscope.hardscope.incdev.dev/graphql
SOCIALSCOPE_CONSUMER_KEY=<key-id>.<secret>
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.
Add one helper that every SocialScope call goes through:
// 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<T> = {
data?: T;
errors?: {
message: string;
extensions?: { code?: string; correlationId?: string; retryAfterSeconds?: number };
}[];
};
export async function socialscope<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {
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<T> | 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.
3. Check readiness
Before you show a Connect button, ask which providers your app can use right now.
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<boolean> {
try {
const { integrationReadiness } = await socialscope<Readiness>(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.
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.
// lib/socialscope-launch.ts
// Server only. Returns a standard Response, usable as-is in a Next.js route handler.
type LaunchPath = "/api/connect/redeem" | "/api/identity/redeem";
const ESCAPES: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (char) => ESCAPES[char]);
export function launchPage(launchUrl: string, launchToken: string, expectedPath: LaunchPath): Response {
const connectOrigin = new URL(process.env.SOCIALSCOPE_GRAPHQL_URL!).origin;
const launch = new URL(launchUrl);
if (launch.origin !== connectOrigin || launch.pathname !== expectedPath || launch.search || launch.hash) {
throw new Error("Unexpected SocialScope launch URL");
}
if (!/^[0-9a-f]{64}$/.test(launchToken)) throw new Error("Unexpected SocialScope launch token");
const html = `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Continuing</title></head>
<body>
<form method="post" action="${escapeHtml(launchUrl)}">
<input type="hidden" name="launchToken" value="${escapeHtml(launchToken)}">
<button type="submit">Continue</button>
</form>
<script>document.forms[0].submit()</script>
</body>
</html>`;
return new Response(html, {
headers: {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-store",
"referrer-policy": "strict-origin",
"content-security-policy": `default-src 'none'; script-src 'unsafe-inline'; form-action ${connectOrigin}; base-uri 'none'`,
"x-content-type-options": "nosniff",
},
});
}
5. Verify the return before showing success
SocialScope sends the browser back to your return URL with ?ss_session_id=<attempt 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.
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
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 for refresh, completeness and URL lookup.
Next
- Guide for coding agents has the full rule list and checklists.
- Connect covers every result, reconnect and disconnect.
- API reference lists every operation. The raw schema is at /schema.graphql.