# Token Social Graph API — Consumer Reference

Audience: an engineer or agent writing a client for this endpoint in **another service**.
Everything below is measured against production on 2026-08-27 unless stated otherwise.

Design rationale (why the shape is what it is) lives in
`docs/features/token-social-graph-api/design.md`. **This document is the contract.**

---

## 1. Endpoint

```
GET /api/v1/tokens/{address}/social-graph
Header: x-api-key: <APP_SECRET_KEY>
```

`{address}` is a Solana mint address.

| Status | When | Body |
|---|---|---|
| `200` | Token is known to this service | `{ "data": TokenSocialGraph, "meta": {} }` |
| `401` | `x-api-key` missing or wrong | `{ "statusCode": 401, "message": "..." }` |
| `404` | **No token document exists for this address** | `{ "statusCode": 404, "message": "token not found: ..." }` |

`404` means *"this service has never observed this token"*, not *"this token has no socials"*.
A token with zero social URLs returns `200` with `urls: []`. Do not conflate the two — the
first is worth retrying later, the second is a final answer.

Every payload is wrapped in `{ data, meta }`. `meta` is always `{}` today.

---

## 2. Mental model — three layers

Read the response as **token → URL → node → object**, never as a flat object list.

```
TokenSocialGraph
├── address, symbol
├── collection { total, ok, pending, failed }     ← "is what I'm looking at complete?"
└── urls[]                                        ← one entry per social URL on the token
    ├── url, platform, status, retryable, ...     ← what happened when we opened it
    └── nodes[]                                   ← ordered by depth, ascending
        ├── depth 0   ← the object the URL points at DIRECTLY
        │   ├── content   (a post)      | null
        │   ├── account   (its author)  | null
        │   └── venue     (a community) | null
        ├── depth 1   ← the post that depth 0 quoted / replied to
        └── depth 2   ← and so on, up to depth 8
```

Three facts that make this simple:

1. **`urls[]` mirrors the token's social URL list exactly.** URLs that produced nothing still
   appear, with `nodes: []`. Iterate `urls`, never try to reconstruct URLs from objects.
2. **A node holds at most one of each kind.** This is guaranteed by storage, not by luck —
   all 151,603 stored `(token, url, depth, kind)` groups resolve to exactly one object.
3. **`nodes[]` is always a flat array**, whether the chain is 1 deep or 9 deep. There is no
   nesting and no recursion anywhere in this response.

### Which slots are actually filled

Only five combinations occur in production:

| `content` | `account` | `venue` | Meaning |
|:-:|:-:|:-:|---|
| ✓ | ✓ | | A post and its author — the common case |
| ✓ | | | A post with no author concept (web page, Reddit post, search term) |
| | ✓ | | The URL was a **profile page**. Instagram *story* URLs also land here |
| | | ✓ | A Telegram channel |
| | ✓ | ✓ | An X community and its creator |

`content` + `venue` never co-occur: posts are not linked to venues in the current data.

---

## 3. Types

This block is the canonical field list. Every field is always present; optional data is
expressed as `null`, never by omitting the key.

