Custom guild emoji let a guild upload image-based expressions that are referenced as <:name:id> in messages. The asset itself is fetched only by emoji_id, while permission to insert that emoji into a message is checked against membership in the source guild.
- Global asset URL by
emoji_id - Guild-local unique names
- DB-free public media fetch
- Guild-scoped upload and moderation permissions
- Cached metadata reads for compose-time validation and user settings
Emoji metadata lives in PostgreSQL/Citus.
guild_emojisis distributed byguild_idand colocated with guild relational data.emoji_lookupis distributed byidfor direct lookup byemoji_idduring message compose.
This data intentionally stays in Citus instead of ScyllaDB because the feature depends on relational guarantees: guild-local unique names, per-guild static and animated quotas, guild membership checks, and guild-scoped delete and rename operations.
The attachments service stores three deterministic S3 objects per emoji:
emojis/{emoji_id}/master.webpemojis/{emoji_id}/96.webpemojis/{emoji_id}/44.webp
guild_id is intentionally not part of the object key. emoji_id is globally unique and the public fetch path must stay cheap.
On guild delete, the API loads all emoji IDs from Citus, removes their metadata rows, and deletes these deterministic object keys from storage.
- Allowed characters:
A-Z,a-z,0-9,- - Validation regex:
^[A-Za-z0-9-]+$ - Names are unique per guild after lowercase normalization
- Max declared upload size:
256 KB - Max source dimensions:
128x128 - Max ready static emojis per guild:
50 - Max ready animated emojis per guild:
50 - Max total active rows per guild (ready plus unexpired pending):
100
Accepted uploads are converted to WebP. Animated GIF, APNG, and animated WebP inputs stay animated after conversion.
- Upload requires guild owner,
Administrator, orCreate Expressions - Rename and delete require guild owner,
Administrator, orManage Expressions Manage Expressionsdoes not allow upload- Neither permission is granted by the default guild permission set
POST /api/v1/guild/{guild_id}/emojis
Request:
{
"name": "party-cat",
"file_size": 182331,
"content_type": "image/gif"
}Response:
{
"id": "2230469276416868352",
"guild_id": "2226022078304223200",
"name": "party-cat"
}This reserves the emoji ID, validates the name, checks guild-local uniqueness, and creates a pending row with an upload expiration time.
POST /api/v1/upload/emojis/{guild_id}/{emoji_id}
- Send the image as the raw request body
- The attachments service validates content type, declared size, actual dimensions, and placeholder expiry
- The binary is converted to WebP and three variants are written
- Final quota enforcement happens after animation detection, because static and animated limits are separate
A successful upload returns 201 Created. Re-uploading an already finalized emoji returns 204 No Content.
GET /api/v1/guild/{guild_id}/emojis
Only guild members can list emojis. The response contains ready emojis only:
[
{
"id": "2230469276416868352",
"guild_id": "2226022078304223200",
"name": "party-cat",
"animated": true
}
]PATCH /api/v1/guild/{guild_id}/emojis/{emoji_id}DELETE /api/v1/guild/{guild_id}/emojis/{emoji_id}
Rename request:
{
"name": "party-cat-fast"
}Delete removes both Citus rows and all three object variants.
GET /emoji/{emoji_id}.webp?size={n}
This route is intentionally hot-path friendly:
- no auth
- no Citus lookup
- no KeyDB lookup
- direct redirect to the deterministic S3 or CDN object key
Size selection:
- no
size: usemaster.webp - exact
44or96: use that variant - other positive values: choose the closest of
44,96, andmaster masteris treated as128for distance comparisons- ties prefer the larger variant
Examples:
/emoji/2230469276416868352.webp/emoji/2230469276416868352.webp?size=44/emoji/2230469276416868352.webp?size=80redirects to96.webp
If the object does not exist because the emoji is still pending or has been deleted, object storage returns 404.
GET /api/v1/user/me/settings now includes a top-level guild_emojis map:
{
"guild_emojis": {
"2226022078304223200": [
{
"name": "party-cat",
"id": "2230469276416868352"
}
],
"2226022078304223201": []
}
}Notes:
- The Go type is
map[int64][]EmojiRef - JSON object keys are strings on the wire
- Only ready emojis from guilds the user belongs to are included
- No separate
emoji_storage_urlsetting is needed because/emoji/{emoji_id}.webpis the public fetch entry point
The backend only rewrites canonical custom emoji tags:
- accepted form:
<:name:id> - bare
:name:is left as plain text
On message create and update:
- Parse each
<:name:id>tag - Resolve
emoji_idthrough KeyDB oremoji_lookup - Verify the emoji is ready
- Verify the sender is still a member of the emoji's source guild
Sanitization result:
- accessible emoji: rewrite to canonical
<:stored-name:id> - missing, pending, deleted, or inaccessible emoji: downgrade to
:original-name:
This lets clients optimistically insert emoji tags while the API remains the authority for access control.
KeyDB is used only for metadata reads that are hot but still permissioned:
emoji:id:{emojiId}for compose-time lookupemoji:guild:{guildId}for guild emoji lists and user settings
Default TTLs:
- lookup cache:
3600s - negative lookup cache:
60s - guild list cache:
600s
Cache entries are invalidated on finalize, rename, delete, and guild delete.
Guild subscribers receive:
Guild Emoji CreateGuild Emoji UpdateGuild Emoji Delete
See WebSocket Event Types for event numbers and payloads.