Skip to content

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:

Kind What it is
"sticker" A 512 px image or animation sent as a message of its own.
"emoji" A 100×100 custom emoji used inline in text as :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.

Sticker File Max size Rules
static PNG or WEBP, a single frame 512 KB One side exactly 512 px, the other at most 512. A PNG is stored as lossless WEBP, and the WEBP has to fit the size.
lottie TGS, or plain Lottie JSON (gzipped by the server) 64 KB Canvas exactly 512×512, at most 3 s and 60 fps, none of the forbidden features. The size is the gzipped one; plain JSON may arrive up to 4 MB.
video WEBM, VP9, one video track, no audio 256 KB One side exactly 512 px, the other at most 512; at most 3 s and 30 fps (Telegram's 33 ms frames pass).
Emoji File Max size Rules
static PNG or WEBP, a single frame 128 KB Exactly 100×100. A PNG is stored as lossless WEBP.
lottie TGS, or plain Lottie JSON 64 KB Canvas exactly 100×100, at most 3 s and 60 fps, none of the forbidden features.
video WEBM, VP9, one video track, no audio 256 KB Exactly 100×100, at most 3 s and 30 fps.
Thumbnail File Max size Rules
thumb WEBP, a single frame 128 KB The first frame of an animated item, at exactly the item's width and height. Never sent for a static item. See when to send one.

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

Field Rule
name (emoji) [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.
name (sticker) 2–30 characters of plain text: no leading or trailing spaces, no control characters. Need not be unique.
emoji 0–20 associated Unicode emoji, each non-blank and at most 32 characters. Pickers search by them.
keywords Up to 20, non-blank, at most 64 characters all together. Pickers search by them too.
textColor The emoji is drawn in the text colour, like Telegram's needs_repainting. Meant for single-colour emoji; defaults to false.
title (pack) 1–64 characters of plain text.
slug (pack) [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.

Boost level 0 1 2 3
Sticker slots 6 18 36 72
Emoji slots 60 120 180 300

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.

1

Add CreateExpressions to the required entitlements. Spaces that install the bot from now on grant it at once.

2

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.

3

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:

You send Meaning
a file part named file The part is the file. The simplest form.
file=attach://clip A text field naming another part of the same request (clip). A name that matches no part returns 400 invalid_request.
file=5f1d0c3a-... The 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-..."
}
purpose Takes
"sticker" / "emoji" PNG, WEBP, TGS, Lottie JSON or WEBM, up to 4 MB (a plain Lottie JSON is judged by its gzipped size later).
"thumb" WEBP only, up to 128 KB. Anything else is 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 with invalid_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.

1

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
2

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.

3

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.

4

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"
5

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.

6

Handle refusals #

Branch on the error code (see Error Handling). Some refusals are about one sticker and the import goes on; others end it.

Status Code What to do
400 invalid_format A name, emoji or keyword breaks its rule, or the file does (dimensions, duration, frame rate, a Lottie feature, a thumb where none belongs). Skip the sticker and log it.
400 invalid_request The form could not be read: a required field is missing or malformed, or an attach:// names no part. A bug in the request — stop.
403 insufficient_permissions No 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.
404 not_found The pack is gone (a person deleted it), or a fileId is unknown or older than 24 hours. Re-create or re-upload.
409 name_taken The emoji name is used in the space (or, on CreatePack, the slug). Pick another name and retry.
413 too_large The request is over 5 MB, or the file over its format's size. Skip the sticker.
422 quota_exceeded The pack is full, the space's slots are, the space has 10 packs, or the bot holds 100 unused uploads. Stop; GetQuota says which.
422 content_rejected Moderation refused the file (an animated one is judged by its first frame). Skip it; the same file is refused again, and each refusal is recorded against the bot.
429 rate_limited This bot's window for the interface — IExpressions 60/min, IFiles 30/min. Wait Retry-After seconds and send the same request again.
429 space_rate_limited All bots together have made 120 changes in this space this minute. 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.

Field Type Description
spaceId string The space whose packs changed.
version string The token after this change — what List would return now.
baseVersion string | null The token the change was made on. Apply delta only if it equals the token you hold.
delta BotExpressionsDeltaV1 | null The change, flattened. null for a change V1 has no shape for — re-read List.
delta.type Fields set Meaning
"packUpserted" pack, packId A pack was created or changed. pack.items is empty — keep the items you hold.
"packDeleted" packId A pack and all its items were removed.
"itemUpserted" item, packId, itemId An item was added or changed.
"itemDeleted" packId, itemId An item was removed.
"packsReordered" kind, ordered ordered is every pack ID of that kind, in the new order.
"itemsReordered" packId, ordered 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 #

Limit Value Description
Packs per space 10 Stickers and emoji together.
Items per pack 120 / 200 Stickers / emoji.
Slots per space 6–72 / 60–300 Stickers / emoji, by boost level. GetQuota has the current numbers.
Request size 5 MB IFiles/Upload and AddItem, all parts together.
Uploads 100 · 24 h Unused uploads a bot may hold, and how long an upload lives.
Rate limit 60/min · 30/min IExpressions · IFiles, sliding window per bot.
Space budget 120/min Changes by all bots in one space; space_rate_limited past it.
Required permission CreateExpressions For every write. Reading needs membership only.
SSE intent Expressions (bit 14 = 16384) Required to receive expressionsUpdate.

Next Steps