```ts
interface TokenSocialGraph {
  address: string;
  /** Display copy only. The source of truth for token metadata is sol-alpha-finder-tracker. */
  symbol: string | null;
  collection: CollectionSummary;
  urls: SocialUrlGroup[];
}

interface CollectionSummary {
  total: number;    // == urls.length
  ok: number;       // fetched successfully
  pending: number;  // calling again may change the result — see `retryable`
  failed: number;   // settled, but not ok. Calling again will not help
}

interface SocialUrlGroup {
  url: string;                       // normalized canonical URL
  platform: Platform;                // re-derived at read time — see Traps
  status: FetchStatus;
  retryable: boolean;                // true => a later call may produce nodes
  attemptedAt: string | null;        // ISO 8601. null = we never actually called out
  attempts: number;                  // external calls made. 0 when never called
  lastErrorCode: ExternalErrorCode | null;
  nodes: Node[];                     // ascending by depth. [] is normal when status != "ok"
}

interface Node {
  depth: number;                     // 0 = directly linked. 1+ = reached by following a quote/reply
  parent: ParentRef | null;          // null = this node's content has no known parent
  content: Content | null;
  account: Account | null;
  venue: Venue | null;
}

interface ParentRef {
  /** Only "quote" and "reply" occur today; "retweet" and "fork" are defined but never produced. */
  relation: "quote" | "reply" | "retweet" | "fork";
  /** id of the parent content. Match it against any `content.id` in this response. */
  contentId: string | null;
  /** true => the parent is the next node in this same `nodes[]` array (depth + 1). */
  inChain: boolean;
}

interface Content {
  id: string;                        // our id. Stable. Use it to dedupe across urls[]
  platform: Platform;
  subtype: ContentSubtype;
  platformKey: string;               // the platform's own id. For `web`, a URL
  publishedAt: string | null;        // when it was posted, not when we stored it
  text: {
    primary: string | null;          // tweet/post text, video or article title, repo description
    body: string | null;
    fromMedia: string | null;        // IG alt text, TikTok transcript link
  };
  outboundUrls: string[];
  tags: string[];
  mediaUrls: string[];
  mentions: { platformKey: string; screenName: string | null }[];
  metrics: Metrics | null;
  data: Record<string, unknown>;     // NOT part of the contract — see Traps
}

interface Account {
  id: string;
  platform: Platform;
  platformKey: string;               // the platform's own stable id. NOT the handle
  handle: string | null;             // display only. Handles change and get reassigned
  displayName: string | null;
  bio: string | null;
  avatarUrl: string | null;
  accountCreatedAt: string | null;
  outboundUrls: string[];
  metrics: { followers: number | null } | null;
  data: Record<string, unknown>;     // NOT part of the contract
}

interface Venue {
  id: string;
  platform: Platform;
  /** Only these three occur. "subreddit" and "guard_group" exist in the enum but are never created. */
  subtype: "community" | "channel" | "portal";
  platformKey: string;
  name: string | null;
  description: string | null;
  venueCreatedAt: string | null;
  metrics: { members: number | null; moderatorCount: number | null } | null;
  data: Record<string, unknown>;     // NOT part of the contract
}

/** Keys differ per platform on purpose. See Traps. */
type Metrics = { observedAt: string } & Record<string, number>;

type Platform =
  | "x" | "tt" | "ig" | "yt" | "rd" | "gh" | "tg" | "web";
//   X    TikTok  Insta  YouTube Reddit GitHub Telegram  everything else

type FetchStatus =
  | "ok" | "pending" | "not_found" | "blocked" | "suspended"
  | "invalid" | "exhausted" | "unsupported" | "skipped_paid" | "error";

type ContentSubtype =
  | "tweet" | "video" | "photo" | "post" | "reel" | "tv" | "repo"
  | "search" | "intent" | "trend" | "social_post"
  | "site" | "reference" | "directory";

type ExternalErrorCode =
  | "RATE_LIMITED" | "QUOTA_EXHAUSTED" | "CREDIT_EXHAUSTED"
  | "CREDENTIAL_MISSING" | "CREDENTIAL_REJECTED"
  | "TARGET_BLOCKED" | "UPSTREAM_UNAVAILABLE" | "UPSTREAM_CONTRACT_BROKEN";
```

### `FetchStatus` — what each value means for you

| Value | Meaning | In `retryable` / `collection.pending` |
|---|---|:-:|
| `ok` | Fetched | — (counted in `ok`) |
| `pending` | Not processed yet | ✓ |
| `error` | Transient failure | ✓ |
| `unsupported` | No handler for this URL shape | ✓ |
| `skipped_paid` | A paid-platform gate blocked the call. **Zero external calls were made** | ✓ |
| `not_found` | Target does not exist | ✗ |
| `blocked` | Login wall / bot block | ✗ |
| `suspended` | **Account suspended or deleted** | ✗ |
| `invalid` | Our own bug produced an unusable result | ✗ |
| `exhausted` | Retry limit reached | ✗ |

