Learn how to discover, filter, and monitor new Pump.fun tokens using CoinMarketCap’s DEX API. Includes Python examples, rate-limit strategy, and production best practices.
Pump.fun launches move fast. New Solana tokens appear continuously, liquidity forms in seconds, and price discovery often happens before a person watching a screen can react.
That is why this kind of workflow gets automated. Speed alone is not the interesting part. A system worth running is structured: it discovers new tokens, it discards the ones that do not meet its rules, and it watches the survivors under a policy that does not change with your mood.
This guide builds that structure in Python on the CoinMarketCap API, in four layers:
- Discover new tokens on a chain
- Filter out launches that fail your rules
- Monitor the shortlist, by stream where your plan allows it and by polling where it does not
- Store what happened, so the rules can be judged later
This guide is educational. It is not financial advice.
Join us in showcasing the cryptocurrency revolution, one newsletter at a time. Subscribe now to get daily news and market updates right to your inbox, along with our millions of other subscribers (that’s right, millions love us!) — what are you waiting for?
Key Takeaways
- Discovery is a POST, not a GET.
/v1/dex/new/listanswers a GET with HTTP 405 Method Not Allowed, and it needs a key on a plan tier that includes DEX discovery. - Three error formats are in play. Errors come back with a string
error_code, a numericerror_code, or, for HTTP 405 and some server errors, nostatusobject at all. One handler has to cover all three, or it hides the real message. - DEX responses use short field names. They return names like
addr,pu,v24h,mcandliqrather than the Standard API's full names. A parser built on guessed names fails silently, and a quiet market is hard to tell from a broken bot. - Streaming starts on Startup. Eight on-chain WebSocket channels cost 0.025 credits per message, so a quiet token costs almost nothing to follow. That is the same plan tier token discovery needs.
- Record each entry's conditions alongside its result. The filter thresholds are guesses until the data you log can show which one to change.
What You Need Before You Start
Two of the four layers need a key, and one of them needs a particular kind of key. Worth knowing up front so you do not build against a wall.
| Layer | Endpoint family | Access |
|---|---|---|
| Chain lookup | /v1/dex/platform/list | Keyless |
| Discovery | /v1/dex/new/list | Keyed, on a tier that includes DEX discovery |
| Monitoring by polling | /v1/dex/token/price | Keyed |
| Monitoring by stream | wss://pro-stream.coinmarketcap.com/v1 | Startup plan or above |
error_code 1005 and the message "An API Key is required for this call." A key on a tier without DEX discovery returns a plan-authorization error instead. Check which one you are getting before you start changing the request.Streaming needs Startup or above, which is the same tier boundary discovery sits behind. If you have a key that can discover tokens, you almost certainly have a key that can stream them.
Project Setup
pip install requests pandas numpy websocket-client
# .env, or your shell profile. Never commit the key.
CMC_API_KEY=your_key_here
CMC_BASE_URL=https://pro-api.coinmarketcap.com
import json
import os
import time
from typing import Any, Dict, List
import numpy as np
import pandas as pd
import requests
CMC_API_KEY = os.getenv("CMC_API_KEY")
CMC_BASE_URL = os.getenv("CMC_BASE_URL", "https://pro-api.coinmarketcap.com")
CMC_KEYLESS = CMC_BASE_URL + "/public-api"
HEADERS = {
"Accept": "application/json",
"X-CMC_PRO_API_KEY": CMC_API_KEY or "",
}
Content-Type is set per request rather than globally, because only the POST calls carry a body and requests adds the header itself when you pass json=.Error Handling First, Because Three Envelopes Are in Play
Most guides put error handling last. Put it first here, because this API answers in three different shapes and a handler written for one of them crashes on the others.
{"status": {"error_code": "1005", "error_message": "..."}}{"status": {"error_code": 1005, ...}}. Success has the same split: "0" on some routes, 0 on others.{
"timestamp": 1790750998137,
"path": "/v1/dex/new/list",
"status": 405,
"error": "Method Not Allowed",
"requestId": "e11461cd-20"
}
status object there, so payload["status"]["error_code"] raises TypeError and you lose the actual message. One function handles all three:class CMCError(RuntimeError):
"""Any CoinMarketCap failure, whichever envelope it arrived in."""
def cmc_payload(response: requests.Response) -> Dict[str, Any]:
try:
payload = response.json()
except ValueError:
raise CMCError(
f"HTTP {response.status_code}, non-JSON body: {response.text[:200]}"
)
status = payload.get("status")
# Gateway-level errors (405, some 5xx) have no CMC status object at all.
if not isinstance(status, dict):
detail = payload.get("error") or response.text[:200]
path = payload.get("path") or response.url
raise CMCError(f"HTTP {response.status_code} {detail} on {path}")
# error_code is "0" as a string on some routes and 0 as a number on others.
if str(status.get("error_code", "0")) != "0":
raise CMCError(
f"CMC {status.get('error_code')}: {status.get('error_message')}"
)
return payload
Every call below goes through it.
Step 1: Find Your Chain's Platform ID
The DEX routes identify chains by a numeric platform ID, and the route that lists them needs no key, so this is a step you can run before you have one.
def platform_ids() -> Dict[str, int]:
"""Chain name to platform ID. Keyless."""
response = requests.get(
f"{CMC_KEYLESS}/v1/dex/platform/list",
headers={"Accept": "application/json"},
timeout=30,
)
rows = cmc_payload(response)["data"]
# DEX responses abbreviate field names: "n" is the chain name.
return {row["n"]: row["id"] for row in rows}
SOLANA_PLATFORM_ID = 16
For reference, from a live call:
| Chain | Platform ID |
|---|---|
| Ethereum | 1 |
| BNB Smart Chain (BEP20) | 14 |
| Solana | 16 |
| Polygon | 25 |
| Avalanche C-Chain | 28 |
| Tron20 | 47 |
| Arbitrum | 51 |
| Sui Network | 176 |
| Base | 199 |
row["n"] is not a typo, and it is the first sign of something that shapes the rest of this guide.Step 2: Read One Real Response Before You Parse Any of Them
/v1/dex/search uses addr for the contract address, pu for price, pc24h for 24-hour change, v24h for volume, mc for market cap, liq for liquidity and ut24h for unique traders. The platform list uses n, dn, pltA, cid and chId.The response envelope carries over from the Standard API. The field names do not.
None, every filter then evaluates false, and the run reports that no tokens qualified. It reads as a quiet market rather than a broken parser, which is why it can survive a long way into production.So print one real response before writing the parser:
def show_fields(rows: List[Dict[str, Any]], limit: int = 3) -> None:
"""Print the field names and a sample row, so the mapping is not guesswork."""
if not rows:
print("no rows returned")
return
print("fields:", ", ".join(sorted(rows[0])))
print(json.dumps(rows[:limit], indent=2)[:2000])
And make the mapping explicit, so a name that is absent fails loudly:
def to_frame(rows: List[Dict[str, Any]], mapping: Dict[str, str]) -> pd.DataFrame:
"""
Build a DataFrame from {our_column: api_field}.
Any mapped field missing from the response raises, rather than producing a
column of None that silently filters every candidate out.
"""
if not rows:
return pd.DataFrame(columns=list(mapping))
missing = [api for api in mapping.values() if api not in rows[0]]
if missing:
raise CMCError(
"response is missing mapped fields: " + ", ".join(missing)
+ " | fields present: " + ", ".join(sorted(rows[0]))
)
return pd.DataFrame(
[{col: row.get(api) for col, api in mapping.items()} for row in rows]
)
Step 3: Discover New Tokens
/v1/dex/new/list returns HTTP 405 Method Not Allowed, from the gateway, in the envelope-free shape shown above.def fetch_new_tokens(body: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
POST /v1/dex/new/list. Requires a key on a tier that includes DEX discovery.
Build body from the request schema for this endpoint in the CoinMarketCap
endpoint reference. The DEX family is not uniform about snake_case versus
camelCase between routes, so take the key names from the reference for your
API version rather than from any guide, this one included.
"""
response = requests.post(
f"{CMC_BASE_URL}/v1/dex/new/list",
headers={**HEADERS, "Content-Type": "application/json"},
json=body,
timeout=30,
)
return cmc_payload(response)["data"]
Run it once and look at what came back:
raw = fetch_new_tokens({"limit": 20, "platformId": SOLANA_PLATFORM_ID})
show_fields(raw)
to_frame will tell you immediately if any of them is wrong:CANDIDATE_FIELDS = {
"token_address": "addr",
"symbol": "s",
"name": "n",
"platform_id": "pltId",
"price_usd": "pu",
"volume_24h": "v24h",
"market_cap": "mc",
"liquidity_usd": "liq",
"created_at": "pt",
}
candidates = to_frame(raw, CANDIDATE_FIELDS)
Step 4: Filter
Filtering is where a launch monitor earns its keep, and it is the part that does not depend on any endpoint contract.
def prepare_candidates(df: pd.DataFrame) -> pd.DataFrame:
now = pd.Timestamp.now(tz="UTC")
df = df.copy()
# Several DEX routes return epoch milliseconds as a STRING. to_numeric
# first, then to_datetime, so a string timestamp does not become NaT.
created = pd.to_numeric(df["created_at"], errors="coerce")
df["created_at"] = pd.to_datetime(created, unit="ms", utc=True, errors="coerce")
df["age_minutes"] = (now - df["created_at"]).dt.total_seconds() / 60
for column in ("price_usd", "volume_24h", "market_cap", "liquidity_usd"):
df[column] = pd.to_numeric(df[column], errors="coerce")
df["liquidity_to_mcap"] = df["liquidity_usd"] / df["market_cap"].replace(0, np.nan)
return df
def filter_candidates(df: pd.DataFrame) -> pd.DataFrame:
return df[
(df["age_minutes"] <= 10)
& (df["liquidity_usd"] >= 5000)
& (df["market_cap"] >= 2500)
& (df["liquidity_to_mcap"] >= 0.05)
]
The thresholds are illustrative. What each one is for:
pd.Timestamp.now(tz="UTC") rather than pd.Timestamp.utcnow(), which is deprecated in pandas 2.x and emits a FutureWarning.Step 5: Monitor
Two ways to do this, and which one you use is a plan question rather than a design question.
Streaming, on Startup and Above
wss://pro-stream.coinmarketcap.com/v1, authenticated with the same X-CMC_PRO_API_KEY header. For a launch monitor this is the closer fit: you are waiting for events, not asking repeatedly whether one has occurred.Eight on-chain channels are available. The ones a launch monitor wants:
| Channel | Parameters | Pushes on |
|---|---|---|
onchain@token_agg_event | platform_id, address | Each swap |
onchain@transaction | platform_id, address | Each swap |
onchain@liquidity_event | platform_id, address | Each liquidity transaction |
onchain@token_metric | platform_id, address | Each metric update |
onchain@kline | platform_id, address, interval | Each interval close |
onchain@pool_metric | platform_id, pool_address | Each metric update |
onchain@unique_trader | platform_id, address, interval | Each interval close |
onchain@holders_metrics | platform_id, address | Each holder update |
import websocket
CMC_STREAM_URL = "wss://pro-stream.coinmarketcap.com/v1"
def stream_tokens(watchlist: List[Dict[str, Any]], on_event) -> None:
"""
Subscribe to per-swap price events for every token in the watchlist.
watchlist entries are {"platform_id": int, "address": str}.
on_event(channel, payload) is called for each push.
"""
def on_open(ws: "websocket.WebSocketApp") -> None:
for index, token in enumerate(watchlist, start=1):
ws.send(json.dumps({
"id": index,
"method": "subscribe",
"channel": "onchain@token_agg_event",
"params": {
"platform_id": token["platform_id"],
"address": token["address"],
},
}))
def on_message(ws: "websocket.WebSocketApp", raw: str) -> None:
message = json.loads(raw)
on_event(message.get("channel"), message)
def on_error(ws: "websocket.WebSocketApp", error: Exception) -> None:
print("stream error:", error)
app = websocket.WebSocketApp(
CMC_STREAM_URL,
header=[f"X-CMC_PRO_API_KEY: {CMC_API_KEY}"],
on_open=on_open,
on_message=on_message,
on_error=on_error,
)
# reconnect_delay: the stream is in beta and excluded from the SLA, so
# assume disconnects and let the client re-establish rather than exiting.
app.run_forever(reconnect=5, ping_interval=20)
One connection carries up to 100 subscriptions, and an account gets up to 10 concurrent connections. Billing is 0.025 credits per message received, so cost scales with how busy your tokens are rather than with how often you ask. A token doing 40 swaps a minute costs one credit a minute to follow.
The stream is in beta and excluded from the SLA. Design for disconnects and keep the polling path below as a fallback, rather than treating the connection as permanent.
Polling, on Any Keyed Plan
def fetch_token_price(platform_id: int, address: str) -> Dict[str, Any]:
"""
GET /v1/dex/token/price. Confirm the exact parameter names against the
endpoint reference for your API version before relying on this in
production; the DEX routes differ between versions.
"""
response = requests.get(
f"{CMC_BASE_URL}/v1/dex/token/price",
headers=HEADERS,
params={"platform_id": platform_id, "address": address},
timeout=15,
)
return cmc_payload(response)["data"]
def poll_token(platform_id: int, address: str, entry_price: float,
take_profit: float = 0.20, stop_loss: float = -0.08,
poll_seconds: int = 30, max_failures: int = 6) -> str:
"""Poll one token until a rule fires or the failure budget runs out."""
failures = 0
while failures < max_failures:
try:
data = fetch_token_price(platform_id, address)
except CMCError as error:
failures += 1
backoff = min(poll_seconds * (2 ** failures), 900)
print(f"{error} | backing off {backoff}s")
time.sleep(backoff)
continue
# data may be a dict or a single-item list depending on the route.
record = data[0] if isinstance(data, list) and data else data
price = record.get("pu") if isinstance(record, dict) else None
if price is None:
failures += 1
time.sleep(poll_seconds)
continue
failures = 0
change = (float(price) - entry_price) / entry_price
if change >= take_profit:
return "take_profit"
if change <= stop_loss:
return "stop_loss"
time.sleep(poll_seconds)
return "gave_up"
Polling every two seconds does not make the bot faster, it makes it rate-limited. Thirty to sixty seconds against a shortlist, with the interval matched to your plan's per-minute limit, leaves room for the discovery calls to keep running.
Step 6: Store the Outcome
A monitor that does not record its decisions cannot tell you whether the filters were any good.
def record_outcome(signals: List[Dict[str, Any]], row: pd.Series,
outcome: str, exit_price: float) -> None:
signals.append({
"observed_at": pd.Timestamp.now(tz="UTC").isoformat(),
"token_address": row["token_address"],
"symbol": row["symbol"],
"age_minutes_at_entry": row["age_minutes"],
"liquidity_usd_at_entry": row["liquidity_usd"],
"liquidity_to_mcap_at_entry": row["liquidity_to_mcap"],
"entry_price": row["price_usd"],
"exit_price": exit_price,
"outcome": outcome,
})
Storing the filter values alongside the outcome is what makes the thresholds testable later. An outcome column on its own tells you what happened; the entry conditions tell you which rule to change.
Rate Limits and Credit Economics
The two access modes bill differently, and the difference is the whole argument for streaming in this use case. Polling costs credits per request whether or not anything changed, so a quiet token costs the same as a busy one. Streaming costs 0.025 credits per message received, so a quiet token costs almost nothing and a busy one costs in proportion to the activity you actually wanted to see.
Three habits keep a launch monitor inside its limits: poll a shortlist rather than everything discovered, cache the platform-ID lookup instead of refetching it, and back off on failure rather than retrying immediately.
Minimal End-to-End
def run_once(discovery_body: Dict[str, Any], top_n: int = 5) -> pd.DataFrame:
raw = fetch_new_tokens(discovery_body)
candidates = to_frame(raw, CANDIDATE_FIELDS)
prepared = prepare_candidates(candidates)
shortlist = filter_candidates(prepared)
return shortlist.sort_values("liquidity_usd", ascending=False).head(top_n)
if __name__ == "__main__":
watchlist = run_once({"limit": 50, "platformId": SOLANA_PLATFORM_ID})
print(watchlist[["symbol", "age_minutes", "liquidity_usd", "liquidity_to_mcap"]])
signals: List[Dict[str, Any]] = []
for _, row in watchlist.iterrows():
outcome = poll_token(
platform_id=int(row["platform_id"]),
address=row["token_address"],
entry_price=float(row["price_usd"]),
)
record_outcome(signals, row, outcome, exit_price=float(row["price_usd"]))
pd.DataFrame(signals).to_csv("signals.csv", index=False)
Common Mistakes
- Sending GET to a discovery endpoint.
/v1/dex/new/listanswers a GET with HTTP 405 Method Not Allowed. It is a POST with a JSON body. - Reading
payload["status"]["error_code"]unconditionally. A 405 comes back with nostatusobject, so that access raisesTypeErrorand hides the real message. Check the shape first. - Treating
error_codeas one type. It is"1005"as a string on one route and1005as a number on another. Compare withstr(). - Copying DEX field names from the Standard API. The DEX routes abbreviate. Guessing produces a DataFrame of
None, every filter evaluates false, and the run reports no qualifying tokens rather than an error. - Parsing millisecond timestamps directly. Several DEX routes return epoch milliseconds as a string. Convert to a number first, or every timestamp becomes
NaTand every age filter drops everything. - Assuming there is no streaming. There is, from Startup upward, including eight on-chain channels keyed on
platform_idandaddress. If your plan can call DEX discovery it can almost certainly stream. - Polling every two seconds. That spends your per-minute limit on unchanged data and starves the discovery calls. Match the interval to the plan limit.
- Using
pd.Timestamp.utcnow(). Deprecated in pandas 2.x. Usepd.Timestamp.now(tz="UTC"). - Putting the key in the code. Read it from the environment. A key in a committed file is a key you have to rotate.
Frequently Asked Questions
Can I build a Pump.fun bot with the CoinMarketCap API?
Yes. The CoinMarketCap API has the pieces for all four layers of a launch monitor: finding new tokens on a chain, pricing them, streaming on-chain events, and looking up platform IDs. The platform ID lookup needs no key. Token discovery and streaming need a key on the Startup plan or above.
Why does /v1/dex/new/list return HTTP 405?
status object.What is Solana's platform ID on CoinMarketCap?
/v1/dex/platform/list route returns the full list, including Ethereum (1), BNB Smart Chain (14), Base (199) and Sui Network (176).Why does my bot report no qualifying tokens?
The usual cause is that the parser guessed the field names. The DEX routes use short names, so wrong names produce empty columns and every filter rejects everything. Print one real response first and build the name mapping from that.
Should I stream or poll?
Stream if your plan is Startup or above. You pay 0.025 credits per message received, so the cost follows how busy the token is rather than how often you check. Poll on lower plans, every 30 to 60 seconds against a shortlist. The stream is in beta and not covered by the uptime guarantee, so keep polling as a fallback.
How much does streaming cost?
0.025 credits per message. One connection carries up to 100 subscriptions, and an account can have up to 10 connections open at once. A token doing 40 swaps a minute costs about one credit a minute to follow.
Final Thoughts
A launch monitor is three decisions and one loop: what counts as new, what counts as worth watching, and what counts as done. The API supplies the data for all three, and the structure above keeps each one in a place you can change without touching the others.
The part worth iterating on is the filter. Discovery and monitoring are plumbing, and once they work they stay working. The thresholds in Step 4 are guesses until the CSV from Step 6 has enough rows to argue with.
Stream Launches as They Happen
Discovery and the WebSocket stream both start on the Startup plan. Compare tiers, or read the streaming reference before you wire it in.
The platforms, protocols, and projects referenced in these articles are used for illustrative purposes only to demonstrate how the CoinMarketCap API can be integrated in practice. CoinMarketCap makes no representation that these projects endorse or are affiliated with CoinMarketCap. If you represent a referenced project and have concerns, please contact us at content@coinmarketcap.com and we will respond promptly.
