Skip to content
Direct MessagesTwitter APIAuthenticationDeveloper Guide

GUIDE

The X Direct Messages API in 2026: Reading, Sending, and the Six Things It Will Not Do

A working guide to the X Direct Messages API: why a pooled key cannot read a DM, what a conversation id refers to, and the limits nobody documents.

TwitterAPIs·
Diagram of the X Direct Messages API path: your own X session reaches TwitterAPIs, which reaches your X inbox

Almost every guide to direct messages on X stops at the send call. It shows you a request body, prints a 200, and leaves. That is the easy tenth of the problem. The other nine tenths are the parts that make people give up on day two: which credential is even allowed to see a DM, what an id refers to when you have two kinds of id, how far back you can read, what a full mailbox costs when the meter runs per message rather than per request, and which single innocuous request will silently take your integration offline.

This guide covers those parts. Every endpoint below was called live against the production API on 2026-08-02, with the status codes reported as they came back, including the ones that were not what I expected.

TL;DR: X direct messages are account-scoped, so no anonymous or pooled API key can read them. On TwitterAPIs you register your own X session once with POST /twitter/customer/session, then use three endpoints: GET /twitter/dm/list for conversations, GET /twitter/dm/conversation for the messages inside one, and POST /twitter/dm/send to write. All three bill at $0.0016 per call. Neither read endpoint accepts a cursor, so DM history cannot be paged. Sends are capped at 150 per hour per key. Requesting a conversation your account is not part of will currently deregister your session.

Three node diagram showing your X session reaching TwitterAPIs, which reaches your X inbox

The credential chain. The account in the middle never substitutes for yours.

Why a Pooled API Key Cannot Read a Single DM

This is the first thing to internalise, because it changes the shape of your whole integration and it is the reason DM work feels different from every other endpoint you have used on this API.

Reading public tweets is anonymous by nature. The data is public, so a provider can serve it from a shared pool of accounts and you never think about whose credential fetched it. Your API key is the whole story.

A direct message is not public. It exists inside a conversation between specific accounts, and X will only release it to a session belonging to one of those accounts. There is no clever way around that, and any vendor claiming otherwise is either wrong or describing something you should not build on. It follows that no shared pool can help you: a pool account is somebody else's handle, and it is not in your conversation.

So the DM endpoints run on a different authentication model from the rest of the surface. You register the X session you want to act as, once, and every subsequent DM call runs as that account:

curl -X POST "https://api.twitterapis.com/twitter/customer/session" \
  -H "X-API-Key: $TWITTERAPIS_KEY" \
  -H "content-type: application/json" \
  -d '{"auth_token": "<your auth_token cookie>", "ct0": "<your ct0 cookie>"}'

Both values are cookies from a logged-in x.com browser session. Registration is on the free tier, so this call itself costs nothing, and the response tells you whether X accepted the cookies rather than optimistically reporting success:

{
  "ok": true,
  "validated": true,
  "validation": "validated",
  "username": "your_handle"
}

Read validated rather than ok. A validation state of unknown means the probe could not reach a verdict, which is different from the cookies being rejected, and treating unknown as failure will send you chasing a credential problem you do not have.

Comparison grid of a pooled API key against a registered customer session across five criteria

What each credential can reach. The DM rows are the whole reason this second path exists.

Skip registration and the DM endpoints tell you so directly. Calling dm/list with no session registered returned this on 2026-08-02:

HTTP 409
{"error":"session_required","message":"Register your Twitter session first: POST /twitter/customer/session with { auth_token, ct0 }. ..."}

A 409 here is not a bug and not a rate limit. It means the key is fine and the session is missing.

The part worth being honest about

Handing over auth_token and ct0 is not the same as granting a scoped OAuth permission. Those two cookies are the account. A service holding them can do anything the logged-in account can do, not just read direct messages. That is a real trust decision and it deserves to be made deliberately rather than discovered later.

Two practical consequences. First, if you are building a product where your users connect their own X accounts, you are asking them for full session credentials, and your onboarding copy should say so plainly rather than burying it. Second, there is a per-call alternative: the same credentials can be passed as x-auth-token and x-ct0 request headers instead of being stored, which lets one API key act as many senders and means nothing is persisted on our side. For a multi-tenant product that is usually the right shape.