`retryable` is computed server-side from a single source. **Do not reimplement this
classification** — a new status value would silently fall outside your copy of the list.

### Why a field is `null`

| Field | `null` means |
|---|---|
| `content.publishedAt` | Platform gave no timestamp |
| `content.metrics` | No metric observation exists. **Not zero engagement** |
| `content.text.primary` | Genuinely no text — e.g. a web page matched a fingerprint-only pattern |
| `account` on a node with `content` | That content has no author concept (web, Reddit, search) |
| `account.accountCreatedAt` | Instagram never provides it |
| `account.metrics` | No follower observation |
| `venue.metrics` | Telegram — deliberate. Subscriber count is in `venue.data.subscriber_count` |
| `parent` | The content has no recorded parent relation |
| `parent.contentId` | Relation known but the parent object was never resolved (0 cases observed) |
| `attemptedAt` | We never made an external call for this URL |
| `symbol` | Not copied from upstream for this token |

---

## 4. Worked examples

### 4.1 A token whose URLs all failed

Both URLs are settled and unfixable. `nodes` is empty and that is the correct, final answer.

```json
{
  "address": "C6ASDsxzqBvF5bCQFVAGMRWwfgZNhfozbb9DkgiKpump",
  "symbol": "ORANGE",
  "collection": { "total": 2, "ok": 0, "pending": 0, "failed": 2 },
  "urls": [
    {
      "url": "https://x.com/Orange_Coin_SOL",
      "platform": "x",
      "status": "suspended",
      "retryable": false,
      "attemptedAt": "2026-08-13T10:35:26.848Z",
      "attempts": 1,
      "lastErrorCode": null,
      "nodes": []
    },
    {
      "url": "https://t.me/OrangeCoinOnSOL",
      "platform": "tg",
      "status": "exhausted",
      "retryable": false,
      "attemptedAt": "2026-08-13T17:17:34.545Z",
      "attempts": 5,
      "lastErrorCode": null,
      "nodes": []
    }
  ]
}
```

`suspended` on an X profile is itself a signal: the project's account was taken down after launch.

### 4.2 A profile URL and a Telegram channel

Two single-node groups. Note that neither node has a `content` — the URLs point at an
account and a venue, not at a post.

```json
{
  "address": "ALMSXM2Pt9QosS8PEBYSHZVKEJWswTbxBEZbJ48zpump",
  "symbol": "DAPLG",
  "collection": { "total": 2, "ok": 2, "pending": 0, "failed": 0 },
  "urls": [
    {
      "url": "https://x.com/DaPlugToken",
      "platform": "x", "status": "ok", "retryable": false,
      "attemptedAt": "2026-08-18T11:29:47.316Z", "attempts": 1, "lastErrorCode": null,
      "nodes": [
        {
          "depth": 0,
          "parent": null,
          "content": null,
          "account": {
            "id": "6a84422c400217418d3e6c98",
            "platform": "x",
            "platformKey": "2089440507407556608",
            "handle": "DaPlugToken",
            "displayName": "DaPlug",
            "bio": "“Who’s your Plug” get your fix ...",
            "avatarUrl": "https://pbs.twimg.com/profile_images/...",
            "accountCreatedAt": "2026-08-17T19:55:28.000Z",
            "outboundUrls": ["https://t.me/DaPlugCommunity"],
            "metrics": { "followers": 40 },
            "data": {
              "badges": [], "content_count": 2, "following": 32,
              "isVerified": false, "isBlueVerified": false, "declared_handles": []
            }
          },
          "venue": null
        }
      ]
    },
    {
      "url": "https://t.me/DaPlugCommunity",
      "platform": "tg", "status": "ok", "retryable": false,
      "attemptedAt": "2026-08-18T12:33:34.891Z", "attempts": 2, "lastErrorCode": null,
      "nodes": [
        {
          "depth": 0,
          "parent": null,
          "content": null,
          "account": null,
          "venue": {
            "id": "6a845120ac9d05d1bae4d0c5",
            "platform": "tg",
            "subtype": "portal",
            "platformKey": "daplugcommunity",
            "name": "DaPlug Community",
            "description": "",
            "venueCreatedAt": "2026-08-18T03:52:38.000Z",
            "metrics": null,
            "data": {
              "guard_bot": "safeguard",
              "guard_chat_ids": ["-1003849050974"],
              "subscriber_count": 16,
              "preview_available": true,
              "content_message_count": 2,
              "first_message_at": "2026-08-18T03:52:38.000Z",
              "last_message_at": "2026-08-18T03:57:46.000Z"
            }
          }
        }
      ]
    }
  ]
}
```

