How to Send DMs with the X API
in 2026 (v2, OAuth2)
The old v1.1 send_direct_message endpoint and Tweepy's api.send_direct_message() are deprecated. Here is the current v2 way, with OAuth2 PKCE, the dm.write scope, and copy-ready curl and Node.
7-day free trial. Cancel anytime.
First, stop using the v1.1 endpoint
Most tutorials that still rank tell you to call the v1.1POST direct_messages/events/newendpoint, or to use Tweepy'sapi.send_direct_message(). Those are the v1.1 path and are deprecated. Build on the v2 endpoint instead.
On v2 you send a DM with a single POST to/2/dm_conversations/with/:participant_id/messages, authenticated with an OAuth2 user access token that carries thedm.writescope. Reading replies usesGET /2/dm_eventswith the dm.read scope.
Deprecated, do not copy from old tutorials
v1.1direct_messages/events/new, Tweepy api.send_direct_message(), and API.send_direct_message are the old way. Use the v2 dm_conversations endpoint below.Step-by-step: send a DM on v2
Request the DM scopes with OAuth2 PKCE
DMs require a user access token, not an app-only Bearer token. Use OAuth2 Authorization Code with PKCE and request the scopes dm.read, dm.write, users.read, tweet.read, and offline.access (so you get a refresh token). Send the user to the authorize URL, then exchange the returned code for a token.
# 1. Send the user to authorize (PKCE code_challenge required)
https://twitter.com/i/oauth2/authorize?response_type=code\
&client_id=YOUR_CLIENT_ID\
&redirect_uri=https://yourapp.com/callback\
&scope=dm.read%20dm.write%20users.read%20tweet.read%20offline.access\
&state=STATE&code_challenge=CHALLENGE&code_challenge_method=S256
# 2. Exchange the returned code for a user access token
curl -X POST https://api.twitter.com/2/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=RETURNED_CODE" \
-d "client_id=YOUR_CLIENT_ID" \
-d "redirect_uri=https://yourapp.com/callback" \
-d "code_verifier=VERIFIER"
# Response includes access_token (dm.write) and refresh_tokenSend the DM (curl)
POST to /2/dm_conversations/with/:participant_id/messages with the user access token in the Authorization header and a JSON body of {"text":"..."}. The participant_id is the recipient's numeric user id (resolve a handle via GET /2/users/by/username/:username).
curl -X POST \
"https://api.twitter.com/2/dm_conversations/with/RECIPIENT_USER_ID/messages" \
-H "Authorization: Bearer USER_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"text":"Hey, saw your post on shipping fast. Quick question."}'
# 201 Created returns the dm_conversation_id and dm_event_idSend the DM (Node fetch)
The same call from Node 18+ using the global fetch. Keep the user access token in an environment variable, and always check the status because fetch does not throw on a 403.
const recipientId = "RECIPIENT_USER_ID";
const res = await fetch(
`https://api.twitter.com/2/dm_conversations/with/${recipientId}/messages`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.X_USER_ACCESS_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
text: "Hey, saw your post on shipping fast. Quick question.",
}),
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
// 401 = bad/expired token, 403 = missing dm.write or recipient unreachable,
// 429 = rate limited
throw new Error(`X DM ${res.status}: ${JSON.stringify(err)}`);
}
console.log(await res.json()); // { data: { dm_conversation_id, dm_event_id } }Read replies from /2/dm_events
To build an inbox, poll GET /2/dm_events with a token that has the dm.read scope. Each read event bills separately, so poll on a sensible interval rather than in a tight loop.
const res = await fetch(
"https://api.twitter.com/2/dm_events?dm_event.fields=text,sender_id,created_at&max_results=50",
{
headers: {
Authorization: `Bearer ${process.env.X_USER_ACCESS_TOKEN}`,
},
}
);
const { data = [] } = await res.json();
for (const event of data) {
console.log(event.sender_id, "->", event.text);
}Cost, rate limits, and reachability
Three things trip up most DM integrations. Plan for all three before you scale.
Cost
About $0.015 per DM sent and about $0.01 per DM event read on 2026 pay-per-use pricing. There is no free tier for new API projects, and reads add up when you poll for replies.
Volume limits
Roughly 500 DMs per day for a standard account, about 1000 for Premium, and about 1500 for Premium+, with an app-level ceiling near 15000 per 24 hours. X prohibits unsolicited automated bulk DMs.
Reachability
You can only DM someone who follows you, has open DMs, or accepts your message request. Cold requests effectively require the sender to have X Premium, or they are silently dropped. This is the most common cause of a 403.
Or skip the token plumbing entirely
OpenTweet DM Campaigns handles OAuth tokens, rate limits, warm-up, opt-outs, and a human-approval queue for you, over the official X API. It ships as a REST API and 6 MCP tools, so an AI agent can run outreach end to end.
Managed DM outreach with human approval
You connect your X account once, describe your ideal customer, and OpenTweet discovers leads, AI qualifies and drafts a personal message per lead, and you approve every message before anything sends. Sends are paced and capped, and opt-outs are honored automatically. The account owner stays in control.# Create a DM campaign over the OpenTweet REST API
curl -X POST https://opentweet.io/api/v1/dm-campaigns \
-H "Authorization: Bearer ot_your_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Founders shipping fast",
"x_account_id": "your_x_account_id",
"sources": [{ "type": "search", "value": "shipping in public", "max_candidates": 500 }],
"icp_prompt": "Indie SaaS founders posting build-in-public updates",
"message_brief": { "offer": "a scheduling tool for build-in-public founders" },
"accept_policy": true
}'
# Leads are AI-qualified and drafted, then wait for your approval.
# Equivalent MCP tool for AI agents: dm_campaign_createDM Campaigns is available on the Advanced and Agency plans. See the DM outreach overview and the MCP server.
Frequently asked questions
Which OAuth scope do I need to send a DM with the X API?
You need a user access token with the dm.write scope to send. Request dm.read as well to read replies from /2/dm_events, plus users.read and offline.access (for a refresh token). Auth is OAuth2 Authorization Code with PKCE. An app-only Bearer token cannot send DMs.
Why does my X DM return 403?
A 403 on a DM send is usually reachability or scope, not a bug. The token may be missing dm.write, or the recipient does not follow you, has DMs closed, or has not accepted your message request. Cold message requests from a non-Premium sender are often silently dropped, so a Premium or Premium+ sending account is recommended.
How much does it cost to send a DM with the X API?
On the 2026 pay-per-use pricing it is about $0.015 per DM sent and about $0.01 per DM event read. There is no free tier for new API projects, so budget for both the sends and the reads when you poll for replies.
Is bulk DM sending allowed on X?
X prohibits unsolicited automated bulk DMs under its automation rules. The API enforces volume ceilings of roughly 500 DMs per day for a standard account, about 1000 for Premium, and about 1500 for Premium+, with an app-level limit near 15000 per 24 hours. Message people who have a reason to hear from you, keep volume low, personalize each message, and honor opt-outs. The account owner is responsible for compliance.
Keep exploring
X DM Outreach
Human-approved DM outreach over the official API, built for people and agents.
Twitter MCP Server
Give an AI agent tools to post, schedule, and run DM outreach on X.
Automate X DMs Safely
The compliance checklist for DM automation that keeps your account safe.
OpenTweet vs Drippi
Human-approved and official-API outreach compared to volume-first DM tools.
Ship DM outreach without the token plumbing
Get your ot_ API key and run compliant, human-approved X DM campaigns over REST or MCP.
Get your API key7-day free trial. Cancel anytime.