# 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](/schema.graphql). All operations except `status` need your application key. Every call is a `POST` with a JSON body holding one operation.

## Endpoint

| Mode                      | SocialScope API endpoint                                 |
| ------------------------- | -------------------------------------------------------- |
| Sandbox (available now)   | `https://connect.socialscope-dev.hardscope.com/graphql`  |
| Live (coming soon)        | `https://connect.socialscope.hardscope.com/graphql`      |

Sandbox mode is available now: only tester accounts can connect. Live mode is coming soon. The API and your code are the same in both modes. Switch by base URL and key. Each mode has its own client registration and key.

```http
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](/authentication.md#request-limits).

## Calling from TypeScript

Every example below uses this server-only helper.

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

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

## Queries

### `status`

Unauthenticated liveness check.

```graphql
query Status {
  status {
    ok
    message
  }
}
```

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

```sh
curl -sS -X POST https://connect.socialscope-dev.hardscope.com/graphql \
  -H 'content-type: application/json' \
  -d '{"query":"query Status { status { ok message } }"}'
```

### `integrationReadiness`

What your app can offer right now.

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

```sh
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](/errors.md#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`.

```ts
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. In `connections`, a row that failed its read check has each one `UNAVAILABLE` with the error code as `reason` |
| `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. `connections` reports the same accounts without throwing. Other statuses return the row with a null profile. A `SUSPENDED` or `RECONNECT_REQUIRED` row has every capability `UNAVAILABLE` with `reason` `AUTHORIZATION_SUSPENDED` or `RECONNECT_REQUIRED`. An erased or foreign connection returns `NOT_FOUND`.

```ts
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`, but an account that fails it never fails the call. It comes back with `status` `RECONNECT_REQUIRED` when its grant stopped working, otherwise `SUSPENDED`, every capability `UNAVAILABLE` with the code `connection` would throw as `reason`, `syncStatus` `STALE`, and a null `profile`, `lastSuccessfulSyncAt` and `nextRetryAt`. A row erased while the page is read is left out, so a page can hold fewer than `first` items. Keep paging while `hasNextPage` is true. See [Errors](/errors.md#reads-by-connection-status).

```ts
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 capabilities { capability availability reason } 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`.

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

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

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

`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). `FORBIDDEN` is also returned for a while after a YouTube or TikTok disconnect, until its revoke settles and every other account on that provider has been re-checked. Retry later.

```ts
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. A YouTube or TikTok disconnect also suspends other accounts on that provider until the revoke settles and each of them has been re-checked. See [Connect](/connect.md#disconnect).

| 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                                                                                                 |
| `creatorAction`             | `ConnectCreatorAction` | `REMOVE_APP_AT_PROVIDER` for Instagram, which has no revoke API, unless the person already removed the app at Meta. Null for YouTube and TikTok, whose revoke receipt reports the outcome later |

A second call before erasure returns the same job IDs, and its `creatorAction` matches the revoke receipt at that moment. A null `creatorAction` here does not mean the revoke worked: read the revoke receipt for that. After erasure it returns `NOT_FOUND`. Errors: `NOT_FOUND`, and `RATE_LIMITED` with `retryAfterSeconds: 1` when other accounts on the provider kept connecting during the call; nothing changed, so retry.

```ts
const { disconnectConnection } = await socialscope<{
  disconnectConnection: { accepted: boolean; connectionId: string; revokeJobId: string; eraseJobId: string; creatorAction: string | null };
}>(
  `mutation Disconnect($scope: SubjectScope!, $connectionId: ID!) {
    disconnectConnection(scope: $scope, connectionId: $connectionId) {
      accepted connectionId upstreamRevocationPending revokeJobId eraseJobId creatorAction
    }
  }`,
  { 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`).

```ts
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](/reading-data.md#look-up-a-post-by-url) |

```ts
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](/google-identity.md).

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

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

```ts
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](/errors.md).
