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
scopefrom your signed-in session on every call. Never taketenantKeyorexternalSubjectIdfrom the browser.A metric is a number only when its
availabilityisAVAILABLE. ShowUNSUPPORTED,UNAVAILABLEandNOT_GRANTEDas "not available", never as0.DecimalCountvalues are non-negative integer strings such as"10482". Keep them as strings or convert withBigInt, or withNumberonly when you know they fit.Show
observedAtnext to counts, so nobody mistakes a stored count for a live one.Keep
sourceFieldwhen you interpret a count, because the same metric name comes from different provider fields.Do not call a list complete unless
pageInfo.completeForWindowistruefor the window you asked about.Nothing refreshes automatically after the first sync. If you need fresh data, call
requestRefreshyourself.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. 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
connectionsfails, fall back to the per-ID reads. Never show "no accounts" because the list call failed.
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<ConnectionSummary[]> {
const all: ConnectionSummary[] = [];
let after: string | null = null;
do {
const page: ConnectionsPage = await socialscope<ConnectionsPage>(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:
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<string, ProblemState> = {
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<AccountState[]> {
return Promise.all(
storedIds.map(async (id): Promise<AccountState> => {
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:
profileis returned only while the connection isCONNECTED, has thePROFILEcapability and the stored profile is less than 30 days old. Otherwise it is null.lastSuccessfulSyncAtandnextRetryAtare null unless the connection isCONNECTED.nextRetryAtis set while a failed sync waits to retry.capabilitieslists 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_REQUIREDneeds a reconnect.DISCONNECTEDaccounts 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 |
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. contentreturns only posts observed in the last 30 days.contentnever calls the provider. To load posts outside what was already scanned, refresh 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.
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<string, unknown>): Promise<Job> {
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 differentmaxItems. - Your application can have at most 20 queued or running read jobs across all connections. Beyond that you get
RATE_LIMITEDwithextensions.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.
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.
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
nullwhen SocialScope never observed that post. - A post last observed more than 30 days ago comes back with its fields null,
availability: UNAVAILABLE,reason: EXPIREDand no metrics. Refresh it to read it again. - A post a lookup found is not owned by this account comes back
UNAVAILABLEwithreason: 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=<id>, www.youtube.com/watch?v=<id>, youtu.be/<id> |
| TikTok | tiktok.com/@<user>/video/<id> or www.tiktok.com/@<user>/video/<id> |
instagram.com/p/<code>, instagram.com/reel/<code>, 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.
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.
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<SyncJob> {
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:
connectionIdis 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.
CANCELEDusually means the connection changed under the job, for example a reconnect or disconnect. Read the connection again.FAILEDwithRECONNECT_REQUIREDmeans the provider grant stopped working. Read the connection, and offer a reconnect when its status isRECONNECT_REQUIRED.FAILEDwithUPSTREAM_UNAVAILABLEorRATE_LIMITEDmeans 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.