# 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

| Environment | URL                                                        |
| ----------- | ---------------------------------------------------------- |
| Dev         | `https://connect.socialscope.hardscope.incdev.dev/graphql` |
| Production  | To be announced                                            |

```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.hardscope.incdev.dev/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                   |
| `profile`              | `Profile`             | Null unless `CONNECTED`, `PROFILE` is available and the profile is under 30 days old |
| `lastSuccessfulSyncAt` | `DateTime`            | Null unless `CONNECTED`                                                              |
| `nextRetryAt`          | `DateTime`            | Set while a failed sync waits to retry. Null unless `CONNECTED`                      |

`Profile` fields: `providerAccountId`, `displayName`, `handle`, `avatarUrl`, `profileUrl`, `description`, `observedAt`.

SocialScope rechecks a `CONNECTED` connection on every read. Instead of returning the row, `connection` throws `AUTHORIZATION_SUSPENDED` when the provider registration is paused, `RECONNECT_REQUIRED` when the grant is no longer active or its refresh credential expired, `CAPABILITY_UNAVAILABLE` when the registration no longer serves your app, and `FORBIDDEN` when your app's policy no longer allows the tenant, provider or capability. Map each code to that account's state. Other statuses return the row with a null profile. An erased or foreign connection returns `NOT_FOUND`.

```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`, so one account that fails it makes the whole call fail with that account's code. Keep connection IDs in your own database and read them with `connection`, using this list as a convenience.

```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 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). In production `FORBIDDEN` is also returned for a while after a YouTube or TikTok disconnect, until its revoke settles. 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. In production, a YouTube or TikTok disconnect also suspends other accounts on that provider until the revoke settles. 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  |

A second call before erasure returns the same job IDs. After erasure it returns `NOT_FOUND`.

```ts
const { disconnectConnection } = await socialscope<{
  disconnectConnection: { accepted: boolean; connectionId: string; revokeJobId: string; eraseJobId: string };
}>(
  `mutation Disconnect($scope: SubjectScope!, $connectionId: ID!) {
    disconnectConnection(scope: $scope, connectionId: $connectionId) {
      accepted connectionId upstreamRevocationPending revokeJobId eraseJobId
    }
  }`,
  { scope, connectionId },
);
```

### `requestRefresh(scope, connectionId, providerContentId, window, continuationCursor)`

Queue a scan of a window, or a metrics refresh of one stored post. Returns a `SyncJob`.

| Argument             | Type            | Rule                                                                                                                                   |
| -------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `scope`              | `SubjectScope!` |                                                                                                                                        |
| `connectionId`       | `ID!`           |                                                                                                                                        |
| `providerContentId`  | `String`        | Set for a metrics refresh of a stored post. Needs `CONTENT_METRICS`. No `window` or cursor                                             |
| `window`             | `SyncWindow`    | For a scan. `publishedAfter`, `publishedBefore` (at most 90 days apart), `maxItems` 1 to 100, default 100. Defaults to the last 7 days |
| `continuationCursor` | `String`        | From an incomplete scan's `syncJob.continuationCursor`. A `window`, if also passed, must equal the original                            |

A scan needs `CONTENT_LIST`. The same window or post while one is in flight returns the existing job. A different window while a scan is in flight fails with `SYNC_WINDOW_BUSY`. More than 20 in-flight read jobs for your app fail with `RATE_LIMITED` (`retryAfterSeconds: 60`).

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