The Three Endpoints, and What Each One Actually Returns

There are exactly three. The router registers GET /twitter/dm/list, GET /twitter/dm/conversation and POST /twitter/dm/send, and nothing else in the DM family.

Grid comparing the three DM endpoints across method, session requirement, cursor support, price and rate cap

The three endpoints on the five axes that decide your architecture.

GET /twitter/dm/list

Returns the conversations in the acting account's inbox. It takes no parameters at all.

curl "https://api.twitterapis.com/twitter/dm/list" -H "X-API-Key: $TWITTERAPIS_KEY"

Live on 2026-08-02 this returned HTTP 200 and 28 conversations, shaped like this:

{
  "count": 28,
  "conversations": [
    {
      "conversation_id": "1587816802264006657-1938492597917650949",
      "type": "ONE_TO_ONE",
      "participants": ["1587816802264006657", "1938492597917650949"]
    }
  ]
}

Three things about that payload are worth naming. The type field is passed through from X rather than normalised, so a group thread would surface with its own type value; on the account tested, all 28 conversations were ONE_TO_ONE with exactly two participants each, so group behaviour is not something this run can vouch for. Participants are numeric user ids, not handles, so resolving them to something human readable is a separate lookup you will have to make. And there is no cursor in that response, which matters more than it looks and is covered below.

GET /twitter/dm/conversation

Takes one required parameter, conversation_id, and returns the messages inside that thread.

curl "https://api.twitterapis.com/twitter/dm/conversation?conversation_id=100215065-1938492597917650949" \
  -H "X-API-Key: $TWITTERAPIS_KEY"
{
  "conversation_id": "100215065-1938492597917650949",
  "count": 20,
  "messages": [
    {
      "id": "1939...",
      "time": "1751295650053",
      "sender_id": "1938492597917650949",
      "text": "..."
    }
  ]
}

Four fields per message and no more: id, time, sender_id, text. time is a millisecond epoch delivered as a string, so parse it as an integer and divide by 1000 before handing it to a date library. There is no field for attachments, reactions, read state or edits, so if a message carried an image, what you get back is whatever text accompanied it.

Omit the parameter and the route answers before it does any work, which is the behaviour you want:

HTTP 400
{"error":"bad_request","message":"Provide `conversation_id`."}

POST /twitter/dm/send

Takes recipient_id, a numeric X user id, and text. Query string or JSON body both work, and the query string wins if you supply both.

curl -X POST "https://api.twitterapis.com/twitter/dm/send" \
  -H "X-API-Key: $TWITTERAPIS_KEY" \
  -H "content-type: application/json" \
  -d '{"recipient_id": "44196397", "text": "hello"}'

Note that recipient_id is a user id and not a handle. If you have a handle you need a user lookup first, which is a separate call at the standard read rate.

Validation runs before anything is sent or billed. All three of these returned HTTP 400 on 2026-08-02, with no upstream call made:

{}                                              -> 400 numeric recipient_id required
{"recipient_id":"notanumber","text":"hi"}       -> 400 numeric recipient_id required
{"recipient_id":"44196397","text":"  "}         -> 400 non-empty text required

The send path also does something worth calling out, because it is the opposite of a mistake I have seen in plenty of write endpoints. A 200 from X does not automatically count as success. The route only reports ok: true when X returns an actual message id; if the upstream answers 200 with no message id, which is what happens on a duplicate send or a recipient who does not accept messages from you, the call surfaces as a 502 and is not billed. You can trust the status code without parsing the body to find out whether your message really left.

Four step flow from registering a session to sending a direct message

Zero to a readable message in four calls, one of which you make only once.

Conversation Versus Message: What an Id Refers To

This trips people up because both ids are long numeric strings and neither is labelled in a way that tells you which is which.

A conversation id identifies a thread. For a one-to-one conversation it is the two participant user ids joined by a hyphen, which is why the real examples above look like two numbers stuck together. You get conversation ids from dm/list and you pass the whole string, hyphen included, to dm/conversation.

A message id identifies one line inside a thread. It comes back inside the messages array and it is not accepted anywhere as an input. There is no endpoint that fetches a single message by id, no endpoint that deletes one, and no endpoint that edits one.

