# 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 `<key-id>.<secret>` 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=<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](/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<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](/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<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.

```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<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (char) => ESCAPES[char]);

export function launchPage(launchUrl: string, launchToken: string, expectedPath: LaunchPath): Response {
  const connectOrigin = new URL(process.env.SOCIALSCOPE_GRAPHQL_URL!).origin;
  const launch = new URL(launchUrl);
  if (launch.origin !== connectOrigin || launch.pathname !== expectedPath || launch.search || launch.hash) {
    throw new Error("Unexpected SocialScope launch URL");
  }
  if (!/^[0-9a-f]{64}$/.test(launchToken)) throw new Error("Unexpected SocialScope launch token");

  const html = `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Continuing</title></head>
<body>
<form method="post" action="${escapeHtml(launchUrl)}">
<input type="hidden" name="launchToken" value="${escapeHtml(launchToken)}">
<button type="submit">Continue</button>
</form>
<script>document.forms[0].submit()</script>
</body>
</html>`;

  return new Response(html, {
    headers: {
      "content-type": "text/html; charset=utf-8",
      "cache-control": "no-store",
      "referrer-policy": "strict-origin",
      "content-security-policy": `default-src 'none'; script-src 'unsafe-inline'; form-action ${connectOrigin}; base-uri 'none'`,
      "x-content-type-options": "nosniff",
    },
  });
}
```

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

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