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.