Skip to content

Stickers & Custom Emoji

Read a space's sticker and custom emoji packs, create packs of your own and fill them. 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).

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-...",
      "width": 512,
      "height": 512,
      "fileSize": 38112,
      "emoji": ["🍒"],
      "keywords": ["cherry"],
      "textColor": false,
      "sortOrder": 0,
      "creatorId": "d3b07384-...",
      "createdByBot": true
    }
  ]
}

Files travel as URLs. An item carries its file as url. 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 MiB.
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 (frames of 33 ms 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.

Forbidden in Lottie / TGS

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

Animated items. The server renders the preview of every lottie and video item itself; you send the file and nothing else. When it cannot render right now, AddItem answers 503 render_unavailable and adds nothing; send the same request again later.

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. 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. 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 adding many items and tell the user how many fit, 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.

CreateExpressions is an ordinary entitlement: add it to the bot's required entitlements on the app's Entitlements tab in the Developer Console, like any other. See Permissions & Intents.

Files #

AddItem is a multipart/form-data request. 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 field takes 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.

An AddItem request carries at most 5 MiB 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

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 "file=@wave.webp" \
     https://gateway.argon.zone/IFiles/v1/Upload

Response (BotFileV1)

{
  "fileId": "5f1d0c3a-...",
  "size": 23120,
  "contentType": "image/webp",
  "url": ".../files/5f1d0c3a-...",
  "fileName": null
}
purpose Takes
"sticker" / "emoji" PNG, WEBP, TGS, Lottie JSON or WEBM, up to 4 MiB (a plain Lottie JSON is judged by its gzipped size later).
"attachment" A file for a message, not for AddItem — see Files & Attachments.

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.

Creating Packs & Adding Items #

POST /IExpressions/v1/CreatePack takes JSON: the space, the kind, a title and a slug. It answers with the pack (BotExpressionPackV1); its packId is what AddItem needs.

CreatePack

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

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. The same slug held by someone else's pack, or by your pack of the other kind, is 409 name_taken.

POST /IExpressions/v1/AddItem adds one sticker or emoji to a pack, with the file given as described in Files. It answers with the new item (BotExpressionItemV1). More associated emoji or keywords: repeat the field (-F "emoji=🍒" -F "emoji=❤️"), or send one field holding a JSON array.

AddItem

curl -X POST \
     -H "Authorization: Bot YOUR_TOKEN" \
     -F "spaceId=aaaabbbb-..." \
     -F "packId=7c9e6679-..." \
     -F "name=Hot Cherry 1" \
     -F "emoji=🍒" \
     -F "keywords=cherry" \
     -F "file=@cherry.tgs" \
     https://gateway.argon.zone/IExpressions/v1/AddItem

AddItem is not idempotent: each call adds a new item, even with the same file, so keeping a re-import from adding duplicates is the bot's own concern.

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.

UpdatePack

curl -X PATCH \
     -H "Authorization: Bot YOUR_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"spaceId":"aaaabbbb-...","packId":"7c9e6679-...","coverItemId":"9b2f4a1e-..."}' \
     https://gateway.argon.zone/IExpressions/v1/UpdatePack

UpdateItem

curl -X PATCH \
     -H "Authorization: Bot YOUR_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"spaceId":"aaaabbbb-...","itemId":"9b2f4a1e-...","keywords":["cherry","love"]}' \
     https://gateway.argon.zone/IExpressions/v1/UpdateItem

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.

Errors #

Branch on the error code (see Error Handling). Some refusals are about one file and the next one may pass; others mean every further write fails the same way.

Status Code Meaning
400 invalid_format A title, slug, name, emoji or keyword breaks its rule, or the file does (format, dimensions, duration, frame rate, a Lottie feature). The same request is refused again.
400 invalid_request The form could not be read: a required field is missing or malformed, or an attach:// names no part.
403 insufficient_permissions No CreateExpressions in this space, or, on an update, the pack or item is not the bot's.
404 not_found The pack or item is gone (a person deleted it), or a fileId is unknown or older than 24 hours.
409 name_taken The emoji name is used in the space (or, on CreatePack, the slug). Pick another.
413 too_large The request is over its limit, or the file over its format's size.
422 quota_exceeded The pack is full, the space's slots are, the space has 10 packs, or the bot holds 100 unused uploads. GetQuota says which.
422 content_rejected Moderation refused the file. 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.
503 render_unavailable The server cannot render an animated item right now. Nothing was added; send the same request again later.

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.

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 one of the default intents a stream gets when it names none.

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-...",
      "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)

const API = "https://gateway.argon.zone";
const AUTH = { Authorization: "Bot " + process.env.ARGON_TOKEN };

// 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=" + encodeURIComponent(held.version) : "";
  const res = await fetch(API + "/IExpressions/v1/List?spaceId=" + spaceId + known, { headers: AUTH });
  const list = await res.json();
  // packs is null when the version you sent is still current.
  spaces.set(spaceId, { version: list.version, packs: list.packs ?? held!.packs });
}

// es: the bot's event stream (see Real-time Events).
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 in messages 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.

A custom emoji can be a reaction, though: IReactions/v1/AddCustom reacts with an emoji item by its itemId — see Reactions.

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 MiB · ≈ 10 MiB AddItem · IFiles/Upload, 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