Meme link API
Build a meme with a URL. No account or API key is required. Get the ordered template catalog from /api/templates: slug, name, lines (caption slots), example_url, example_page_url, url_template, template_url (editor), and image_url (the same rendered meme as example_url).
Use https://memepression.com/TEMPLATE/LINE1/LINE2 for the HTML card.
Add .jpg or .png to the last segment for an image.
Use the template's caption slot order; panel templates may have more than two slots.
Caption encoding
Encode each caption using this table, then percent-encode each URL segment (including Unicode and emoji). Decoding reads left to right, longest match first.
| URL token | Caption character |
|---|---|
__ | _ |
-- | - |
'' | " |
~n | newline |
~q | ? |
~a | & |
~p | % |
~h | # |
~s | / |
~b | \ |
~l | < |
~g | > |
A single _ or - separates words (a space). An empty segment or a segment exactly
_ is a blank caption. Builders drop trailing blank captions and keep at least one segment.
Normalization collapses spaces and tabs and trims them on each line. Curly quotes ‘’ become
', “” become ", and the en dash – becomes -.
A literal tilde followed by n q a p h s b l g is ambiguous and decodes as that escape;
unknown tilde sequences stay literal.
Limits and caching
Each caption allows up to 100 characters. Unrepresentable captions are rejected with
400; builders return null. This includes dot segments, ambiguous escape combinations, and a final caption
ending in lowercase .jpg or .png, which would be interpreted as an image extension.
New image renders are limited to 30 per minute per client. Cached images do not count. A rate-limited render returns 429 with Retry-After; a busy renderer returns 503 with Retry-After. The catalog needs no render and is public-cacheable for 300 seconds. Use its ETag with If-None-Match for a 304 response. X-Link-Meme-Render-Limit reports the effective render limit on both 200 and 304 responses.
Unfurling and examples
Just paste a bare page URL into Discord or Slack: their bots fetch the HTML card and its preview image. Reply with the bare page URL, without markdown or angle brackets, to allow unfurling. Use an image URL for markdown images:

