> ## Documentation Index
> Fetch the complete documentation index at: https://docs.letmepost.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Twitter / X

> Twitter / X v2 API. OAuth 2.0 PKCE. Up to 4 images, video via chunked upload, reply chains, quote tweets.

<Warning>
  **Before you start.** Every URL costs **23 characters** regardless of its real length — even after t.co wrapping. The grapheme counter subtracts the raw URL and adds the t.co weight so the 280-character preflight matches X's server-side count exactly. Long links don't help you fit; they just hurt the visible text.
</Warning>

## Quick reference

| Limit / capability   | Value                                                                   |
| -------------------- | ----------------------------------------------------------------------- |
| Character limit      | 280 graphemes (t.co-weighted)                                           |
| Images per tweet     | 4                                                                       |
| Image formats        | jpeg, png, webp                                                         |
| Image size           | 5,000,000 bytes (5 MB)                                                  |
| GIF size             | 15,000,000 bytes (15 MB)                                                |
| Video formats        | mp4 (H.264 + AAC recommended)                                           |
| Video size           | 512,000,000 bytes (512 MB)                                              |
| Video duration       | 140 s on the standard tier (platform-enforced)                          |
| Mixed image + video  | Rejected — pick one per tweet                                           |
| Alt text per item    | 1,000 graphemes (best-effort write)                                     |
| Post types supported | text, single image, multi-image, single video, single GIF, reply, quote |
| Scheduling           | Yes (via `scheduledAt`)                                                 |
| Thread (reply chain) | Yes (`twitter.replyToTweetId`)                                          |
| Quote tweet          | Yes (`twitter.quoteTweetId`)                                            |
| First comment        | Not supported (use a reply chain)                                       |
| Inbox / DM           | Not supported in v1                                                     |
| Analytics            | Not supported in v1                                                     |

## Connect an account

`POST /v1/accounts/connect/twitter`. OAuth 2.0 PKCE. letmepost signs a state token carrying the PKCE `code_verifier` through the redirect so the dashboard never stashes it client-side. After the X callback, the code+verifier exchange runs server-side.

### Scopes

```
tweet.write     — create tweets
tweet.read      — required to mint the token
users.read      — required to mint the token
offline.access  — required for refresh tokens
```

Extended: `like.read`, `follows.read` — off by default; not needed for publishing.

### Token lifecycle

X access tokens are short-lived (\~2 hours); `offline.access` mints a refresh token that lets the publisher refresh on schedule. If a user revokes app access from X's settings the next call surfaces [`platform_auth_failed`](/errors/platform_auth_failed).

## Post types

### Text post

```json text.json theme={"system"}
{
  "targets": [{ "accountId": "..." }],
  "text": "shipped"
}
```

### Single image

```json single-image.json theme={"system"}
{
  "targets": [{ "accountId": "..." }],
  "text": "shipped",
  "media": [{ "kind": "image", "mediaId": "med_…", "altText": "..." }]
}
```

### Multi-image (up to 4)

```json multi-image.json theme={"system"}
{
  "targets": [{ "accountId": "..." }],
  "text": "four shots",
  "media": [
    { "kind": "image", "mediaId": "med_a…", "altText": "front" },
    { "kind": "image", "mediaId": "med_b…", "altText": "back" },
    { "kind": "image", "mediaId": "med_c…", "altText": "left" },
    { "kind": "image", "mediaId": "med_d…", "altText": "right" }
  ]
}
```

### Single video

Chunked upload runs transparently — small clips return immediately, larger ones run `INIT → APPEND → FINALIZE → STATUS` until X reports `succeeded`.

```json video.json theme={"system"}
{
  "targets": [{ "accountId": "..." }],
  "text": "demo clip",
  "media": [{ "kind": "video", "mediaId": "med_…" }]
}
```

### Reply (and threads)

Threads on X are reply chains — pass the previous tweet id on each follow-up. The first tweet in a thread is just a normal post; the second carries `replyToTweetId` set to the first's id, the third to the second's, and so on.

```json reply.json theme={"system"}
{
  "targets": [
    {
      "accountId": "...",
      "options": { "platform": "twitter", "replyToTweetId": "1700000000000000001" }
    }
  ],
  "text": "(2/3) more details"
}
```

### Quote tweet

Mutually exclusive with `replyToTweetId` — preflight rejects setting both, since X itself does.