The practical rule: ids flow in one direction only. dm/list produces conversation ids, dm/conversation consumes them and produces message ids, and message ids are terminal. If you find yourself wanting to pass a message id back into the API, the operation you are reaching for does not exist.

The Paging Problem, Stated Plainly

Here is the limit that will decide whether this API fits your product, and it is the thing the easy half of the internet never mentions.

Neither read endpoint pages. dm/list accepts no cursor and returns none. dm/conversation accepts no cursor and returns none. Each is a single request that hands back the current state and stops.

I checked this in the route source rather than inferring it from a response, because an absent cursor in one response could just mean there was nothing more to fetch. There is no pagination parameter on either read path and no cursor field in either response shape. The measurements line up: one busy thread returned 20 messages, a quiet one returned 1, and neither response contained anything that would let me ask for what came before.

What this means concretely:

  • You cannot backfill a conversation's history. Whatever the first call gives you is what you get.
  • You cannot enumerate an inbox larger than one response. dm/list makes a single call to X's inbox state and returns what that returns.
  • Building an archive means polling and accumulating. Call dm/list on a schedule, diff it against what you already hold, and pull the conversations that changed. Your database becomes the history, because the API will not replay it for you.

If your requirement is "import three years of DMs on signup", that requirement is not met here, and it is better to know now than after you have written the importer.

Start building with TwitterAPIs

$0.04 per 1,000 tweets. $0.50 free credits. No credit card required.

The Trap That Will Cost You an Afternoon

This is the finding I did not expect, and it is reproducible.

Requesting a conversation the acting account is not a participant in does not return a clean 403 or 404. It returns HTTP 401, and as a side effect it deregisters the session behind your API key. Every DM call on that key then answers 409 until you register fresh credentials.

The four calls, in order, on 2026-08-02:

GET /twitter/dm/list                                        -> 200, 28 conversations
GET /twitter/dm/conversation?conversation_id=44196397-12345678 -> 401 session_dead
GET /twitter/dm/list                                        -> 409 session_required
GET /twitter/dm/conversation?conversation_id=<a real one>    -> 409 session_required

The second call used a well-formed id for a conversation the account is not in. The session that had answered a healthy 200 seconds earlier was gone.

Four step flow showing how one request for a foreign conversation id ends a session

The sequence, as measured. Nothing about step two looks dangerous when you write it.

The mechanism is a classification problem. X refuses the request with an authentication-shaped error, and the classifier treats that shape as proof the credential itself is dead rather than as proof this particular resource is off limits. Those are different failures. An expired cookie means re-authenticate. A conversation you are not in means you asked for the wrong thing, and your credential is perfectly healthy.

What makes this worth writing down rather than working around quietly is that the same codebase already learned this lesson one branch away. The ambiguous 403 case was narrowed some time ago so that it only counts as a dead session when X's response body carries a real authentication error code, precisely because treating every 403 as a death had once benched a pile of healthy accounts. The 401 branch never got the same treatment, and the DM conversation route is the endpoint that hands a caller the easiest way to produce a per-resource 401.

Until that changes, three rules will keep you out of it:

  1. Only pass conversation ids you just received from dm/list. Do not construct them, do not read them from a config file, and do not replay them from a stale cache. Ids from a list call are ids you are a participant in by definition.
  2. Treat a 401 on this route as "re-register", not "rotate credentials". The error text will tell you your session is no longer valid. It may well still be valid. Rotating cookies that were never the problem is how this costs you a day rather than a minute.
  3. Do not loop over ids without a circuit breaker. A batch job iterating a list where one entry has gone stale will kill the session on that entry and then fail every remaining item with a 409, which reads like a total outage rather than one bad row.

Stat panel showing that one request for a conversation you are not part of deregisters the session

The blast radius of a single mistyped id.

Rate, Volume, and the Cap That Is Protecting You

POST /twitter/dm/send is capped at 150 sends per hour per API key by default. Over the cap you get a clean refusal:

HTTP 429
Retry-After: <seconds>
{"error":"rate_limited","limit_per_hour":150,"retry_after_seconds":<n>, ...}

Three design details are worth knowing because they change how you should retry.

