Undercurrent CapitalbetaSign in

Undercurrent API

Stories, storylines and public profiles are free to read by machine. Signal data needs an API key.

Endpoint

https://api.undercurrent.capital/graphql

GraphQL over POST, or GET for published operations. Introspection is disabled in production — download the schema instead: schema.graphql. AI agents can also use the MCP server.

Who can call

CallerHowWhat it can read
AnonymousNo auth headerPublished operations only — send extensions.persistedQuery.sha256Hash (GET or POST). Free fields; API-key fields are refused with error code API_KEY_REQUIRED (see API-key fields below). Rate-limited per IP.
API keyX-API-Key: <key>Read-only. Any query text, including API-key fields. No me, no mutations, no subscriptions, no WebSocket. Shares one per-key rate limit with REST /v1/signals. Sending Authorization as well is a 400.
Signed-in readerAuthorization: Bearer <session>The website's own session.
Undercurrent's own serversInternalLets the website show signal panels to every visitor. Not available to third parties.

Free fields

Root queries marked @public — no key needed.

  • post(id: ID, slug: String): Post
  • posts(sort: PostSort = HOT, scope: PostScope, tag: String, flair: String, asset: String, unread: Boolean, q: String, author: String, source: String, squad: ID, after: String, first: Int = 20, tags: [String!], catchup: Boolean): PostConnection!
  • storyline(key: ID!): Storyline
  • storylines(status: StorylineStatus, sort: String = "latest", q: String, curator: String): [Storyline!]!
  • user(handle: String!): PublicUser

API-key fields

Fields marked @apiKey. Without a key each one resolves to null with an API_KEY_REQUIRED error. GraphQL null rules apply: a nullable field comes back as null and the rest of its object still arrives, but a non-null field passes the null up to its nearest nullable parent.

Non-null API-key fields: Query.signals: [Signal!]!, Signal.attentionSeries: [Float!]!, Signal.confidence: Float!, Signal.severity: Int!. Selecting one without a key nulls the enclosing object — post.signal as a whole, free fields included — and for signals the whole data. Without a key, select only the nullable fields.

TypeFields
Querysignals: [Signal!]!
SignalattentionSeries: [Float!]! attentionStatus: AttentionStatus attentionVelocity: Float confidence: Float! direction: SignalDirection divergence: Divergence eventStudy: EventStudy eventTimestamp: DateTime eventType: String expertAttentionPct: Float severity: Int! smartReaders: Int smartReadersMult: Float

Published operations

Anonymous callers run these by hash. Cacheable ones answer GET with a public Cache-Control header.

PublicPosts · cacheable

query PublicPosts($first: Int!, $after: String) {
  posts(sort: NEW, first: $first, after: $after) {
    edges {
      slug
      title
      publishedAt
      tldr
      body
      author {
        displayName
        handle
        indexable
      }
      source {
        name
        slug
      }
    }
    pageInfo {
      endCursor
      hasNextPage
    }
  }
}
GET https://api.undercurrent.capital/graphql?operationName=PublicPosts&variables=%7B%22first%22%3A20%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%2253f08445db578b03e5fde6bbdee23b00ea83dc7c829ff194491a9557b0a72238%22%7D%7D

PublicUserExtras · cacheable

query PublicUserExtras($handle: String!) {
  user(handle: $handle) {
    stats {
      posts
      comments
      upvotesReceived
    }
    currentStreak
    foundingCircle
    similar {
      handle
      displayName
      avatar
    }
  }
}
GET https://api.undercurrent.capital/graphql?operationName=PublicUserExtras&variables=%7B%22handle%22%3A%22example%22%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%2200456baa205ecad8200de8bfd89686e30db8d7b322f229f49d3e0c21557421cd%22%7D%7D

PublicUserPage · cacheable

query PublicUserPage($handle: String!) {
  user(handle: $handle) {
    handle
    displayName
    avatar
    bio
    repLevel
    trustBadge
    kind
    orgUrl
    joinedAt
    viewerFollowing
    viewerBlocked
    indexable
    headline
    banner
    links {
      github
      x
      website
    }
  }
}
GET https://api.undercurrent.capital/graphql?operationName=PublicUserPage&variables=%7B%22handle%22%3A%22example%22%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22dcc112379bea9ce31a9c882f7cb4cecded29178923f5d670c41a8826c0504709%22%7D%7D

PublicUserReading · cacheable

query PublicUserReading($handle: String!) {
  user(handle: $handle) {
    reading {
      year {
        year
        from
        days
        readingDays
      }
      rhythm
      topTags {
        label
        count
      }
      storylineProgress {
        storyline {
          key
          title
        }
        read
        total
      }
      privateSections
    }
  }
}
GET https://api.undercurrent.capital/graphql?operationName=PublicUserReading&variables=%7B%22handle%22%3A%22example%22%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%221d9524a626d77165efbaa586114a8044265bf46ae8a84d379770165f28609efd%22%7D%7D

REST: signal log

GET https://api.undercurrent.capital/v1/signals?asset=BTC&severity_gte=4&since=2026-07-21T00:00:00Z
X-API-Key: <key>
ParameterMeaning
assetTicker, e.g. BTC.
sinceRFC 3339 time; only signals at or after it.
severity_gteInteger 1–5; minimum severity.
cursorThe next_cursor of the previous page.

Response: { data, next_cursor, count } — count is the rows on this page; next_cursor is null on the last page. Errors: 400 bad parameter, 401 missing or unknown key, 403 revoked key, 429 rate limit (with Retry-After).

Webhook signatures

Every alert webhook is signed with the Standard Webhooks scheme, so any Standard Webhooks library can verify it. Each request carries three headers:

webhook-id: <delivery id, the same on every retry>
webhook-timestamp: <Unix seconds of this attempt>
webhook-signature: v1,<base64 signature>

To verify: take your signing secret (whsec_…), base64-decode the part after whsec_ to get the key, and compute HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{body} using the raw request body exactly as received (JSON or CSV). Base64-encode the result and compare it, in constant time, with the value after v1,. Reject timestamps more than five minutes from your clock, and use webhook-id to drop duplicates.

You have one signing secret for all your rules. Find it, or rotate it, in Settings under Alert delivery; a new secret is shown once, and the old one stops working immediately — a delivery already in flight when you rotate may still be signed with the old secret and get rejected, so rotate right after generating a new one on your end, not before.

Get a key

Sign in and create one on Data export. One key per account; keys are read-only.