Two things to read correctly here:

- `venue.subtype: "portal"` means the real chat sits **behind a verification bot**. So
  `subscriber_count: 16` is the front desk's count, not the community's.
- The account's `outboundUrls` contains the Telegram URL that is itself another entry in
  `urls[]`. Both strings are normalized the same way, so **exact string matching works**.

### 4.3 A quote chain, including a parent in a different URL group

Token `4Dqn…E9pump` linked two tweets. Trimmed to the second URL group and its first two nodes.

```json
{
  "url": "https://x.com/Kirodev7/status/2091186167383249306",
  "platform": "x", "status": "ok", "retryable": false,
  "attemptedAt": "2026-08-22T15:30:22.707Z", "attempts": 1, "lastErrorCode": null,
  "nodes": [
    {
      "depth": 0,
      "parent": { "relation": "quote", "contentId": "6a89bfd2febb66ee94afed65", "inChain": true },
      "content": {
        "id": "6a89c095febb66ee94aff4e5",
        "platform": "x", "subtype": "tweet",
        "platformKey": "2091186167383249306",
        "publishedAt": "2026-08-22T15:30:09.000Z",
        "text": { "primary": "they are thinking of launching tokens for all the trump fami...",
                  "body": null, "fromMedia": null },
        "outboundUrls": ["https://x.com/goodalexander/status/2091185302819819541"],
        "tags": [], "mediaUrls": [], "mentions": [],
        "metrics": { "observedAt": "2026-08-22T15:30:22.707Z",
                     "viewCount": 14, "likeCount": 0, "replyCount": 0,
                     "retweetCount": 0, "quoteCount": 0, "bookmarkCount": 0 },
        "data": { "conversationId": "2091186167383249306",
                  "inReplyToId": "", "inReplyToUserId": "" }
      },
      "account": {
        "id": "6a7da2662953b9a57d7bbddd", "platform": "x",
        "platformKey": "2022838894384033792", "handle": "Kirodev7",
        "displayName": "Kiro", "bio": "", "avatarUrl": "https://...",
        "accountCreatedAt": "2026-02-15T01:02:37.000Z",
        "outboundUrls": [], "metrics": { "followers": 1497 },
        "data": { "content_count": 1611, "following": 15, "isBlueVerified": true }
      },
      "venue": null
    },
    {
      "depth": 1,
      "parent": { "relation": "reply", "contentId": "6a899b4bfebb66ee94aec23f", "inChain": false },
      "content": {
        "id": "6a89bfd2febb66ee94afed65",
        "platform": "x", "subtype": "tweet",
        "platformKey": "2091185302819819541",
        "publishedAt": "2026-08-22T15:26:43.000Z",
        "text": { "primary": "@blknoiz06 @rektmando I would buy the Kai Trump coin if she...",
                  "body": null, "fromMedia": null },
        "outboundUrls": [], "tags": [], "mediaUrls": [],
        "mentions": [ { "platformKey": "973261472", "screenName": "blknoiz06" },
                      { "platformKey": "1357410364355395587", "screenName": "rektmando" } ],
        "metrics": { "observedAt": "2026-08-22T15:27:12.023Z", "viewCount": 23, "likeCount": 0,
                     "replyCount": 0, "retweetCount": 0, "quoteCount": 0, "bookmarkCount": 0 },
        "data": { "conversationId": "2091107977210089588",
                  "inReplyToId": "2091146028426690986", "inReplyToUserId": "973261472" }
      },
      "account": { "id": "6a89bfd2febb66ee94afed61", "platform": "x",
                   "platformKey": "22110973", "handle": "goodalexander",
                   "displayName": "goodalexander", "bio": "What comes after Fiat? Founder @postfiat",
                   "avatarUrl": "https://...", "accountCreatedAt": "2009-02-27T05:18:52.000Z",
                   "outboundUrls": ["https://goodalexander.com"],
                   "metrics": { "followers": 169497 },
                   "data": { "content_count": 16446, "following": 1325, "isBlueVerified": true } },
      "venue": null
    }
  ]
}
```