It is a sliding window, not a calendar hour. A fixed hourly bucket would let a caller send the full cap at 59 minutes past and the full cap again a minute later, which is double the intended rate at exactly the moment that matters. The sliding window caps the true trailing rate, and it means Retry-After is exact rather than a guess: it is the time until the oldest send in the window ages out and frees a slot.

It counts attempts, not successes. If your sends are failing upstream because the recipient does not accept messages from you, those attempts still consume budget. That feels harsh until you consider what the cap exists for. Hammering the send path with failing requests is precisely the pattern X reads as automation and locks accounts over. The cap is containment on your own account's behalf.

A refused send is not billed and never reaches X. The check runs after parameter validation and before the upstream call, so a 429 costs you nothing but the round trip.

Stat panel of four numbers describing the DM surface: three endpoints, zero cursors, 150 sends per hour, 409 when no session

The four numbers that determine whether this surface fits your build.

The wider point about volume: X enforces its own soft DM velocity ceiling and escalates to account locks when it is crossed, and no API can protect you from a sending pattern that looks like spam. If you are planning outbound DM volume, the cap is not your constraint. The recipient's tolerance and X's abuse systems are.

What a Mailbox Actually Costs

Every DM endpoint bills at $0.0016 per call. That is the same rate for dm/list, dm/conversation and dm/send.

The number that matters is not the rate though. It is the billing unit, and this is where the comparison with X's own API stops being a rounding difference.

X publishes its rates on a public card. Read on 2026-08-02, the two DM rows are DM Event: Read at $0.010 per resource and DM Interaction: Create at $0.015 per request. The same page states the rule that makes the read row expensive: charges are levied "per resource fetched (reads) or per request (writes/actions)".

Per resource means per message. So the cost of reading a conversation on the official API scales with how chatty the conversation is, while here it does not.

Sending is the cleaner comparison, because both sides bill per request and no assumption is needed:

Bar chart comparing the cost to send one direct message on the X API at fifteen thousandths of a dollar against TwitterAPIs at sixteen ten-thousandths

Both figures are published per-request rates. No workload assumption applied.

Reading is where the unit difference shows up. Across the two conversations I actually read on 2026-08-02, the API returned 21 messages in total, for two calls:

Bar chart comparing the cost to read twenty one messages: twenty one cents on the X API billed per event against thirty two hundredths of a cent here billed per call

Same 21 messages, each vendor's own billing unit applied. The gap is the unit, not the discount.

For your own planning the arithmetic is short. A full sweep of an inbox costs one dm/list call plus one dm/conversation call per thread, so an inbox of N conversations costs (1 + N) x $0.0016 regardless of how many messages those threads contain. The 28 conversation inbox measured here would cost about 4.6 cents to sweep completely. On a per-resource meter the same sweep costs whatever the total message count happens to be, which is a number you do not know before you start.

Worth stating plainly: those X figures are that vendor's own published list prices, retrieved on 2026-08-02 and stamped accordingly, and they change. Our figure is the live per-call rate. Neither is a benchmark of speed or quality, and this section is not claiming one.

Six Things This API Will Not Do

Every item here was established by reading the router source, not by trusting documentation.

Numbered list of six things the DM API cannot do

The honest limits. Any of them can be a dealbreaker depending on what you are building.

  1. Page back through history. Covered above. Neither read takes a cursor.
  2. Reach a conversation you are not part of. Participation is the access boundary. Asking anyway currently costs you the session.
  3. Send from anything but your own account. There is no pooled sender and no way to send as somebody else. The message leaves from the handle whose cookies you registered.
  4. Attach media. dm/send takes recipient_id and text. There is no media parameter on the route.
  5. Mark read, or show a typing indicator. No read-receipt route and no typing route is registered.
  6. Subscribe to new messages. There is no webhook and no stream on this surface. Detecting new mail means polling dm/list on a schedule you choose.

Numbers three and six are the ones that most often reshape a design. If you are building a support inbox, the absence of a webhook means you own the polling loop and its cost. If you are building anything that sends on behalf of multiple users, each of those users has to connect their own session.

Building the Polling Loop You Are Now Responsible For

Because there is no webhook and no cursor, the ingestion design is not optional and it is not provided. You own it. It is worth walking through properly, because the naive version is both expensive and lossy.

