Skip to content
View as Markdown

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 scope from your signed-in session on every call. Never take tenantKey or externalSubjectId from the browser.

  • A metric is a number only when its availability is AVAILABLE. Show UNSUPPORTED, UNAVAILABLE and NOT_GRANTED as "not available", never as 0.

  • DecimalCount values are non-negative integer strings such as "10482". Keep them as strings or convert with BigInt, or with Number only when you know they fit.

  • Show observedAt next to counts, so nobody mistakes a stored count for a live one.

  • Keep sourceField when you interpret a count, because the same metric name comes from different provider fields.

  • Do not call a list complete unless pageInfo.completeForWindow is true for the window you asked about.

  • Nothing refreshes automatically after the first sync. If you need fresh data, call requestRefresh yourself.

  • 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 connections fails, 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:

  • profile is returned only while the connection is CONNECTED, has the PROFILE capability and the stored profile is less than 30 days old. Otherwise it is null.
  • lastSuccessfulSyncAt and nextRetryAt are null unless the connection is CONNECTED. nextRetryAt is set while a failed sync waits to retry.
  • capabilities lists 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_REQUIRED needs a reconnect. DISCONNECTED accounts 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.
  • content returns only posts observed in the last 30 days.
  • content never 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 different maxItems.
  • Your application can have at most 20 queued or running read jobs across all connections. Beyond that you get RATE_LIMITED with extensions.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 null when SocialScope never observed that post.
  • A post last observed more than 30 days ago comes back with its fields null, availability: UNAVAILABLE, reason: EXPIRED and no metrics. Refresh it to read it again.
  • A post a lookup found is not owned by this account comes back UNAVAILABLE with reason: 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 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:

  • connectionId is 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.
  • CANCELED usually means the connection changed under the job, for example a reconnect or disconnect. Read the connection again.
  • FAILED with RECONNECT_REQUIRED means the provider grant stopped working. Read the connection, and offer a reconnect when its status is RECONNECT_REQUIRED.
  • FAILED with UPSTREAM_UNAVAILABLE or RATE_LIMITED means 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.