Verified against Mastodon v4.7.3 source (activity/create.rb process_emoji, parser/custom_emoji_parser.rb, custom_emoji.rb, emoji_serializer.rb, activity/like.rb), FEP-c0e0 and FEP-9098 (DRAFT, Codeberg 2026-10-15) on 2026-10-15.
Custom Emoji
Custom emoji allow servers to define their own emoji that can be used in posts and display names. They're implemented using the Emoji tag type — a Mastodon extension (toot:Emoji), not part of AS2, described for other implementers in FEP-9098 (DRAFT).
Emoji Structure
Emoji Tag Format
{
"type": "Emoji",
"id": "https://example.com/emoji/blobcat",
"name": ":blobcat:",
"icon": {
"type": "Image",
"mediaType": "image/png",
"url": "https://example.com/emoji/blobcat.png"
}
}
Post with Custom Emoji
{
"type": "Note",
"content": "<p>Hello! <img src=\"https://example.com/emoji/blobcat.png\" alt=\":blobcat:\" class=\"emoji\"> How are you?</p>",
"tag": [
{
"type": "Emoji",
"id": "https://example.com/emoji/blobcat",
"name": ":blobcat:",
"icon": {
"type": "Image",
"mediaType": "image/png",
"url": "https://example.com/emoji/blobcat.png"
}
}
]
}
Emoji Properties
| Property | Description |
|---|---|
type | Always "Emoji" |
id | Unique identifier URL |
name | Shortcode with colons (e.g., :blobcat:) |
icon | Image object with the emoji graphic; Mastodon reads only icon.url |
updated | When the emoji was last modified (optional); Mastodon re-downloads when it is newer |
This is what Mastodon sends (emoji_serializer.rb) and reads (custom_emoji_parser.rb).
Storing Custom Emoji
Database Schema
const emojiSchema = {
shortcode: String, // 'blobcat' (without colons)
domain: String, // null for local, domain for remote
url: String, // Image URL (our copy, also for remote emoji)
mediaType: String, // image/png, image/gif or image/webp
remoteUrl: String, // remote emoji: the icon.url we copied from
staticUrl: String, // Static version for animated emoji
visible: Boolean, // Show in emoji picker
category: String, // Optional category
createdAt: Date,
updatedAt: Date
};
Adding Local Emoji
async function addEmoji(shortcode, imageFile, options = {}) {
// Same rule as Mastodon: 2+ chars of letters, digits and underscores
if (!/^[a-zA-Z0-9_]{2,128}$/.test(shortcode)) {
throw new Error('Invalid shortcode: 2-128 letters, numbers or underscores');
}
// Check for duplicates
const existing = await db.emoji.findOne({
shortcode,
domain: null
});
if (existing) {
throw new Error('Emoji shortcode already exists');
}
// Detect the real format (don't trust the declared MIME type)
const meta = await sharp(imageFile.buffer).metadata();
if (!['png', 'gif', 'webp'].includes(meta.format)) {
throw new Error('Emoji must be PNG, GIF or WebP'); // Mastodon's allowed types
}
const animated = (meta.pages || 1) > 1;
const fit = { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } };
// Keep animation in the main image; also make a static PNG, as Mastodon does
const filename = `emoji/${shortcode}.${animated ? meta.format : 'png'}`;
const main = sharp(imageFile.buffer, { animated }).resize(128, 128, fit);
await (animated ? main.toFormat(meta.format) : main.png()).toFile(filename);
const staticFilename = `emoji/${shortcode}_static.png`;
await sharp(imageFile.buffer).resize(128, 128, fit).png().toFile(staticFilename);
const staticUrl = `https://${config.domain}/${staticFilename}`;
const emoji = {
shortcode,
domain: null,
url: `https://${config.domain}/${filename}`,
mediaType: `image/${animated ? meta.format : 'png'}`,
staticUrl,
visible: options.visible !== false,
category: options.category || null,
createdAt: new Date(),
updatedAt: new Date()
};
await db.emoji.insert(emoji);
return emoji;
}
Using Emoji in Posts
Parsing Emoji Shortcodes
Mastodon only recognises :shortcode: when it is not glued to other letters, digits or colons (CustomEmoji::SCAN_RE):
function parseEmoji(text) {
const emojiRegex = /(?<=^|[^\p{L}\p{N}:]):([a-zA-Z0-9_]{2,}):(?=$|[^\p{L}\p{N}:])/gmu;
const shortcodes = [];
let match;
while ((match = emojiRegex.exec(text)) !== null) {
shortcodes.push(match[1]);
}
return [...new Set(shortcodes)];
}
Building Emoji Tags
async function buildEmojiTags(text) {
const shortcodes = parseEmoji(text);
const tags = [];
for (const shortcode of shortcodes) {
const emoji = await db.emoji.findOne({
shortcode,
domain: null // Only local emoji
});
if (emoji) {
tags.push({
type: 'Emoji',
id: `https://${config.domain}/emoji/${shortcode}`,
name: `:${shortcode}:`,
updated: emoji.updatedAt.toISOString(),
icon: {
type: 'Image',
mediaType: emoji.mediaType, // image/png, image/gif or image/webp
url: emoji.url
}
});
}
}
return tags;
}
Converting to HTML
For your own posts (text you escaped yourself), replacing shortcodes is safe — escape the values anyway:
function escapeHtml(str) {
return String(str ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
}
function emojiToHtml(escapedText, tags) {
let html = escapedText;
for (const emoji of [].concat(tags || []).filter(t => t?.type === 'Emoji')) {
const shortcode = escapeHtml(emoji.name); // :shortcode:
const img = `<img src="${escapeHtml(emoji.icon.url)}" alt="${shortcode}" title="${shortcode}" class="emoji" draggable="false">`;
html = html.split(shortcode).join(img);
}
return html;
}
Mastodon itself sends the plain :shortcode: text in content and lets each receiver render the image.
Complete Post Creation
async function createPostWithEmoji(author, content, options = {}) {
// Build all tags
const emojiTags = await buildEmojiTags(content);
const mentionTags = await buildMentionTags(content);
const hashtagTags = buildHashtagTags(content);
// Convert content to HTML with emoji
let html = escapeHtml(content);
html = await emojiToHtml(html, emojiTags);
html = mentionsToHtml(html, mentionTags);
html = hashtagsToHtml(html, config.domain);
html = `<p>${html}</p>`;
const note = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `https://${config.domain}/notes/${uuid()}`,
type: 'Note',
attributedTo: author.id,
content: html,
published: new Date().toISOString(),
tag: [...emojiTags, ...mentionTags, ...hashtagTags],
to: options.to || ['https://www.w3.org/ns/activitystreams#Public'],
cc: options.cc || [author.followers]
};
await db.notes.insert(note);
return note;
}
Processing Remote Emoji
Caching Remote Emoji
Namespace remote emoji by the domain of the actor who sent them, never by the tag's id or icon.url. Both are attacker-controlled: if you key on new URL(tag.id).hostname, any server can send {"id": "https://victim.example/emoji/blobcat", ...} and replace :blobcat: for every post from victim.example. Mastodon stores remote emoji under the sending account's domain (create.rb process_emoji).
async function processIncomingEmoji(tags, senderActor) {
const domain = new URL(senderActor.id).hostname; // the verified sender
const emojiTags = [].concat(tags || []).filter(t => t?.type === 'Emoji');
for (const tag of emojiTags) {
const shortcode = typeof tag.name === 'string' ? tag.name.replace(/:/g, '') : '';
const iconUrl = tag.icon?.url;
if (!/^[a-zA-Z0-9_]{2,}$/.test(shortcode) || !iconUrl) continue;
if (new URL(iconUrl).protocol !== 'https:') continue;
const existing = await db.emoji.findOne({ shortcode, domain });
const updated = tag.updated ? new Date(tag.updated) : null;
// Same rule as Mastodon: (re)fetch if new, if the image URL changed,
// or if `updated` is newer than our copy
if (existing && existing.remoteUrl === iconUrl && !(updated > existing.updatedAt)) continue;
// Download and re-host the image (SSRF-safe fetch, size cap, decode
// and re-encode) instead of hot-linking a remote URL
const url = await cacheRemoteEmojiImage(iconUrl);
if (!url) continue;
await db.emoji.upsert({ shortcode, domain }, {
$set: { remoteUrl: iconUrl, url, uri: tag.id, visible: false, updatedAt: new Date() },
});
}
}
Rendering Remote Emoji
Sanitize the remote content first, then replace shortcodes only in text nodes (a blind string replace can land inside an attribute such as title=":blobcat:"), using your cached copy keyed by the author's domain:
async function renderEmojiInto(sanitizedFragment, note, authorDomain) {
const shortcodes = new Set(
[].concat(note.tag || []).filter(t => t?.type === 'Emoji').map(t => t.name)
);
// `sanitizedFragment` is a DOM fragment from your HTML sanitizer
for (const textNode of textNodesOf(sanitizedFragment)) {
for (const name of shortcodes) {
if (!textNode.data.includes(name)) continue;
const cached = await db.emoji.findOne({ shortcode: name.replace(/:/g, ''), domain: authorDomain });
if (!cached) continue;
replaceTextWithImg(textNode, name, { src: cached.url, alt: name, className: 'emoji' });
}
}
}
Emoji in Display Names
Actors can use emoji in their display name:
{
"type": "Person",
"id": "https://example.com/users/alice",
"name": "Alice :verified:",
"tag": [
{
"type": "Emoji",
"name": ":verified:",
"icon": {
"type": "Image",
"url": "https://example.com/emoji/verified.png"
}
}
]
}
Processing Actor Emoji
async function renderActorName(actor) {
// The name is plain text: escape it, then swap in cached emoji images
let name = escapeHtml(actor.name || actor.preferredUsername);
const domain = new URL(actor.id).hostname;
for (const emoji of [].concat(actor.tag || []).filter(t => t?.type === 'Emoji')) {
const cached = await db.emoji.findOne({ shortcode: String(emoji.name).replace(/:/g, ''), domain });
if (!cached) continue;
const alt = escapeHtml(emoji.name);
name = name.split(alt).join(`<img src="${escapeHtml(cached.url)}" alt="${alt}" class="emoji">`);
}
return name;
}
Emoji API
List Available Emoji
app.get('/api/custom_emojis', async (req, res) => {
const emoji = await db.emoji.find({
domain: null,
visible: true
});
res.json(emoji.map(e => ({
shortcode: e.shortcode,
url: e.url,
static_url: e.staticUrl || e.url,
visible_in_picker: e.visible,
category: e.category
})));
});
Emoji Picker Data
async function getEmojiPicker() {
const emoji = await db.emoji.find({
domain: null,
visible: true
}).sort({ category: 1, shortcode: 1 });
// Group by category
const categories = {};
for (const e of emoji) {
const cat = e.category || 'Custom';
if (!categories[cat]) {
categories[cat] = [];
}
categories[cat].push({
shortcode: e.shortcode,
url: e.staticUrl || e.url
});
}
return categories;
}
Emoji Reactions
Emoji reactions are described in FEP-c0e0 (DRAFT), based on Misskey, Pleroma and Fedibird; Hollo, Mitra, PieFed and Iceshrimp.NET have announced support. A reaction is an EmojiReact (http://litepub.social/ns#EmojiReact) whose content is a single Unicode emoji or a :shortcode:; a custom emoji is carried as one Emoji in tag:
{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"toot": "http://joinmastodon.org/ns#",
"Emoji": "toot:Emoji",
"litepub": "http://litepub.social/ns#",
"EmojiReact": "litepub:EmojiReact"
}
],
"type": "EmojiReact",
"id": "https://example.com/activities/react-1",
"actor": "https://example.com/users/bob",
"object": "https://other.example/notes/123",
"content": ":blobcat:",
"tag": [
{
"type": "Emoji",
"id": "https://example.com/emoji/blobcat",
"name": ":blobcat:",
"icon": {
"type": "Image",
"mediaType": "image/png",
"url": "https://example.com/emoji/blobcat.png"
}
}
],
"to": ["https://other.example/users/alice"]
}
A reaction can also be sent as a Like with content, which non-supporting software treats as a plain like; FEP-c0e0 says implementations MUST treat such a Like like an EmojiReact. Reactions are retracted with Undo.
:::note Mastodon
Mastodon (4.7.3) has no EmojiReact handler and treats a Like with content as an ordinary favourite (like.rb). Its toot:Emoji term is still the one reactions use.
:::
CSS Styling
.emoji {
width: 1.2em;
height: 1.2em;
vertical-align: middle;
object-fit: contain;
margin: 0 0.1em;
}
/* Larger emoji in picker */
.emoji-picker .emoji {
width: 32px;
height: 32px;
cursor: pointer;
}
/* Animated emoji - pause on reduced motion */
@media (prefers-reduced-motion: reduce) {
.emoji {
animation: none;
}
}
Common Issues
Shortcode Extraction
Extract shortcode correctly from the name:
function extractShortcode(name) {
// Handle ":shortcode:" format
return name.replace(/^:/, '').replace(/:$/, '');
}
Missing Icon Data
Always send icon as an Image with a url: Mastodon reads only icon.url and skips emoji without it.
Case Sensitivity
Mastodon matches shortcodes exactly (case-sensitive), unique per domain. Look them up by exact value — and never build a regular expression from a shortcode you received:
async function findEmoji(shortcode, domain) {
return db.emoji.findOne({ shortcode, domain });
}
Next Steps
- Account Migration - Moving accounts
- Content Moderation - Handling abuse
- Mastodon Compatibility - Platform specifics