The naive version calls dm/list, then calls dm/conversation on all 28 threads, every minute. At the measured inbox size that is 29 calls a minute, which is 41,760 calls a day, or about $67 a day at the per-call rate, to detect messages that mostly did not arrive. Over a month that is roughly $2,000 spent almost entirely on re-reading conversations that did not change. It is also lossy in a way that is easy to miss: if two messages land in the same thread between two polls, you see the newer state and you may never learn about the older one, because you cannot page backwards to find it.

The workable version uses the inbox listing as a change detector rather than as a payload:

import os, time, requests

BASE = "https://api.twitterapis.com"
H = {"X-API-Key": os.environ["TWITTERAPIS_KEY"]}
seen = {}  # conversation_id -> newest message id we have stored

def poll_once():
    r = requests.get(f"{BASE}/twitter/dm/list", headers=H, timeout=30)
    if r.status_code == 409:
        raise RuntimeError("session deregistered; re-register before continuing")
    r.raise_for_status()

    for conv in r.json()["conversations"]:
        cid = conv["conversation_id"]
        # Only spend a call on a thread we have never read, or that we have
        # some other reason to believe moved. Everything else is skipped.
        if cid in seen and not should_recheck(cid):
            continue

        m = requests.get(f"{BASE}/twitter/dm/conversation", headers=H,
                         params={"conversation_id": cid}, timeout=30)
        if m.status_code == 401:
            # This id came from dm/list, so a 401 here is a real session
            # problem rather than a foreign-conversation mistake. Stop the loop.
            raise RuntimeError("session deregistered mid-sweep")
        if m.status_code != 200:
            continue  # 502 from upstream: log it, move on, do not kill the loop

        messages = m.json()["messages"]
        newest = messages[0]["id"] if messages else None
        if newest != seen.get(cid):
            store_new_messages(cid, messages, since=seen.get(cid))
            seen[cid] = newest

Three decisions in there are worth making consciously.

Store on your side, always. Your database is the history, because the API will not replay it. Every message you receive should be written down the first time you see it, keyed by message id so a repeat poll is idempotent. This is not an optimisation. It is the only archive that will exist.

Poll the list often, the conversations rarely. dm/list is one call and it is the cheap way to notice that something changed. Reading every thread on every tick is where the cost goes. Exactly how you decide should_recheck depends on your product, but any heuristic that avoids re-reading quiet threads on every pass will dominate the naive version on cost.

Do not let one bad thread stop the sweep, and do let a dead session stop it. Those are different failures and they need different handling. A 502 on one conversation is a bad row: log it and continue. A 401 mid-sweep, on an id that came from dm/list moments earlier, means the session itself went away, and continuing just burns calls that will all fail.

The uncomfortable part is the gap between polls. If your loop runs every five minutes, your worst-case detection latency is five minutes, and there is no configuration anywhere that improves it, because nothing pushes. For a support inbox that is usually acceptable. For anything advertising real-time delivery, it is a claim you cannot back with this surface, and it is better to design the expectation than to promise around it.

The cheapest pay-as-you-go Twitter API. Try it free.

$0.04 per 1,000 tweets. $0.50 free credits. No credit card required.

The Errors You Will Actually Meet

List of six HTTP status codes a DM integration encounters and what each one means

Every code here came back from a real call during this write-up.

The two that get misread most often are 409 and 502. A 409 means the key is healthy and no session is registered, which is a setup problem, not an auth problem. A 502 means X refused the call; a malformed conversation id lands here, and so does a send that X declined for its own reasons such as a duplicate or a recipient who does not accept messages from strangers.

The one that is genuinely misleading is 401. Its message will tell you your registered session is no longer valid. Sometimes that is true. Sometimes you just asked for a conversation you are not in, and the credential is fine.

Code That Runs

Python, listing the inbox and reading the most recent thread. This is the shape of nearly every DM integration's read loop:

import os, requests

BASE = "https://api.twitterapis.com"
H = {"X-API-Key": os.environ["TWITTERAPIS_KEY"]}

# 1. the inbox. one call, no cursor, no parameters.
r = requests.get(f"{BASE}/twitter/dm/list", headers=H, timeout=30)
r.raise_for_status()
conversations = r.json()["conversations"]
print(f"{len(conversations)} conversations")

