# Guide: Post on X for someone else (no password)

This guide matches the implementation in this repository and the official X docs as of **2026-07-12**.

## Goal — Person A gives access to Person B

| Role | Who | What they do |
|---|---|---|
| **Person A** | X **account owner** (the timeline you want to post on) | Logs into X and clicks **Allow** once |
| **Person B** | **Operator / developer** (the one who will post) | Owns the developer app, runs the local server, keeps tokens, calls the API |

- ❌ A does **not** give B username/password  
- ✅ A authorizes **B’s developer app** once via OAuth on official x.com  
- ✅ B stores `access_token` + `refresh_token` and calls `POST /2/tweets` **as A**

### How Person A actually “gives access” (checklist)

1. **Person B** creates an X developer app and configures callback `http://127.0.0.1:8787/callback` (or a production HTTPS URL).
2. **Person B** runs `python -m x_posting.authorize` on their machine (callback server must be reachable when A finishes).
3. **Person B** sends Person A the printed **authorize URL** (or a screenshare / same machine).
4. **Person A** opens that URL while logged into **their own** X account → sees B’s app name + scopes → clicks **Allow**.
5. Browser redirects to B’s callback; B’s tool exchanges the code for tokens. **Access is now granted.**
6. **Person B** posts with `python -m x_posting.post "..."`.
7. **Person A** can revoke anytime: [Connected apps](https://x.com/settings/connected_apps).

A never shares a password. A only uses X’s own consent screen.

## Mental model

| Credential | Can post as user B? |
|---|---|
| B’s password | Don’t use. Not an API path. |
| App-only Bearer (portal) | **No** — app context only |
| User access token after OAuth | **Yes** — user context |

## Official docs map

| Topic | URL |
|---|---|
| Create post | https://docs.x.com/x-api/posts/create-post |
| OAuth 2.0 PKCE reference | https://docs.x.com/fundamentals/authentication/oauth-2-0/authorization-code |
| Step-by-step user token | https://docs.x.com/fundamentals/authentication/oauth-2-0/user-access-token |
| Pricing | https://docs.x.com/x-api/getting-started/pricing |

## API contract: createPosts

From OpenAPI (`POST /2/tweets`, `operationId: createPosts`):

**Security schemes accepted:**

1. `OAuth2UserToken` with scopes `tweet.read`, `tweet.write`, `users.read`
2. OAuth 1.0a `UserToken`

**Minimal request:**

```http
POST /2/tweets HTTP/1.1
Host: api.x.com
Authorization: Bearer <USER_ACCESS_TOKEN>
Content-Type: application/json

{"text":"Hello"}
```

**Success:** HTTP `201`

```json
{
  "data": {
    "id": "1346889436626259968",
    "text": "Hello"
  }
}
```

Optional body fields (see schema `TweetCreateRequest`): `media`, `reply`, `poll`, `quote_tweet_id` (Enterprise), `edit_options`, `paid_partnership`, `made_with_ai`, etc.

## OAuth 2.0 Authorization Code + PKCE

### Endpoints

| Name | Method | URL |
|---|---|---|
| Authorize | GET | `https://x.com/i/oauth2/authorize` |
| Token | POST | `https://api.x.com/2/oauth2/token` |
| Revoke | POST | `https://api.x.com/2/oauth2/revoke` |

### Parameters on authorize URL

| Param | Value |
|---|---|
| `response_type` | `code` |
| `client_id` | from Developer Console |
| `redirect_uri` | **exact** match to app settings |
| `scope` | space-separated, URL-encoded |
| `state` | random CSRF string (≤500 chars) |
| `code_challenge` | S256(challenge) or plain verifier |
| `code_challenge_method` | `S256` (recommended) or `plain` |

### Scopes used by this repo

```text
tweet.read tweet.write users.read offline.access
```

| Scope | Purpose |
|---|---|
| `tweet.write` | Post / repost |
| `tweet.read` | Required alongside write for createPosts security |
| `users.read` | `GET /2/users/me` to confirm who connected |
| `offline.access` | Issue refresh token; stay connected after access token expires (~2h) |

### Token exchange (confidential client)

```http
POST /2/oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)

code=...&grant_type=authorization_code&redirect_uri=...&code_verifier=...
```

**Important (X glossary):** authorization `code` is valid ~**30 seconds**. Exchange immediately.

### Refresh

```http
POST /2/oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)

grant_type=refresh_token&refresh_token=...
```

Always save a new `refresh_token` if returned (rotation).

### Access token lifetime

Without `offline.access`, access tokens last **two hours** and you cannot refresh. With `offline.access`, you get a refresh token.

## Run the implementation

See [README.md](../README.md) Quick start.

```bash
git clone https://github.com/rainbowpuffpuff/x-posting-as-other.git
cd x-posting-as-other
cp .env.example .env   # fill Client ID / Secret
export PYTHONPATH=src
python -m x_posting.authorize
python -m x_posting.post "First post via delegated OAuth"
```

### Code entry points

| CLI | Module | Key functions |
|---|---|---|
| `python -m x_posting.authorize` | `authorize.main` | `build_authorize_url`, `exchange_code`, `OAuthCallbackServer` |
| `python -m x_posting.post` | `post.main` | `create_post` → `POST /2/tweets` |
| `python -m x_posting.refresh` | `refresh.main` | `refresh_access_token` |
| `python -m x_posting.revoke` | `revoke.main` | `revoke_token` |

## Production notes

- Use an HTTPS public `redirect_uri`, not `127.0.0.1`
- Store tokens encrypted at rest
- One token bundle per connected user
- Refresh before each post if `expires_at` is near
- Surface a “Disconnect” that calls revoke + deletes local tokens

## What Person A (account owner) sees

1. Official X authorization page (x.com)  
2. App name (Person B’s developer app) + scopes (post tweets, etc.)  
3. **Allow** / Deny  
4. Redirect back to B’s callback (“Connected”)  

Revoke anytime: https://x.com/settings/connected_apps

## What Person B (operator) must not ask A for

- Password, email login codes, 2FA codes, session cookies  
Only: “open this link and click Allow while logged into your account.”

## Not covered here

- OAuth 1.0a 3-legged (also works for createPosts; tokens don’t expire the same way)
- Media upload (`media.write` + media endpoints) then attach `media.media_ids`
- Multi-tenant production SaaS (same flow; add user DB + HTTPS)

---

*Implementation: https://github.com/rainbowpuffpuff/x-posting-as-other*
