Skip to main content
A bot can hold a 1:1 direct message with a human, end to end encrypted exactly like a channel. Group DMs are not supported. Two things shape every DM handler you write. Inbound DMs arrive on the same messageCreate event as server messages, and a DM message has no server, so msg.serverId is null.

Who may DM whom

The server decides, and it decides deny-by-default. A human and a bot may DM each other only if that human owns the bot, or the two share at least one server. The SDK never pre-computes this. An ineligible target rejects with a CloakActionError from the create step, before anything is encrypted or sent.

Sending a DM

client.sendDM(userId, text, opts?) sends a direct message to a user, creating the DM on first contact.
There is no separate “open a DM” step. First contact with a peer costs a create plus a key exchange. Every later send is just a send.
string
required
The user to DM. A bot cannot DM itself, and that rejects locally.
string
required
The message body. Encrypted before it leaves the process.
SendOptions
{ groupId?, mentions?, replyTo? }, with reduced DM semantics. See below.
SendOptions is the same object you pass to send(), with three differences inside a DM:
  • replyTo works. The DM branch parses the same reply slot.
  • mentions only notifies for direct user mentions. Role, @everyone, and @here pairs are dropped server-side inside a DM.
  • groupId is meaningless in a DM and is ignored.
sendDM() is fire-and-forget on the send itself, like send(). The create and key-exchange steps are awaited and can reject, but a server-side denial of the message does not reject the promise. It arrives on the sendRejected event, where serverId is null and channelId is the dm id. Bot sends are rate limited server-side, which surfaces there as code -9.

Cards in a DM

client.sendCardDM(userId, card) is sendCard() on the DM lane: the same CardMessage, the same v1 or v2 envelope, encrypted under the peer-keyed DM key. It is set up exactly like sendDM(), so first contact costs the same create and key exchange.
A click comes back on the interaction event with serverId: null and channelId equal to the dm id, and ephemeral() and showModal() answer under the same DM key. threadId and postId are not valid on a DM card, groupId is ignored, and a bot cannot DM itself. See Buttons and selects and the Approval bot for the full flow, including how the card is retired once the click arrives.

Receiving a DM

DMs come in on messageCreate, with isDM set.
boolean
true for a direct message.
string | null
null for a DM. A direct message has no server at all.
string | null
The dm id for a DM, null otherwise. Always equal to channelId when set.
string
For a DM this holds the dm id. One convention throughout the SDK: channelId is always “the thing this message lives in”, so msg.reply() and msg.channel.send() work without branching.
Date | null
The row’s send time on a backend that honors wire version 3. null when the message came through the legacy notify signal alone, which carries no timestamp. See below.
object | null
The quoted message, on a wire version 3 backend. null on an older backend, whose notify frame carries no reply array.
MessageCard | null
A card someone sent you, or the echo of your own sendCardDM(), on a wire version 3 backend. null on an older backend, whose notify frame has no card slot.

What a wire version 3 backend adds

The SDK declares wire version 3 at login. A backend that honors it puts the bot on the DM lane’s own message broadcast, the same frame humans get, while the legacy notify signal keeps arriving. What that buys:
  • A DM message arrives with everything the notify signal lacks: createdAt from the row’s own send time, repliedTo, a poll, and card. The two frames are deduped by message id. Whichever lands first fires messageCreate; if the broadcast lands second and carries a card, a messageUpdate with card follows so it is not lost.
  • Your own DM sends echo back as messageCreate with authorId === client.user.id, exactly like the server lane. Keep that echo: its createdAt is what msg.editCard() and msg.edit() need later, because rows are addressed by send time. Filter echoes by authorId in your handler as you do for channels. An echo that arrives before anything has named the DM’s peer to this session (a fresh session that has neither sent nor received in that DM) cannot select a key and is dropped with an error.
  • react(), unreact(), edit(), editCard(), and delete() work on a DM message that carries createdAt. The DM-lane hooks then arrive on messageUpdate, messageDelete, and reactionUpdate with serverId: null and channelId equal to the dm id.
  • DM history and the DM / picker. fetchDmMessages() and fetchMessages(null, dmId) page the DM, and a /command picked from the DM’s / picker dispatches through your registry. A typed /command in a DM works on any backend. Both are covered below.