# 2. read one thread. ONLY ever pass an id that came from the call above:
#    a conversation you are not a participant in will end your session.
cid = conversations[0]["conversation_id"]
r = requests.get(
    f"{BASE}/twitter/dm/conversation",
    headers=H, params={"conversation_id": cid}, timeout=30,
)
if r.status_code == 409:
    raise SystemExit("no session registered; POST /twitter/customer/session first")
if r.status_code == 401:
    # re-register the session. do NOT assume the cookies expired.
    raise SystemExit("session was deregistered; register again before retrying")
r.raise_for_status()

for m in r.json()["messages"]:
    when = int(m["time"]) / 1000  # milliseconds, delivered as a string
    print(when, m["sender_id"], m["text"][:80])

Node, sending one message and reading the result honestly:

const BASE = "https://api.twitterapis.com";
const headers = {
  "X-API-Key": process.env.TWITTERAPIS_KEY,
  "content-type": "application/json",
};

const res = await fetch(`${BASE}/twitter/dm/send`, {
  method: "POST",
  headers,
  // recipient_id is a numeric USER id, not a handle.
  body: JSON.stringify({ recipient_id: "44196397", text: "hello" }),
});

if (res.status === 429) {
  // sliding-window cap. Retry-After is exact, not a guess.
  const wait = Number(res.headers.get("retry-after") ?? 60);
  console.log(`send cap reached, a slot frees in ${wait}s`);
} else if (res.status === 400) {
  console.log("bad parameters; nothing was sent and nothing was billed");
} else if (res.ok) {
  const body = await res.json();
  // ok is true only when X returned a real message id.
  console.log(body.ok ? `sent ${body.message_id}` : "not sent");
} else {
  console.log(`send failed upstream: ${res.status}`);
}

Both samples were written against the responses recorded in this guide. The status-code branches are not defensive padding; each one is a case that came back during testing.

Why Developers Keep Hitting a Wall Here

The reason this surface is under-documented is not that it is boring. It is that access to it has been genuinely hard to get. The complaint is public and specific:

In the replies to that thread, the same developer named exactly which capability the top tier was gating:

That is a working indie developer offering to pay four figures a month purely to reach DM functionality. The same pattern shows up in build communities, where the conclusion is usually to go looking for another route entirely:

the r/n8n thread asking for a third-party API to send X DMs from an automation, because the official API is priced out of reach from r/n8n

The poster's framing was blunt: "Is there any third party api which can send dm to particular person inside Twitter using N8n as offical API is too expensive."

Even the boolean of whether an account can be messaged is gated behind authentication, which pushes people toward account-scale workarounds:

the r/webscraping thread documenting that the can_dm field is hidden from unauthenticated requests, forcing many accounts and rate-limit workarounds from r/webscraping

That developer's summary was precise: "I can pull almost every other bit of a profile not logged in except the DM status."

That one is worth answering directly, because it is the exception to everything above. The can_dm boolean does come back on a plain profile read here, with no session registered and no cookies involved, because it is a property of the target account rather than of a private conversation. Checked on 2026-08-02, GET /twitter/user/info returned can_dm: false for one account and can_dm: true for another, so it is live data and not a constant.

That makes it a cheap pre-flight worth building in. A send to someone who does not accept messages from you comes back as a 502 after the round trip, so reading can_dm at the standard read rate first lets you skip the attempt, and skipped attempts do not consume your hourly send budget.

The lesson in the rest of it is the same one this guide opened with. DM content is account-scoped all the way down, and the moment a design assumes otherwise, it stops working.

Privacy, Briefly

Direct messages are the most sensitive material on the platform, and building on them carries obligations that reading public tweets does not.

Numbered checklist of five privacy questions to settle before reading a DM inbox in production

Five questions worth answering before the first production call, not after.

Two of those deserve a sentence more. Only one side consented. The account owner agreed to connect their inbox to your service. The person on the other end of every conversation did not, and their messages are now in your logs. Whatever retention policy you write applies to people who never agreed to it. And a session cookie is not a scoped grant, so "we only read DMs" is a promise about your code rather than a limit on your access. Users who understand the difference will ask, and having a real answer is better than improvising one.

None of that is a reason not to build. It is a reason to decide the retention window, the access controls and the deletion path before the first dm/list call runs in production rather than after somebody asks.

