Last updated: August 2026
Why does the X API return 403 Forbidden when posting?
A 403 on POST /2/tweets means you authenticated fine but the write is refused. Six causes: read-only app permissions, a token issued before you changed them, a missing tweet.write scope, an app not attached to a project or not enrolled in billing, a restricted account or restricted reply, and duplicate content.
The order below is deliberate. It is roughly the order these show up in practice, and the second one is the reason most people think they already fixed the first.
First, confirm it is really a 403
A 401 and a 403 get debugged very differently and the libraries do not always make the distinction obvious. A 401 means X cannot verify who you are: bad key, bad signature, expired or revoked token. A 403 means X knows exactly who you are and is refusing the action anyway.
So the first move is to print the status code and the whole response body, not just the message your HTTP client surfaced. X puts the actual reason in the body, and most wrappers throw it away.
curl -i -X POST https://api.x.com/2/tweets \
-H "Authorization: Bearer $X_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"text":"permission probe"}'
# Read three things from the output:
# the status line -> 401 vs 403 vs 429
# the "title" field -> "Forbidden", "Client Forbidden", "Unauthorized"
# the "detail" field -> the sentence that names the actual causeIf it is a 429, not a 403
A 429 is rate limiting and needs a completely different fix: read the reset header and sleep, do not regenerate anything. See X API rate limit 429.The six causes, in the order to check them
Each one has a distinct tell in the response or the behaviour. Match the tell first, then apply the fix. Working down the list in order takes a few minutes and beats guessing.
App permissions are set to Read only
The tell: Reads succeed, every write returns 403. On OAuth 1.0a you often see the phrase about your credentials not allowing access to this resource.
The fix: Developer portal, your app, User authentication settings, set App permissions to Read and Write. Then regenerate tokens, see cause 2.
Your token predates the permission change
The tell: You already set Read and Write, and it still 403s. This is the single most common false fix.
The fix: Access tokens carry the permission level they were minted with. Regenerate the access token and secret, or rerun the OAuth flow, then retry.
The OAuth 2.0 token is missing tweet.write
The tell: Bearer token flows only. The token authenticates fine and reads work, writes do not.
The fix: Request tweet.read, tweet.write and users.read at authorize time, plus offline.access if you want refresh. Scopes are fixed at issue time, so reauthorize.
App not attached to a project, or not enrolled in billing
The tell: The body contains a title of Client Forbidden along with registration_url and required_enrollment.
The fix: Attach the app to a project in the developer portal, and enrol the project in pay-per-use billing. Since February 2026 there is no free tier for new projects.
The account or the action is restricted
The tell: Same code worked yesterday, nothing changed. Or standalone posts work and only replies 403.
The fix: Check the account is not suspended or read-only. Since 23 February 2026 replies are restricted more tightly than posts, so in_reply_to_tweet_id can fail on its own.
Duplicate content
The tell: The message says you are not allowed to create a Tweet with duplicate content. Very often a retry after a request that actually succeeded.
The fix: Vary the text, or check for the post before retrying. Make retries idempotent so a timeout does not resend text X already accepted.
Cause 2 in detail, because it is the one that wastes days
Permissions in the X developer portal describe what the app is allowed to do. Access tokens carry a snapshot of that permission at the moment they were issued. Flipping the app from Read to Read and Write does not reach back and upgrade tokens already in your environment file.
So the sequence has to be: change the permission, then mint new credentials, then deploy them. If you skip the middle step you get an identical 403 and conclude the permission change did not apply.
1. Developer portal -> your app -> User authentication settings
2. App permissions -> Read and Write (Save)
3. Keys and tokens -> Access Token and Secret -> Regenerate
4. Replace the values in your .env, redeploy
5. Retry the POST
Skipping step 3 reproduces the exact same 403 you started with.OAuth 2.0 users: scopes are also fixed at issue time
The same trap applies to bearer tokens. If your authorize URL did not request tweet.write, no portal setting will retrofit it onto a token you already hold. Send the user through the authorize flow again with tweet.read, tweet.write, users.read and offline.access, then use the new token. If your refresh token itself has stopped working, that is a separate problem covered in fix X OAuth refresh token expired.The two 2026-specific causes
Billing enrolment, since February 2026
There is no free tier for new API projects anymore. If your app is not attached to a project, or the project is not enrolled in pay-per-use billing, writes are refused with a Client Forbidden body that carries a registration_url and a required_enrollment field. This one reads like a permissions bug and is actually a billing bug. What that billing looks like once it is on is covered in is the X API free in 2026 and does X API pay-per-use have a spend cap.
The reply restriction, since 23 February 2026
X restricts automated replies more tightly than standalone posts. The diagnostic is clean: send the same text as a top level post with no in_reply_to_tweet_id. If that succeeds and the reply does not, your credentials are fine and the reply restriction is what you are hitting. Reply-heavy bots are the ones that notice this first, usually as a sudden 403 on code that had been running for months.
Using n8n?
The n8n Twitter node has its own version of this failure with its own credential UI. The step by step for that specific case is in fix the n8n Twitter 403 error. Same underlying causes, different place to click.Duplicate content, and why your retry logic causes it
The duplicate content refusal is X telling you it already has this text from you. It fires on near-identical text, not only exact matches, and there is no documented window to code against.
The common way to trigger it is a retry after a request that actually worked. Your client times out, your code assumes failure, it resends, X sees the same text twice. The post is live and your logs say it failed. Fix the retry, not the text: give each publish a client-side identifier, and check whether the post already exists before sending it again.
// A timeout is not a failure. Confirm before you resend.
async function publishOnce(text: string, key: string) {
if (await store.wasSent(key)) return store.result(key);
try {
const res = await sendToX(text);
await store.markSent(key, res);
return res;
} catch (err) {
if (isTimeout(err)) {
const found = await findRecentPostByText(text); // may already be live
if (found) { await store.markSent(key, found); return found; }
}
throw err;
}
}Or skip the class of problem
Every cause on this page comes from owning the X app yourself: its permissions, its scopes, its project attachment, its billing enrolment, its token lifecycle. If you are posting rather than doing archive research, you can hand all of that to a service that already maintains it.
OpenTweet holds the X connection. You get one bearer key, one endpoint, and no developer app to misconfigure. Duplicate posts are caught before they reach X rather than coming back as a 403.
curl -X POST https://opentweet.io/api/v1/posts \
-H "Authorization: Bearer ot_your_key" \
-H "Content-Type: application/json" \
-d '{"text":"No app permissions to configure.","publish_now":true}'The error codes you can get back are listed in the API error reference, and the setup takes about a minute via the quickstart.
7-day free trial. Cancel anytime.
Frequently asked questions
Why does the X API return 403 Forbidden when posting?
A 403 on POST /2/tweets means you authenticated successfully but are not allowed to perform this write. The six real causes are read-only app permissions, an access token issued before you changed those permissions, a missing tweet.write scope on an OAuth 2.0 token, an app not attached to a project or not enrolled in billing, a restricted account or a restricted action such as replying, and duplicate content.
What is the difference between a 401 and a 403 on the X API?
A 401 means X could not verify who you are: a bad key, a bad signature, an expired or revoked token. A 403 means X knows exactly who you are and is refusing the action anyway. If you are seeing 403, stop debugging your credentials and start looking at permissions, scopes, project attachment, and account state.
I set my app to Read and Write and it still returns 403. Why?
Because access tokens carry the permission level they were issued with. Changing the app from Read to Read and Write in the developer portal does not upgrade tokens you already have. Regenerate the access token and secret after the permission change, or run the OAuth flow again, then retry. This single step fixes the majority of reported 403s.
What does "Client Forbidden" mean in an X API 403 response?
It means the app you authenticated with is not attached to a project, or the project is not enrolled in the pay-per-use billing that has been required since February 2026. The response body includes a registration_url and a required_enrollment field. Attach the app to a project in the developer portal and enrol the project in billing.
Why do replies get a 403 when normal posts work?
Since 23 February 2026 X restricts automated replies more tightly than standalone posts, so a request carrying in_reply_to_tweet_id can be refused even when the same account can publish a top level post successfully. If your standalone post succeeds and only the reply fails, the reply restriction is the cause, not your credentials.
What is the duplicate content 403 on the X API?
X refuses a post whose text matches something you recently published, with the message that you are not allowed to create a Tweet with duplicate content. It fires on near-identical text as well as exact matches, and it commonly appears when a retry loop resends a post that actually succeeded the first time. Vary the text or check whether the post already exists before retrying.
How do I avoid X API 403 errors entirely?
Post through a service that owns the app permissions, the scopes, the project attachment, and the token refresh for you. With OpenTweet you send one POST to https://opentweet.io/api/v1/posts with an ot_ bearer key. There is no app to configure, no scope to forget, and no token to regenerate after a permission change.
Keep exploring
Post to X without owning the app permissions, scopes, and token lifecycle.
OpenTweet vs the X API
Post, schedule, and automate X without a developer account or the $200/mo minimum.
X DM outreach, human-approved
Find and qualify leads, AI-draft DMs, approve each one, and drip-send from your own account via API or MCP.
Post to X without an API
The clean, account-safe way to post to X from your code or an AI agent.
Twitter MCP Server
Give Claude, Cursor, and OpenClaw the ability to post to X. 36 tools included.
Developer docs
Quickstart, API keys, MCP setup, and the REST reference for posting to X from code or an agent.
XMCP vs OpenTweet
X's official MCP server bills per API call and cannot schedule. Compare it with the flat-fee hosted MCP.
MCP for AI agents
Connect your AI client to X in under two minutes, no X developer account.
Developer API and keys
REST endpoints, one bearer key, and usage tracking. Build on OpenTweet.
OpenTweet for AI agents
The posting layer for autonomous agents and automations that live on X.
Build an AI Twitter persona
Give your AI agent its own X account. Setup, cadence, and the rules that keep it safe.
Best MCP servers for social media
The 2026 ranked list. How the hosted OpenTweet MCP compares with the official X MCP and others.
Cheapest way to post to X via code
Flat fee vs pay-per-use. Why the $0.20 per-link fee flips the math past ~60 posts a month.
Stop debugging X app permissions
One bearer key, one endpoint, no developer app. Post and schedule on X for a flat $11.99 a month.
7-day free trial. Cancel anytime.