Engineering

X (Twitter) DM API: Send Direct Messages Without a Window

Instagram and Facebook make you wait for a comment, then give you 24 hours to answer it. X lets an account DM anyone whose settings accept it, cold, with no clock running. The whole surface: BYOK setup, the dm.read scope that replaces your defaults, the webhook you have to register before you connect anything, and the four lookups X bills to your own app.

Every other messaging network in our API makes you wait to be spoken to. Instagram and Facebook only let a business open a thread as a private reply to a comment on its own post, and then give you 24 hours to say something. X does neither. An X account can message anyone whose DM settings accept it, cold, with no comment to answer and no clock running.

That one difference is most of what you need to know to build on it, because it moves the hard part somewhere else. On Meta the hard part is the window. On X the hard part is that you own the app: X is bring-your-own-key only, the DM scope is not requested by default, and every lookup the API does on your behalf is billed to your credits, not ours.

Here is the whole surface, the three ways to address a contact, and the four things X bills you for.

What shipped, and when

X Direct Messages landed in the Conversations API on 2 November 2026, and X replies started arriving as comment.received webhooks on 4 November 2026. Before that, the Conversations API was Instagram and Facebook Pages only. The endpoints, the conversation and message objects and the webhook events are identical across all three networks — what differs is the connection step and who you are allowed to message first.

If you have built against the Instagram DM API already, your inbox code does not change. Your onboarding does.

The one-time setup X requires

X is BYOK, and that is not optional

Outstand ships no managed X OAuth app. For Instagram or a Facebook Page you can connect an account against our app and start building; for X you register your own X application in the developer portal, add its OAuth 2.0 Client ID and Client Secret to Outstand, and only then can an X account be connected at all. The full walkthrough is in the X configuration guide.

One trap worth knowing before you start: as of February 2026, X closed its legacy Free, Basic and Pro tiers to new sign-ups and moved new developers onto pay-per-usage credits. A brand-new app is not enrolled on POST /2/tweets by default. OAuth consent will succeed and your first publish will come back client-not-enrolled. We wrote up the rate card separately in X API pricing.

Register the webhook before you connect anything

X delivers replies and DMs through the X Activity API to a webhook on your app, so you register our URL on it once, using your app-only bearer token:

bash
1curl -X POST https://api.x.com/2/webhooks \
2  -H "Authorization: Bearer YOUR_X_APP_BEARER_TOKEN" \
3  -H "Content-Type: application/json" \
4  -d '{"url": "https://webhooks.outstand.so/x/YOUR_CLIENT_ID"}'

YOUR_CLIENT_ID is the OAuth 2.0 Client ID you added to Outstand. X issues a CRC challenge during that call, which we answer with the Client Secret you stored.

The ordering matters and the failure is silent. Outstand subscribes an account to its events when the account connects. Register the webhook first, and every account you connect afterwards is wired up automatically. Connect accounts first, and they receive nothing until you reconnect them. There is no error to catch here — just an inbox that stays empty.

The DM scope replaces the defaults, it does not add to them

An X connection requests tweet.read, tweet.write, users.read, offline.access, follows.read and dm.write by default. dm.read is not among them, and without it you receive no inbound DMs.

To get it, generate the auth URL with an explicit scopes list. That list replaces the defaults rather than extending them, so you have to restate the ones you still want:

bash
1curl -X POST https://api.outstand.so/v1/social-networks/x/auth-url \
2  -H "Authorization: Bearer YOUR_API_KEY" \
3  -H "Content-Type: application/json" \
4  -d '{
5    "redirect_uri": "https://your-app.example.com/social/callback",
6    "scopes": "tweet.read,tweet.write,users.read,follows.read,dm.read,dm.write,media.write,offline.access"
7  }'

Drop tweet.write from that string because you only care about DMs, and you have also just removed the account's ability to publish.

The endpoints

The Conversations API is six paths and nine operations, and they are the same ones for X as for Instagram and Facebook. Seven of them matter on X:

Route

What it does

POST /v1/conversations

Start a conversation, or add to an existing one

GET /v1/conversations

List conversations

GET /v1/conversations/{id}

Read one conversation

GET /v1/conversations/{id}/messages

List messages in a thread

POST /v1/conversations/{id}/messages

Send text, media, or both

DELETE /v1/conversations/{id}/messages/{messageId}

Cancel a scheduled, undelivered message

POST /v1/conversations/{id}/read

Mark the thread read

The two that are left are POST and DELETE on .../messages/{messageId}/reactions, which X rejects — see below.

All of it is the same https://api.outstand.so base URL and the same Bearer API key authentication as the Publishing API. Use the Outstand conversation and message IDs the API returns, not X's platform IDs.

Three ways to address a contact

POST /v1/conversations takes an x object, and you pass exactly one of three fields. This is the part that has no Meta equivalent.

By username

