Yapper
TermsPrivacyGuidelinesBugsDownload

Make a Yapper bot

Last updated 16 October 2026

A bot is an account your code drives. It runs on your own computer (nothing to host) and talks to Yapper over plain HTTPS. Free.

Reference: every endpoint at /developers/api, or the OpenAPI 3.1 file. Roadmap: /roadmap. Bots people made: /bots. Rule one: bots use bot accounts. Automating your own account (a selfbot) is against the Terms.

Quick start, 5 minutes

  1. Make the bot. In Yapper: Settings → Developer → type a name → Create. Copy the token, it is shown once. Never post it anywhere.
  2. Add it to a server you manage. Same screen, or Server settings → Bots → Your bots → Add. Your own bots work in your own servers right away.
  3. Run it. Make a folder, save yapper.js and starter.js into it, paste the token into starter.js, then in a terminal in that folder:
    node starter.js
    You need Node 18 or newer. Python instead? starter.py needs nothing installed: python starter.py.
  4. In the server, type /ping. The bot answers pong. The terminal shows what it receives.

Give it a picture in Settings → Developer → Manage → Picture, or from code with bot.setAvatar('./avatar.png').

Stuck? Settings → Developer has a Send test event button: if your bot prints the test, it is wired up. The Yapper Developers server is at yapper.website/invite/developers.

The SDK

import { Bot } from './yapper.js';
const bot = new Bot('yb_…');                    // your token

// Commands: people type /name (or !name). They show up in the "/" menu for everyone in the server.
bot.command('ping', 'Is the bot alive?', (msg) => msg.reply('pong'));
bot.command('roll', 'Roll a die, like /roll 20', (msg) => {
  const sides = Number(msg.argv[0]) || 6;        // msg.args = "20", msg.argv = ["20"]
  msg.reply(`🎲 ${1 + Math.floor(Math.random() * sides)}`);
});

bot.on('message', (msg) => {                    // every message in the bot's servers (not its own)
  if (msg.mentioned) msg.reply(`hey ${msg.authorMention}`);
});
bot.on('member_join', (ev) => bot.send(someChannelId, `welcome <@${ev.user_id}>, member #${ev.member_count}`));
bot.on('member_leave', (ev) => console.log(ev.username, 'left'));
bot.on('ready', (me) => console.log('online as', me.username));
bot.on('error', (e) => console.error(e));