Where to Go Next

If you have not yet worked out which credential model your build needs, the Twitter API authentication guide covers the difference between key-only access and session-scoped access across the whole surface. The endpoint reference enumerates everything else available alongside the DM routes, and the error codes guide covers the status codes you will meet outside this family. For the read side of things, pagination on this API explains how cursors work on the endpoints that do have them, which is useful context for understanding what the DM reads are missing. Current per-call rates for every endpoint are on the pricing page. For the condensed version of the send path on its own, how to send a DM with the Twitter API covers the session, the numeric recipient id, and the hourly send cap in one place.

The summary, if you only take one thing: DMs run on your session, not on your API key, and the two limits that will shape your design are that history does not page and that new messages do not push. Everything else is a detail you can work around.

// sources

Where these numbers come from

Each row is a figure in this post and the artefact it was read from. Prices and limits on this platform move, so check the date on the source before you plan against it.

X API pricing rate card
Read on 2026-08-02, the source for the two DM rows the post quotes, DM Event Read at $0.010 per resource and DM Interaction Create at $0.015 per request, plus the stated rule that charges are levied per resource fetched on reads and per request on writes.

Frequently Asked Questions

No. Public tweets can be read with nothing but an API key, because they are public. A direct message is private to the accounts in the conversation, so there is no anonymous path to it and no shared pool of accounts that could stand in for you. On TwitterAPIs you first register your own X session with POST /twitter/customer/session, passing the auth_token and ct0 cookies from a logged-in x.com session. Every later DM call runs as that account. Skip the registration step and the DM endpoints answer HTTP 409 with a session_required error, which was the measured response on 2026-08-02.

You cannot, and this is the sharpest limit on the surface. Neither GET /twitter/dm/list nor GET /twitter/dm/conversation accepts a cursor parameter, and neither returns one in the response body. Each call is a single shot at the most recent state. Reading one busy thread on 2026-08-02 returned 20 messages and a quiet thread returned 1, with no field in either response that would let you ask for what came before. If you need a durable archive, the only workable pattern is to poll on a schedule and accumulate messages on your side as they appear.

Yes, and it is deliberate. POST /twitter/dm/send is capped at 150 sends per hour per API key by default, enforced as a sliding window rather than a calendar hour so a caller cannot burst twice the cap across a boundary. Crossing it returns HTTP 429 with a Retry-After header and the send is refused before any upstream call, so it is never billed. The cap counts attempts rather than successes, because hammering the send path with failing requests is exactly the behaviour X locks accounts for. It protects your own X account, not our infrastructure.

The call fails, and on 2026-08-02 it also deregistered the session behind the API key. X refuses a conversation the acting account is not a participant in, and that refusal is currently classified as a dead credential rather than a refused resource, so the stored session is marked dead and every later DM call on that key answers HTTP 409 until a fresh session is registered. The practical rule is to only ever pass conversation ids you just received from dm/list, and to read a 401 on this route as a signal to re-register rather than as proof your cookies expired.

A conversation is a thread and a message is one line inside it. GET /twitter/dm/list returns conversations, each carrying a conversation_id and the numeric user ids of its participants. For a one-to-one thread the conversation_id is the two participant user ids joined by a hyphen, for example 100215065-1938492597917650949. You then pass that whole string to GET /twitter/dm/conversation, which returns the individual messages, each with its own id, a millisecond epoch time, a sender_id and the text. You cannot pass a message id where a conversation id is expected.

On TwitterAPIs all three DM endpoints bill at $0.0016 per call, and a call to dm/conversation returns the thread's messages together rather than charging for each one. X's own published rate card bills DM Event: Read at $0.010 per resource and DM Interaction: Create at $0.015 per request, both read from docs.x.com on 2026-08-02. The billing unit matters more than the sticker price: X charges per message returned, so a 20 message thread bills 20 times on the official API and once here.

Not through this endpoint. POST /twitter/dm/send accepts a numeric recipient_id and a text body, and nothing else. There is no media parameter on the DM send route, so an image, a file or a card cannot be attached to an outbound direct message. Media upload exists as a separate capability for composing posts, but it is not wired into the DM path. If your product depends on sending an attachment by DM, that requirement is not met here and you should design around a link instead.