bash
1curl -X POST https://api.outstand.so/v1/conversations \
2  -H "Authorization: Bearer YOUR_API_KEY" \
3  -H "Content-Type: application/json" \
4  -d '{
5    "social_account_id": "x8Kp2",
6    "content": "Thanks for the mention - here is that changelog link you asked about.",
7    "x": { "username": "XDevelopers" }
8  }'

The @ is optional. X usernames are 1–15 characters of A-Za-z0-9_, and the API validates that shape before it calls anything. Addressing someone this way costs you one user read on X, because we have to resolve the username to an ID.

By user ID

json
1"x": { "user_id": "2244994945" }

A numeric ID of up to 19 digits. If you already have it, use it — this is the only one of the three that triggers no extra lookup.

By the reply they left you

This is comment-to-DM, and on X it is much less restricted than on Meta. Take commentId straight off the comment.received webhook:

json
1"x": { "comment_id": "1849000000000000001" }

We look the reply up and message whoever wrote it. Unlike Instagram and Facebook — where a comment can receive exactly one private reply, within 7 days, and nothing further until the contact answers — X imposes no per-reply limit and no deadline. You can send more than one DM for the same reply, which makes retrying a failed send safe rather than a policy problem.

This post is about sending a DM. If what you actually want is the reactive flow — a comment arrives, you answer it in a DM automatically, across Instagram, Facebook Pages and X — that is a different design problem, with its own deduplication and windowing rules, and we have written it up separately under comment-to-DM automation. Everything below assumes you already know who you want to message.

Sending, scheduling, and cancelling

Replies go to POST /v1/conversations/{id}/messages with content, media_urls, or both:

bash
1curl -X POST https://api.outstand.so/v1/conversations/CONVERSATION_ID/messages \
2  -H "Authorization: Bearer YOUR_API_KEY" \
3  -H "Content-Type: application/json" \
4  -d '{
5    "content": "Here is the walkthrough.",
6    "media_urls": ["https://cdn.example.com/walkthrough.png"]
7  }'

The response is 202 Accepted with the message in pending. Delivery is asynchronous, so the outcome arrives as a message.sent or message.failed webhook, or by polling the messages list. Treat the 202 as "queued", never as "delivered".

Pass scheduled_at to send later. A scheduled message comes back pending with scheduled_at set, and you can still call it back with DELETE /v1/conversations/{id}/messages/{messageId} — but only while it is undelivered and only if it was scheduled in the first place. Anything already sent, failed, or never scheduled returns 409.

Because X has no messaging window, scheduling on X is genuinely unconstrained. On Instagram and Facebook the window check is applied to scheduled_at up front, so a send that would land outside 24 hours is rejected when you submit it.

What X does not give you

Three honest gaps, all of which are Instagram-only features rather than things we have not got round to:

  • No quick replies and no buttons. instagram.quick_replies (up to 13) and instagram.buttons (up to 3) live under the instagram object because Instagram is the only network whose messaging product has them. There is no x equivalent.
  • No reactions. POST /v1/conversations/{id}/messages/{messageId}/reactions works on Instagram and returns 400 for X. Facebook returns 400 too, for now.
  • No replies to replies. comment.received fires for direct replies to the account's posts, including posts published outside Outstand. X does not deliver replies to replies, replies from protected accounts, or the account's own replies.

What X bills to your app

This is the part that surprises people, so it is worth being precise. These reads land on your X app's credits, not on Outstand's:

When

What we call

Billed as

A reply is delivered

The post.reply.create event itself

1 post read

comment.received is sent

A lookup of the reply's author for follow status, follower count and verification

1 user read per connected account the reply is sent for

You start a conversation with x.username

A lookup of the user's ID

1 user read

You start a conversation with x.comment_id

A lookup of the reply and its author

1 post read

Publishing and sending DMs are billed at X's own rates on top of that. Note the per-account multiplier on the second row: if the same reply is relevant to three connected accounts, that is three user reads, not one.

Subscriptions have their own ceiling. Each event type for each account is one subscription on your app, and a self-serve X app allows 1,500. An account uses one subscription, or three once Direct Messages are enabled — so roughly 500 accounts per app with DMs on.

The errors you will actually hit

Status

Meaning

404

The social account does not exist, the comment is not a reply to this account's post, or no X user has that username

422

The contact's DM settings do not accept messages from this account

400

Reactions on X, or more than one of user_id / username / comment_id

409

Cancelling a message that was already delivered, or was never scheduled

The 422 is the one to design for. X has no window, but it does have per-user DM settings, and there is no way to check them before you try. A 422 is not a bug in your integration and not a retryable failure — it is a person who does not accept DMs from accounts they do not follow. Treat it as a terminal outcome for that contact and fall back to a public reply.

Where to start

If you are wiring up an inbox, read the Conversations overview first and then start a conversation, which documents the per-network addressing fields side by side. If you are connecting X for the first time, the X configuration guide covers the developer app, the OAuth settings, the webhook registration and the media limits in order.

And if the reason you want X DMs is that you already run Instagram comment-to-DM and want the same flow on a second network, the good news is that it is the same two calls. The X API page has the publishing side, and Outstand covers both through one key.