Read the two `parent` objects carefully — they behave differently and both are correct:

- **depth 0** quotes the tweet at depth 1. `inChain: true`, so the parent is simply the next
  array element.
- **depth 1** is itself a reply, and its parent is **not** in this array (`inChain: false`).
  But `contentId` is `6a899b4b…`, which *is* present in this response — under the token's
  **other** URL group. Resolve it by scanning every `content.id` in the payload.

---

## 5. Recipes

### Build an index first

Everything below assumes this:

```ts
const contents = new Map<string, { node: Node; url: SocialUrlGroup }>();
for (const url of graph.urls)
  for (const node of url.nodes)
    if (node.content) contents.set(node.content.id, { node, url });
```

### "Which accounts did this token actually promote?"

Depth 0 only. Anything deeper is the author of *someone else's* post that got pulled in by
following a quote — including it will report unrelated third parties as project accounts.

```ts
const promoted = graph.urls
  .flatMap((u) => u.nodes)
  .filter((n) => n.depth === 0 && n.account)
  .map((n) => n.account!);
```

### "All text associated with this token"

Include every depth — quoted originals carry the context a reply alone cannot express.

```ts
const texts = graph.urls
  .flatMap((u) => u.nodes)
  .map((n) => n.content?.text.primary)
  .filter((t): t is string => !!t);
```

### "Resolve a node's parent"

```ts
function parentOf(node: Node, url: SocialUrlGroup) {
  if (!node.parent?.contentId) return null;
  if (node.parent.inChain) return url.nodes.find((n) => n.depth === node.depth + 1) ?? null;
  return contents.get(node.parent.contentId)?.node ?? null;   // may live under another url
}
```

A `null` result means the parent was never collected — that is a real gap in the data, not a
client bug.

### "Is this snapshot complete, and should I call again?"

```ts
const done = graph.collection.pending === 0;
```

`failed > 0` with `pending === 0` is a terminal state: those URLs will not resolve without
manual intervention on our side.

### "Deduplicate objects across URL groups"

The same content or account is emitted in full every place it appears. Ids are stable, so
dedupe on `content.id` / `account.id` / `venue.id`. Never dedupe on `handle` or on a URL.

### "Compare engagement between two posts"

Only when `platform` matches **and** you account for `metrics.observedAt`. See Traps.

---

## 6. Traps

These are the places where a reasonable-looking read produces a wrong answer with no error.

**Metric keys are deliberately not unified.** X reports `viewCount`, TikTok `playCount`,
Reddit `upVotes`, GitHub `stars`. Never sum a metric across platforms — a TikTok autoplay and
a YouTube 30-second view are not the same event.

**Metrics are a snapshot, not a current value.** `observedAt` is the anchor. Two posts observed
days apart are not comparable, and using today's follower count to describe an account "at
launch" is look-ahead leakage. Only `publishedAt`, `accountCreatedAt` and `venueCreatedAt` are
immutable and safe for as-of analysis.

**Nothing is updated after first observation.** Every field freezes when we first see the
object. `metrics`, `bio`, `displayName`, `followers`, `venue.subtype` are all first-observation
values and may be stale. This also means `handle` reflects the name at first sight.

