Stickers & Custom Emoji
Read a space's sticker and custom emoji packs, create packs of your own and fill them — for example by importing a Telegram sticker set.
Files go up once through IFiles or inline with the request,
and changes arrive in real time via the Expressions intent.
Packs & Items #
Stickers and custom emoji are called expressions. They live in packs that belong to a space,
and every pack holds one kind of item:
:name:, and as a reaction.
Each item has a format:
static (WEBP),
lottie (TGS, gzipped Lottie JSON) or
video (WEBM, VP9).
The rules and sizes are Telegram's, so a Telegram sticker set imports file for file.
Example — a pack with one item (BotExpressionPackV1)
{
"packId": "7c9e6679-...",
"spaceId": "aaaabbbb-...",
"kind": "sticker",
"title": "Hot Cherry",
"slug": "hotcherry",
"coverItemId": null,
"sortOrder": 0,
"version": 2,
"creatorId": "d3b07384-...",
"createdByBot": true,
"items": [
{
"itemId": "9b2f4a1e-...",
"packId": "7c9e6679-...",
"spaceId": "aaaabbbb-...",
"kind": "sticker",
"format": "lottie",
"name": "Hot Cherry 1",
"url": ".../files/0d6c1f2e-...",
"thumbUrl": ".../files/41ab77c0-...",
"width": 512,
"height": 512,
"fileSize": 38112,
"emoji": ["🍒"],
"keywords": ["tg:AgADBQADwDZPEw"],
"textColor": false,
"sortOrder": 0,
"creatorId": "d3b07384-...",
"createdByBot": true
}
]
} Files travel as URLs. An item carries url and,
for an animated item, thumbUrl (its first frame as WEBP; null for a static one).
createdByBot is set when a bot account added the pack or item;
creatorId is that account's user ID.
Formats & Limits #
The server reads the file's own signature, not its name or Content-Type, and checks it when an item is added.
A file that breaks a rule is refused with invalid_format; one over its size with
too_large. Sizes are in KB of 1024 bytes.
Forbidden in Lottie / TGS
Telegram's list. A file that uses any of these is refused with invalid_format:
- Expressions (script on an animated property)
- 3D layers (
ddd), on the animation or a layer - Solid, image, text, audio and camera layers
- Masks, layer effects, auto-orient, time remapping and time stretching
- Image assets, linked or embedded
- Gradient strokes, repeaters, merge paths and star / polygon shapes
Names & metadata
[a-z0-9_], 2–32 characters, unique among all the space's emoji — it is the :name: people type. A taken name returns 409 name_taken. needs_repainting. Meant for single-colour emoji; defaults to false. [a-z0-9_], 1–64 characters, unique in the space across both kinds. Set once: the Bot API cannot change it. Quotas #
A space holds a limited number of packs and items. The item slots grow with the space's boost level;
a full space, a full pack or too many packs refuse the next addition with 422 quota_exceeded.
On top of the slots: at most 10 packs per space (stickers and emoji together),
120 stickers per sticker pack and 200 emoji per emoji pack — Telegram's set sizes.
These are the defaults; GetQuota tells you what applies to the space right now.
Request
GET /IExpressions/v1/GetQuota?spaceId=aaaabbbb-...
Authorization: Bot YOUR_TOKEN Response (BotQuotaV1)
{
"packs": { "used": 1, "max": 10 },
"stickers": { "used": 4, "max": 18 },
"emoji": { "used": 0, "max": 120 },
"itemsPerPack": { "sticker": 120, "emoji": 200 },
"boostLevel": 1
} A space without boosts holds 6 stickers. Read GetQuota before an import
and tell the user how much of the set fits, rather than stopping part way through with quota_exceeded.
Who May Change What #
Reading — List, GetPack,
GetQuota — only needs the bot to be a member of the space.
Writing — CreatePack, UpdatePack,
AddItem, UpdateItem — needs the
CreateExpressions permission, and even then a bot is held to three rules:
Only its own. A bot updates only the packs and items it created (creatorId is the bot);
updating a person's pack or item returns 403 insufficient_permissions.
Adding is not limited that way: AddItem takes any pack of the space, and the item it makes is the bot's.
Never delete, never reorder. There are no routes for it, and the server refuses a bot either way.
Removing a pack or item is left to members with ManageExpressions.
ManageExpressions adds nothing. For people it opens everyone's packs; granting it to a bot changes none of the above.
Getting CreateExpressions
A bot's permissions in a space come from its required entitlements, set on the app's Entitlements tab in the Developer Console. Installing the bot creates a locked role holding exactly those.
Add CreateExpressions to the required entitlements. Spaces that install the bot from now on grant it at once.
Spaces that installed it earlier keep the old set: the bot shows as pending approval in the space's bot settings until the owner approves it again.
Until then writes return 403 insufficient_permissions.
When the bot connects to the event stream, the ready event marks
every such space with pendingApproval: true, and a
botEntitlementsUpdated event follows for each
(requiredEntitlements vs grantedEntitlements) — the cue to ask the owner.
Files #
AddItem is a multipart/form-data request,
read the way Telegram reads one. Every field is a text part; a list is the field repeated once per value (or one field holding a JSON array); a boolean is
true or false.
The file and thumb fields take a file in one of three ways:
clip). A name that matches no part returns 400 invalid_request. fileId of an earlier IFiles/Upload. Unknown or expired returns 404 not_found.
A request carries at most 5 MB in total; more is refused with 413 too_large.
Every item gets its own copy of the file, so the same file can make any number of items.
Uploading once with IFiles
Like Telegram's uploadStickerFile:
POST /IFiles/v1/Upload takes a file part
and a purpose, and returns a fileId
that AddItem takes in place of the part — in any space, any number of times, for 24 hours.
Useful when one file goes into several spaces, or when you want uploads and additions apart.
Upload
curl -X POST \
-H "Authorization: Bot YOUR_TOKEN" \
-F "purpose=sticker" \
-F "[email protected]" \
https://gateway.argon.zone/IFiles/v1/Upload Response (BotFileV1)
{
"fileId": "5f1d0c3a-...",
"size": 23120,
"contentType": "image/webp",
"url": ".../files/5f1d0c3a-..."
} 400 invalid_format. Upload checks the type and the size, nothing more. Dimensions, duration, frame rate and Lottie features are checked when
AddItem uses the file, against the kind of the pack it goes into.
A bot holds at most 100 unused uploads at a time — the next returns 422 quota_exceeded.
An upload stops counting once an item is made from it, and is deleted 24 hours after it was sent.
GET /IFiles/v1/Get?fileId=… describes a file the bot uploaded, or any sticker or emoji file:
its size, contentType and a download
url. Anything else is 404 not_found.
The thumbnail #
An animated item (lottie or video)
needs a still first frame: pickers show it and moderation judges it. Argon draws that frame itself, so thumb is optional:
- Static item — never send
thumb; it is refused withinvalid_format. - Animated item, no thumb — the server renders frame 0 and stores it as the thumbnail.
- Animated item with a thumb — it still has to be valid (WEBP, one frame, exactly the item's size, at most 128 KB) or the whole request fails with
invalid_format; the frame the server draws replaces it. - When the server cannot draw the format — a deployment without the renderer, or a file it fails to read — the client's thumb is used, and an animated item without one is refused with
invalid_format.
Telegram's own thumbnail will not do — it is smaller than the sticker.
If you need a thumb, render frame 0 at full size: rlottie or any Lottie renderer for TGS, and for WEBM, for example
ffmpeg -c:v libvpx-vp9 -i sticker.webm -frames:v 1 -c:v libwebp -quality 80 thumb.webp
(the libvpx-vp9 decoder keeps the transparency).
An animated sticker with its thumbnail, both as named parts
curl -X POST \
-H "Authorization: Bot YOUR_TOKEN" \
-F "spaceId=aaaabbbb-..." \
-F "packId=7c9e6679-..." \
-F "name=Spin" \
-F "file=attach://clip" \
-F "thumb=attach://first_frame" \
-F "[email protected]" \
-F "[email protected]" \
https://gateway.argon.zone/IExpressions/v1/AddItem Import a Telegram Sticker Set #
A walkthrough with curl, then the same as one TypeScript script.
It needs your Argon bot token with CreateExpressions in the space, and the token of any Telegram bot
(TG_TOKEN) to read a public set from the Telegram Bot API.
Check the room #
Read the set from Telegram (step 4) and the space's quota. A set of 30 stickers does not fit a space that has 6 slots; say so before you start.
curl -H "Authorization: Bot YOUR_TOKEN" \
"https://gateway.argon.zone/IExpressions/v1/GetQuota?spaceId=aaaabbbb-..."
# free sticker slots = stickers.max - stickers.used Create the pack #
A Telegram set's sticker_type of custom_emoji
makes an emoji pack; regular and
mask make a sticker pack.
The set's name, lower-cased, is a valid slug.
curl -X POST \
-H "Authorization: Bot YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"spaceId":"aaaabbbb-...","kind":"sticker","title":"Hot Cherry","slug":"hotcherry"}' \
https://gateway.argon.zone/IExpressions/v1/CreatePack The response is the pack; packId is what AddItem needs.
{
"packId": "7c9e6679-...",
"spaceId": "aaaabbbb-...",
"kind": "sticker",
"title": "Hot Cherry",
"slug": "hotcherry",
"coverItemId": null,
"sortOrder": 0,
"version": 1,
"items": [],
"creatorId": "d3b07384-...",
"createdByBot": true
} CreatePack is safe to repeat. Called again by the same bot with the same slug and kind, it returns the pack it made — with its items —
and changes nothing, so a re-run of an import needs no bookkeeping to find its pack. The same slug held by someone else's pack, or by your pack of the other kind,
is 409 name_taken.
Skip what is already there #
AddItem is not idempotent: each call adds a new item, even with the same file.
Keeping re-imports from doubling up is the bot's job. Give every imported item the keyword
tg:<file_unique_id> — Telegram's ID that stays the same for the same file —
and before adding, read List and skip every sticker whose key is already present.
curl -H "Authorization: Bot YOUR_TOKEN" \
"https://gateway.argon.zone/IExpressions/v1/List?spaceId=aaaabbbb-..."
# every packs[].items[].keywords entry that starts with "tg:" is done
Keywords share 64 characters per item, and a tg: key takes about 20 of them; keep the rest short.
Download from Telegram #
getStickerSet lists the stickers; getFile
turns each file_id into a path to download. The files are already in Argon's formats —
WEBP, TGS (is_animated) or WEBM (is_video) — so they go up unchanged.
curl "https://api.telegram.org/botTG_TOKEN/getStickerSet?name=HotCherry"
# result.title, result.sticker_type, result.stickers[]:
# file_id, file_unique_id, emoji, is_animated, is_video, needs_repainting
curl "https://api.telegram.org/botTG_TOKEN/getFile?file_id=CAACAgIAAxkBAAE..."
# result.file_path: "stickers/file_12.tgs"
curl -o 1.tgs "https://api.telegram.org/file/botTG_TOKEN/stickers/file_12.tgs" Add each sticker #
One AddItem per sticker, the file as a part. Carry Telegram's
emoji over as the associated emoji, the tg: key as a keyword,
and for custom emoji needs_repainting as textColor.
Telegram gives stickers no names, so make them: free text for a sticker, [a-z0-9_] unique in the space for an emoji.
curl -X POST \
-H "Authorization: Bot YOUR_TOKEN" \
-F "spaceId=aaaabbbb-..." \
-F "packId=7c9e6679-..." \
-F "name=Hot Cherry 1" \
-F "emoji=🍒" \
-F "keywords=tg:AgADBQADwDZPEw" \
-F "[email protected]" \
https://gateway.argon.zone/IExpressions/v1/AddItem
The response is the new item (BotExpressionItemV1).
More associated emoji or keywords: repeat the field (-F "emoji=🍒" -F "emoji=❤️"), or send one field holding a JSON array.
The file can also be an attach:// reference or a fileId — see Files.
Handle refusals #
Branch on the error code (see Error Handling).
Some refusals are about one sticker and the import goes on; others end it.
attach:// names no part. A bug in the request — stop. CreateExpressions in this space yet — the owner has not approved it. (On an update, also: the pack or item is not the bot's.) Stop and tell the user. fileId is unknown or older than 24 hours. Re-create or re-upload. CreatePack, the slug). Pick another name and retry. GetQuota says which. IExpressions 60/min, IFiles 30/min. Wait Retry-After seconds and send the same request again. Retry-After: 60; wait and send it again. Two different 429s. rate_limited is your bot's own limit, across all spaces.
space_rate_limited is the space's budget for sticker and emoji changes, shared by every bot in it
(people have a budget of their own). An AddItem refused for its file —
invalid_format, too_large or
content_rejected — has already used one change of that budget. See Rate Limits.
Complete script #
The six steps in one function. Run it again after a failure or when the set grows: it skips what it added before.
TypeScript (Bun / Node.js)
// import-telegram-set.ts — Bun, or Node 18+ (global fetch, FormData, Blob)
const ARGON = "https://gateway.argon.zone";
const TG = "https://api.telegram.org";
const ARGON_AUTH = "Bot " + process.env.ARGON_TOKEN;
const TG_BOT = "bot" + process.env.TG_TOKEN; // any Telegram bot: getStickerSet reads public sets
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
class ArgonError extends Error {
constructor(readonly status: number, readonly code: string) { super(code); }
}
// One Argon call. Both 429s are waited out: rate_limited (this bot's window for the
// interface) and space_rate_limited (the budget all bots share in the space).
async function argon(method: string, path: string, body?: object | FormData): Promise<any> {
const form = body instanceof FormData;
for (;;) {
const res = await fetch(ARGON + path, {
method,
headers: body && !form
? { Authorization: ARGON_AUTH, "Content-Type": "application/json" }
: { Authorization: ARGON_AUTH },
body: form ? body : body ? JSON.stringify(body) : undefined,
});
if (res.status === 429) {
await sleep(Number(res.headers.get("Retry-After") ?? 60) * 1000);
continue;
}
const data = await res.json();
if (!res.ok) throw new ArgonError(res.status, data.error);
return data;
}
}
async function telegram(method: string, params: Record<string, string>): Promise<any> {
const res = await fetch(TG + "/" + TG_BOT + "/" + method + "?" + new URLSearchParams(params));
const data = await res.json();
if (!data.ok) throw new Error(method + ": " + data.description);
return data.result;
}
async function download(fileId: string): Promise<Blob> {
const file = await telegram("getFile", { file_id: fileId });
const res = await fetch(TG + "/file/" + TG_BOT + "/" + file.file_path);
return await res.blob();
}
export async function importSet(spaceId: string, setName: string) {
const set = await telegram("getStickerSet", { name: setName });
const kind = set.sticker_type === "custom_emoji" ? "emoji" : "sticker";
const slug = setName.toLowerCase(); // Telegram set names are [A-Za-z0-9_], up to 64
// 1. Room: the space's free slots for this kind.
const quota = await argon("GET", "/IExpressions/v1/GetQuota?spaceId=" + spaceId);
const slots = kind === "emoji" ? quota.emoji : quota.stickers;
let room = slots.max - slots.used;
// 2. The pack. Asking again with the same slug returns the pack this bot made.
const pack = await argon("POST", "/IExpressions/v1/CreatePack",
{ spaceId, kind, title: set.title.slice(0, 64).trim(), slug });
// 3. What an earlier run added: every imported item carries tg:<file_unique_id>.
const { packs } = await argon("GET", "/IExpressions/v1/List?spaceId=" + spaceId);
const done = new Set<string>(packs.flatMap((p: any) => p.items)
.flatMap((i: any) => i.keywords).filter((k: string) => k.startsWith("tg:")));
// Emoji names are [a-z0-9_] and unique in the space; sticker names are plain text.
let n = pack.items.length;
const nameFor = (i: number) =>
kind === "emoji" ? slug.slice(0, 24) + "_" + i : set.title.slice(0, 24).trim() + " " + i;
for (const sticker of set.stickers) {
const key = "tg:" + sticker.file_unique_id;
if (done.has(key)) continue;
if (room <= 0) {
console.warn("No slots left in the space: boost it or remove items");
return;
}
// 4. Download, and 5. add it with the file as an inline part.
const ext = sticker.is_animated ? "tgs" : sticker.is_video ? "webm" : "webp";
const form = new FormData();
form.append("spaceId", spaceId);
form.append("packId", pack.packId);
if (sticker.emoji) form.append("emoji", sticker.emoji);
form.append("keywords", key);
if (sticker.needs_repainting) form.append("textColor", "true");
form.append("file", await download(sticker.file_id), sticker.file_unique_id + "." + ext);
// 6. Refusals. The 429s never get here; argon() waits them out.
for (let attempt = 1; ; attempt++) {
form.set("name", nameFor(++n));
try {
await argon("POST", "/IExpressions/v1/AddItem", form);
done.add(key);
room--;
break;
} catch (e) {
if (!(e instanceof ArgonError)) throw e;
// A person's emoji already has this name: try the next number.
if (e.code === "name_taken" && attempt < 5) continue;
// This file breaks a rule, or moderation refused it; it will be refused again.
if (["invalid_format", "too_large", "content_rejected", "name_taken"].includes(e.code)) {
console.warn("Skipped " + key + ": " + e.code);
break;
}
// The pack (120 stickers, 200 emoji) or the space is full.
if (e.code === "quota_exceeded") {
console.warn("Quota reached; the rest of the set was not imported");
return;
}
throw e; // insufficient_permissions, not_found, invalid_request: a fault to fix
}
}
}
} Updating Packs & Items #
PATCH /IExpressions/v1/UpdatePack changes the title
or the coverItemId (an item of that pack) of a pack the bot made.
PATCH /IExpressions/v1/UpdateItem changes the name,
emoji, keywords or
textColor of an item the bot made. Both are JSON. A field left out — or null — is kept;
an empty list clears emoji or keywords.
A list you send replaces the old one, so send the whole list — including the tg: key.
Request
PATCH /IExpressions/v1/UpdateItem
Authorization: Bot YOUR_TOKEN
Content-Type: application/json
{
"spaceId": "aaaabbbb-...",
"itemId": "9b2f4a1e-...",
"keywords": ["tg:AgADBQADwDZPEw", "cherry", "love"]
}
Both answer with the pack or item as it now is. A request that changes nothing succeeds without counting against the space's budget.
The file of an item cannot be replaced: add a new item, and ask a member with ManageExpressions to remove the old one.
Reading & the Version Token #
GET /IExpressions/v1/List returns every pack of the space with its items, sorted by kind and order, and a
version token that changes whenever any pack or item does.
Keep the token and pass it back as known: while nothing changed, packs
comes back null and costs nothing to send.
Request
GET /IExpressions/v1/List?spaceId=aaaabbbb-...&known=3F9A0C1B7E22D4A5
Authorization: Bot YOUR_TOKEN Response — still current
{
"version": "3F9A0C1B7E22D4A5",
"packs": null
}
The token is opaque: compare it for equality, never parse it. For one pack, GET /IExpressions/v1/GetPack
takes packId or slug (one of them is required) and answers
404 not_found for a pack the space does not have.
Real-time Events #
The Expressions intent (bit 14, value 16384) delivers
expressionsUpdate whenever a pack or item of a space the bot is in changes — including the bot's own changes.
It is not privileged and is part of the default (all non-privileged) intents.
List would return now. delta only if it equals the token you hold. null for a change V1 has no shape for — re-read List. pack.items is empty — keep the items you hold. ordered is every pack ID of that kind, in the new order. ordered is every item ID of the pack, in the new order. SSE example — event: expressionsUpdate, data:
{
"spaceId": "aaaabbbb-...",
"version": "3F9A0C1B7E22D4A5",
"baseVersion": "C04D19E8A7B3F210",
"delta": {
"type": "itemUpserted",
"packId": "7c9e6679-...",
"itemId": "9b2f4a1e-...",
"item": {
"itemId": "9b2f4a1e-...",
"packId": "7c9e6679-...",
"kind": "sticker",
"format": "lottie",
"name": "Hot Cherry 1",
"url": ".../files/0d6c1f2e-...",
"thumbUrl": ".../files/41ab77c0-...",
"createdByBot": true
}
}
} The rule: if baseVersion is the token you hold and delta is not
null, apply the delta and hold version.
Otherwise — you missed an event, or just started — call List with known and take what it returns.
Fields a delta type does not use are empty; ignore them.
Keeping a space's packs in sync (TypeScript, argon() from the script above)
// Per space: the version token you hold and the packs it stands for.
const spaces = new Map<string, { version: string; packs: any[] }>();
async function reload(spaceId: string) {
const held = spaces.get(spaceId);
const known = held ? "&known=" + held.version : "";
const res = await argon("GET", "/IExpressions/v1/List?spaceId=" + spaceId + known);
// packs is null when the version you sent is still current.
spaces.set(spaceId, { version: res.version, packs: res.packs ?? held!.packs });
}
es.addEventListener("expressionsUpdate", (e) => {
const { spaceId, version, baseVersion, delta } = JSON.parse(e.data);
const held = spaces.get(spaceId);
// Nothing held, a missed change, or one V1 has no delta for: read the list again.
if (!held || !delta || baseVersion !== held.version || !apply(held.packs, delta))
return reload(spaceId);
held.version = version;
});
// false when the delta names a pack you do not hold.
function apply(packs: any[], d: any): boolean {
if (d.type === "packUpserted") {
// The pack comes without its items: keep the ones you hold.
const old = packs.find((p) => p.packId === d.pack.packId);
if (old) Object.assign(old, { ...d.pack, items: old.items });
else packs.push(d.pack);
return true;
}
if (d.type === "packsReordered") {
for (const p of packs) if (p.kind === d.kind) p.sortOrder = d.ordered.indexOf(p.packId);
return true;
}
const pack = packs.find((p) => p.packId === d.packId);
if (!pack) return false;
switch (d.type) {
case "packDeleted":
packs.splice(packs.indexOf(pack), 1);
return true;
case "itemUpserted":
pack.items = [...pack.items.filter((i: any) => i.itemId !== d.itemId), d.item];
break;
case "itemDeleted":
pack.items = pack.items.filter((i: any) => i.itemId !== d.itemId);
break;
case "itemsReordered":
for (const i of pack.items) i.sortOrder = d.ordered.indexOf(i.itemId);
break;
default:
return false; // a type added later: the list is the safe answer
}
pack.items.sort((a: any, b: any) => a.sortOrder - b.sortOrder);
return true;
} In Messages #
Bots cannot send stickers or custom emoji yet. People's messages carry them as
sticker and customEmoji entities,
but V1 of the Bot API neither accepts nor delivers them. See Message Entities for what a bot sees instead.
Limits & Permissions #
GetQuota has the current numbers. IFiles/Upload and AddItem, all parts together. IExpressions · IFiles, sliding window per bot. space_rate_limited past it. expressionsUpdate.