bot.start();                                    // registers the commands, then listens until you stop it
CallWhat it does
new Bot(token, opts?)opts.prefixes (default ['/', '!']), opts.log (default true), opts.ignoreBots (default true: messages from other bots and webhooks are skipped).
bot.command(name, description, fn)Registers a command. Names: 1 to 32 lowercase letters, numbers, - or _. Up to 100.
bot.on(event, fn)message, command, member_join, member_leave, reaction_add, reaction_remove, voice_join, voice_leave, interaction, test, ready, error.
bot.send(channelId, text, { replyTo, embeds, components })Posts a message. Returns it. Up to 2000 characters, markdown works, mention with <@user id>, link a channel with <#channel id>. Add up to 5 embeds; text can be empty then. components: up to 5 rows of buttons.
bot.onButton(customId, fn) / new Row(…) / Button.primary(label, id)Buttons and their clicks, see Buttons. customId can be a string or a RegExp like /^vote:/.
bot.sendEmbed(channelId, embed) / new Embed()Send a card. new Embed().title(…).description(…).color('#5865f2').field(name, value, inline).image(url).footer(text). msg.reply('', { embeds: [e] }) works too.
bot.delete(channelId, messageId)Deletes one of the bot's own messages.
bot.servers() / bot.members(serverId, after?)What the bot can see. Members come 200 a page (the API takes limit up to 500); pass the last user id as after for the next page.
bot.channels(serverId?, { type }) / bot.categories(serverId) / bot.channel(id)Channels the bot can see, in sidebar order: id, name, type, category, position, private, read_only, slowmode. Leave out the server for every server at once; type: 'text' for text only.
bot.messages(channelId, limit?) / bot.user(userId)Recent messages (oldest first, with authors), someone's public profile.
bot.setAvatar(file, type?)The bot's profile picture: a file path ('./avatar.png'), a Blob, or bytes plus the type. PNG, JPG, GIF or WebP, 8 MB max. Once is enough, it sticks.
bot.setProfile({ displayName, bio })Display name (32 characters) and bio (190).
bot.setBanner(file, type?) / bot.setStatus(status, activity?)Banner like the avatar. Status online, idle or dnd plus an activity line, kept while the bot polls.
bot.edit(ch, msg, text, { embeds, components }) / bot.message(ch, msg) / bot.messagesAfter(ch, time)Edit the bot's own message (pass null as text to keep it, components: null removes the buttons), fetch one by id, read forward from a time.
bot.bulkDelete(ch, ids) / bot.pin(ch, msg) / bot.unpin(ch, msg)Up to 100 at once. Other people's messages and pins need the Manage messages permission.
bot.react(ch, msg, emoji) / bot.unreact(…) / bot.reactions(ch, msg)React with a unicode emoji. Events reaction_add / reaction_remove tell you when others do.
bot.server(id) / bot.roles(id) / bot.member(id, userId)Server details, its roles, one member with nickname, role ids and timeout.
bot.addRole / bot.removeRole(server, user, role) / bot.setNickname(server, user, nick)Manage roles, Manage nicknames.
bot.kick / bot.ban / bot.unban(server, user) / bot.bans(server) / bot.timeout(server, user, minutes, reason?)Kick, Ban and Timeout permissions. Minutes 0 clears a timeout.
bot.createChannel(server, name, { kind, categoryId }) / bot.editChannel(ch, {…}) / bot.deleteChannel(ch)Manage channels. Edit takes name, categoryId, private, roleIds, readOnly, postRoleIds, slowmode.
bot.createCategory(server, name) / bot.deleteCategory(server, id)Manage channels.
bot.voice(server) / bot.moveMember(server, user, ch) / bot.disconnectMember(server, user)Who is in voice ([{ channel_id, user_ids }]), move someone to another voice channel, drop them from voice. Move Members. See Voice, in testing.
bot.dm(userId) / bot.dmSend(userId, text)Opens a DM (their DM setting applies, like for a person) and returns the channel id.
bot.api(method, path, body?)Any endpoint below, with the token added for you.
bot.stop()Stops listening after the current poll.

A msg has every field of the message event (below) plus reply(text) (quotes the message), say(text) (same channel, no quote), authorMention, and for commands command, args (the rest of the line) and argv (split on spaces).

How it works

Yapper keeps every event for your bot for a day: new messages in its servers, joins, leaves, tests. Your bot asks GET /events?after=<last id>&wait=25; the call returns as soon as there is something, or after 25 seconds with an empty list. The SDK runs that loop for you. Events come in order and each id once; if a poll fails, ask again with the same after and nothing is lost. No public URL, no port forwarding, works on a laptop behind any router. If you do host the bot somewhere, set a webhook URL in Settings → Developer and the same events are also POSTed there (see Webhooks).

Publishing to Community bots

Your bot works in your own servers from the start. To let other servers add it, write a description and press Publish for review in Settings → Developer. Yapper checks every bot by hand (Yapper Plus members are checked first). Once approved it appears in Server settings → Bots → Community bots for every server manager, and anyone can add it by its ID. If it is sent back you see why, fix it and submit again.

Permissions

A bot is a member. Everything it does beyond reading and posting is checked with the bot's own roles, the same way as for a person: give the bot a role with Manage messages and it can delete and pin, Manage roles and it can hand out roles, Kick, Ban, Timeout, Manage channels, Manage nicknames, Move Members likewise. Server settings → Members → the bot → roles. Without a role it can read, post, react and DM.

REST API

Base URL https://www.yapper.website/api/bot/v1. Every request sends Authorization: Bot <token>. Bodies and answers are JSON, ids are UUIDs.