On an older backend nothing changes: the notify signal alone, card null, createdAt null, and the actions above reject with a message saying the frame carried no timestamp. reply() and channel.send() work everywhere.
client.editCard(null, dmId, messageId, card, { createdAt }) is the explicit form: a null server id plus the dm id is the DM edit.

Guard before anything server-scoped

Message.serverId is string | null. Anywhere you pass msg.serverId to a server-scoped call (client.can(), client.guild(), client.send(), sendEmbed, moderation, fetchMessages), guard first:
This is the single most common way a bot written before 0.2.x breaks. TypeScript points at every site.

What does not work on a DM message

pin() and unpin() reject on a DM message, with a plain Error naming the method. The pin operation has no DM lane a bot can observe, so the SDK refuses rather than writing blind. react(), unreact(), edit(), editCard(), and delete() reject with a plain Error only when the message carries no createdAt, which is the case for a DM that came through the legacy notify signal alone on a backend below wire version 3. The message says so: wait for the DM-lane echo, or fetch the message again. reply() and channel.send() work everywhere. Slash commands work in a DM on both lanes: a typed /play ... on any backend, and the DM’s / picker on a wire version 3 backend. See Slash commands in a DM. Group DMs are not supported. A bot holds 1:1 conversations only.

DM history

Two reads page a DM’s history, on a backend that honors wire version 3. Both use the same cursors and limits as a channel read, and neither needs message_read_history: no permission gate applies in a DM.
fetchDmMessages(userId, { before?, after?, around?, limit? }) takes the peer’s user id. fetchMessages(null, dmId, opts) is the same read by dm id, and it rejects, naming fetchDmMessages() as the alternative, when the DM’s peer is not yet known to this session. threadId and postId are rejected on the DM form, because a DM has no containers. Rows decrypt under the DM key and come back as ordinary Messages: content, repliedTo, card, poll, createdAt from the row’s own send time, isDM: true, serverId: null, and channelId equal to the dm id. The bot’s own messages are included, and because each row carries createdAt, a recovered row can be edited or have its card retired with editCard(). That is how a bot finds a card it sent before a restart; see the Approval bot. command is never set on a history row, so a past invocation is never replayed. On a backend that denies the DM history read, the call resolves empty, exactly like an empty channel.

Slash commands in a DM

A /command in a DM dispatches through the same registry as a channel command, on either lane. Typed by hand (/status, using your commandPrefix), it rides the message text and works on any backend. Picked from the DM’s / picker, it rides the DM lane’s message broadcast as a structured invocation, which needs a backend that honors wire version 3; the client fetches your bot’s menu when it opens the DM, so anything registered before login() is offered there.
There is no permission gate in a DM: a grant is a server concept. Check ctx.userId yourself when a command should be limited to certain people. Declare dm: false on a command that must not run in a DM. The DM picker hides it, and an invocation that arrives anyway is dropped with a debug warning and never answered. dm defaults to true. See Commands in direct messages. A message dispatches at most once: on a wire version 3 backend the DM arrives on two frames, and whichever lands first carries the invocation. On an older backend only the typed form reaches the bot, because the notify frame carries the text and no command sidecar.

The keystore is not optional here

DM state lives in the same keystore as your identity and server keys: both the peer-to-dm map and the conversation key are written there. Without a keystore, every restart re-runs the key exchange, and, far worse, a regenerated identity can never be re-published. See Keys and the keystore for why a bot with no keystore is bricked on its second run. Losing only the DM half is mild by comparison. The peer-to-dm map is re-derivable, because the create call is idempotent for an existing pair and returns the same id. A lost DM key means the bot establishes a new one, and the peer’s older messages stay unreadable. If an inbound DM arrives that the bot cannot decrypt, content is the empty string '' and the SDK quietly re-runs the key exchange in the background, throttled per peer. Handle '' rather than assuming every DM is readable on arrival.

A complete DM echo bot

This is examples/dm-smoke.ts. Run it with npm run example:dm.

Next

Sending messages

send(), SendOptions, and why sends are fire-and-forget.

Keys and the keystore

Why a bot without a keystore cannot be restarted.

Mentions and replies

The pill, the ping, and what rides in plaintext.

Approval bot

A DM card with buttons, retired through the bot’s own echo.