Skip to main content

CabalSpy ID — SocialFi API & SDK for Python

PyPI ci Python license

Official Python SDK (sync and async) for CabalSpy ID. CabalSpy ID is the SocialFi API for crypto apps: one SocialFi integration gives your platform login with X, wallets verified by a tiny on-chain payment (no wallet connect, no signatures), reviewed KOL badges, live trades and PnL of your users and a drop-in SocialFi widget for token theses — on Solana, BNB Chain, Base, Ethereum and Robinhood Chain.

Your platform adds "Login with CabalSpy ID" and receives the user's X profile, verified wallets, KOL badge and full trading stats — PnL, win rate, trades, holdings — for copy trading, leaderboards and social features.

Website · Docs · Sign in · API dashboard

pip install cabalspy-id

The live stream needs one extra dependency:

pip install "cabalspy-id[stream]"
CabalSpyIDAuth / AsyncCabalSpyIDAuth OpenID Connect for your server: PKCE, code exchange, refresh, revoke, userinfo, ID token verification (ES256 / JWKS)
CabalSpyID / AsyncCabalSpyID Data API client for https://api-id.cabalspy.xyz/v1, typed responses, pagination, live trade stream, white-label platform users, trading competitions, callouts (on request), the Trade Feed and copy trading
ThesisClient / AsyncThesisClient public token thesis API with your public client_id and a user's access token, plus live() updates
verify_webhook Standard Webhooks signature verification

Sync and async, fully typed (py.typed, TypedDict responses). Python 3.10+.

Everything for developers — instance (client_id), client secrets, redirect URIs, button builder, webhooks, API keys — lives in the API dashboard, tab CabalSpy ID. For React and the browser, see the TypeScript SDK.

What can you build

  • Social trading feed — every trade of your users live, with their X profile and PnL
  • Leaderboards — rank your users by PnL, volume, win rate or trades
  • Copy trading — follow verified wallets of real, identified traders; every trade of a copied trader arrives as a ready-to-execute instruction sized by the user's settings
  • Token theses — holders-only opinions on a token with the author's real position, as a SocialFi widget or your own UI
  • Trader profiles — X profile, verified wallets, holdings, PnL calendar
  • KOL badges — reviewed badges for influential traders, shown wherever they sign in
  • White-label — keep your own login, import your users and their wallets, run trading competitions
  • Callouts (on request) — your users call tokens; every call is stamped and tracked, with a score, a callers leaderboard and callout competitions

Packages

One codebase, one version line. The focused packages re-export parts of cabalspy-id, so they always behave exactly the same.

Package npm PyPI What it contains
cabalspy-id cabalspy-id cabalspy-id everything below
socialfi-api (repo) socialfi-api socialfi-api the SocialFi API: Data API, Login with CabalSpy ID, trade stream, webhooks (= cabalspy-id/api, cabalspy_id.api)
socialfi-thesis (repo) socialfi-thesis socialfi-thesis token theses: Data API thesis methods, public thesis client, theses stream (= cabalspy-id/thesis, cabalspy_id.thesis)
socialfi-widget (repo) socialfi-widget – the browser SocialFi widgets: login button and theses widget, vanilla and React (= cabalspy-id/widget)

Modules

Import Contents
cabalspy_id everything
cabalspy_id.api CabalSpyID, AsyncCabalSpyID, CabalSpyIDStream, CalloutsStream, CopyTradingStream, TradeFeedStream, CabalSpyIDAuth, verify_webhook, errors and Data API types
cabalspy_id.thesis CabalSpyID (thesis methods), ThesisClient, AsyncThesisClient, ThesisLive, ThesesStream, thesis types

The objects are the same as in cabalspy_id.

Login button

Put the official button on your page and point it at your login route:

<div data-csid-login data-client-id="csid_..." data-href="/login"></div>
<script src="https://id.cabalspy.xyz/widget.js" async></script>

Signed in, mark the person so the button shows their avatar menu: data-user="<sub>" data-logout-href="/logout". Design overrides: data-theme, data-color, data-text-color, data-shape, data-size, data-label, data-full-width, data-show-logo.

Server-side login (OpenID Connect)

Authorization code flow with PKCE S256 (required for every client), state and nonce.

from cabalspy_id import CabalSpyIDAuth, is_limited

auth = CabalSpyIDAuth(
    "csid_...",                                      # your instance's client_id
    client_secret=os.environ["CSID_CLIENT_SECRET"],  # csid_sk_... (omit for public clients)
    redirect_uri="https://your.app/callback",        # registered in the dashboard
)

# GET /login
req = auth.create_authorization_url(scope=["openid", "profile", "wallets", "offline_access"])
session["csid"] = req.to_dict()   # state, nonce, code_verifier, redirect_uri
return redirect(req.url)

# GET /callback
result = auth.handle_callback(request.url, **session.pop("csid"))
# checks error, state and iss, exchanges the code, verifies the ID token and its nonce

if is_limited(result.claims):
    ...  # over your plan's user limit: anonymous sub "anon_...", no profile or wallet data
else:
    user = auth.userinfo(result.tokens["access_token"])  # x profile, wallets, onchain
Method
discover() the discovery document (cached)
create_authorization_url(scope=, prompt=, redirect_uri=, state=, nonce=) returns AuthorizationRequest(url, state, nonce, code_verifier, redirect_uri)
handle_callback(url, state=, nonce=, code_verifier=) full callback handling, returns LoginResult(tokens, claims)
exchange_code(code=, code_verifier=, redirect_uri=) POST /oauth/token, authorization_code
refresh(refresh_token) POST /oauth/token, refresh_token. Tokens rotate: store the new one; reusing an old one revokes the chain
revoke(token) POST /oauth/revoke
userinfo(access_token) GET /oauth/userinfo
verify_id_token(id_token, nonce=, max_age=) ES256 signature via JWKS, iss, aud, azp, exp, iat, nonce
verify_access_token(access_token) for access tokens your frontend sends to your API (typ: at+jwt)

