Building a Polymarket Scanner Bot with the Struct SDK
How we built an open-source Telegram bot that lets you look up live Polymarket odds, trader stats, and top holders by pasting a URL or wallet address: architecture, implementation, and key decisions.

What we built
We built an open-source Polymarket scanner bot for Telegram. Paste any Polymarket URL (a market, an event, a trader profile) and the bot replies instantly with live data. Paste a wallet address and you get a full trader breakdown: lifetime PnL, win rate, best and worst trades, total volume. Tap an inline button on any market card to see the top 5 holders per outcome.
No setup for the end user. Paste a link and you get an answer. Use /search to find events by name when you don't have a URL handy.
It runs on Bun, uses grammY for the Telegram layer, and the Struct SDK for all Polymarket data. The whole thing is under 600 lines of TypeScript across 15 files.
This article covers how we built it: the URL parsing, the formatting pipeline, the pagination system, and a few implementation details that made the difference.
Input classification
The first thing the bot needs to do is figure out what the user sent it. Polymarket URLs come in two shapes:
https://polymarket.com/event/{event-slug}
https://polymarket.com/event/{event-slug}/{market-slug}
The first is an event (a group of markets). The second is a specific market. Wallet addresses (40-hex-character strings starting with 0x) trigger a trader lookup. Everything else is ignored.
We handle this with three regex patterns in polymarket-url.ts:
const POLYMARKET_EVENT_RE =
/^https?:\/\/(?:www\.)?polymarket\.com\/event\/([a-z0-9-]+)$/;
const POLYMARKET_MARKET_RE =
/^https?:\/\/(?:www\.)?polymarket\.com\/event\/[a-z0-9-]+\/([a-z0-9-]+)$/;
const EVM_ADDRESS_RE = /\b(0x[0-9a-fA-F]{40})\b/;The market regex is tested before the event regex, since a market URL is a superset of an event URL. We also strip fragments and query strings (?ref=..., #trade) before matching; Polymarket's share URLs often include these and they'd break the patterns otherwise.
The output is a discriminated union with three cases:
type PolymarketUrl =
| { type: "event"; slug: string }
| { type: "market"; slug: string }
| { type: "trader"; address: string };The findPolymarketUrl function extracts all URLs from free text, matches the first Polymarket URL it finds, then falls back to scanning the full message for an EVM address. Users can paste a raw wallet address, a Polymarket profile URL, or a message containing a URL embedded in prose. All three work.
Routing and data fetching
Once input is classified, the service layer fetches data and routes to the right formatter. All Struct SDK calls go through a thin fetch layer that unwraps the response and returns null on a miss rather than throwing:
export async function fetchEventBySlug(slug: string) {
const response = await struct.events.getEventBySlug({ slug, include_tags: false });
return unwrapStructData(response.data);
}For trader lookups, we fire both requests in parallel: the profile and the global PnL data come from different endpoints, and they're independent:
const [profile, pnl] = await Promise.all([
fetchTraderProfile(address),
fetchTraderPnl(address),
]);If both return null, the trader doesn't exist on Polymarket. If only one comes back, we render with whatever we have. A trader might have PnL data from on-chain activity but no Polymarket profile, or the reverse.
Smart event handling
Events on Polymarket range from a single binary market ("Will X happen?") to election events with hundreds of candidates. We handle both differently.
If an event has exactly one market, there's no point showing the event overview. We collapse it and reply with the market card directly, using the event's aggregate metrics since the single-market view doesn't include them separately.
If the event has more than 12 markets, we paginate (more on that below). Otherwise we reply with the full event overview as a single message.
The event overview sorts open markets by probability (highest first), then appends resolved markets at the bottom. Open markets get a probability percentage as their primary label; resolved markets get a trophy icon and their winning outcome. Each market line links to a bot deep link that opens a direct market card without leaving Telegram.

Pagination for large events
Events with more than 12 markets render with ◀️ / ▶️ navigation buttons. When the user navigates, we edit the existing message in place rather than sending a new one, keeping the conversation clean.
The challenge with serverless-style bots is that callback handlers are stateless. When a user taps ▶️, the handler needs access to the full event data to render page 2. We can't re-fetch it on every navigation tap; it would be too slow and would hit the API unnecessarily.
Our solution is a simple in-memory LRU-style cache:
const eventCache = new Map<number, CachedEvent>();
const MAX_CACHE_SIZE = 500;
let nextId = 1;
export function cacheEvent(event: EventRecord, botUsername?: string): number {
if (eventCache.size >= MAX_CACHE_SIZE) {
const firstKey = eventCache.keys().next().value!;
eventCache.delete(firstKey);
}
const id = nextId++;
eventCache.set(id, { event, botUsername });
return id;
}The cache ID is embedded in the callback data as ep:{cacheId}:{page}. Navigation taps decode the cache ID, look up the event, and re-render the page. If the cache entry has expired (500 events is a ceiling; oldest entries get evicted), we tell the user to resend the link.
The keyboard only renders prev/next buttons for valid directions, and shows the current page indicator as a non-clickable center button: 1/8.

Top holders as an inline button
Every market card includes a "👥 Top Holders" button. Tapping it sends a follow-up message showing the top 5 holders per outcome: name (or truncated address), share count, and USD value.

We use the same in-memory cache pattern as pagination; the market slug is stored keyed by a numeric ID, and that ID goes into the callback data as th:{cacheId}. The callback handler looks up the slug and makes a single Struct SDK call:
const response = await struct.holders.getMarketHolders({
market_slug: slug,
limit: 5,
});Holder names are hyperlinked to their Polymarket profiles. Verified accounts get a ✅ badge. Share counts are formatted compactly (1.2K, 3.4M). The reply includes a "✕ Close" button that deletes the message.
Search command
Pasting a URL works well once you know what you're looking for. For discovery, we added /search (aliased to /s) to search Polymarket events by name directly from the chat.

The handler calls struct.search.search() with the query, requests up to 6 results sorted by volume, and then applies a smart auto-select step before deciding what to render:
const exactMatch = findExactEventMatch(query, events);
const selectedEvent = exactMatch ?? (events.length === 1 ? events[0] : null);If the query matches an event title or slug exactly (case-insensitive), we skip the results list and go straight to the event view. If there's only one result, same thing. Only when there's genuine ambiguity do we render the multi-result list.
Each result in the list shows the event title (hyperlinked to a bot deep link), status emoji, end date, market count, volume, and trader count. If there are more results beyond the limit, a footer line tells the user to refine their query.
The exact-match promotion is what makes /s fed rate cut feel instant: you get the event card directly rather than an intermediate results screen you have to tap through.
Message formatting
Telegram's bot API supports a subset of HTML: bold, italic, code, links. We use it throughout rather than Markdown, because Markdown mode in Telegram has more escaping footguns.
The formatting pipeline builds an array of line strings and joins with \n. A treeLines helper handles the tree-drawing prefix characters (┣ / ┗) for list sections:
function treeLines(items: string[]): string[] {
return items.map((item, i) =>
i < items.length - 1 ? `┣ ${item}` : `┗ ${item}`,
);
}
Prices are formatted as cents (¢72) rather than decimals (0.72) to match how Polymarket displays them. Resolved markets replace the prices section with a winner line: 🏆 Resolved → Yes. The status emoji (🟢/🔴/⏸️) is derived from the market status field.
When a market has an image, the card is sent as a photo with the formatted text as the caption. If the image URL fails, we fall back to a plain text message; the replyWithOptionalPhoto helper handles both paths.
Deep links
The /start command doubles as a deep link entry point. Polymarket market URLs embed a condition ID (a 64-character hex string) or a slug, and both work as /start payloads.
When the bot's URL is shared with a ?start= parameter, Telegram delivers the payload to the bot as a /start command with the value in ctx.match. We detect whether it looks like a condition ID (64 hex chars) or a market slug (starts with m_) and fetch accordingly:
const isConditionId = CONDITION_ID_RE.test(payload);
const isSlug = payload.startsWith("m_") && payload.length > 2;
const market = isConditionId
? await fetchMarketByConditionId(`0x${payload}`)
: await fetchMarketBySlug(payload.slice(2));The event overview formatter generates these deep links for each market in the list, embedding the condition ID directly in the t.me/{botUsername}?start={conditionId} URL. Users tapping a market line get taken straight to that market's card.
Rate limiting and throttling
Two grammY plugins protect the bot from abuse and API hammering. @grammyjs/ratelimiter limits how many updates a single user can send per second. @grammyjs/transformer-throttler queues outgoing API calls to stay within Telegram's bot API rate limits, important when multiple users are active simultaneously and you're sending photos (which count against different limits than text messages).
Both are registered as middleware at bot setup, before any handlers run:
bot.use(limit());
bot.api.config.use(apiThrottler());Tech stack
| Layer | Technology |
|---|---|
| Runtime | Bun (or Node.js v18+) |
| Bot framework | grammY |
| Data | Struct SDK |
| Rate limiting | @grammyjs/ratelimiter + @grammyjs/transformer-throttler |
| Language | TypeScript |
Four runtime dependencies: grammy, the grammy plugins, and the Struct SDK. No database. No server infrastructure beyond wherever you run the bot process.
Lessons learned
Discriminated unions make routing obvious. The PolymarketUrl type with explicit type fields means the router never has to guess what it has. The TypeScript compiler enforces that every case is handled.
In-memory caches work for callback state. For pagination and inline button callbacks, we don't need a database; an in-memory Map with a size cap is sufficient. If the bot restarts, users just resend the link. The UX cost is low; the operational complexity savings are real.
Collapse single-market events. Showing an event overview for a market with one outcome is worse than showing the market card directly. A small check on markets.length === 1 eliminates a whole class of awkward replies.
Photo fallback is non-negotiable. Image URLs from Polymarket are occasionally invalid or unreachable. Always have a text fallback path: catching the photo send error and re-sending as text takes five lines and prevents a silent failure that the user never understands.
Test the URL parser edge cases. Query strings, fragments, mixed case, www. prefixes; Polymarket URLs come in many forms in the wild. Stripping fragments and query strings before regex matching was something we added after seeing real messages fail.
Build your own
The project shows a pattern that applies to any Telegram lookup bot over a structured data API:
- Classify input: A discriminated union of input types keeps routing clean
- Fetch in parallel: Independent data sources (profile + PnL, multiple markets) load concurrently
- Format for readability: Structured HTML with tree characters and status emojis is fast to scan on mobile
- Handle callbacks statelessly: In-memory caches with eviction for pagination and inline buttons
Fork it, swap the Struct SDK calls for any data source, and you have the skeleton of a Telegram lookup bot in an afternoon.
The project is open source: github.com/structbuild/polymarket-telegram-bot
Get a Struct API key: trader and market data access starts on the free plan