**`platform: "web"` is not a claim that the URL is a website.** Any host without a dedicated
handler falls into `web`. It is a provisional bucket, so it can never be `null`.

**`data` is not part of the contract.** It is a staging area: when one of its keys becomes a
query axis, it is promoted to a top-level field and disappears from `data`. Read it if it
helps, but expect keys to move without notice. Inside it, **X fields use `""` rather than
`null`** when absent (`inReplyToId: ""`), so test truthiness, not `!== null`.

**Telegram has no accounts.** A Telegram node will never have an `account`, even though the
channel obviously has an owner.

**Reddit post authors are not accounts.** The author's name is only in
`content.data.authorName`, as a plain string, and `"[deleted]"` is a real value there. A
Reddit `account` only appears when the linked URL was itself a user profile.

**Reddit has no venues.** The subreddit is in `content.data.communityName`.

**Telegram `subtype: "portal"` means the real room is behind a guard bot.** Its
`data.subscriber_count` is the portal's, not the community's.

**Instagram story URLs produce an account, not a content.** The story itself is never
captured, so the link reads as "this token promoted this account" at depth 0.

**`mentions[]` is X-only.** `tags[]` is empty for X, Reddit and YouTube. An empty array means
"we do not collect this here", not "there were none".

**`skipped_paid` means we never called.** TikTok, Instagram and Reddit are behind a cost gate
that is off by default, so a small TikTok footprint is usually a configuration fact, not a
market fact.

**`attempts: 0` with `status: "unsupported"` or `"skipped_paid"` is normal** — no external
call was made, so nothing was counted.

---

## 7. Platform coverage

A field existing in the schema does not mean it is populated. `✓` = populated, blank = never.

| | x | tt | ig | yt | rd | gh | tg | web |
|---|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
| `content` produced | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ |
| `account` produced | ✓ | ✓ | ✓ | ✓ | ✓¹ | ✓ | | |
| `venue` produced | ✓² | | | | | | ✓ | |
| `content.metrics` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | |
| `content.mentions` | ✓ | | | | | | | |
| `content.tags` | | ✓ | ✓ | | | ✓ | | ✓³ |
| `content.outboundUrls` | ✓ | ✓ | ✓ | ✓ | ✓⁴ | ✓ | | |
| `account.accountCreatedAt` | ✓ | ✓ | | ✓ | ✓ | ✓ | | |
| `venue.metrics` | ✓² | | | | | | ⁵ | |
| `depth > 0` (chains) | ✓ | | | | | | | |

¹ Only when the linked URL is a user profile. Post authors never become accounts.
² X communities only.
³ Only for one extraction pattern.
⁴ Empty when the link is Reddit-owned.
⁵ Telegram deliberately leaves this null; use `venue.data.subscriber_count`.

**Only X ever produces `depth > 0`.** Every other platform yields chains of length 1, so their
nodes are always `depth: 0`. Observed depth distribution: depth 0 is 73.5% of
content nodes, depth 1 21.2%, depth 2 4.7%, and the remaining 0.5% tails off to depth 8.

---

## 8. Size, latency and call pattern

| | |
|---|---|
| Social URLs per token | avg 1.35, **max 4** |
| Nodes per URL | max 9 (depth 0–8) |
| Payload | ~500 B for an all-failed token, ~14 KB for a full 9-node chain |
| Database queries per request | **6, fixed** — independent of graph size |
| Observed latency | 13 ms (404) to ~215 ms (9-node chain, cold connection) |

There is no pagination and no truncation: the response is always the complete graph. There is
no batch endpoint — call once per address.

---

## 9. What is stable and what is not

**Contract.** Field names and types in §3, enum values, the `{ data, meta }` envelope, the
URL/node/depth structure, and the meaning of `retryable` and `collection`.

**Additive changes to expect.** New enum values (especially `ContentSubtype`), new top-level
fields, and new keys inside `metrics` as platforms are added. Treat unknown enum values as
opaque rather than failing.

**Not a contract.** Every `data` object, and `symbol` (a display copy whose source of truth is
another service).