Not with TwitterAPIs. There is no developer portal application, no access tier review and no OAuth app to register and rotate. You authenticate with one API key issued from your TwitterAPIs account and register the X session you want to act as. On X's own API the picture is different: developers have publicly complained that DM API access sits behind the highest pricing tier, with one indie developer asking in September 2025 for a middle tier because the jump from $200 to $5,000 a month was the barrier to getting DM access at all.

Check out similar blogs

More guides on the Twitter/X API, scraping, and pricing.

Twitter (X) API authentication in 2026, covering OAuth 1.0a and OAuth 2.0 bearer tokens, the four credential types, and how to fix 401 Unauthorized and 403 errors in Python and Node.js
Twitter API AuthenticationOAuth 2.0

Twitter API Authentication in 2026: OAuth, Bearer Tokens, and Fixing 401

How Twitter (X) API authentication works in 2026: the four credential types, OAuth 1.0a versus OAuth 2.0, generating and using a bearer token, runnable Python and Node.js, and a fix for every 401 Unauthorized and 403 error, plus the one-header alternative.

TwitterAPIs·
The complete 2026 reference of X (Twitter) API error codes, covering authentication, permission, rate limit, and write errors with the cause and the exact fix for each
Error CodesDeveloper Reference

Twitter API Error Codes: The Complete 2026 Reference

The complete 2026 reference to X (Twitter) API error codes. What every code means (32, 88, 187, 226, 401, 403, 429, 453 and the rest), the real cause behind it, and the exact fix, plus the HTTP status versus error code distinction that trips up most developers.

TwitterAPIs·
Building a Twitter bot in 2026, no-code and Python paths, runnable code, and the real X API cost reality after the free tier ended
Twitter BotX Bot

How to Build a Twitter Bot in 2026: The Complete Guide

Build a Twitter bot in 2026 with no-code or Python. Working Tweepy and requests code, auth explained, and the cheap API path at $0.04 per 1,000 reads.

TwitterAPIs·
Is the Twitter API free in 2026, the write-only free tier explained against the full X API pay-per-use cost ladder
Free TierAPI Pricing

Is the Twitter API Free in 2026? What the Free Tier Actually Gives You

The X API free tier is write only: 1,500 posts a month, zero read access. Here is the full 2026 cost ladder and where pay-per-call APIs fit for read-heavy work.

TwitterAPIs·
Twitter API tutorial 2026 complete developer guide, pricing collapse era, with auth flows, endpoints, code samples, and cost math
TutorialDeveloper Guide

Twitter API Tutorial 2026: The Complete Developer Guide

The 2026 Twitter API tutorial built after the pricing collapse. Auth, endpoints, code, rate limits, real costs, and the alternative when official gets too expensive.

TwitterAPIs·
What rate limited means on X in 2026, covering the consumer account action limits and the developer API 429, with the current published numbers for both
Rate Limits429

What "Rate Limited" Actually Means on X (Every Limit, Measured)

"Sorry, you are rate limited" is one message covering two different systems: a consumer action ceiling and a developer API window. Here is what the term means, the current numbers for both, where it came from, and how long it actually lasts.

TwitterAPIs·
The 2026 Twitter/X API developer reference: an indexed catalog of endpoints, authentication, rate limits, error codes, and cursor pagination, with the per-call cost of each request
Twitter APIX API

The Twitter API Developer Reference (2026): Endpoints, Rate Limits, Error Codes and Pagination

A single indexed reference for the Twitter/X API in 2026: the endpoint catalog, how authentication and bearer tokens work, the rate limits behind every 429, what error codes 401, 403, and 429 mean, cursor pagination, response shapes, and the real per-call cost of each call.

TwitterAPIs·
How to choose a Twitter/X API in 2026: a buyer's-guide framework weighing pricing model, data coverage, rate limits, authentication, reliability, compliance, and migration cost across the official X API and third-party providers
Twitter APIX API

How to Choose a Twitter/X API in 2026: The Complete Buyer's Guide

A decision framework for choosing a Twitter/X API in 2026: the seven criteria that actually matter (pricing model, data coverage, rate limits, auth, reliability, compliance, migration cost), a use-case decision tree, and where each path wins.

TwitterAPIs·