AsyncCabalSpyIDAuth has the same methods as coroutines. Scopes: openid profile wallets onchain thesis offline_access (thesis: post theses and replies, react and report in the user's name). Client authentication: client_secret_basic (default with a secret), client_secret_post, none. Access and ID tokens live 15 minutes; refresh tokens 30 days of inactivity, 90 days absolute.

Browser apps (public clients) let the widget run PKCE itself; it hands your page an idToken. Send it to your backend and verify it there:

auth = CabalSpyIDAuth("csid_...")
claims = auth.verify_id_token(id_token_from_the_browser)

Users over the plan's limit can always sign in. Their token carries an anonymous sub (anon_...), limited: true and reason: "plan_user_limit", and no profile or wallet data, until the platform upgrades. is_limited(claims) checks both.

A runnable server is in examples/login_server.py.

Users without X

People can sign in to CabalSpy ID with X, Telegram or Google. Accounts without X can do everything except earn the KOL badge, and connecting X later fills in their profile. For them:

Field With X Without X
preferred_username (claims) / username X handle, e.g. alice tg.<telegram name> (Telegram) or id.<hex> (Google) — always contains a dot
x (userinfo, /v1/profile) X profile / stats None
twitter (profiles, trading profile blocks, leaderboards) https://x.com/<handle> None
telegram (profiles, trading profile blocks, leaderboards) https://t.me/<name> or "" https://t.me/<name> or ""
name, picture / image_url from X from Telegram or Google

profile_url (https://id.cabalspy.xyz/u/<handle>) works for everyone. Thesis authors use the same handles.

from cabalspy_id import has_x, handle_kind

has_x(claims)              # False for Telegram/Google sign-ins
handle_kind("tg.alice")    # "telegram"
x_link = profile["twitter"]  # None without X: never build x.com links from tg./id. handles

Data API

from cabalspy_id import CabalSpyID

csid = CabalSpyID()  # reads CABALSPY_ID_API_KEY, then CABALSPY_API_KEY

me = csid.me()  # instance, plan, rate limit, users with data

# The person who just signed in: look them up by the `sub` of their ID token
profile = csid.profile(csid_id=claims["sub"])
wallet = next(w for w in profile["wallets"] if "solana" in w["chains"])

# Full trading profile for a period, same numbers as the CabalSpy API
t = csid.tracker(blockchain="solana", address=wallet["address"], period="7d")
print(t["period_stats"]["realized_pnl_usd"], t["period_win_rate_distribution"]["win_rate_percentage"])

# Your users ranked by PnL
board = csid.leaderboard(blockchain="solana", period="7d", sort="pnl_usd", limit=25)

Async:

from cabalspy_id import AsyncCabalSpyID

async with AsyncCabalSpyID() as csid:
    users = await csid.users(kol=True)

The API key is the same key as the CabalSpy API, with the per-key toggle "CabalSpy ID" enabled on the API keys page. A key sees its instance only: users that signed in to its apps, within the plan's user limit. scope="global" (every CabalSpy ID user) is Enterprise only.

Method Endpoint
health() GET /v1/health (no key needed)
me() GET /v1/me
users(limit=, kol=, cursor=, scope=) GET /v1/users
profile(username= | csid_id= | address=) GET /v1/profile
wallet_lookup(address) GET /v1/wallets/lookup
tracker(blockchain=, address=, period=) GET /v1/wallets/tracker
history(blockchain=, address=, limit=, cursor=) GET /v1/wallets/history
holdings(blockchain=, address=) GET /v1/wallets/holdings
pnl_calendar(blockchain=, address=) GET /v1/wallets/pnl_calendar
leaderboard(blockchain=, period=, sort=, limit=, cursor=, scope=) GET /v1/wallets/leaderboard
latest_transactions(blockchain=, limit=) GET /v1/transactions/latest

Chains: solana, bnb, base, eth, rh. Periods: 6h, 1d, 7d, 30d. Leaderboard sorts: volume, pnl, pnl_usd, win_rate, trades. Responses are plain dicts typed with TypedDicts (Profile, TrackerResponse, LeaderboardResponse, …).

If your account has several CabalSpy ID instances, pass instance="csid_..." (the client_id).

Pagination

Iterators follow next_cursor for you (async generators on AsyncCabalSpyID):

for user in csid.iter_users(kol=True):
    print(user["username"])
for trade in csid.iter_history(blockchain="solana", address=address):
    print(trade["tx_signature"])
for entry in csid.iter_leaderboard(blockchain="base", period="30d"):
    print(entry["rank"])

For whole pages with metadata: csid.pages("/users", {"limit": 100}) yields Envelopes, and csid.get_raw(path, params) returns one.

Errors

Every failure is a subclass of CabalSpyIDError carrying .code, .status, .request_id, .docs (a link to the error's documentation), and for validation errors .parameter and .allowed.

from cabalspy_id import NotFoundError, PlanUserLimitError, RateLimitError

try:
    csid.profile(csid_id=claims["sub"])
except PlanUserLimitError:
    show_upgrade_notice()          # anon_ sub: no data until you upgrade
except NotFoundError:
    pass                           # not one of your users
except RateLimitError as err:
    print(err.retry_after)
Class Status Codes
BadRequestError 400, 422 missing_parameter, invalid_parameter, instance_required, wallet_required, invalid_address, invalid_return_url, cannot_add_self, no_price (422)
AuthenticationError 401 missing_api_key
PermissionError 403 invalid_api_key, product_not_enabled, no_instance, scope_not_allowed, claim_disabled, feature_not_enabled, kol_only, not_a_holder, invalid_account, posting_paused, consent_required, sandbox_user, trading_private
PlanUserLimitError 403 plan_user_limit
NotFoundError 404 profile_not_found, wallet_not_found, instance_not_found, platform_user_not_found, verification_not_found, competition_not_found, callout_not_found, trader_not_found, not_in_trade_feed, not_found
ConflictError 409 account_claimed, last_wallet, too_many_wallets, wallet_already_verified, kol_badge, x_required, kol_not_eligible, competition_limit, competition_closed, competition_full, late_join_closed, already_called, copy_managed_elsewhere, already_reported, trade_feed_full
RateLimitError 429 rate_limit_exceeded, rate_limited (also the Trade Feed's 120 changes per hour; not retried), too_many_open_verifications, callout_cooldown
ServerError 5xx internal_error, service_unavailable
APIConnectionError – network failure, timeout
OAuthError 4xx OAuth errors from the login endpoints (invalid_grant, invalid_client, …)
TokenVerificationError – ID / access token failed verification
WebhookVerificationError – webhook signature or timestamp invalid
PlatformImportError – bulk_import_platform_users stopped; carries result and remaining

429, 5xx and network errors are retried with exponential backoff (default 2 retries); Retry-After wins. Writes (POST) are retried only after the API-wide rate limit (rate_limit_exceeded), since they did not run. callout_cooldown is never retried: the wait (retry_after) can be hours. The rate limit of the last call is in csid.last_rate_limit (X-RateLimit-Limit/Remaining/Reset).

White-label (platform users)

Keep your own login. Your platform imports its users — by your own user id (external_id) — together with the wallets they already have (Privy, Dynamic, any provider), and then uses their trades, PnL, leaderboards and trading competitions through the API. No CabalSpy ID login, no wallet connect: imported wallets count right away (unless you set wallet_trust="verified_only"), and the on-chain payment verification stays available as a badge.

from cabalspy_id import CabalSpyID, PlanUserLimitError

csid = CabalSpyID()

# One user: create or update (a new user needs 1-10 wallets)
user = csid.upsert_platform_user(
    "user_42",
    wallets=["So1...", {"address": "0xabc...", "chains": ["base"], "source": "privy"}],
    display_name="Alice",
)

# Everyone else: any number of users, sent in chunks of 1,000
result = csid.bulk_import_platform_users(
    {"external_id": u.id, "wallets": u.wallet_addresses, "display_name": u.name} for u in all_users
)
print(result["summary"])  # {"created": ..., "updated": ..., "unchanged": ..., "rejected": ...}

# A profile page in one call, and your own leaderboard
profile = csid.platform_user_profile("user_42", period="7d")
board = csid.platform_leaderboard(blockchain="solana", period="7d", sort="pnl_usd")
for entry in board["leaderboard"]:
    print(entry["rank"], entry["external_id"], entry["period_stats"]["realized_pnl_usd"])

# A trading competition between your users
comp = csid.create_competition(name="Weekly PnL", chain="solana", metric="pnl_usd",
                               duration_hours=168, enrollment="all_users")
standings = csid.competition_standings(comp["id"])

Every platform-users method that takes a user accepts the external_id positionally or csid_id= (exactly one). The rest of the API also accepts external_id in place of csid_id (pass it with get_raw / post_raw), and every platform user carries its csid_id for the other SDK methods. A full tour is in examples/white_label.py.

Bulk import. import_platform_users(users) is one call (at most 1,000 users). bulk_import_platform_users(users) takes any number: it sends chunks of at most 1,000 users and under the server's 1 MB body limit, retries a chunk refused with 429 (Retry-After wins), and sends users rejected with rate_limited (too many new users per minute) again on their own once the minute is over. A chunk that succeeded is never sent again. Results come back aggregated, one per user in input order. If anything else stops it, PlatformImportError carries result (what was imported) and remaining (pass it again to resume). Each user is handled on its own on the server: a bad item or the plan's user limit (plan_user_limit) never undoes the others.

Method Endpoint
upsert_platform_user(external_id, wallets=, display_name=, avatar_url=) POST /v1/platform-users
import_platform_users(users) / bulk_import_platform_users(users) POST /v1/platform-users/import
platform_user(external_id | csid_id=) GET /v1/platform-users
update_platform_user(external_id, display_name=, avatar_url=) POST /v1/platform-users/update
platform_users(limit=, cursor=) / iter_platform_users() GET /v1/platform-users/list
delete_platform_user(external_id) POST /v1/platform-users/delete
add_platform_user_wallets(external_id, wallets=) POST /v1/platform-users/wallets/add
delete_platform_user_wallet(external_id, address=) POST /v1/platform-users/wallets/delete
start_wallet_verification(external_id, chain=, address=) POST /v1/platform-users/wallets/verify
wallet_verification(id) / wallet_verification_qr(id) (SVG) / cancel_wallet_verification(id) /v1/platform-users/wallets/verification[/qr|/cancel]
platform_user_profile(external_id, period=) GET /v1/platform-users/profile
platform_leaderboard(blockchain=, period=, sort=, limit=, cursor=, trust=) / iter_platform_leaderboard() GET /v1/platform-users/leaderboard
platform_settings() / update_platform_settings(...) GET / POST /v1/platform-users/settings
platform_inbound() / enable_platform_inbound() / disable_platform_inbound() /v1/platform-users/inbound[/enable|/disable]
platform_user_session(external_id) POST /v1/platform-users/session (verify widget, 15 min)
platform_user_claim_link(external_id, return_url=) POST /v1/platform-users/claim-link
connect_platform_user_x(external_id, return_url=) / disconnect_platform_user_x(external_id) POST /v1/platform-users/x/connect, /x/disconnect
link_platform_user_telegram / platform_user_telegram / unlink_platform_user_telegram /v1/platform-users/telegram[/link|/unlink]
platform_user_kol / request_platform_user_volume_check / apply_platform_user_kol /v1/platform-users/kol[/volume-check|/apply]

Settings (update_platform_settings): white_label, data_scope (csid: part of the CabalSpy ID network, default; platform: only you see your users), wallet_trust and leaderboard_trust (all or verified_only), claim_links, telegram_alerts. Wallets carry a status: verified, active (unverified, counts), needs_verification, verified_elsewhere or pending.

Inbound URL. enable_platform_inbound() returns a secret URL (shown once) that your platform or its auth provider calls on sign-up; users are then created like upsert_platform_user. Formats: csid, privy, dynamic, generic.

Trading competitions

Individual competitions between your platform users, at most 5 scheduled + running at a time per account. A competition starts at starts_at (or at once) once min_participants joined, then runs duration_hours. Metrics: pnl_usd, pnl, volume_usd, volume, trades, win_rate. Traders below min_trades / min_volume_usd are listed but not ranked. Rules can change at any time, also while running; every change is recorded (changes, rules_version).

Method Endpoint
create_competition(name=, chain=, metric=, duration_hours=, ...) POST /v1/competitions
competitions(status=, limit=, offset=) / iter_competitions() GET /v1/competitions
competition(id) GET /v1/competitions/detail
update_competition(id, note=, **fields) POST /v1/competitions/update
cancel_competition(id) POST /v1/competitions/cancel
join_competition(id, external_id) / leave_competition(id, external_id) POST /v1/competitions/join, /leave
competition_participants(id, limit=, offset=) / iter_competition_participants(id) GET /v1/competitions/participants
competition_standings(id, limit=, offset=) / iter_competition_standings(id) GET /v1/competitions/standings

The competition routes page by offset; the iter_* helpers walk them until pagination.total.

Webhooks: platform_user.created, platform_user.claimed, platform_user.x_connected, wallet.added, verification.expired, and per instance competition.started, competition.updated, competition.standings, competition.ended (PLATFORM_WEBHOOK_EVENTS, COMPETITION_WEBHOOK_EVENTS).

Callouts (on request)

Callouts are an extra for CabalSpy ID instances: they are switched on per instance on request (contact CabalSpy). Until then every callout call raises PermissionError with the code feature_not_enabled; csid.me()["features"] lists "callouts" once they are on.

A user calls a token: its price and market cap are stamped at that moment and tracked.

  • First 24 hours: read every minute (current and peak multiple, milestones 2x, 3x, 5x, 10x, ...), then the day is scored on price candles (first_day, webhook callout.scored).
  • After that: read every 5 minutes while the token is active, plus each day's candles. Tracking stops after 24 hours in a row below a $10k market cap (tracking.below_10k_since, then callout.final).

A callout's score is score_x, its peak multiple since the call. On top: stats per user and period, a callers leaderboard, a live stream, webhooks and callout competitions.

from cabalspy_id import CabalSpyID, ConflictError, RateLimitError

csid = CabalSpyID()

try:
    c = csid.create_callout("user_42", chain="solana", mint=MINT, note="Undervalued, team ships")
    print(c["at_call"]["market_cap_usd"], c["score_x"])
except ConflictError:              # already_called: one call per token and 24 hours
    pass
except RateLimitError as err:      # callout_cooldown: the instance's cooldown, never retried
    print("next callout in", err.retry_after, "seconds")

for c in csid.iter_callouts(chain="solana", mint=MINT):           # chart markers, newest first
    print(c["called_at"], c["author"]["name"], c["peak"]["x"], c["tracking"]["phase"])

stats = csid.callout_stats("user_42", hit_x=2)                     # per period: 6h, 1d, 7d, 30d
print(stats["periods"]["7d"]["avg_peak_x"], stats["periods"]["7d"]["hit_rate"])

board = csid.callout_leaderboard(period="7d", sort="avg_peak_x", min_callouts=3)
for e in board["leaderboard"]:
    print(e["rank"], e["author"]["name"], e["avg_peak_x"], e["best_call"]["symbol"])

csid.update_callout_settings(who="all", require_holding=True, cooldown_minutes=360)
Method Endpoint
create_callout(external_id | csid_id=, chain=, mint=, note=) POST /v1/callouts
remove_callout(id, reason=) POST /v1/callouts/remove (moderation)
callouts(chain=, mint=, external_id=, csid_id=, since=, until=, limit=, cursor=) / iter_callouts() GET /v1/callouts
callout(id) GET /v1/callouts/item
callout_stats(external_id | csid_id=, hit_x=) GET /v1/callouts/stats
callout_leaderboard(period=, chain=, sort=, min_callouts=, hit_x=, limit=, cursor=) / iter_callout_leaderboard() GET /v1/callouts/leaderboard
callout_settings() / update_callout_settings(who=, require_holding=, cooldown_minutes=) GET / POST /v1/callouts/settings
callouts_stream(chain=, mint=) WS /v1/callouts/stream

A callout (Callout): at_call (price and market cap at the call), now and peak (price, market cap, multiple x, time), score_x, status (tracking or final), tracking (active, phase = first_day / extended / stopped, next_check_at, below_10k_since, daily_checks, stopped_at, stop_reason) and, once the first day is scored, first_day (kind = candles or live, peak_x, close_x, minutes_to_peak, flags).

Stats and leaderboard. Per period (the callouts made in the last 6h / 1d / 7d / 30d): callouts, avg_peak_x, median_peak_x, best_peak_x, sum_peak_x, hits and hit_rate (percent of callouts whose peak reached hit_x, default 2), plus avg_now_x and tracking in the stats. The leaderboard sorts by avg_peak_x (default), best_peak_x, sum_peak_x, hit_rate, callouts or median_peak_x; each entry has best_call.

Settings. who (all or kol_only), require_holding (only holders may call a token, default on), cooldown_minutes (0-10080 between two callouts of one user, default 360). Errors on create_callout: kol_only, not_a_holder, posting_paused (PermissionError), already_called (ConflictError), callout_cooldown (RateLimitError with retry_after), no_price (BadRequestError, 422).

Live stream (async, needs cabalspy-id[stream]):

async with AsyncCabalSpyID() as csid:
    async with csid.callouts_stream(chain="solana") as cs:    # mint= one token (needs chain)
        async for ev in cs:
            if ev["event"] == "callout.milestone":
                print(ev["data"]["token"]["symbol"], ev["data"]["milestone_x"], "x")

Events: callout.created, callout.update (a new price: price_usd, market_cap_usd, x, peak_x), callout.milestone (the callout plus milestone_x), callout.scored, callout.final, callout.removed, plus ready, heartbeat, pong, open, close, reconnecting, error. feature_not_enabled and invalid_api_key are fatal; too_many_connections (at most 5 per key) reconnects with backoff.

Callout competitions. create_competition(kind="callouts", ...) ranks your users on the callouts they make during the competition: metrics avg_peak_x, best_peak_x, sum_peak_x, hit_rate, callouts; min_callouts to be ranked, hit_x the multiple that counts as a hit, and chain="all" to count every chain. Standings carry the callout stats and best_call. The kind cannot change later; min_trades / min_volume_usd belong to trading competitions.

comp = csid.create_competition(name="Caller of the week", kind="callouts", chain="all",
                               metric="avg_peak_x", duration_hours=168, min_callouts=3, hit_x=2,
                               enrollment="all_users")

A full tour is in examples/callouts.py.

Trade Feed

A user's Trade Feed is the traders they follow: every trade and thesis of those traders, alerts filtered per trader, and the copy settings. Read and manage it from your backend in the user's name: a person who signed in to your app with the scope trade_feed (else PermissionError consent_required), or one of your platform users, within your plan's user limit. Every method takes the user as external_id (positional, your own user id) or csid_id=; a trader is a handle (with or without @) or a csid_id, or trader_external_id= for one of your platform users.

from cabalspy_id import CabalSpyID, ConflictError, PermissionError

csid = CabalSpyID()

# Add @MazeCCC to user_42's Trade Feed: alerts for buys from 0.5 SOL, no sells
try:
    r = csid.add_trade_feed_trader("user_42", trader="@MazeCCC", min_sol=0.5, notify_sells=False)
    print(r["created"], r["settings"]["min_native"])     # True on HTTP 201, False when already there
except PermissionError as err:                           # consent_required, trading_private
    print(err.code)
except ConflictError:                                    # trade_feed_full: 300 traders
    pass

for item in csid.iter_trade_feed("user_42", filter="buys"):  # newest first
    t = item["trade"]
    print(item["trader"]["username"], t["side"], t["symbol"], t["amount_usd"], t["label"])

for entry in csid.iter_trade_feed_traders("user_42"):
    print(entry["trader"]["username"], entry["settings"]["muted"], entry["settings"]["copy"]["enabled"])

csid.remove_trade_feed_trader("user_42", trader="MazeCCC")  # also ends copying
Method Endpoint
trade_feed(external_id | csid_id=, filter=, limit=, cursor=) / iter_trade_feed() GET /v1/trade-feed
trade_feed_traders(external_id | csid_id=, limit=, cursor=) / iter_trade_feed_traders() GET /v1/trade-feed/traders
add_trade_feed_trader(external_id | csid_id=, trader= | trader_external_id=, notify_buys=, notify_sells=, notify_theses=, min_sol=, min_bnb=, min_eth=, muted=) POST /v1/trade-feed/traders
remove_trade_feed_trader(external_id | csid_id=, trader= | trader_external_id=) POST /v1/trade-feed/traders/delete
trade_feed_notifications(external_id | csid_id=, limit=, cursor=) / iter_trade_feed_notifications() GET /v1/trade-feed/notifications
trade_feed_stream() WS /v1/trade-feed/stream

The feed (TradeFeedItem, newest first; filter all, buys, sells or theses; limit 1-100, default 30): trade items (type trade: trader, trade with side, label (new, add, partial, close, sell), sold_percent, amount_native in currency, amount_usd, price_usd, pnl_usd / pnl_percent for sells with a known entry, wallet, tx_signature and explorer links) and thesis items (type thesis, id h + the thesis id). An item's id is the same in the feed, the webhook, the stream and the notifications. Traders who keep their trading private are left out.

Traders (TradeFeedTrader, most recently added first; limit 1-300, default 100; the envelope's pagination has total and max): trader, trading_public, added_at, in_trade_feeds and settings (notify_buys, notify_sells, notify_theses, min_native {"SOL", "BNB", "ETH"}, muted, and copy, the CopySettings). add_trade_feed_trader uses the user's default alert settings unless you pass others; for a trader already there it updates what you pass (None = unchanged). min_* are 0 to 1,000,000 (0 = every trade).

Notifications (TradeFeedNotification, limit 1-100, default 50): every alert that passed the user's filters, with id, event (trade_feed.trade / trade_feed.thesis), a ready-made text, created_at and the item as data. Useful as an inbox when you do not use webhooks.

Errors. BadRequestError cannot_add_self; PermissionError consent_required, trading_private; PlanUserLimitError plan_user_limit (an anon_ id); NotFoundError trader_not_found, platform_user_not_found; ConflictError trade_feed_full (300 traders); RateLimitError rate_limited (more than 120 changes of a user's Trade Feed in an hour; not retried).

Live stream (async, needs cabalspy-id[stream]) and webhooks trade_feed.trade / trade_feed.thesis (TRADE_FEED_WEBHOOK_EVENTS) carry every new trade and thesis that concerns your users, once per event (not per user): data is the item plus recipients (csid_ids of your users whose filters let it through) and recipients_external (your own ids of those who are platform users).

async with AsyncCabalSpyID() as csid:
    async with csid.trade_feed_stream() as tf:
        async for ev in tf:                       # {"event": "trade_feed.trade", "id": ..., "data": ...}
            for user in ev["data"]["recipients_external"]:
                await notify(user, ev["data"])

Events: trade_feed.trade, trade_feed.thesis, plus ready, heartbeat, pong, open, close, reconnecting, error. invalid_api_key is fatal; too_many_connections (at most 5 per key) reconnects with backoff. Events made while disconnected are not replayed: read the notifications or the feed to catch up.

A full tour is in examples/trade_feed.py.

Copy trading

Every trade of a trader one of your users copies becomes a ready-to-execute instruction for that user, sized by their copy settings for that trader. CabalSpy ID never holds keys and never executes: your app executes the instruction with the user's wallet and reports what happened. It needs the user's consent with the scope trade_feed (else PermissionError consent_required); a copied trader is in the user's Trade Feed.

from cabalspy_id import CabalSpyID, ConflictError

csid = CabalSpyID()

# Copy @ctkol for user_42: $25 per buy, at most $20, buys and sells, 1.5 % price limit
try:
    s = csid.update_copy_trading_settings("user_42", trader="ctkol", enabled=True, mode="fixed_usd",
                                          amount_usd=25, max_usd=20, sides="both", slippage_bps=150)
except ConflictError:              # copy_managed_elsewhere: another platform copies this trader for the user
    pass

for s in csid.copy_trading_settings("user_42"):                     # every trader in the Trade Feed
    print(s["trader"]["username"], s["copy"]["enabled"], s["managed_by_other_platform"])

for i in csid.iter_copy_instructions("user_42", status="pending"):  # newest first
    a = i["action"]
    sig = execute(i["csid_id"], a)                                   # your swap, before i["expires_at"]
    csid.report_copy_instruction(i["id"], status="executed", tx_signature=sig, executed_amount_usd=a["amount_usd"])
Method Endpoint
copy_trading_settings(external_id | csid_id=, trader=, trader_external_id=) GET /v1/copy-trading/settings (a list, or one with a trader)
update_copy_trading_settings(external_id | csid_id=, trader= | trader_external_id=, enabled=, mode=, amount_usd=, percent=, max_usd=, sides=, slippage_bps=) POST /v1/copy-trading/settings
copy_instructions(external_id | csid_id=, status=, limit=, cursor=) / iter_copy_instructions() GET /v1/copy-trading/instructions
copy_instruction(id) GET /v1/copy-trading/instructions/item
report_copy_instruction(id, status=, tx_signature=, executed_amount_usd=, note=) POST /v1/copy-trading/instructions/status
copy_trading_stream() WS /v1/copy-trading/stream

Settings (CopySettings, per user and trader): enabled, mode (fixed_usd: every buy is amount_usd, default 50; percent: percent % of the trader's buy in USD, default 100, at most 1000), max_usd (cap per buy; max_usd=None removes it), sides (buys or both, default both), slippage_bps (1-5000, default 300). Only the settings you pass change; the trader (handle or csid_id, or trader_external_id for one of your platform users) is added to the Trade Feed if needed (HTTP 201). Copying a trader is switched on by one platform at a time: only its apps get the instructions, so a user on two platforms is never copied twice (managed_by_other_platform, copy_managed_elsewhere).

An instruction (CopyInstruction): id, status, created_at, expires_at (120 seconds later), csid_id, trader, action (blockchain, mint, symbol, side; buys: amount_usd, amount_native in currency and max_price_usd; sells: sell_percent of the copied position and min_price_usd; price_usd, slippage_bps), source_trade (the trader's trade), settings (as they were), links, and once reported tx_signature, executed_amount_usd, note. Buys under $1 are not sent. A sell is only sent after a copied buy of that token that was not reported failed or skipped; a closing sell is sell_percent 100. A pending instruction past expires_at reads as expired (it may still be reported executed).

Reporting. status is executed, failed or skipped and final: the same report twice is fine, another one raises ConflictError already_reported. Reports are not retried after a server or network error.

Live stream (async, needs cabalspy-id[stream]), webhook copy_trade.instruction (COPY_TRADING_WEBHOOK_EVENTS) and REST carry the same instructions; the stream and the webhook add external_id:

async with AsyncCabalSpyID() as csid:
    async with csid.copy_trading_stream() as cs:
        async for ev in cs:                       # {"event": "copy_trade.instruction", "id": ..., "data": ...}
            i = ev["data"]
            await execute_and_report(csid, i)

Events: copy_trade.instruction, plus ready, heartbeat, pong, open, close, reconnecting, error. invalid_api_key is fatal; too_many_connections (at most 5 per key) reconnects with backoff.

A full tour is in examples/copy_trading.py.

Token theses

A thesis is a short opinion on a token (up to 1000 characters), optionally tagged bull or bear. Only holders post theses: one of the author's verified wallets bought the token and still holds it. Every thesis shows the author's real position — what they bought when posting and their live PnL now (or "sold"); authors who keep their trading private show only that they hold. Anyone signed in can reply and react (like plus 🔥 🚀 💎 👀 😂 🤝 💀 🐂 🐻). The same theses appear in the widget, through the Data API and on every platform.

Writing needs the scope thesis in your login (scope=["openid", "profile", "thesis"]).

Scope of reads. Theses are shared across platforms. Every read (list_theses, thesis_summary, latest_theses, ThesisClient, the widget, the live sockets) takes scope: global (default, every platform) or instance (only theses written on your instance). In the Data API your key decides the instance; the public API and widget use your client_id.

Your public client_id is required for the public thesis API (ThesisClient, AsyncThesisClient, ThesisLive) and the theses widget. It is your instance's client_id (csid_...), shown in the dashboard next to the API key. It is public, not a secret and not the API key (the API key must never reach a browser). The SDK sends it as the X-CSID-Client-Id header on every call to https://id.cabalspy.xyz/thesis/v1/... and as client_id on the live socket URL (wss://id.cabalspy.xyz/thesis/v1/ws/{chain}/{mint}?client_id=...). Without it ThesisClient() raises AuthenticationError with code == "client_id_required". Browser calls must come from one of the instance's allowed origins or verified domains (instances in dev mode also allow http://localhost:*). The Data API (CabalSpyID, API key) is unchanged.

Widget

<div data-csid-thesis data-chain="solana" data-mint="..." data-client-id="csid_..." data-theme="dark"></div>
<script src="https://id.cabalspy.xyz/widget.js" async></script>
<script>CSID.thesis.setAccessToken(accessToken)  // optional: the signed-in person writes right away</script>

Data API (server to server)

Reading works with any key, for every token and user. Writing happens in the name of one of your users: pass their csid_id (the sub of their ID token); they must have signed in to your app with the scope thesis and be within your plan's user limit.

theses = csid.list_theses(blockchain="solana", mint=mint, sort="new")
counts = csid.thesis_summary(blockchain="solana", mint=mint)  # theses, bull, bear, authors, token

ok = csid.thesis_eligibility(blockchain="solana", mint=mint, csid_id=claims["sub"])
if ok["can_post"]:
    t = csid.post_thesis(blockchain="solana", mint=mint, body="Accumulating here.", stance="bull", csid_id=claims["sub"])
Method Endpoint
list_theses(blockchain=, mint=, sort=, stance=, limit=, cursor=, csid_id=) / iter_theses() GET /v1/theses
thesis_summary(blockchain=, mint=) GET /v1/theses/summary
thesis(id, csid_id=) GET /v1/theses/item
thesis_replies(id, limit=, cursor=) / iter_thesis_replies() GET /v1/theses/replies
latest_theses(blockchain=, users="all" | "instance", limit=, cursor=) GET /v1/theses/latest
user_theses(csid_id= | username=) GET /v1/theses/user
thesis_eligibility(blockchain=, mint=, csid_id=) GET /v1/theses/eligibility
post_thesis(blockchain=, mint=, body=, stance=, csid_id=) POST /v1/theses
reply_to_thesis(thesis_id=, body=, csid_id=) POST /v1/theses/replies
react_to_thesis(thesis_id=, emoji=, on=, csid_id=) POST /v1/theses/reactions
report_thesis(thesis_id=, reason=, note=, csid_id=) POST /v1/theses/report
delete_thesis(thesis_id=, csid_id=) POST /v1/theses/delete

| thesis_notifications(csid_id) | GET /v1/theses/notifications | | appeal_thesis_decision(csid_id, notification_id, message) | POST /v1/theses/appeal |

Every thesis carries reacted_by (the latest reactions with who reacted) and, for theses, latest_replies (a preview of the newest replies).

Moderation. Posts hidden after reports, pauses and moderator decisions reach the author as notifications (id, kind, text, data, created_at, read, appealable, appeal); appealable ones can be appealed within 30 days with 10-1000 characters. Through the Data API in your user's name: thesis_notifications(csid_id) / appeal_thesis_decision(...); with the user's token: ThesisClient.notifications(), mark_notifications_read(), appeal(id, message). ThesisClient.me() also returns notices (unread, latest, url) for a badge.

Thesis error codes: not_a_holder, consent_required, scope_missing, plan_user_limit (PlanUserLimitError), posting_paused, reporting_paused, sandbox_user (403, PermissionError); invalid_body, duplicate (400); not_found (404); rate_limited (429). Writes are never retried after a server or network error, so a post can't be duplicated; only the API's own rate limit is retried.

With data-client-id the widget loads the design saved in the dashboard; data-theme, data-scope, data-sort, data-font, data-radius, data-bg, data-card, data-text, data-muted, data-border, data-accent, data-accent-text, data-bull, data-bear, data-show-header, data-show-sentiment, data-compact and data-height override it.

Theses stream (every plan)

Live theses for the tokens you choose, e.g. to put markers on a chart. Up to 200 tokens per connection (or all), on every plan; subscriptions are sent again after every reconnect. Async, needs the stream extra.

async with AsyncCabalSpyID() as csid:
    ts = csid.theses_stream(scope="global")          # or "instance"

    @ts.on("thesis")
    def on_thesis(t):
        add_marker(t["blockchain"], t["mint"], t)

    await ts.connect()                               # returns on "ready"
    await ts.subscribe([{"blockchain": "solana", "mint": MINT_A}, {"blockchain": "base", "mint": TOKEN_B}])
    await ts.unsubscribe([{"blockchain": "base", "mint": TOKEN_B}])
    # await ts.subscribe_all() / await ts.unsubscribe_all()
    await ts.run_forever()

    # or iterate {"event": ..., "data": ...}
    async with csid.theses_stream() as ts:
        await ts.subscribe_all()
        async for ev in ts:
            print(ev["event"], ev["data"])

Events: ready, subscribed, thesis, thesis.reply, thesis.reaction, thesis.removed, heartbeat, reconnecting, close, error. A refused connection (invalid_api_key, …) is fatal; a bad frame (too_many_tokens, an invalid address) is reported as error and the connection stays.

Also over the trade stream (Pro plan): csid.stream(events=["theses"], blockchain="solana", mint=mint) emits thesis, thesis.reply, thesis.reaction and thesis.removed (or iterate stream.theses()). Your users' activity also arrives as webhooks: thesis.created (event["data"]["thesis"]), thesis.reply (event["data"]["reply"]) and thesis.reaction (thesis_id, chain, mint, emoji, on, reactions).

Public thesis API: ThesisClient

https://id.cabalspy.xyz/thesis/v1 needs no API key, only your public client_id (required): reading needs nothing else, writing uses the user's access token (scope thesis).

from cabalspy_id import ThesisClient

# client_id: your instance's PUBLIC client_id from the dashboard (not the API key); it also credits
# posts to your app. Add scope="instance" to show only theses written on your platform.
with ThesisClient(access_token=result.tokens["access_token"], client_id="csid_...") as theses:
    page = theses.list("solana", mint, sort="top")     # items, pagination, token, summary
    if theses.me("solana", mint)["can_post"]:
        theses.post("solana", mint, body="Strong community, still early.", stance="bull")
    theses.react(page.items[0]["id"], "🚀")

Methods: list, iterate, summary, get, replies, by_user, me, post, reply, react, report, delete, notifications, mark_notifications_read, appeal, live, set_access_token. AsyncThesisClient has the same methods as coroutines. Live updates for one token (async):

live = ThesisClient(client_id="csid_...").live("solana", mint)  # ?client_id=... (also takes scope=)
live.on("thesis", lambda t: print("new", t["body"]))   # also: reply, reaction, removed
await live.connect()
await live.run_forever()

client_id errors use the SDK's exception classes, so check err.code (docs: https://docs.cabalspy.xyz/cabalspy-id/errors#<code>):

Code HTTP Live socket close Exception Meaning
client_id_required 401 4401 AuthenticationError no client_id sent
invalid_client 401 4401 AuthenticationError unknown or inactive client_id
origin_not_allowed 403 4403 PermissionError the browser's Origin is not one of the instance's allowed origins / verified domains
rate_limited 429 4429 RateLimitError (retry_after == 60) too many requests per client or per IP

ThesisLive does not reconnect after 4401 or 4403 (run_forever() raises the error); after 4429 it waits 60 s before reconnecting.

Live stream

Every trade of your users the moment it is indexed, with the trader's profile (Pro plan and up). Async only, needs pip install "cabalspy-id[stream]".

async with AsyncCabalSpyID() as csid:
    stream = csid.stream(blockchain="solana")  # omit blockchain for every chain
    # events=["trades"] (default), ["theses"] or both; mint= theses of one token

    @stream.on("trade")
    def on_trade(t):
        print(t["trader"]["username"], t["transaction_type"], t["token_symbol"], t["value_usd"])

    await stream.connect()        # returns once the server confirmed the subscription
    await stream.run_forever()    # until stream.close() or a fatal error

    # or iterate
    async with csid.stream() as stream:
        async for trade in stream:
            print(trade)

The server sends a heartbeat every 30 seconds. The client reconnects with exponential backoff after a dropped connection or when heartbeats stop; configuration errors (invalid_api_key, plan_required, scope_not_allowed) are fatal and stop reconnecting. Events: trade, thesis, thesis.reply, thesis.reaction, thesis.removed, subscribed, heartbeat, open, close, reconnecting, error; handlers may be sync or async.

Webhooks

Deliveries follow the Standard Webhooks spec (webhook-id, webhook-timestamp, webhook-signature, secret whsec_...) and are retried for about 2 days. Events: wallet.verified, wallet.removed, profile.updated, kol.granted, consent.revoked, thesis.created, thesis.reply, thesis.reaction — only for users within the plan's limit. White-label adds platform_user.created, platform_user.claimed, platform_user.x_connected, wallet.added, verification.expired and the instance-level competition.started, competition.updated, competition.standings, competition.ended (these carry competition_id instead of sub). Callouts add callout.created, callout.milestone, callout.scored, callout.final (data.callout; CALLOUT_WEBHOOK_EVENTS). Copy trading adds copy_trade.instruction (one per user and copied trade; data is the instruction plus external_id; COPY_TRADING_WEBHOOK_EVENTS). The Trade Feed adds trade_feed.trade and trade_feed.thesis (one per trade or thesis, not per user; data is the feed item plus recipients and recipients_external; TradeFeedTradeEvent / TradeFeedThesisEvent; TRADE_FEED_WEBHOOK_EVENTS).

from cabalspy_id import verify_webhook

# pass the RAW body, exactly as received
event = verify_webhook(request.body, request.headers, os.environ["CSID_WEBHOOK_SECRET"])

if event["type"] == "consent.revoked":
    delete_user_data(event["data"]["sub"])

Timestamps older than 5 minutes are rejected (tolerance=). During a secret roll several signatures are sent; any match is accepted. Dedupe on event["id"], it is the same on every retry. sign_webhook() builds test requests for your receiver.

Configuration

CabalSpyID(
    api_key=None,          # env fallback: CABALSPY_ID_API_KEY, then CABALSPY_API_KEY
    instance=None,         # client_id, when the account has several instances
    base_url="https://api-id.cabalspy.xyz/v1",
    stream_url="wss://api-id.cabalspy.xyz/v1/stream",
    callouts_stream_url="wss://api-id.cabalspy.xyz/v1/callouts/stream",
    copy_trading_stream_url="wss://api-id.cabalspy.xyz/v1/copy-trading/stream",
    trade_feed_stream_url="wss://api-id.cabalspy.xyz/v1/trade-feed/stream",
    timeout=30.0,
    max_retries=2,
    headers={},
    http_client=None,      # bring your own httpx.Client / httpx.AsyncClient
)

CabalSpyIDAuth(
    "csid_...",
    client_secret=None,    # csid_sk_..., server apps only
    redirect_uri=None,
    issuer="https://id.cabalspy.xyz",
    token_endpoint_auth_method=None,  # client_secret_basic | client_secret_post | none
    clock_tolerance=60,
    timeout=15.0,
    http_client=None,
)

Keep the API key and the client secret on your server; read them from the environment or a secret manager.

Pricing

Per month and registrable domain. Everyone can always sign in; above the limit the platform receives no data for the extra users until it upgrades.

Plan Price / month Users with data API rate
Free $0 50 1 req/s
Starter $49 200 5 req/s
Pro $149 500 10 req/s + live stream
Business $499 1,000 25 req/s
Enterprise custom unlimited custom + SLA scope=global

Yearly billing is 20% off. Card or crypto at apidashboard.cabalspy.xyz/billing/csid.

Docs

docs.cabalspy.xyz/cabalspy-id · Discovery: id.cabalspy.xyz/.well-known/openid-configuration

License

MIT

Metadata

Release files for cabalspy-id 0.9.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for cabalspy-id 0.9.0
File Size Uploaded Provenance Actions
cabalspy_id-0.9.0.tar.gz 164.3 kB With provenance Details for cabalspy_id-0.9.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for cabalspy-id 0.9.0
File Interpreter ABI Platform Actions
cabalspy_id-0.9.0-py3-none-any.whl Python 3 none any Details for cabalspy_id-0.9.0-py3-none-any.whl

Total release size: 273.8 kB

Release files / cabalspy_id-0.9.0.tar.gz

Download URL cabalspy_id-0.9.0.tar.gz
Size 164.3 kB
Tags Source
SHA-256 checksum
How to use checksums
8ce989f6536c88dc956cbeb23a467e1df45e1e1cf479e1d41b3371ef837cdfa1
BLAKE2b-256 checksum
How to use checksums
00d0f444e670aca4abbe1af5a3a5c95ce59d013f5ddee481797e1823cd257739
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.

Transparency log

Release files / cabalspy_id-0.9.0-py3-none-any.whl

Download URL cabalspy_id-0.9.0-py3-none-any.whl
Size 109.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c7abe821567aafe1d30d67f75466ba59c907fa1120c67aae72d07361c974a007
BLAKE2b-256 checksum
How to use checksums
8a5e60d3fcfd99eb496e5c88a2b25e4aa3fdc5b5e09d503040e2b01c339ed654
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.

Transparency log

Security for cabalspy-id 0.9.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for cabalspy_id-0.9.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page