MethodPathWhat it does
GET/events?after=&wait=Events with id above after. after=0 returns no events and the newest id as last, so start there; to catch up after a restart, save last and pass it back. wait up to 25 s. Returns { events: [{ id, type, data, created_at }], last }. Pass last back as after.
GET / PUT/commandsRead or replace the bot's commands: { "commands": [{ "name": "ping", "description": "…" }] }. PUT replaces the whole list. Up to 100.
GET/meThe bot's own profile and settings.
PATCH/me{ "display_name": "…", "bio": "…" }
PUT/me/avatarThe image is the request body with its Content-Type (image/png, image/jpeg, image/gif, image/webp), or multipart field file. 8 MB max, checked like every upload. DELETE removes it.
GET/serversServers the bot was added to.
GET/servers/:id/channels?type=text|voiceChannels in a server the bot can see, in sidebar order, plus categories. Each: { id, name, type, server_id, position, category: { id, name, position } | null, category_id, private, read_only, slowmode }. Private channels only show if the bot or one of its roles was let in.
GET/channels?type=The same list for every server the bot is in.
GET/servers/:id/members?limit=200&after=:userIdMembers, sorted by user id: 200 a page by default, up to 500 with limit.
GET/channels/:id/messages?limit=50&before=:isoTimeRecent messages, oldest first, plus an authors list. Every message has an id; the id of what you posted comes back from POST.
POST/channels/:id/messagesSend { "content": "hi", "reply_to": "<message id>", "embeds": [ … ] }. Up to 2000 characters, 30 messages a minute per bot. content or embeds is required. Add "components" for buttons. PATCH takes the same content / embeds / components (null removes them).
DELETE/channels/:id/messages/:messageIdDelete one of the bot's own messages.
POST/interactions/:id/:token/callbackAnswer a button click: { "type": "reply" | "ephemeral" | "update" | "ack", "content", "embeds", "components" }. See Buttons.
GET/users/:idSomeone's public profile.
POST/users/:id/dmOpen or find a DM with them. Returns { channel_id }; post there like any channel.
PUT/me/banner, /me/statusBanner like the avatar. Status: { "status": "dnd", "activity": "Moderating" }.
GET/servers/:id, /servers/:id/roles, /servers/:id/members/:uidServer details; roles; one member with roles (ids), nickname, timeout. The members list carries roles too.
GET/channels/:id/messages?after=:isoTimeForward from a time, oldest first. /channels/:id/messages/:mid fetches one.
PATCH/channels/:id/messages/:midEdit the bot's own message: { "content": "…", "embeds": [ … ], "components": [ … ] }. Leave a key out to keep it. Changing only the buttons doesn't mark the message edited.
POST/channels/:id/messages/bulk-delete{ "ids": […] }, up to 100. Returns deleted and skipped.
PUT / DELETE/channels/:id/messages/:mid/pinPin or unpin (Manage messages).
GET / PUT / DELETE/channels/:id/messages/:mid/reactions[/:emoji]List reactions, add or remove the bot's own. URL-encode the emoji.
PUT / DELETE/servers/:id/members/:uid/roles/:ridGive or take a role (Manage roles).
PATCH/servers/:id/members/:uid{ "nickname": "…" }, empty clears (Manage nicknames).
POST/servers/:id/kick, /servers/:id/bans, /servers/:id/timeouts{ "user_id" }; timeouts also minutes (0 clears) and reason. GET /servers/:id/bans lists, DELETE /servers/:id/bans/:uid unbans.
POST/servers/:id/channels, /servers/:id/categories{ "name", "kind": "text"|"voice", "category_id" } / { "name" }. DELETE /channels/:id, /servers/:id/categories/:cid.
PATCH/channels/:idname, category_id, private + role_ids, read_only + post_role_ids, slowmode (seconds).
GET/servers/:id/voiceWho is in voice: { voice: [{ channel_id, user_ids }] } for every voice channel the bot can see. In testing, see Voice.
POST/servers/:id/voice/move, /servers/:id/voice/disconnect{ "user_id", "channel_id" } / { "user_id" }. Move someone to another voice channel, or drop them from voice (Move Members).

