Build an Upbit & Bithumb Listing Bot in Python
Last updated
Upbit and Bithumb publish their new listings, delistings and caution releases in Korean, on their own notice boards, and a listing notice comes before trading opens. This tutorial builds a small Python bot that receives those announcements as structured JSON over WebSocket, understands what is specific to the Korean exchanges — quote markets, the caution track, grouped tickers — and places a sized, deduplicated order on the venue of your choice, in dry-run mode until you decide otherwise. It is the Korean-exchange sibling of our Binance listing sniper bot tutorial.
What the bot does
The bot has three stages, and each one is shaped by how the Korean exchanges publish:
- Receive — one WebSocket connection delivers every Upbit and Bithumb announcement, with the ticker already extracted from the Korean title.
- Classify — branch on
listingType:spot_listing(a new market opens),caution_released(a caution designation is lifted) orspot_delisting(trading support ends). For Upbit listings, also read which quote markets open. - Act — look the ticker up on your venue, apply your risk limits, then place the order, or print it while in dry-run.
Why the Korean exchanges deserve their own bot: the titles are in Korean, one notice can open several tokens at once, a listing on the won (KRW) market is a different event from one that only adds a BTC or USDT pair, and both exchanges run a caution track on tokens that already trade. Each of these details changes what your code should do. For the market background, read the Upbit listing effect and the kimchi premium and Bithumb vs Upbit.
Prerequisites
- An API key. Two tiers are free. SpeedTrial delivers every event with a +1 ms courtesy delay, but redacts the ticker and title on tradeable events. FreeDelayed delivers the full ticker and title with a +240 ms delay. For this tutorial FreeDelayed is the right choice: the bot sees real symbols, so its dry-run log is realistic. See pricing & tiers; keys are requested on Telegram.
- Python 3.10+, the
websocketslibrary (version 14 or newer) andccxt. - Venue API credentials — only when you leave dry-run.
pip install "websockets>=14" ccxt
Step 1 — Connect to the announcement WebSocket
Upbit and Bithumb are served from two endpoints: wss://kr.cryptolisting.ws in Seoul (Upbit and Bithumb) and wss://cryptolisting.ws in Tokyo (Binance, Upbit and Bithumb). Pick the one closest to where your bot and its venue run; if the bot also trades Binance announcements, Tokyo carries all three. The same key works on both, and rate limits and connection caps are counted per endpoint. Authentication is a single X-API-Key header on the handshake — keys in the query string are refused.
# websockets>=14 import asyncio, json, websockets API_KEY = "YOUR_KEY" FEED_URL = "wss://kr.cryptolisting.ws?cex=upbit,bithumb" async def listen(): attempt = 0 while True: try: async with websockets.connect( FEED_URL, additional_headers={"X-API-Key": API_KEY} ) as ws: attempt = 0 while True: # a heartbeat arrives every 30 s: 60 s of silence = dead link raw = await asyncio.wait_for(ws.recv(), timeout=60) await handle(json.loads(raw)) except websockets.ConnectionClosed as e: reason = e.rcvd.reason if e.rcvd else "" if reason in ("key_expired", "key_invalidated"): print(f"key rejected: {reason}") return except websockets.InvalidStatus as e: status = e.response.status_code if status in (401, 403): print(f"key refused: HTTP {status}") return print(f"handshake refused: HTTP {status}") # 429: back off except (asyncio.TimeoutError, OSError) as e: print(f"connection lost: {e!r}") await asyncio.sleep(min(2 ** attempt, 300)) attempt += 1
The library answers the server's PING frames on its own. What it cannot notice is a server that disappears without closing the connection, so the loop treats 60 seconds without any message as a dead link and reconnects. A key that is refused at the handshake (HTTP 401 or 403), or that expires or is revoked during a session (key_expired, key_invalidated), stops the bot instead of making it reconnect in a loop; every other disconnect is retried with exponential backoff, capped at five minutes.
Step 2 — Read the announcement
Every frame is UTF-8 JSON with a type field. Only announcement is a market event. welcome, heartbeat, changelog, renewal_notice, test_announcement and error are not, and new types can appear at any time — ignore what you do not know. A real-format Upbit listing:
{
"type": "announcement",
"title": "플루언트(BLEND) 신규 거래지원 안내 (KRW, BTC, USDT 마켓)",
"ticker": "BLEND",
"publisher": "upbit",
"listingType": "spot_listing",
"detectedTimestampUs": 1710345000005000,
"dispatchTimestampUs": 1710345000006000,
"abnormalDetectionLatency": false,
"markets": "KRW,BTC,USDT"
}
A Bithumb event has the same envelope, without markets. The title stays in Korean — for example 셀로(CELO) 원화 마켓 추가 for a new won-market listing — but you never have to read it: ticker, publisher and listingType are already extracted. Three rules keep a handler robust:
- Split the ticker. Upbit groups tokens: a notice such as
BTC, USDT 마켓 신규 거래지원 안내 (CYS, ICNT, XAN, EDEN, AIOZ, ALLO)arrives as one event withticker: "CYS,ICNT,XAN,EDEN,AIOZ,ALLO"and a singlemarketsvalue for the whole group. Alwayssplit(",")— otherwise you would send the whole string to your venue as a symbol. - Ignore unknown fields. New fields may be added without notice; never reject a message because it carries something extra.
- Keep the title as a cross-check.
titleis always the exchange's original text. Checking that each ticker appears in it protects you from a single extraction error.
Step 3 — Read the Upbit markets field
Upbit lists an asset on one or more of its three quote markets: KRW (the Korean won), BTC and USDT. A listing that opens the won market is a different event from one that only adds a BTC or USDT pair, and markets carries that distinction. It is present only on Upbit spot_listing events, always in the fixed order KRW, BTC, USDT (seven possible values), and it is delivered on every tier, SpeedTrial included.
One rule matters more than the others: a missing markets means "unknown", never "no market". The field is derived from the headline, and an unusual wording yields no field rather than a wrong one. Use it to size an order, not to decide whether the event exists:
BASE_SIZE_USDT = 50 # notional per token NON_KRW_FACTOR = 0.5 # your policy for BTC/USDT-only listings def order_size(msg): markets = msg.get("markets") # Upbit spot listings only; absent = unknown if markets is None or "KRW" in markets.split(","): return BASE_SIZE_USDT return BASE_SIZE_USDT * NON_KRW_FACTOR
The factor is a setting for you to choose from your own records, not a recommendation. Bithumb events carry no markets field: the market is named in the title (원화 마켓 is the won market).
Step 4 — Handle caution_released vs spot_delisting
Both Korean exchanges run a multi-stage risk track on tokens that already trade: a warning, a formal caution designation (유의 종목 on Upbit, 투자유의종목 on Bithumb), extensions, then either a release or a delisting (거래지원 종료, end of trading support). The feed forwards the two stages that drive a trading decision:
caution_released— the designation is lifted. It is often read as a positive signal for the token. See what a caution release is.spot_delisting— trading support ends. Holders have a deadline to sell or withdraw.
The intermediate stages are not forwarded. A notice that combines a release and a delisting arrives as separate events, one per listingType. Route each type to its own function:
async def route(msg): lt = msg["listingType"] tickers = [t for t in msg["ticker"].split(",") if t] if not tickers: return # SpeedTrial: ticker redacted if lt == "spot_listing": size = order_size(msg) await asyncio.gather(*(buy(t, size, msg) for t in tickers[:MAX_TOKENS_PER_EVENT])) elif lt == "caution_released": print(f"watch-list: caution lifted on {msg['publisher']} for {tickers}") elif lt == "spot_delisting": await asyncio.gather(*(reduce_exposure(t) for t in tickers))
In this example a caution release only goes to a watch-list: decide from your own history before you trade it. A delisting only reduces a spot holding you already have — the bot never opens a short on it.
Step 5 — Find where the token already trades
A token that Upbit or Bithumb lists has often traded on other exchanges before, which is why a Korean listing can move its price everywhere. Your bot needs to know, before the event, whether your venue has a market for it. Load the venue's markets once at startup and refresh them on a timer, in the background — then resolving a symbol inside the handler is a dictionary lookup, not a network call:
import ccxt.async_support as ccxt venue = ccxt.binance({"apiKey": "VENUE_KEY", "secret": "VENUE_SECRET"}) def resolve(ticker): # spot pair first, then the USDT-margined perpetual for symbol in (f"{ticker}/USDT", f"{ticker}/USDT:USDT"): if symbol in venue.markets: return symbol return None async def refresh_markets(): while True: await asyncio.sleep(300) await venue.load_markets(reload=True)
Symbols do not always match between exchanges: tokens get renamed, and some perpetuals trade under a multiplied ticker such as a 1000 prefix. When resolve returns None, skip the token — never guess a symbol.
Step 6 — Risk limits and dry-run on FreeDelayed
Put the guard-rails in before the first real order:
DRY_RUN = Trueby default. The bot prints the order it would send.- A cap per token and per event. A grouped Upbit notice can open six tokens at once.
- Deduplicate on
(publisher, listingType, ticker). A reconnect, or a bot connected to both endpoints, can see the same event twice. - Skip events flagged
abnormalDetectionLatency: truein automated trading: the payload is valid, but its timing assumptions do not hold. - Never trade
test_announcement.{"type":"test"}returns a fakeDUMMYTOKENevent, useful only to check your plumbing.
FreeDelayed is the natural tier for this phase: it delivers the full ticker and title, with the same schema as the paid tiers and a +240 ms delay. Let the bot run in dry-run for a few weeks of real Upbit and Bithumb notices, then read its log — which symbols it would have bought, at what size, and which tokens it skipped because your venue did not list them. When you trust that log, set DRY_RUN = False and move to a paid tier: Basic adds +20 ms, Premium adds no delay.
Full code
The complete bot, in under 150 lines. Fill in your two keys; it starts in dry-run.
# Upbit & Bithumb listing bot — pip install "websockets>=14" ccxt import asyncio, json, websockets import ccxt.async_support as ccxt API_KEY = "YOUR_KEY" FEED_URL = "wss://kr.cryptolisting.ws?cex=upbit,bithumb" DRY_RUN = True BASE_SIZE_USDT = 50 NON_KRW_FACTOR = 0.5 MAX_TOKENS_PER_EVENT = 3 venue = ccxt.binance({"apiKey": "VENUE_KEY", "secret": "VENUE_SECRET"}) seen = set() def order_size(msg): markets = msg.get("markets") # Upbit spot listings only; absent = unknown if markets is None or "KRW" in markets.split(","): return BASE_SIZE_USDT return BASE_SIZE_USDT * NON_KRW_FACTOR def resolve(ticker): for symbol in (f"{ticker}/USDT", f"{ticker}/USDT:USDT"): if symbol in venue.markets: return symbol return None async def buy(ticker, size_usdt, msg): symbol = resolve(ticker) if symbol is None: print(f"skip {ticker}: no market on {venue.id}") return try: price = (await venue.fetch_ticker(symbol)).get("last") if not price: print(f"skip {symbol}: no last price yet") return amount = float(venue.amount_to_precision(symbol, size_usdt / price)) if DRY_RUN: print(f"[dry-run] buy {amount} {symbol} (~{size_usdt} USDT) after {msg['publisher']} listing") return order = await venue.create_market_buy_order(symbol, amount) print(f"bought {symbol}: order {order['id']}") except ccxt.BaseError as e: print(f"order failed for {symbol}: {e}") async def reduce_exposure(ticker): symbol = f"{ticker}/USDT" if symbol not in venue.markets: return try: held = (await venue.fetch_balance()).get("free", {}).get(ticker) or 0 if held and DRY_RUN: print(f"[dry-run] sell {held} {symbol} after a delisting notice") elif held: await venue.create_market_sell_order(symbol, float(venue.amount_to_precision(symbol, held))) except ccxt.BaseError as e: print(f"reduce failed for {symbol}: {e}") async def route(msg): lt = msg["listingType"] tickers = [t for t in msg["ticker"].split(",") if t] tickers = [t for t in tickers if (msg["publisher"], lt, t) not in seen] seen.update((msg["publisher"], lt, t) for t in tickers) if not tickers: return # duplicate, or SpeedTrial redaction if lt == "spot_listing": size = order_size(msg) await asyncio.gather(*(buy(t, size, msg) for t in tickers[:MAX_TOKENS_PER_EVENT])) elif lt == "caution_released": print(f"watch-list: caution lifted on {msg['publisher']} for {tickers}") elif lt == "spot_delisting": await asyncio.gather(*(reduce_exposure(t) for t in tickers)) async def handle(msg): kind = msg.get("type") if kind == "welcome": print(f"connected: tier={msg.get('tier')} cex={msg.get('allowedCex')}") elif kind == "renewal_notice": print(msg.get("title")) elif kind == "announcement": # never test_announcement if msg.get("abnormalDetectionLatency"): print(f"flagged event, not traded: {msg.get('title')}") return await route(msg) async def listen(): attempt = 0 while True: try: async with websockets.connect( FEED_URL, additional_headers={"X-API-Key": API_KEY} ) as ws: attempt = 0 while True: raw = await asyncio.wait_for(ws.recv(), timeout=60) await handle(json.loads(raw)) except websockets.ConnectionClosed as e: reason = e.rcvd.reason if e.rcvd else "" if reason in ("key_expired", "key_invalidated"): print(f"key rejected: {reason}") return except websockets.InvalidStatus as e: status = e.response.status_code if status in (401, 403): print(f"key refused: HTTP {status}") return print(f"handshake refused: HTTP {status}") # 429: back off except (asyncio.TimeoutError, OSError) as e: print(f"connection lost: {e!r}") await asyncio.sleep(min(2 ** attempt, 300)) attempt += 1 async def refresh_markets(): while True: await asyncio.sleep(300) try: await venue.load_markets(reload=True) except ccxt.BaseError as e: print(f"market refresh failed: {e}") async def main(): await venue.load_markets() refresher = asyncio.create_task(refresh_markets()) try: await listen() finally: refresher.cancel() await venue.close() asyncio.run(main())
What the skeleton leaves to you: exchange-specific order rules (minimum notional, lot size — amount_to_precision covers only the rounding), position limits across events, and persistence of the seen set across restarts. The API documentation covers the protocol in full: the message reference lists every field and listing type, and the exchange filtering page explains ?cex= against your key's scope.
Frequently asked questions
Which endpoint should an Upbit or Bithumb bot connect to?
wss://kr.cryptolisting.ws (Seoul) carries Upbit and Bithumb; wss://cryptolisting.ws (Tokyo) carries Binance, Upbit and Bithumb. Pick the endpoint closest to where your bot and its trading venue run, or Tokyo if the bot also needs Binance. The same API key works on both.
Do I need to parse the Korean announcement title?
No. ticker, publisher and listingType are already extracted, so the bot branches on them. The original Korean title stays in title, which is useful as a cross-check.
Does the feed include every stage of the caution track?
No. It forwards the two stages that drive a trading decision: caution_released and spot_delisting. The intermediate stages are not forwarded.
Is the markets field available for Bithumb listings?
No. markets is present only on Upbit spot listings. When it is absent, the markets are unknown — which never means there is no KRW market.
Can I build and run this bot for free?
Yes. FreeDelayed delivers the full ticker and title with a +240 ms delay, enough to run the bot end to end in dry-run. SpeedTrial delivers every event but redacts the ticker on tradeable events.
This article is an engineering tutorial for informational purposes only and is not financial or trading advice. Automated trading carries substantial risk. CryptoListing.ws is a technical data feed service — see Legal.
Related
- Build a Binance Listing Sniper Bot in Python — the same approach for Binance spot and futures listings.
- Upbit Listing Alerts and Bithumb Listing Alerts — every event type the feed delivers for each exchange.
- Upbit WebSocket and Bithumb WebSocket — connection details for both endpoints.
- Latest listings — recent Upbit and Bithumb announcements, to check your bot's dry-run log against.
Build your Korean-exchange bot on a free key
Real-time Upbit and Bithumb announcements — listings, caution releases, delistings — as structured JSON over WebSocket. See pricing & tiers — FreeDelayed delivers the full ticker for a realistic dry-run, paid tiers remove the delay.
Get started on Telegram