REST API¶

bot.rest has a method for every operation of Discord's REST API that a bot can call, grouped by the resource it acts on: bot.rest.messages.create, bot.rest.members.add_role, bot.rest.guilds.fetch. Requests go through bostie's Rust HTTP client, which follows Discord's rate limits.
import asyncio
from datetime import UTC, datetime, timedelta
from pathlib import Path
import bostie
from bostie import ChannelType, Context, File, OverwriteType, Permissions
bot = bostie.Bot()
@bot.command(default_permissions=Permissions.MANAGE_CHANNELS)
async def private_room(ctx: Context, name: str) -> None:
"""Create a channel only you and the bot can see."""
if ctx.guild_id is None:
await ctx.respond("Use this in a server.", ephemeral=True)
return
me = bot.user.id if bot.user is not None else 0
channel = await bot.rest.channels.create(
ctx.guild_id,
name=name,
type=ChannelType.GUILD_TEXT,
permission_overwrites=[
{
"id": ctx.guild_id,
"type": OverwriteType.ROLE,
"deny": Permissions.VIEW_CHANNEL,
},
{
"id": ctx.user.id,
"type": OverwriteType.MEMBER,
"allow": Permissions.VIEW_CHANNEL,
},
{"id": me, "type": OverwriteType.MEMBER, "allow": Permissions.VIEW_CHANNEL},
],
reason=f"/private_room by {ctx.user.username}",
)
await ctx.respond(f"Created {channel.mention}.", ephemeral=True)
@bot.command
async def today(ctx: Context) -> None:
"""Count the messages sent here in the last 24 hours."""
if ctx.channel_id is None:
await ctx.respond("Use this in a channel.", ephemeral=True)
return
since = datetime.now(UTC) - timedelta(days=1)
count = 0
async for _ in bot.rest.messages.list(ctx.channel_id, after=since, limit=1000):
count += 1
await ctx.respond(f"{count} messages in the last 24 hours.")
@bot.command(default_permissions=Permissions.MANAGE_GUILD_EXPRESSIONS)
async def add_logo(ctx: Context) -> None:
"""Upload the logo as an emoji."""
if ctx.guild_id is None:
await ctx.respond("Use this in a server.", ephemeral=True)
return
image = await asyncio.to_thread(Path("logo.png").read_bytes)
emoji = await bot.rest.emojis.create(
ctx.guild_id, name="logo", image=File("logo.png", image)
)
await ctx.respond(f"Added <:{emoji.name}:{emoji.id}>.")
@bot.command
async def gateway_url(ctx: Context) -> None:
"""Show what Discord answers on an endpoint, through the raw client."""
answer = await bot.http.request("GET", "/gateway")
await ctx.respond(f"The gateway is at {answer['url']}.", ephemeral=True)
Conventions¶
- The ids in the URL come first, positional-only, in the URL's order:
bot.rest.members.add_role(guild_id, user_id, role_id). - Everything else is a keyword argument named as in Discord's documentation, checked against a
TypedDictper operation, so a typo is a type error and aTypeErrorat run time. - Operations that Discord records in the audit log take
reason=, 1 to 512 characters, shown to the guild's moderators. - An image or sound field, such as an emoji's
imageor a guild'sicon, takes aFile; bostie sends it as the data URI Discord expects. Permission fields takePermissions. - An emoji, such as the one
reactions.createadds, takes a Unicode emoji, a custom emoji as<:name:id>,<a:name:id>orname:id, or anEmojiorPartialEmojimodel. - Methods named
fetchget one object,listiterates over several,editmodifies,createcreates anddeletedeletes. Other verbs keep the documentation's word, such asmembers.add_roleorguilds.begin_prune. - Methods return models, or
Nonewhen Discord answers with no content. They do not changebot.cache, except thatchannels.create_dmkeeps the DM channel whenmax_dm_channelsallows; the gateway events that follow a change update the cache.
Paging¶
list methods of endpoints that Discord pages return an async iterator, which fetches the next page as you go: async for message in bot.rest.messages.list(channel_id, limit=500):, as /today does above. Without limit it walks every item. before, after and around start somewhere else, at most one of them; they take an id or a datetime, which bostie turns into the id of that moment. Messages come newest first, or oldest first with after. A list method of an endpoint that Discord does not page is a coroutine that returns a tuple. audit_log.list yields whole AuditLogPage objects, with the users, webhooks and other objects the entries refer to.
Purging messages¶
messages.purge(channel_id, limit=100, check=None) walks the last limit messages, newest first, and deletes those for which check returns true, or all of them without one; limit=None walks the whole channel, and before and after start elsewhere, as in list. Messages younger than 14 days go in bulk deletes of up to 100, older ones one request each, since Discord bulk deletes only young messages; a type Discord does not let be deleted, such as a thread's starter message, is skipped. bulk=False deletes one at a time, as in a DM, where bulk deletes do not exist. It returns the deleted messages, and reason goes to the audit log. Bulk deletes and others' messages need Manage Messages.
import bostie
from bostie import Context, Message, Permissions, Range, User, WebhookMessage
bot = bostie.Bot()
@bot.command(default_permissions=Permissions.MANAGE_MESSAGES)
async def purge(
ctx: Context, count: Range[int, 1, 1000], user: User | None = None
) -> None:
"""Delete recent messages here, or only those of one user."""
if ctx.channel_id is None:
await ctx.respond("Use this in a channel.", ephemeral=True)
return
def wanted(message: Message | WebhookMessage) -> bool:
return user is None or message.author.id == user.id
await ctx.defer(ephemeral=True)
deleted = await bot.rest.messages.purge(
ctx.channel_id,
limit=count,
check=wanted,
reason=f"/purge by {ctx.user.username}",
)
await ctx.respond(f"Deleted {len(deleted)} messages.")
The audit log¶
audit_log.list(guild_id) yields AuditLogPage objects, and the gateway sends each new entry as an AuditLogEntryCreate event. An entry's changes hold one AuditLogChange per changed field, whose new_value and old_value have the type the changed object gives that field: Permissions for a role's permissions, a datetime for a member's communication_disabled_until, a tuple of Overwrite for a channel's permission_overwrites. Discord's exceptions follow its documentation: $add and $remove on a member's roles hold AuditLogRole objects, a webhook's avatar_hash and an invite's channel_id take the type of avatar and of an id, and a command permission change is keyed by the role, user or channel id and holds a CommandPermission. A key bostie does not know, or a value in a shape the field does not take, keeps its JSON value, and a value Discord leaves out is None.
import os
import bostie
from bostie import AuditLogEvent, Permissions
from bostie.events import AuditLogEntryCreate
ALERTS_CHANNEL_ID = int(os.environ.get("ALERTS_CHANNEL_ID", "0"))
bot = bostie.Bot()
@bot.listen
async def admin_granted(event: AuditLogEntryCreate) -> None:
entry = event.entry
if entry.action_type is not AuditLogEvent.ROLE_UPDATE:
return
for change in entry.changes:
before = change.old_value or Permissions(0)
after = change.new_value or Permissions(0)
if change.key == "permissions" and after & ~before & Permissions.ADMINISTRATOR:
await bot.rest.messages.create(
ALERTS_CHANNEL_ID,
f"<@{entry.user_id}> gave <@&{entry.target_id}> Administrator.",
)
Errors¶
A request that Discord answers with an error status raises an HTTPException: BadRequest for 400, Forbidden for 403, NotFound for 404, ServerError for 5xx. The exception has the status, Discord's code as a JSONErrorCode, its message, the per-field errors, and the raw body; its text lists every field error on its own line. UnexpectedResponse means Discord accepted the request but its answer did not decode; its message says whether the change took effect, and error.raw holds the answer.
Rate limits, retries and timeouts¶
The Rust client tracks every route's rate limit bucket and Discord's global limit, and holds a request until its bucket has room. Bot(http_settings=HTTPSettings(...)) changes how:
HTTPSettings field |
Default | Meaning |
|---|---|---|
global_rate_limit |
50 | requests per second across all routes; raise it if Discord gave the bot a higher limit |
max_rate_limit_wait |
30.0 | the longest wait for a rate limit; a request that would wait longer raises RateLimited without being sent |
retries |
2 | how often a failed request is retried, with exponential backoff that waits at most max_rate_limit_wait |
connect_timeout |
10.0 | seconds to open a connection |
read_timeout |
60.0 | seconds to wait for an answer |
rate_limiter |
True |
False sends at once and only waits out the 429s that come back, for use behind a rate-limit proxy |
proxy |
None |
an HTTP or HTTPS proxy URL for every request, voice.Url audio and CDN downloads included, credentials included as in http://user:password@host:3128 |
A 429 is retried after the delay Discord gives, if that is within max_rate_limit_wait. A 500, 502, 503, 504 or 524, or a connection that broke, is retried retries times for every method but POST, which might have taken effect; a connection that never opened is retried for every method.
Discord bans an IP that sends 10 000 invalid requests (401, 403, 429) in 10 minutes. bostie counts them and logs a warning at 1000, 5000 and 9000. At 9500 it refuses every request with RequestBudgetExhausted instead of sending it, until the count of the last 11 minutes drops below 9000.
Any endpoint¶
bot.http.request(method, route, **params) sends any request through the same client and returns the decoded JSON, or None for an empty answer. The route keeps its placeholders, which keyword arguments fill: await bot.http.request("GET", "/channels/{channel_id}/pins", channel_id=123). It takes json=, files= for a multipart upload, form= for named form fields, urlencoded= for a form-urlencoded body, query= and reason=, and raises the same exceptions. bot.http.send(...) takes the same arguments and returns the answer's raw bytes, for an endpoint that does not answer with JSON.
Downloading files¶
Attachments and images live on Discord's CDN, not behind the API, so a download sends no token and spends no rate limit. bot.http fetches them through the same Rust client, with its timeouts, retries and proxy:
| Method | Does |
|---|---|
read(source) |
returns the bytes |
save(source, path) |
writes them to the file at path |
to_file(source) |
returns a File to upload again; from an Attachment it keeps the file name, description, title, spoiler and voice message details, from a URL the last part of its path names it |
source is an Attachment, or a URL such as the bostie.cdn helpers build. A missing file raises NotFound.
import os
import bostie
from bostie import Context, Message, WebhookMessage, cdn
ARCHIVE_CHANNEL_ID = int(os.environ.get("ARCHIVE_CHANNEL_ID", "0"))
bot = bostie.Bot()
@bot.message_command("Archive files")
async def archive(ctx: Context, message: Message | WebhookMessage) -> None:
await ctx.defer(ephemeral=True)
files = [await bot.http.to_file(attachment) for attachment in message.attachments]
if files:
await bot.rest.messages.create(
ARCHIVE_CHANNEL_ID, f"From {message.jump_url}", files=files
)
await ctx.respond(f"Archived {len(files)} files.")
@bot.command
async def avatar(ctx: Context) -> None:
"""Send your avatar as a file."""
url = cdn.display_avatar(ctx.user, size=1024, format="png")
await ctx.respond(files=[await bot.http.to_file(url)])
Without a bot¶
A script or a web backend that needs only REST uses a RESTClient:
"""A script that uses the REST API without a gateway: list the bot's guilds."""
import asyncio
import logging
import bostie
log = logging.getLogger("rest-example")
async def main() -> None:
async with bostie.RESTClient() as client:
me = await client.rest.users.fetch_current()
async for guild in client.rest.guilds.list():
log.info("%s is in %s", me.username, guild.name)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
asyncio.run(main())
async with RESTClient(token) as client: opens the HTTP client; client.rest has every group of bot.rest, and client.http the raw client. The token defaults to BOSTIE_TOKEN, and the client takes api_url, http_settings and allowed_mentions like the bot. The OAuth2 guide covers BearerClient, which calls the API with a user's token, and the webhooks guide the clients that need no token at all.
The groups¶
bot.rest. |
Methods |
|---|---|
application |
edit, fetch, fetch_activity_instance, fetch_bot_application, list_role_connection_metadata, update_role_connection_metadata |
audit_log |
list |
auto_moderation |
create, delete, edit, fetch, list |
bans |
bulk_create, create, fetch, list, remove |
channels |
add_recipient, create, create_dm, create_group_dm, delete, delete_permission, edit, edit_permissions, edit_positions, fetch, fetch_member_permissions, fetch_role_permissions, follow, list, remove_recipient, set_voice_status, trigger_typing_indicator |
commands |
bulk_overwrite, bulk_overwrite_guild, create, create_guild, delete, delete_guild, edit, edit_guild, fetch, fetch_guild, fetch_permissions, list, list_guild, list_permissions |
emojis |
create, create_application, delete, delete_application, edit, edit_application, fetch, fetch_application, list, list_application |
gateway |
fetch, fetch_bot |
guilds |
begin_prune, edit, edit_incident_actions, edit_welcome_screen, edit_widget, fetch, fetch_preview, fetch_prune_count, fetch_vanity_url, fetch_welcome_screen, fetch_widget, fetch_widget_image, fetch_widget_settings, leave, list |
identities |
delete, fetch_profile, list, list_by_external_id, update_profile |
integrations |
delete, list |
invites |
add_target_user, bulk_add_target_users, bulk_delete_target_users, create, delete, fetch, fetch_target_users_job_status, list, list_guild, list_target_users, remove_target_user, update_target_users |
lobbies |
add_member, bulk_update_members, create, create_invite_for_user, delete, edit, fetch, remove_member, update_message_moderation_metadata |
members |
add, add_role, edit, edit_current, fetch, fetch_permissions, list, remove, remove_role, search |
messages |
bulk_delete, create, crosspost, delete, edit, fetch, list, list_pins, pin, purge, search, unpin |
monetization |
consume_entitlement, create_test_entitlement, delete_test_entitlement, fetch_entitlement, fetch_subscription, list_entitlements, list_skus, list_subscriptions |
onboarding |
edit, fetch |
partner_sdk |
create_provisional_token, exchange_child_token, exchange_external_token, unmerge_external_account, unmerge_provisional_account, update_dm_moderation_metadata |
polls |
end, list_answer_voters |
reactions |
create, delete, delete_all, delete_all_for_emoji, delete_own, list |
roles |
create, delete, edit, edit_positions, fetch, fetch_member_counts, fetch_permissions, list |
scheduled_events |
create, delete, edit, fetch, list, list_users |
soundboard |
create, delete, edit, fetch, list, list_default, send |
stage_instances |
create, delete, edit, fetch |
stickers |
create, delete, edit, fetch, fetch_guild, fetch_pack, list, list_packs |
templates |
create, delete, edit, fetch, list, sync |
threads |
add_member, fetch_member, join, leave, list_active, list_joined_private_archived, list_members, list_private_archived, list_public_archived, remove_member, start, start_from_message, start_in_forum |
users |
edit_current, fetch, fetch_current |
voice |
edit_current_state, edit_state, fetch_current_state, fetch_state, list_guild_regions, list_regions |
webhooks |
create, delete, delete_message, delete_with_token, edit, edit_message, edit_with_token, execute, execute_github, execute_slack, fetch, fetch_message, fetch_with_token, list, list_guild |
Each method's parameters and return type are in the REST API reference. A few operations are left to bot.http.request on purpose: endpoints Discord deprecated or never documented, and the interaction callback and original-response endpoints, which the interaction contexts cover.
Some groups serve Discord features beyond bots in guilds: monetization and application for monetization and Activities, lobbies, identities and partner_sdk for the Social SDK, and application.update_role_connection_metadata for linked roles.