SDK Integration
One method. Pass the LLM response — get it back with a native ad injected and the keyboard merged. SDK handles errors, timeouts, and Telegram limits automatically.
Install
pip install sidekick-ads# with framework extras:pip install sidekick-ads[aiogram] # aiogram 3.xpip install sidekick-ads[ptb] # python-telegram-bot 20+
Quick start
Initialize with your API key and platform ID (from your platforms dashboard), then call inject() on every LLM response.
from sidekick_ads import Sidekicksk = Sidekick(api_key="sk_live_xxxxx",platform_id="plt_xxxxx",)# in your handler:result = await sk.inject(user_id=message.from_user.id,message=llm_reply,language_code=message.from_user.language_code,is_premium=message.from_user.is_premium,keyboard=your_existing_keyboard, # optionalparse_mode="HTML", # optional — matches your bot's parse_modefirst_name=message.from_user.first_name, # optional — better targetinguser_message=message.text, # optional — better targeting)await message.answer(result.message, reply_markup=result.keyboard, parse_mode="HTML")
Constructor
| Parameter | Type | Description |
|---|---|---|
api_key / apiKeyrequired | string | Your API key (sk_live_…). Generate in settings. settings → |
platform_id / platformIdrequired | string | Platform public ID (plt_…). platforms dashboard → |
base_url / baseUrl | string | API base URL. Default: "https://sidekick-ads.com". |
timeout | number | Request timeout in seconds. Default: 3.0. |
platform | string | Surface type. Default: "telegram". |
inject()
The only method you call. Async, never throws — on any error returns the original message and keyboard unchanged.
| Parameter | Type | Description |
|---|---|---|
user_id / userIdrequired | number | Telegram user ID (ctx.from.id). Telegram User docs → |
messagerequired | string | LLM response text to inject an ad into. |
language_code / languageCode | string? | IETF language tag. SDK sends "" if null/None. Telegram User docs → |
is_premium / isPremium | bool? | Telegram Premium status. SDK sends false if null/None. Telegram User docs → |
keyboard | InlineKeyboardMarkup? | Your existing keyboard. SDK merges the ad button into it. |
parse_mode / parseMode | string? | "HTML", "MarkdownV2", or "Markdown". Server escapes and formats the ad CTA for the given parse mode. |
ad_button_position / adButtonPosition | "bottom" | "top" | Where to add the ad button row. Default: "bottom". |
Return value
| Field | Type | Description |
|---|---|---|
message | string | Ready-to-send text — original or with ad CTA appended. |
has_ad / hasAd | bool | Whether an ad was injected. |
impression_id / impressionId | string? | Impression ID for tracking. |
ad | object? | Ad object with text, formatted_text, button_text, button_url. |
ad.formatted_text / ad.formattedText | string | Rich version of ad.text, already rendered into your parse_mode — may contain links and emphasis inside the copy. Not wrapped in the click-tracking link, so clicks on it aren't counted (the button still is). Equals ad.text when the campaign has no rich copy. |
keyboard | InlineKeyboardMarkup? | Merged keyboard (your buttons + ad button), or original if no ad. |
fetch()
Fetch an ad object without sending your message text. Returns the ad as separate fields that you render into your bot's reply yourself. Async, never throws — on any error returns { hasAd: false } (or { has_ad: False } in Python).
from sidekick_ads import Sidekicksk = Sidekick(api_key="sk_live_xxx", platform_id="plt_xxx")result = await sk.fetch(user_id=ctx.from_user.id,language_code=ctx.from_user.language_code or "en",parse_mode="HTML", # optional)if result.has_ad and result.ad is not None:await message.answer(f"{llm_reply}\n\n{result.ad.ad_text_formatted}",parse_mode="HTML",reply_markup={"inline_keyboard": [[{"text": result.ad.button_text, "url": result.ad.button_url}]]},)else:await message.answer(llm_reply)
Parameters
| Parameter | Type | Description |
|---|---|---|
user_id / userIdrequired | number | Telegram user ID (ctx.from.id). Telegram User docs → |
language_code / languageCoderequired | string | IETF language tag from the Telegram User object. Telegram User docs → |
is_premium / isPremium | bool? | Telegram Premium status. Default: false. |
parse_mode / parseMode | string? | "HTML", "MarkdownV2", or "Markdown". When set, response includes a pre-formatted ad_text_formatted field. |
accept_formats / acceptFormats | string[]? | Ad formats this caller renders. Default ["text"] (text-only, legacy behavior). Pass ["text", "image"] to receive image ads too — the SDK will pre-download the asset bytes into ad.image_data / ad.imageData so you can hand them straight to InputFile. |
Return value (FetchResult)
| Field | Type | Description |
|---|---|---|
has_ad / hasAd | bool | Whether an ad was returned. |
impression_id / impressionId | string? | Impression ID (tracks the impression). Null when has_ad is false. |
ad | object? | Null when has_ad is false. Otherwise an object with the fields below. |
ad.ad_text / ad.adText | string | Raw campaign text. |
ad.ad_url / ad.adUrl | string | Click URL. |
ad.button_text / ad.buttonText | string | Inline button label. |
ad.button_url / ad.buttonUrl | string | Inline button URL (click tracking). |
ad.ad_text_formatted / ad.adTextFormatted | string? | Present only if parse_mode was in the request. Escaped text with the click URL embedded per parse mode. |
fetch() vs inject() — use inject() if you want the SDK to handle message splicing and keyboard merging for you. Use fetch() if your bot serves LLM text that may contain user data (personal names, query fragments) and you don't want to send that text to Sidekick's servers.
fetch_image()
Fetch an image ad (photo or animation) to display separately from your LLM reply. Calls the strict image-only /api/v1/ad/fetch-image endpoint. Async, never throws — on any error returns { has_ad: False }.
from sidekick_ads import Sidekickfrom aiogram.types import (InlineKeyboardMarkup, InlineKeyboardButton, BufferedInputFile,)sk = Sidekick(api_key=..., platform_id=...)result = await sk.fetch_image(user_id=message.from_user.id,language_code="en",parse_mode="HTML",)if result.has_ad and result.ad:ad = result.ad# SDK already downloaded the bytes — hand straight to InputFile.media = BufferedInputFile(ad.image_data, filename=ad.image_filename)kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=ad.button_text, url=ad.button_url)]])caption = ad.ad_text_formatted or ad.ad_textif ad.media_type == "animation":await message.answer_animation(media, caption=caption,parse_mode="HTML", reply_markup=kb)else:await message.answer_photo(media, caption=caption,parse_mode="HTML", reply_markup=kb)
Parameters
| Parameter | Type | Description |
|---|---|---|
user_id / userIdrequired | number | Telegram user ID (ctx.from.id). Telegram User docs → |
language_code / languageCoderequired | string | IETF language tag from the Telegram User object. Telegram User docs → |
is_premium / isPremium | bool? | Telegram Premium status. Default: false. |
parse_mode / parseMode | string? | "HTML", "MarkdownV2", or "Markdown". When set, response includes a pre-formatted ad_text_formatted field — the caption wrapped in an anchor pointing at the click URL. |
Return value (FetchImageResult)
| Field | Type | Description |
|---|---|---|
has_ad / hasAd | bool | Whether an image ad was returned. |
impression_id / impressionId | string? | Impression ID. Null when has_ad is false. |
ad | object? | Null when has_ad is false. Otherwise an object with the fields below. |
ad.format | "image" | Always "image" for this endpoint. |
ad.ad_text / ad.adText | string | Plain-text caption for the Telegram message. |
ad.ad_text_formatted / ad.adTextFormatted | string? | Present only if parse_mode was in the request. The caption wrapped in an anchor tag pointing at the click URL — pass this as the Telegram caption to make the text tappable. |
ad.ad_url / ad.adUrl | string | Click-tracking URL (same as button_url). |
ad.button_text / ad.buttonText | string | Inline button label. |
ad.button_url / ad.buttonUrl | string | Inline button URL (click tracking). |
ad.media_type / ad.mediaType | "photo" | "animation" | Telegram media method to use (sendPhoto or sendAnimation). |
ad.image_url / ad.imageUrl | string | Direct URL to the image or GIF. You usually don't need to fetch this yourself — see image_data below. |
ad.image_mime / ad.imageMime | string | MIME type, e.g. "image/jpeg", "image/gif", "video/mp4". |
ad.image_data / ad.imageData | bytes / Uint8Array | Pre-downloaded asset bytes — hand straight to aiogram BufferedInputFile or grammy InputFile. The SDK fetches these for you so you don't need to download the image yourself, and it works even if Telegram's URL fetcher can't reach the asset host. |
ad.image_filename / ad.imageFileName | string | Suggested filename derived from the MIME type, e.g. "ad.jpg". |
Framework examples
Copy-paste examples for every supported framework. Each one is a complete, working handler.
aiogram 3.x — with middleware
from aiogram import Bot, Dispatcher, typesfrom sidekick_ads import Sidekickfrom sidekick_ads.middleware import SidekickMiddlewarebot = Bot(token="BOT_TOKEN")dp = Dispatcher()# Register middleware — injects `sidekick` into every handlersidekick_middleware = SidekickMiddleware(api_key="sk_live_xxxxx",platform_id="plt_xxxxx",)dp.message.middleware(sidekick_middleware)# Close the HTTP pool cleanly on dispatcher shutdowndp.shutdown.register(sidekick_middleware.aclose)@dp.message()async def handle(message: types.Message, sidekick: Sidekick):llm_reply = await get_llm_response(message.text)# Your existing keyboard (if any)kb = types.InlineKeyboardMarkup(inline_keyboard=[[types.InlineKeyboardButton(text="👍", callback_data="like"),types.InlineKeyboardButton(text="👎", callback_data="dislike")],])result = await sidekick.inject(user_id=message.from_user.id,message=llm_reply,language_code=message.from_user.language_code,is_premium=message.from_user.is_premium,keyboard=kb,)await message.answer(result.message, reply_markup=result.keyboard)
aiogram 3.x — without middleware
from aiogram import Bot, Dispatcher, typesfrom sidekick_ads import Sidekickbot = Bot(token="BOT_TOKEN")dp = Dispatcher()sk = Sidekick(api_key="sk_live_xxxxx", platform_id="plt_xxxxx")@dp.message()async def handle(message: types.Message):llm_reply = await get_llm_response(message.text)result = await sk.inject(user_id=message.from_user.id,message=llm_reply,language_code=message.from_user.language_code,is_premium=message.from_user.is_premium,)await message.answer(result.message, reply_markup=result.keyboard)
python-telegram-bot v20+
from telegram import Update, InlineKeyboardMarkup, InlineKeyboardButtonfrom telegram.ext import ApplicationBuilder, MessageHandler, ContextTypes, filtersfrom sidekick_ads import Sidekicksk = Sidekick(api_key="sk_live_xxxxx", platform_id="plt_xxxxx")async def handle(update: Update, context: ContextTypes.DEFAULT_TYPE):user = update.message.from_userllm_reply = await get_llm_response(update.message.text)kb = InlineKeyboardMarkup([[InlineKeyboardButton("👍", callback_data="like"),InlineKeyboardButton("👎", callback_data="dislike")],])result = await sk.inject(user_id=user.id,message=llm_reply,language_code=user.language_code,is_premium=user.is_premium,keyboard=kb,)await update.message.reply_text(result.message, reply_markup=result.keyboard)app = ApplicationBuilder().token("BOT_TOKEN").build()app.add_handler(MessageHandler(filters.TEXT, handle))app.run_polling()
Raw usage (no framework)
import httpxfrom sidekick_ads import Sidekicksk = Sidekick(api_key="sk_live_xxxxx", platform_id="plt_xxxxx")async def on_message(user_id: int, text: str, lang_code: str | None, premium: bool | None):llm_reply = await get_llm_response(text)result = await sk.inject(user_id=user_id,message=llm_reply,language_code=lang_code,is_premium=premium,)# Send via any HTTP clientasync with httpx.AsyncClient() as client:await client.post(f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage",json={"chat_id": user_id,"text": result.message,**({"reply_markup": result.keyboard} if result.keyboard else {}),},)
Error handling
inject() never throws. On any error — timeout, network failure, 5xx, malformed JSON — it returns your original message and keyboard unchanged. Your bot keeps working no matter what.
# Sidekick is down? Bot still works.result = await sk.inject(user_id=123, message="Hello world")# result.message == "Hello world" (unchanged)# result.has_ad == False# result.keyboard == None (or your original keyboard)
Keyboard merging
- •Ad button is always added as a separate row — never mixed with your buttons.
- •Position controlled by
ad_button_position:"bottom"(default) or"top". - •Telegram allows max 13 keyboard rows. If you already have 13 — ad button is skipped, text CTA only.
- •Your existing buttons are never modified or removed.
- •If message + ad exceeds 4096 chars, text CTA is dropped but button is still added.
Rich formats
Beyond the classic text CTA, Sidekick serves four rich ad formats built on Telegram Bot API 10.3 rich messages. The server assembles the complete Bot API payload — your bot (or SDK) only adds chat_id and forwards it. Everything is opt-in: bots that don't declare rich support keep today's behavior byte-for-byte.
| Format | Description |
|---|---|
response | Sponsored response — brand header (logo + name), ad copy, primary button, «Sponsored» footer. Reads like a native bot reply. |
card | Sponsored card — brand header, banner image, short text, button row, footer. The visual banner format. |
consent | Consent ad — a two-line question with «Tell me» / «Not interested». Agreeing reveals the full pitch (slideshow + CTA); declining opts the user out of the campaign. |
quiz | Sponsored quiz — a 2–3 question wizard edited in place, ending in a personalized «match %» result card with the CTA. |
Request additions
Three optional fields on POST /api/v1/ad (and inject()). Omit all of them and nothing changes — you get text ads exactly as today. Unknown accept_formats values are ignored by the server, never rejected, so a newer SDK against an older server keeps serving.
| Field | Type | Description |
|---|---|---|
accept_formats | string[] | Formats your bot can render: any of "text", "response", "card", "consent", "quiz". Omitted → ["text"]. The server never returns a format you didn't declare. |
capabilities | object | Bot self-declaration: { "rich_messages": bool, "callbacks": bool, "custom_emoji": bool }. rich_messages — the bot forwards rich payloads; callbacks — it forwards sk:* callback taps to /api/v1/ad/interact; custom_emoji — the bot owner has Telegram Premium, so custom-emoji brand logos render (without it the server substitutes a bold brand name). |
bot_is_admin | boolean | Group chats only: whether your bot is an admin of the chat (cache getChatMember(chat, me) for ~1h; invalidate on my_chat_member updates). Admin bots get scene 1 delivered as an ephemeral message — visible only to the target user. Non-admin bots get a regular rich message. |
Response — rich ad served
{"message": "Python — это высокоуровневый язык...\n\n<a href=\"https://...\">💡 Ad CTA text</a>","has_ad": true,"impression_id": "imp_a8f3d2c1e9b7","ad": {"format": "consent","send": {"method": "sendRichMessage","params": {"rich_message": { "blocks": [ /* server-assembled Bot API blocks */ ] },"reply_markup": { "inline_keyboard": [[{ "text": "Learn more", "url": "https://..." }]] }},"ephemeral": false},"fallback": {"format": "text","text": "💡 Ad CTA text","formatted_text": "💡 <b>Ad</b> CTA text","button_text": "Learn more","button_url": "https://sidekick-ads.com/api/v1/click/imp_a8f3d2c1e9b7"}}}
- •
ad.sendis a ready Bot API call: forwardsend.paramswith yourchat_idadded tosend.method. Media inside blocks are HTTPS URLs — Telegram downloads them on send, no uploads on your side. - •The invariant:
messageis always filled with the classic text version (your reply + spliced CTA), andad.fallbackcarries the same as separate fields — rendered for yourparse_mode. On any Telegram error, send those instead. A rich ad can never break your bot. - •
send.ephemeral: true(group scene 1 for admin bots) means the payload includes ephemeral delivery — visible only to the target user. If Telegram answersBOT_NOT_ADMIN(rights revoked since your cache), retry the same payload withoutephemeral_message_parameters. The SDK send helper does this automatically. - •Interactive buttons (consent, quiz) carry
callback_dataprefixedsk:. Forward the tap toPOST /api/v1/ad/interact(same Bearer token) — the server renders the next scene and returns another{ method, params }to forward. Taps are free: billing stays CPM per impression, clicks keep going through the tracked redirect.
Integration
With the SDK it's three lines: declare formats on inject(), deliver with the send helper, register the callbacks middleware. The Manual tab shows the full raw-REST equivalent.
from sidekick_ads import Sidekickfrom sidekick_ads.router import sidekick_routersk = Sidekick(api_key="sk_live_xxxxx", platform_id="plt_xxxxx")# Handles every sk:* callback tap (consent / quiz steps) for you.dp.include_router(sidekick_router(sk))@dp.message()async def handle(message: types.Message):llm_reply = await get_llm_response(message.text)result = await sk.inject(user_id=message.from_user.id,message=llm_reply,language_code=message.from_user.language_code,parse_mode="HTML",accept_formats=["text", "response", "card", "consent", "quiz"],capabilities={"rich_messages": True, "callbacks": True, "custom_emoji": True},bot_is_admin=False, # groups: your cached getChatMember(chat, me) result)# Forwards result.send with chat_id merged in; degrades to the text# fallback (result.message + result.keyboard) on any Telegram error.await sk.send(bot, message.chat.id, result,receiver_user_id=message.from_user.id)
Client requirements
- •Rich blocks render on Telegram clients released 25.08.2026 or later. Older clients degrade softly: some blocks disappear, but the text and the classic
reply_markupCTA button (always duplicated by the server) stay visible. - •Custom-emoji brand logos render only when your bot owner has Telegram Premium — declare
capabilities.custom_emojiaccordingly. Without it the logo degrades to a bold brand name. - •Groups: interactive scenes after a tap are always delivered ephemerally (visible only to the tapping user). Scene 1 itself is ephemeral only when your bot is a chat admin (
bot_is_admin: true); otherwise it's a regular message — Telegram allows unprompted ephemeral sends to admin bots only.
Notes
- •Impressions are tracked automatically. When an ad is returned, the server already recorded the impression. No extra call needed.
- •Clicks go through the redirect. The button URL handles click tracking and redirects to the advertiser. Just use it as-is.
- •Connection pooling. Python SDK reuses an httpx.AsyncClient across calls. Node.js uses the built-in fetch agent.
- •Timeout default is 3 seconds. If the API doesn't respond in time, your bot gets the original message back instantly.