CORS is open, so a bot can run in a browser page too (keep the token out of anything public).

Errors come back as { "error": "…" }: 400 bad input, 401 wrong or missing token, 403 not allowed (the bot is not in that server, or its roles lack the permission), 404 not found or not visible, 429 slow down. A 429 that has a Retry-After header (seconds) is safe to retry after that wait (the SDK does it for you); one without it won't go through by waiting (like the same message twice).

Need an id? Turn on Settings → Advanced → Developer Mode, then right-click a server, channel, category, message or person and pick Copy … ID.

Events

Every event has channel_id and server_id. channel_id is null for events that don't happen in a channel (members joining or leaving); server_id is null in DMs.

typedata
messageid, channel_id, channel_name, server_id, author_id, author, content, attachment, reply_to_id, created_at, mentioned, author_bot (true when another bot or a webhook sent it; skip those so two bots don't answer each other forever). When the message starts with one of your registered commands: command and args too. Mentions in content look like <@user id>. The bot's own messages are not sent to it.
member_joinserver_id, channel_id (null), user_id, username, joined_at, member_count
member_leaveserver_id, channel_id (null), user_id, username, member_count
reaction_add / reaction_removemessage_id, channel_id, server_id, user_id, username, emoji, message_author_id. Reaction roles in a few lines: on reaction_add with the right message and emoji, bot.addRole(…).
interactionid, token, type ("button"), custom_id, message_id, channel_id, server_id, user_id, username, expires_at. Someone clicked one of the bot's buttons. Answer with POST /interactions/:id/:token/callback, see Buttons.
voice_join / voice_leaveuser_id, channel_id, server_id. Someone joined or left a voice channel the bot can see. A move is a voice_leave plus a voice_join (in either order), and a dropped connection that comes back sends both too. In testing, see Voice.
testSent by the "Send test event" button in Settings → Developer.

Bot profile

Owners can edit all of this without code in the Developer Portal (Settings → Developer → your bot → Profile), with a live preview. From code:

CallWhat it does
PATCH /me { "display_name": "Dice Bot", "bio": "Rolls dice. /roll 2d6" }Name up to 32 characters, bio up to 190. Same filters as people's profiles. SDK: bot.setProfile({ displayName, bio }).
PUT /me/avatarThe picture: the image as the request body (Content-Type: image/png) or multipart field file. PNG, JPG, GIF or WebP, up to 8 MB. Checked before it shows. SDK: bot.setAvatar('./avatar.png').
PUT /me/bannerThe banner, same way, up to 10 MB. SDK: bot.setBanner('./banner.png').
DELETE /me/avatar, DELETE /me/bannerRemove them.
PUT /me/status { "status": "online", "activity": "Playing chess" }Status (online, idle, dnd) and an activity line. SDK: bot.setStatus('online', 'Playing chess').
GET /meThe profile as it is now, with avatar_url and banner_url.
curl -X PATCH https://www.yapper.website/api/bot/v1/me -H "Authorization: Bot $TOKEN" -H "Content-Type: application/json"   -d '{"display_name":"Dice Bot","bio":"Rolls dice. /roll 2d6"}'
curl -X PUT https://www.yapper.website/api/bot/v1/me/avatar -H "Authorization: Bot $TOKEN" -H "Content-Type: image/png" --data-binary @avatar.png

Embeds

Bot messages can carry up to 5 cards. Unknown keys are ignored; anything over a limit is cut, and a picture from a host we don't allow is an error so you find out.

{
  "content": "",
  "embeds": [{
    "title": "Match result",                // 256
    "url": "https://example.com/match/42",
    "description": "**Blue** won 3-1. [Replay](https://example.com/r/42)",   // 4096, markdown + [masked](links)
    "color": "#5865f2",                     // or 0x5865f2
    "author": { "name": "Score Bot", "icon_url": "https://i.imgur.com/abc.png" },
    "fields": [                             // up to 10; inline ones sit 3 to a row
      { "name": "MVP", "value": "<@user id>", "inline": true },
      { "name": "Time", "value": "34:12", "inline": true }
    ],
    "thumbnail": { "url": "https://i.imgur.com/thumb.png" },
    "image": { "url": "https://media.tenor.com/x.gif" },
    "footer": { "text": "Season 2", "icon_url": "https://i.imgur.com/s2.png" },
    "timestamp": "2026-10-16T12:00:00Z"
  }]
}

Limits: 6000 characters across one message's embeds. Pictures must be https links to Yapper uploads or i.imgur.com, media.tenor.com, media.giphy.com, i.giphy.com, upload.wikimedia.org, raw.githubusercontent.com, avatars.githubusercontent.com, user-images.githubusercontent.com, i.ytimg.com, cdn.discordapp.com, media.discordapp.net, pbs.twimg.com, i.redd.it, static-cdn.jtvnw.net, i.scdn.co, s4.anilist.co, cdn.myanimelist.net, nekos.best, cdn.weeb.sh.

Buttons

Bot messages can carry up to 5 rows of up to 5 buttons under the text and embeds. Styles: primary (blurple), secondary (grey), success (green), danger (red) and link (opens a https link after a "Leaving Yapper" check, nothing is sent to the bot).

import { Bot, Row, Button } from './yapper.js';

bot.command('vote', 'Pizza or not', (msg) => msg.reply('Pizza tonight?', {
  components: [new Row(
    Button.success('Yes', 'vote:yes').emoji('🍕'),
    Button.danger('No', 'vote:no'),
    Button.link('Menu', 'https://example.com/menu'),
  )],
}));

bot.onButton(/^vote:/, async (i) => {
  await i.ephemeral(`You voted ${i.custom_id.slice(5)}`);      // only the clicker sees this
  // or: i.reply('text') for everyone, i.update({ components: [...] }) to change the message, i.ack() to just say ok
});

The same thing without the SDK:

"components": [{
  "type": "row",
  "components": [
    { "type": "button", "style": "success", "label": "Yes", "emoji": "🍕", "custom_id": "vote:yes" },
    { "type": "button", "style": "danger", "label": "No", "custom_id": "vote:no", "disabled": false },
    { "type": "button", "style": "link", "label": "Menu", "url": "https://example.com/menu" }
  ]
}]

Rules: every button needs a label (80 characters) or an emoji. Other buttons need a custom_id (1 to 100 characters, each one different in a message) and no url; link buttons need an https url and no custom_id. Anything else is a 400 that says what's wrong. Disabled buttons show greyed out and can't be clicked.

Clicks. A click on a button that isn't a link sends your bot an interaction event (polling and webhook, like every event). Only people who can see the channel can click, and each person gets 5 clicks per 10 seconds. Answer within 3 seconds or the person sees "This interaction failed". The same interaction stays open for 15 minutes and takes up to 10 answers:

POST https://www.yapper.website/api/bot/v1/interactions/<id>/<token>/callback
{ "type": "ephemeral", "content": "Only you can see this" }

type  reply      a new message in the channel (content, embeds, components)
      ephemeral  a message only the clicker sees (content, embeds). It shows until they reload or dismiss it.
      update     edit the clicked message: leave a key out to keep it, null removes it.
                 e.g. { "type": "update", "components": [ …same buttons with "disabled": true… ] }
      ack        nothing visible, just "got it" so no error shows

Keep the token to yourself: anyone with it can answer as your bot for 15 minutes.

Voice

In testing, so only some servers have it for now. Bots can see who is in voice, move people between voice channels and drop them, and get voice_join / voice_leave events. Moving and dropping need Move Members and a higher role than the person, like for people, and never work on Yapper staff. The person must be allowed to join the new channel, and the bot must see both channels. Up to 30 moves and disconnects plus 60 voice lookups a minute per bot. Bots don't join calls themselves.

A join-to-create bot: make a voice channel called Join to create, give the bot Manage channels and Move Members, then:

const made = new Set();                        // channels this bot made

bot.on('voice_join', async (ev) => {
  const ch = await bot.channel(ev.channel_id);
  if (ch?.name !== 'Join to create') return;
  const who = await bot.user(ev.user_id);
  const room = await bot.createChannel(ev.server_id, `${who.display_name || who.username}'s room`, { kind: 'voice', categoryId: ch.category_id });
  made.add(room.id);
  await bot.moveMember(ev.server_id, ev.user_id, room.id);
});

bot.on('voice_leave', async (ev) => {
  if (!made.has(ev.channel_id)) return;
  const voice = await bot.voice(ev.server_id);
  if (voice.find((v) => v.channel_id === ev.channel_id)?.user_ids.length) return;   // still someone there
  made.delete(ev.channel_id);
  await bot.deleteChannel(ev.channel_id);
});

Servers can make up to 10 new channels a minute, so a busy server may need a moment. made is forgotten when the bot restarts; keep the ids in a file if that matters.

Channel webhooks

No bot needed: a webhook is a secret URL that posts into one channel. Rolling out, so only some servers have it for now. People with Manage Webhooks make them in Edit channel → Webhooks (up to 10 per channel), give each a name and picture, and copy the URL. It's shown once; New URL makes a fresh one and kills the old. Most tools that post to Discord webhooks work as they are.

curl -X POST "https://www.yapper.website/api/webhooks/<id>/<token>?wait=true" \
  -H "Content-Type: application/json" \
  -d '{"content": "Deploy finished", "username": "CI", "embeds": [{"title": "Build #42", "color": 5763719}]}'
FieldWhat it does
contentUp to 2000 characters, markdown works. content or embeds is required.
username, avatar_urlOverride the name (up to 80) and picture for this message. Pictures follow the embed picture hosts; others are left out.
embedsUp to 10, same shape as bot embeds with Discord's limits (25 fields each, 6000 characters total). Pictures from other hosts are left out instead of failing.
allowed_mentions{ "parse": ["users", "roles", "everyone"], "users": [ids], "roles": [ids] }. By default only users are pinged. @everyone and @here only ping when allowed here and the person who made the webhook can mention everyone in that channel.

Answers like Discord: 204 with no body, or 200 with the message when you add ?wait=true. Errors are { "message", "code" }: 10015 unknown webhook (404), 50027 wrong token (401), 50006 empty message, 50035 bad field (400), 50001 can't post here (403). Up to 5 posts per 2 seconds per webhook and 30 a minute per channel; past that you get 429 with retry_after in seconds. GET on the same URL returns the webhook. The same content rules as everyone else apply, and a webhook stops working if the person who made it gets banned from the server.

Event webhooks (optional)

Only if you host the bot somewhere with a public https URL. Polling keeps working alongside.

POST <your webhook url>
Content-Type: application/json
X-Yapper-Bot: <bot id>
X-Yapper-Signature: sha256=<hex HMAC-SHA256 of the raw body, keyed with your webhook secret>

{ "id": 1234, "event": "message", "data": { … same fields as above … } }

Check the signature before trusting a request (the secret is under Advanced in Settings → Developer). Answer 200 quickly and do the slow work after; Yapper waits 3 seconds and doesn't retry. id is the same event id polling uses, so if you can't miss anything, also poll /events and skip ids you already got.

Rules and limits

  • Bots carry a BOT tag and only see and post in servers a manager added them to. Private channels stay hidden unless the bot (or a role it was given) is let in. They show as online while they poll, and do not count towards a server's member count.
  • The same content rules apply to bots as to people: slurs, adult links and spam get the bot, and you, banned.
  • 2 bots per person (10 with Yapper Plus), 25 bots per server, 30 messages a minute per bot, 100 commands per bot, events kept for 1 day.
  • A link straight to an image (.png, .jpg, .gif, .webp) shows as a picture under the message, so bots can post images by URL.
  • Lost the token? "New token" in Settings → Developer makes a fresh one and kills the old.

← Back to Yapper