Warning
This library is under active development. Public APIs and internal structures may change without notice.
Expect breaking changes. Do not treat the current API as production-stable.
oauthcord.py is an async Python wrapper for the Discord OAuth2 API.
It is designed for applications that need to send users through Discord OAuth, exchange authorization codes for tokens, and call Discord endpoints with typed models instead of raw JSON payloads.
This is not a gateway or bot framework. If you need bot events, shards, or gateway state, use a bot SDK such as discord.py.
- Async client built on
aiohttp - Typed models for OAuth2 and related Discord REST resources
- Coverage for user, guild, connection, DM, relationship, lobby, application, entitlement, and store routes
- Strict typing with Pyright
- Python
3.13+
Install directly from GitHub:
python -m pip install "oauthcord.py @ git+https://github.com/Soheab/oauthcord.py"Or with uv:
uv add "oauthcord.py @ git+https://github.com/Soheab/oauthcord.py"The library centers around two objects:
Clientcreates the authorization URL and exchanges an OAuth code for tokens.AuthorisedSessionuses the resulting token to call Discord on behalf of the user.
Typical flow:
- Create a
Clientwith your application ID, client secret, redirect URI, and requested scopes. - Send the user to
client.get_authorization_url(). - Receive
codeon your redirect URI. - Exchange it with
await client.exchange_token(code). - Use the returned
AuthorisedSessionto call Discord endpoints. - Refresh with
await session.refresh()when needed. - Revoke with
await session.revoke()if needed.
Client can keep an in-memory registry of AuthorisedSession instances for applications that need to look up a user's OAuth session after the callback has finished.
Session storage is opt-in. Pass store_session=True to automatically store sessions created by exchange_token(), and pass session_identifier when you want a stable key such as your own user ID instead of a generated UUID lookup key. If storage is enabled globally but one specific session should not be added to the registry, pass session_identifier=None for that exchange.
client = Client(
client_id=123456789012345678,
client_secret="your-client-secret",
redirect_uri="http://127.0.0.1:8000/callback",
scopes=[Scope.IDENTIFY, Scope.GUILDS],
store_session=True, # enabling
)
session = await client.exchange_token(
code,
session_identifier="internal-user-id",
)
same_session = client.get_session("internal-user-id")
temporary_session = await client.exchange_token(
another_code,
session_identifier=None,
)You can also manage sessions manually:
session = AuthorisedSession.from_token(client, token_data)
client.add_session(session, identifier="internal-user-id")
stored = client.get_session("internal-user-id")
client.remove_session("internal-user-id")
client.clear_sessions()
await session.close()Refreshing a session updates its token in place:
await session.refresh(check_expired=True)Closing a session removes it from the client's registry. If the client was created with revoke_tokens_on_session_close=True, closing the session also revokes the current access token.
await session.close()The registry is process-local and in-memory only. Persist session.to_dict() yourself if sessions must survive restarts, then recreate them later with AuthorisedSession.from_token(client, token_data, identifier="internal-user-id") while store_session=True is enabled on the client.
import asyncio
from oauthcord import Client, Scope
async def main() -> None:
client = Client(
client_id=123456789012345678,
client_secret="your-client-secret",
redirect_uri="http://127.0.0.1:8000/callback",
scopes=[Scope.IDENTIFY, Scope.GUILDS],
state="optional-csrf-state",
)
authorize_url = client.get_authorization_url()
print(f"Send the user here: {authorize_url}")
code = "authorization_code_from_your_callback"
session = await client.exchange_token(code)
try:
me = await session.current_user()
guilds = await session.guilds()
print(me.id, me.username)
print(f"Guild count: {len(guilds)}")
finally:
await client.http.close()
asyncio.run(main())Minimal end-to-end callback examples are included in examples/app_aiohttp.py and examples/app_litestar.py.
All returned models expose a .data attribute containing the raw payload from Discord.
Use this when you want the original API data directly instead of typed attributes.
me = await session.current_user()
# typed model attribute
username = me.username
print("Username", username)
# raw Discord payload
raw = me.data
print(raw)
username = raw["username"]
print("Username:", username)Models also support instance["key"] item access: it first looks for a typed attribute named key, falling back to instance.data["key"] if there isn't one.
username = me["username"] # same as me.username
raw_value = me["some_raw_field"] # falls back to me.data["some_raw_field"]The wrapper currently includes typed support for these route groups:
- OAuth2 token exchange, refresh, revoke, and current authorization info
- Current user, account edits, and harvest exports
- User guilds, guild member lookup, and guild join flows
- Connections and linked connections
- DM channels, DM messages, channel-linked accounts, and call endpoints
- Relationships and game relationships
- Invite acceptance
- Lobbies and lobby messages
- Application attachments, partial application data, quick links, and role connections
- Application entitlements
- Store SKUs, listings, assets, and plans
For the current concrete route list implemented by the wrapper:
Implemented endpoints
- OAuth2
POST /oauth2/token(authorization code exchange)POST /oauth2/token(refresh token)POST /oauth2/token/revokeGET /oauth2/@me
- Users and profile
GET /users/@mePATCH /users/@me/accountGET /users/@me/harvestPOST /users/@me/harvest
- Guilds and members
GET /users/@me/guildsGET /users/@me/guilds/{guild_id}/memberPUT /guilds/{guild_id}/members/{user_id}GET /guilds/{guild_id}/channels
- Connections
GET /users/@me/connectionsGET /users/@me/linked-connections
- Channels and calls
GET /users/@me/dms/{user_id}POST /users/@me/channelsGET /channels/{channel_id}/callPOST /channels/{channel_id}/call/ringPOST /channels/{channel_id}/call/stop-ringingGET /channels/{channel_id}/linked-accounts
- Direct messages
GET /users/{user_id}/messagesPOST /users/{user_id}/messagesPATCH /users/{user_id}/messages/{message_id}DELETE /users/{user_id}/messages/{message_id}
- Relationships
GET /users/@me/relationshipsPOST /users/@me/relationshipsPUT /users/@me/relationships/{user_id}DELETE /users/@me/relationships/{user_id}GET /users/@me/game-relationshipsPUT /users/@me/game-relationships/{user_id}DELETE /users/@me/game-relationships/{user_id}
- Invites
POST /invites/{code}
- Lobbies
PUT /lobbiesDELETE /lobbies/{lobby_id}/members/{user_id}POST /lobbies/{lobby_id}/members/@me/invitesPATCH /lobbies/{lobby_id}/channel-linkingGET /lobbies/{lobby_id}/messagesPOST /lobbies/{lobby_id}/messages
- Applications
POST /applications/{application_id}/attachmentGET /applications/{application_id}/partialGET /users/@me/applications/{application_id}/role-connectionPUT /users/@me/applications/{application_id}/role-connectionPOST /applications/{application_id}/quick-links/POST /application-identitiesGET /applications/{application_id}/entitlementsGET /applications/{application_id}/entitlements/{entitlement_id}POST /applications/{application_id}/entitlements/{entitlement_id}/consumeDELETE /applications/{application_id}/entitlements/{entitlement_id}
- Store and SKUs
GET /applications/{application_id}/skusPOST /store/skusGET /store/skus/{sku_id}PATCH /store/skus/{sku_id}GET /store/skus/{sku_id}/listingsPOST /store/listingsGET /store/listings/{listing_id}PATCH /store/listings/{listing_id}DELETE /store/listings/{listing_id}GET /store/skus/{sku_id}/plansGET /store/applications/{application_id}/assetsPOST /store/applications/{application_id}/assetsDELETE /store/applications/{application_id}/assets/{asset_id}
Development tracking: missing models and model method candidates
Missing model tracking
Raw dict[...] fields, raw nested payload attributes, or overly generic
TypedDict fields that likely need dedicated models or more specific payload
types. Ordinary maps such as localization dictionaries, metadata maps, price
maps, caches, headers, and serializer scratch dictionaries are intentionally
excluded.
-
Attachment.application- Payload-only gap right now:
internals/_types/message.pyhasAttachmentResponse.application, butmodels/attachment.pycurrently usesinternals/_types/attachment.py.Attachment, which does not includeapplicationorapplication_id.
- Payload-only gap right now:
-
UnfurledMediaItem.content_scan_metadata- Uses
ContentScanMetadata.
- Uses
-
ContentInventoryEntryComponent.content_inventory_entry- Currently stores
ContentInventoryEntryDataResponsedirectly.
- Currently stores
-
CheckpointCard.checkpoint_data- Currently stores
CheckpointDataResponsedirectly.
- Currently stores
-
EmbedMedia.content_scan_metadata- Uses
ContentScanMetadata.
- Uses
-
Embed.provider- Uses
EmbedProvider.
- Uses
-
QuestRewardsMetadata.reward_code -
Entitlement.sku -
Entitlement.subscription_plan
-
Invite.profile -
Invite.roles -
Invite.stage_instance -
Invite.guild_scheduled_event -
Invite.guild_join_request
-
ThreadMember.mute_config
-
StoreListing.guild -
StorefrontCollection.tenant_metadata
-
ThreadMemberResponse.mute_config- Undocumented.
-
UnfurledMediaItemResponse.content_scan_metadata- Uses
ContentScanMetadataResponse.
- Uses
-
ContentInventoryEntryDataResponse.traits -
ContentInventoryEntryDataResponse.extra -
ContentInventoryEntryDataResponse.signature -
CheckpointDataResponse.top_guild -
CheckpointDataResponse.top_emoji -
CheckpointDataResponse.top_game
-
QuestRewardsMetadataResponse.reward_code -
EntitlementResponse.sku -
EntitlementResponse.subscription_plan
-
InviteResponse.profile -
InviteResponse.roles -
InviteResponse.stage_instance -
InviteResponse.guild_scheduled_event -
InviteResponse.guild_join_request
-
CreateLobbyMessageRequest.poll- Reuses
PollCreateRequest.
- Reuses
-
CreateLobbyMessageRequest.shared_client_theme- Reuses
SharedClientThemeRequest.
- Reuses
-
CreateLobbyMessageRequest.metadata- Likely intentionally generic metadata, but still tracked because it is a
request payload field using
dict[str, object].
- Likely intentionally generic metadata, but still tracked because it is a
request payload field using
-
AttachmentResponse.application -
EmbedMediaResponse.content_scan_metadata- Uses
ContentScanMetadataResponse.
- Uses
-
MessageReferenceRequest.forward_only -
MessageReferenceResponse.forward_only -
MessageInteractionResponse.triggering_interaction_metadata -
MessagePurchaseNotificationResponse.guild_product_purchase -
MessageGiftInfoResponse.sound -
MessageSnapshotResponse.message- Reuses
MessageResponse.
- Reuses
-
MessageResponse.activity -
MessageResponse.application -
MessageResponse.referenced_message- Reuses
MessageResponse | None.
- Reuses
-
MessageResponse.interaction -
MessageResponse.resolved -
MessageResponse.sticker_items -
MessageResponse.stickers -
MessageResponse.soundboard_sounds -
MessageResponse.potions -
CreateDMMessageRequest.metadata- Likely intentionally generic metadata, but still tracked because it is a
request payload field using
dict[str, object].
- Likely intentionally generic metadata, but still tracked because it is a
request payload field using
-
StoreListingResponse.guild -
StorefrontCollectionResponse.tenant_metadata
-
CreateLobbyMessageRequest.pollusesPollCreateRequest. -
CreateLobbyMessageRequest.shared_client_themeusesSharedClientThemeRequest. -
MessageSnapshotResponse.messageandMessageResponse.referenced_messageuse recursiveMessageResponsepayloads.
Model method candidates
This tracks places where a model instance already has the identifiers needed to
call a session/client method directly, similar to DMChannel.get_call_eligibility().
Audited endpoint/client/model files:
internals/endpoints:application,channel,connection,current_auth,guild,invite,lobby,member,message,relationship,store,token, anduser.client:_application,_channel,_connection,_guild,_invite,_lobby,_message,_oauth2,_relationship,_store, and_user.- Session-backed models:
CurrentApplication,CurrentInformation,Guild,GuildMember,ThreadMember,Lobby,PartialMessage,Message,PartialApplication,Entitlement,PartialUser,CurrentUser, channels, relationships, and store models that inheritBaseModelWithSession.
-
refresh(check_expired=...)- Existing token model method.
-
revoke()- Existing token model method.
-
get_partial()- Forwards
get_partial_application(application_id=self.id).
- Forwards
-
skus(...)- Can forward
get_application_skus(application_id=self.id, ...).
- Can forward
-
create_sku(...)- Can forward
create_sku(application_id=self.id, ...).
- Can forward
-
store_assets()- Can forward
get_application_store_assets(application_id=self.id).
- Can forward
-
create_store_asset(file=...)- Can forward
create_application_store_asset(application_id=self.id, file=file).
- Can forward
-
bulk_identities(user_ids=...)- Can forward
get_bulk_application_identities(user_ids=...). - This is current-application scoped and only correct for the application authorized by the session.
- Can forward
-
entitlements(...)- Can forward
get_application_entitlements(application_id=self.id, ...).
- Can forward
-
get_entitlement(entitlement_id=...)- Can forward
get_application_entitlement(application_id=self.id, entitlement_id=...).
- Can forward
These are already declared on CurrentApplication in
src/oauthcord/models/current_auth.py, but currently only contain pass.
-
get_global_application_commands()- Likely needs
GET /applications/{application.id}/commands. - Needs command HTTP/client mixins, then command model construction.
- Likely needs
-
get_global_application_command(command_id)- Likely needs
GET /applications/{application.id}/commands/{command.id}.
- Likely needs
-
create_global_application_command(data)- Likely needs
POST /applications/{application.id}/commands. - Should accept typed command request payloads/builders, not
Any.
- Likely needs
-
edit_global_application_command(command_id, data)- Likely needs
PATCH /applications/{application.id}/commands/{command.id}.
- Likely needs
-
delete_global_application_command(command_id)- Likely needs
DELETE /applications/{application.id}/commands/{command.id}.
- Likely needs
-
bulk_overwrite_global_application_commands(data)- Likely needs
PUT /applications/{application.id}/commands. - Should return command models if the endpoint response is modeled.
- Likely needs
-
get_guild_application_commands(guild_id)- Likely needs
GET /applications/{application.id}/guilds/{guild.id}/commands.
- Likely needs
-
get_guild_application_command(guild_id, command_id)- Likely needs
GET /applications/{application.id}/guilds/{guild.id}/commands/{command.id}.
- Likely needs
-
edit_guild_application_command(guild_id, command_id, data)- Likely needs
PATCH /applications/{application.id}/guilds/{guild.id}/commands/{command.id}. - Should accept typed command request payloads/builders.
- Likely needs
-
delete_guild_application_command(guild_id, command_id)- Likely needs
DELETE /applications/{application.id}/guilds/{guild.id}/commands/{command.id}.
- Likely needs
-
bulk_overwrite_guild_application_commands(guild_id, data)- Likely needs
PUT /applications/{application.id}/guilds/{guild.id}/commands. - Should return command models if the endpoint response is modeled.
- Likely needs
-
get_guild_application_command_permissions(guild_id)- Likely needs
GET /applications/{application.id}/guilds/{guild.id}/commands/permissions. - Model type likely
GuildApplicationCommandPermissions.
- Likely needs
-
get_application_command_permissions(guild_id, command_id)- Likely needs
GET /applications/{application.id}/guilds/{guild.id}/commands/{command.id}/permissions. - Model type likely
GuildApplicationCommandPermissions.
- Likely needs
-
edit_application_command_permissions(guild_id, command_id, data)- Likely needs
PUT /applications/{application.id}/guilds/{guild.id}/commands/{command.id}/permissions. - Should accept typed permission payloads, not
Any.
- Likely needs
-
edit_account(global_name=...)- Can forward
edit_current_user_account(global_name=...). - The session method returns
PartialUser.
- Can forward
-
harvest()- HTTP endpoint exists as
get_user_harvest(), but there is no public client/session wrapper yet.
- HTTP endpoint exists as
-
create_harvest()- HTTP endpoint exists as
create_user_harvest(), but there is no public client/session wrapper yet.
- HTTP endpoint exists as
-
get_call_eligibility()- Existing reference pattern.
- Forwards
get_call_eligibility(channel_id=self.id).
-
messages(limit=...)- Can forward
get_dm_messages(user_id=..., limit=...). - The API takes the recipient user ID, not the channel ID. Only safe for
one-to-one DMs via
self.recipients[0].id.
- Can forward
-
send(...)orcreate_message(...)- Can forward
create_dm_message(user_id=..., ...). - Same one-to-one DM caveat as
messages.
- Can forward
-
consume()- Can forward
consume_application_entitlement(application_id=self.application_id, entitlement_id=self.id).
- Can forward
-
delete()- Can forward
delete_application_entitlement(application_id=self.application_id, entitlement_id=self.id).
- Can forward
-
delete()- Can forward
delete_game_relationship(user_id=self.user_id).
- Can forward
-
get_linked_accounts(user_ids=...)- Existing convenience method.
- Forwards
get_channel_linked_accounts(channel_id=self.id, ...).
-
channels(...)- Existing convenience method.
- Forwards
get_guild_channels(guild_id=self.id, ...).
-
current_member()- Can forward
get_current_guild_member(guild_id=self.id).
- Can forward
-
add_current_user(...)- Can forward
add_current_user_to_guild(guild_id=self.id, ...). - Requires
bot_tokenand may need explicituser_idwhen the session lacksidentify.
- Can forward
-
leave(user_id=...)- Can forward
leave_lobby(lobby_id=self.id, user_id=...). - Could also offer a current-user shortcut if the session can reliably expose the current user ID.
- Can forward
-
create_invite_for_current_user()- Can forward
create_lobby_invite_for_current_user(lobby_id=self.id).
- Can forward
-
edit_linked_channel(channel_id=...)- Can forward
edit_lobby_linked_channel(lobby_id=self.id, channel_id=...). - Prefer
edit_*naming to match repo conventions.
- Can forward
-
messages(limit=...)- Can forward
get_lobby_messages(lobby_id=self.id, limit=...).
- Can forward
-
send(...)orcreate_message(...)- Can forward
create_lobby_message(lobby_id=self.id, ...). - Should mirror
create_lobby_messageparameters closely.
- Can forward
-
edit(content=...)- Can forward
edit_dm_message(user_id=..., message_id=self.id, ...). - Needs reliable recipient/user context. Current message models expose
channel_id,lobby_id, and sometimesrecipient_id, but the DM message endpoint wantsuser_id.
- Can forward
-
delete()- Can forward
delete_dm_message(user_id=..., message_id=self.id). - Same recipient context issue as
edit.
- Can forward
-
create_attachment(file)- Existing convenience method.
- Forwards
create_application_attachment(application_id=self.id, file=file).
-
get_user_role_connection()- Existing method, but the session method derives the application from current
authorization rather than from
self.id.
- Existing method, but the session method derives the application from current
authorization rather than from
-
edit_user_role_connection(...)- Same current-authorization caveat as
get_user_role_connection.
- Same current-authorization caveat as
-
create_quick_link(...)- Same current-authorization caveat; the lower-level HTTP endpoint takes
application_id.
- Same current-authorization caveat; the lower-level HTTP endpoint takes
-
skus(...)- Can forward
get_application_skus(application_id=self.id, ...).
- Can forward
-
create_sku(...)- Can forward
create_sku(application_id=self.id, ...).
- Can forward
-
store_assets()- Can forward
get_application_store_assets(application_id=self.id).
- Can forward
-
create_store_asset(file=...)- Can forward
create_application_store_asset(application_id=self.id, file=file).
- Can forward
-
bulk_identities(user_ids=...)- Can forward
get_bulk_application_identities(user_ids=...). - This is current-application scoped and only correct when
self.idmatches the session's authorized application.
- Can forward
-
entitlements(...)- Can forward
get_application_entitlements(application_id=self.id, ...).
- Can forward
-
get_entitlement(entitlement_id=...)- Can forward
get_application_entitlement(application_id=self.id, entitlement_id=...).
- Can forward
-
dm_channel()- Existing convenience method.
- Forwards
get_dm_channel(user_id=self.id).
-
ring(...)- Existing convenience method.
- Forwards
ring_channel_recipients(channel_id=self.id, ...).
-
stop_ringing(...)- Existing convenience method.
- Forwards
stop_ringing_channel_recipients(channel_id=self.id, ...).
-
delete()- Can forward
delete_relationship(user_id=self.user.id).
- Can forward
-
accept()- Possibly forwards
create_relationship(user_id=self.user.id, ...)if that is the right action for pending requests.
- Possibly forwards
-
refresh(country_code=..., localize=...)- Can forward
get_sku(sku_id=self.id, ...).
- Can forward
-
edit(...)- Can forward
modify_sku(sku_id=self.id, ...). - Method body can mirror
modify_skuminussku_id.
- Can forward
-
store_listings(country_code=..., localize=...)- Can forward
get_sku_store_listings(sku_id=self.id, ...).
- Can forward
-
subscription_plans()- Can forward
get_subscription_plans(sku_id=self.id).
- Can forward
-
refresh(country_code=..., localize=...)- Can forward
get_store_listing(listing_id=self.id, ...).
- Can forward
-
edit(...)- Can forward
modify_store_listing(listing_id=self.id, ...). - Method body can mirror
modify_store_listingminuslisting_id.
- Can forward
-
delete()- Can forward
delete_store_listing(listing_id=self.id).
- Can forward
StoreAsset.delete()- The delete endpoint needs both
application_idandasset_id, butStoreAssetcurrently only carriesid. This becomes straightforward if store assets retain their parent application ID when constructed.
- The delete endpoint needs both
DMChannelmessage helpers onPrivateChannel- Group DMs also inherit
PrivateChannel, but message history/send endpoints use a user ID route. Keep those helpers onDMChannelunless group-DM endpoint semantics are added.
- Group DMs also inherit
Invite.accept()accept_invite()returns anInvite; accepting an invite from an already acceptedInviteobject is less useful unless invite fetch support is added first.
Connectionlist endpointsget_current_user_connections()andget_current_user_linked_connections()are current-user collection fetches. They do not fit a specificConnectioninstance.
send_friend_request(username=...)- Username-based creation has no existing model carrying the required username target in a useful way.
- Token exchange/client close/OAuth URL helpers
- These are client/session lifecycle helpers rather than model instance actions.
This project tracks Discord behavior against:
- Unofficial docs: https://docs.discord.food/
- Official docs: https://docs.discord.com/
- Rate-limit bucket design in
src/oauthcord/internals/_ratelimiter.pyandsrc/oauthcord/internals/http.pyis inspired bydiscord.pyby Rapptz