Skip to content
View as Markdown

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. 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
POST /graphql
Content-Type: application/json
Authorization: Bearer <key-id>.<secret>

{"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.

Calling from TypeScript

Every example below uses this server-only helper.

// 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 };

In the examples, scope is always built on the server from the signed-in session:

const scope = { tenantKey: session.tenantKey, externalSubjectId: session.userId };

Queries

status

Unauthenticated liveness check.

query Status {
  status {
    ok
    message
  }
}

Returns ApiStatus { ok: Boolean!, message: String! }.

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.

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.

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

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.

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.

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.

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.

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

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.

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! <Connect host>/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.

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.

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.

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

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

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! <Connect host>/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).

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.

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.