```json quote.json theme={"system"}
{
  "targets": [
    {
      "accountId": "...",
      "options": { "platform": "twitter", "quoteTweetId": "1700000000000000001" }
    }
  ],
  "text": "this is huge"
}
```

## Wisdom (platform-specific things that bite)

<Tip>
  * **Every URL costs 23 characters** regardless of length. The counter subtracts the real URL and adds the t.co weight, so a 3-char shortlink and a 300-char tracking URL both weigh 23.
  * **Alt text is best-effort.** The `/1.1/media/metadata/create` write happens on a deprecation track separate from `/2/tweets`; if the metadata write fails, the tweet still posts. You don't lose the post — you lose the accessibility text.
  * The free posting tier was retired in 2025; new developers go through **Pay Per Use** or a paid tier. letmepost works on any tier with `tweet.write`. The launch-cap preflight (`twitter.launch_cap.per_account`) surfaces per-account ceilings before X 429s.
  * `replyToTweetId` and `quoteTweetId` are mutually exclusive — X rejects tweets with both. Preflight catches this locally before the upstream round-trip.
  * Chunked video uploads use 4 MiB segments. The publisher polls the FINALIZE → STATUS loop and surfaces failures as [`twitter.media.video_processing_failed`](/preflight/twitter-media-video_processing_failed) with the upstream `processing_info.error` attached.
  * GIFs are not "images" on X — they have their own 15 MB ceiling ([`twitter.media.gif_size_max`](/preflight/twitter-media-gif_size_max)) distinct from static images' 5 MB.
  * Mixed image + video isn't allowed on the same tweet; carousel of mixed media doesn't exist on X. Pick one kind per tweet, or chain a thread.
</Tip>

## Common errors

| Error rule                                                                                  | What it means                       | How to fix                                                                         |
| ------------------------------------------------------------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------- |
| [`twitter.text.max_graphemes`](/preflight/twitter-text-max_graphemes)                       | Weighted character count > 280      | Trim text or shorten URLs (won't help past 23 chars/URL).                          |
| [`twitter.text.non_empty`](/preflight/twitter-text-non_empty)                               | Tweet body was empty                | Provide non-whitespace text — X requires it even on media tweets.                  |
| [`twitter.media.count_max`](/preflight/twitter-media-count_max)                             | > 4 images, or > 1 video/GIF        | Up to 4 images, 1 video, or 1 GIF per tweet.                                       |
| [`twitter.media.image_video_exclusive`](/preflight/twitter-media-image_video_exclusive)     | Mixed image + video                 | Pick one media kind per tweet.                                                     |
| [`twitter.media.image_size_max`](/preflight/twitter-media-image_size_max)                   | Image > 5 MB                        | Re-encode under 5,000,000 bytes.                                                   |
| [`twitter.media.gif_size_max`](/preflight/twitter-media-gif_size_max)                       | GIF > 15 MB                         | Re-encode under 15,000,000 bytes.                                                  |
| [`twitter.media.video_size_max`](/preflight/twitter-media-video_size_max)                   | Video > 512 MB                      | Compress under 512,000,000 bytes.                                                  |
| [`twitter.media.video_processing_failed`](/preflight/twitter-media-video_processing_failed) | FINALIZE / STATUS returned an error | Inspect `platformResponse.processing_info.error` for the codec / container reason. |
| [`twitter.media.processing_timeout`](/preflight/twitter-media-processing_timeout)           | STATUS poll exceeded the deadline   | Retry; X's transcoder sometimes lags.                                              |
| [`twitter.launch_cap.per_account`](/preflight/twitter-launch_cap-per_account)               | Per-account launch-cap exhausted    | Wait for the cap window to roll over, or upgrade the X tier.                       |

## What you can't do (yet)

* Polls.
* Long-form (premium) tweets above 280 graphemes — the preflight cap is wired for the standard limit; long-form support lands in a follow-up slice.
* Reading replies, likes, or retweet metadata.
* DMs.
* Lists (creating tweets that target a list audience).
* Spaces.

## API reference

* [`POST /v1/posts`](/api-reference) — primary publish.
* [`POST /v1/media`](/api-reference) — upload images/videos for `mediaId` references.
* [`POST /v1/accounts/connect/twitter`](/api-reference) — start a Twitter OAuth flow.
