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
| Caller | How | What it can read |
|---|---|---|
| Anonymous | No auth header | Published 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 key | X-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 reader | Authorization: Bearer <session> | The website's own session. |
| Undercurrent's own servers | Internal | Lets 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): Postposts(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!): Storylinestorylines(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.
| Type | Fields |
|---|---|
| Query | signals: [Signal!]! |
| Signal | attentionSeries: [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>
| Parameter | Meaning |
|---|---|
asset | Ticker, e.g. BTC. |
since | RFC 3339 time; only signals at or after it. |
severity_gte | Integer 1–5; minimum severity. |
cursor | The 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.