# 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](/authentication.md#call-it). 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.

```ts
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:

```ts
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](/connect.md#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                        |

```ts
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](#refresh-a-window) 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.

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

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

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

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

```ts
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](/connect.md#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.