curl 'https://memepression.com/api/templates'
curl 'https://memepression.com/drake/top_text/bottom_text.jpg' --output meme.jpg
Discord (discord.js v14)
Run this ES module in a bot with discord.js v14 available. It registers /meme template top bottom.
Choose a slug from the catalog. Replace the obvious placeholders with your own bot credentials.
const escapes = {
"_": "__", "-": "--", '"': "''", "\n": "~n", "?": "~q", "&": "~a",
"%": "~p", "#": "~h", "/": "~s", "\\": "~b", "<": "~l", ">": "~g", " ": "_"
}
const typography = { "‘": "'", "’": "'", "“": '"', "”": '"', "–": "-" }
const decodeEscapes = Object.fromEntries(Object.entries(escapes).map(([line, encoded]) => [encoded, line]))
const normalizeLine = line => line.replace(/[ \t]+/g, " ").split("\n")
.map(part => part.replace(/^ | $/g, "")).join("\n")
// Match LinkMeme.encode, including its normalization and literal ~ escape ambiguity.
export function encodeLine(line) {
line = normalizeLine(line)
if ([...line].length > 100 || line === "." || line === "..") return null
line = [...line].map(char => typography[char] ?? char).join("")
const encoded = line ? [...line].map(char => escapes[char] ?? char).join("") : "_"
const decoded = encoded === "_" ? "" : encoded.replace(/__|--|''|~[nqaphsblg]|[_-]/g,
token => decodeEscapes[token])
return decoded === line.replace(/~[nqaphsblg]/g, token => decodeEscapes[token]) ? encoded : null
}
function pathLines(lines) {
lines = lines.map(normalizeLine)
while (lines.length && /^\p{White_Space}*$/u.test(lines.at(-1))) lines.pop()
if (!lines.length) lines.push("")
return lines
}
export function memeLinkError(lines) {
if (lines.some(line => [...line].length > 100)) return "Meme links allow up to 100 characters per line."
const encoded = pathLines(lines).map(encodeLine)
if (encoded.includes(null) || (encoded.at(-1).endsWith(".jpg") || encoded.at(-1).endsWith(".png")))
return "This caption can't be expressed as a meme link."
return null
}
export function memeLinkPath(slug, lines) {
if (memeLinkError(lines)) return null
const encoded = pathLines(lines).map(encodeLine)
// Match Ruby's URI.encode_www_form_component for the canonical path.
return `/${slug}/${encoded.map(line => encodeURIComponent(line).replace(/[!'()~]/g,
char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)).join("/")}`
}
export function buildMemeUrl(template, lines) {
const path = memeLinkPath(template, lines)
return path === null ? null : "https://memepression.com" + path
}
// Discord bot setup (discord.js v14; save as meme.mjs).
import { Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder } from "discord.js"
const command = new SlashCommandBuilder().setName("meme").setDescription("Make a meme")
.addStringOption(option => option.setName("template").setDescription("Template slug").setRequired(true))
.addStringOption(option => option.setName("top").setDescription("Top caption").setRequired(true))
.addStringOption(option => option.setName("bottom").setDescription("Bottom caption"))
const rest = new REST({ version: "10" }).setToken("YOUR_BOT_TOKEN")
await rest.put(Routes.applicationCommands("YOUR_APPLICATION_ID"), { body: [command.toJSON()] })
const client = new Client({ intents: [GatewayIntentBits.Guilds] })
client.on("interactionCreate", async interaction => {
if (!interaction.isChatInputCommand() || interaction.commandName !== "meme") return
const url = buildMemeUrl(interaction.options.getString("template"),
[interaction.options.getString("top"), interaction.options.getString("bottom") ?? ""])
if (url === null) {
await interaction.reply({ content: "These captions cannot be expressed as a meme link (100 characters per line maximum).", ephemeral: true })
return
}
await interaction.reply(url)
})
client.login("YOUR_BOT_TOKEN")
Slack (Bolt for JS)
Run this ES module with Bolt available and configure the /meme slash command.
Use /meme drake | top caption | bottom caption; the pipe separates captions.
The reply uses response_type: in_channel so the channel can see and unfurl the page URL.
const escapes = {
"_": "__", "-": "--", '"': "''", "\n": "~n", "?": "~q", "&": "~a",
"%": "~p", "#": "~h", "/": "~s", "\\": "~b", "<": "~l", ">": "~g", " ": "_"
}
const typography = { "‘": "'", "’": "'", "“": '"', "”": '"', "–": "-" }
const decodeEscapes = Object.fromEntries(Object.entries(escapes).map(([line, encoded]) => [encoded, line]))
const normalizeLine = line => line.replace(/[ \t]+/g, " ").split("\n")
.map(part => part.replace(/^ | $/g, "")).join("\n")
// Match LinkMeme.encode, including its normalization and literal ~ escape ambiguity.
export function encodeLine(line) {
line = normalizeLine(line)
if ([...line].length > 100 || line === "." || line === "..") return null
line = [...line].map(char => typography[char] ?? char).join("")
const encoded = line ? [...line].map(char => escapes[char] ?? char).join("") : "_"
const decoded = encoded === "_" ? "" : encoded.replace(/__|--|''|~[nqaphsblg]|[_-]/g,
token => decodeEscapes[token])
return decoded === line.replace(/~[nqaphsblg]/g, token => decodeEscapes[token]) ? encoded : null
}
function pathLines(lines) {
lines = lines.map(normalizeLine)
while (lines.length && /^\p{White_Space}*$/u.test(lines.at(-1))) lines.pop()
if (!lines.length) lines.push("")
return lines
}
export function memeLinkError(lines) {
if (lines.some(line => [...line].length > 100)) return "Meme links allow up to 100 characters per line."
const encoded = pathLines(lines).map(encodeLine)
if (encoded.includes(null) || (encoded.at(-1).endsWith(".jpg") || encoded.at(-1).endsWith(".png")))
return "This caption can't be expressed as a meme link."
return null
}
export function memeLinkPath(slug, lines) {
if (memeLinkError(lines)) return null
const encoded = pathLines(lines).map(encodeLine)
// Match Ruby's URI.encode_www_form_component for the canonical path.
return `/${slug}/${encoded.map(line => encodeURIComponent(line).replace(/[!'()~]/g,
char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)).join("/")}`
}
export function buildMemeUrl(template, lines) {
const path = memeLinkPath(template, lines)
return path === null ? null : "https://memepression.com" + path
}
// Slack bot setup (Bolt for JS; save as meme.mjs).
import bolt from "@slack/bolt"
const app = new bolt.App({ token: "YOUR_BOT_TOKEN", signingSecret: "YOUR_SIGNING_SECRET" })
// Configure /meme to send requests to this app's /slack/events endpoint.
// Usage: /meme template | top caption | bottom caption
app.command("/meme", async ({ command, ack, respond }) => {
await ack()
const [template, top = "", bottom = ""] = command.text.split("|").map(part => part.trim())
const url = buildMemeUrl(template, [top, bottom])
if (url === null) {
await respond({ response_type: "ephemeral", text: "These captions cannot be expressed as a meme link (100 characters per line maximum)." })
return
}
await respond({ response_type: "in_channel", text: url })
})
await app.start(3000)