# CLI
Source: https://docs.letmepost.dev/agents/cli
A command-line client for letmepost.dev. Publish from your terminal or from any agentic tool that can shell out.
`@letmepost/cli` is a small, fast CLI that wraps the letmepost.dev REST API. Designed for terminal use, scripts, CI, and agentic tools like [opencode](https://opencode.ai) that need a stable command surface to shell into.
## Install
```bash theme={"system"}
npm install -g @letmepost/cli
# or run without installing
npx @letmepost/cli --help
```
The binary is named `lmp`. Versions older than Node 18 are not supported.
## Login
```bash theme={"system"}
lmp login
```
Opens your browser to authorize the CLI via OAuth 2.1 with PKCE. The CLI:
1. Registers itself dynamically with the OAuth provider (RFC 7591).
2. Opens a browser to the consent page.
3. Receives the auth code on a localhost callback.
4. Exchanges the code for an access token.
5. Trades the access token for a long-lived API key (so subsequent calls don't depend on token refresh).
6. Saves everything to `~/.letmepost/config.json` (mode `0600`).
If you're in a headless environment, the URL is printed so you can copy it to another browser. If the OAuth provider isn't reachable, the CLI falls back to prompting for an API key paste.
```bash theme={"system"}
lmp logout # clears ~/.letmepost/config.json
lmp whoami # prints which credential is loaded and what it can see
lmp version # CLI version + active API base
```
## Environment overrides
| Variable | Description |
| - | - |
| `LMP_API_KEY` | Skip the stored credential entirely and use this Bearer key for the call. |
| `LMP_API_BASE` | Override the API host (defaults to `https://api.letmepost.dev`). For self-hosted. |
## Profiles
A single organization can carve into N profiles for per-client isolation. Pick which one to publish under.
```bash theme={"system"}
lmp profiles list # all profiles in the active org
lmp profiles use prof_01HX... # set as the default for subsequent calls
lmp profiles current # print the active profile
```
Per-command override:
```bash theme={"system"}
lmp post "hello" --to=bluesky --profile prof_01HX...
lmp accounts list --profile prof_01HX...
lmp posts list --profile prof_01HX...
```
If the loaded credential is itself profile-scoped, passing a different `--profile` returns a clean `validation_failed` with `rule: profile.scope_mismatch`.
## Accounts
```bash theme={"system"}
lmp accounts list # every connected account in the active profile
lmp accounts list --platform bluesky # filter
lmp accounts disconnect acc_01HX... # hard-delete (revokes the upstream token if possible)
```
## Posting
```bash theme={"system"}
lmp post "Shipping the CLI today" --to=twitter,bluesky
lmp post "Reels are live" --to=instagram --media=./hero.mp4
lmp post "Lots happening" --to=twitter,bluesky --first-comment="Threading more below."
lmp post "Big news" --to=linkedin --schedule=2026-05-20T18:00:00Z
```
A single call fans out to every platform in `--to`. The response renders per-target results:
```
✔ published to twitter — https://x.com/.../status/1234567890
✔ published to bluesky — at://did:plc:.../app.bsky.feed.post/...
✗ failed on instagram
code: preflight_failed
rule: instagram.media.aspect_ratio
message: Instagram Reels require 9:16 aspect ratio; got 16:9.
remediation: Re-encode the video to 9:16 (1080×1920 is the safe default).
docs: https://docs.letmepost.dev/preflight/instagram-media-aspect_ratio
requestId: req_01HY6X4AWBJM2K9F2PTQMRD9JQ
batch pst_01HX… (partial_failed)
```
Exit code is `0` on full success, `2` on any failure. Useful in pipelines:
```bash theme={"system"}
lmp post "$(date)" --to=bluesky || echo "post failed, alert the on-call"
```
## Post log
```bash theme={"system"}
lmp posts list # most recent 20
lmp posts list --limit 100 --status rejected # filter
lmp posts get pst_01HX... # one post + all attempts
```
## Self-host
Point the CLI at your own deployment by setting `LMP_API_BASE`:
```bash theme={"system"}
LMP_API_BASE=https://api.yourdomain.com lmp login
LMP_API_BASE=https://api.yourdomain.com lmp post "hello" --to=bluesky
```
Or persist it: after `lmp login` the chosen base URL is saved alongside the credential, so subsequent calls reuse it without the env var.
## Use from opencode / shell agents
The CLI prints structured output and uses standard exit codes, so it composes cleanly into agentic tool definitions:
```jsonc theme={"system"}
{
"tools": [
{
"name": "publish_post",
"command": "lmp post \"{text}\" --to={platforms}",
"description": "Publish a text post to one or more social platforms."
}
]
}
```
For tighter integration, use the [MCP server](/agents/mcp) directly. It wraps the same API and exposes one tool per OpenAPI operation.
## Source + roadmap
* Source: [github.com/letmepost/letmepost.dev/tree/main/apps/cli](https://github.com/letmepost/letmepost.dev/tree/main/apps/cli)
* Apache 2.0
* v0.1 ships with: `login`, `logout`, `whoami`, `version`, `accounts list/disconnect`, `post`, `posts list/get`, `profiles list/use/current`
* v0.2 will add: `media upload`, `webhooks` subcommands, `keys` (mint/list/revoke API keys), refresh-token rotation for OAuth-only auth
# MCP server
Source: https://docs.letmepost.dev/agents/mcp
Use letmepost.dev from Claude Code, Cursor, Claude Desktop, or any MCP-compatible client. 21 tools, autogenerated from the OpenAPI spec.
The letmepost.dev MCP server exposes the full publishing API as MCP tools. One tool per OpenAPI operation — posts, media, accounts, webhooks, API keys, Pinterest boards — autogenerated at startup so every documented endpoint is reachable.
Works with Claude Code, Cursor, Cline, Claude Desktop, and anything else that speaks the [Model Context Protocol](https://modelcontextprotocol.io).
## Install
Two install paths. The hosted URL needs zero local setup. The npx path runs everything locally — useful for self-hosters and for keeping platform credentials off our servers.
### Hosted (zero install)
Point your MCP client at `https://api.letmepost.dev/mcp` with your API key as a Bearer token. Works with any client that supports Streamable HTTP. A vanity `mcp.letmepost.dev` alias is coming.
```bash Claude Code theme={"system"}
claude mcp add --transport http letmepost https://api.letmepost.dev/mcp \
--header "Authorization: Bearer lmp_live_..."
```
```json Cursor / Cline / Claude Desktop theme={"system"}
{
"mcpServers": {
"letmepost": {
"url": "https://api.letmepost.dev/mcp",
"headers": { "Authorization": "Bearer lmp_live_..." }
}
}
}
```
### Local (npx)
```bash theme={"system"}
npx @letmepost/mcp
```
Or wire it into your MCP client:
```bash Claude Code theme={"system"}
claude mcp add letmepost npx @letmepost/mcp --env LMP_API_KEY=lmp_live_...
```
```json Cursor / Cline / Claude Desktop theme={"system"}
{
"mcpServers": {
"letmepost": {
"command": "npx",
"args": ["@letmepost/mcp"],
"env": { "LMP_API_KEY": "lmp_live_..." }
}
}
}
```
## Getting an API key
Two paths. Either:
1. **Via the CLI** — `npm install -g @letmepost/cli && lmp login`. Opens a browser for OAuth, then mints and stores an API key for you. Print the key with `cat ~/.letmepost/config.json | jq .accessToken` and paste into your MCP client config.
2. **Via the dashboard** — [dashboard.letmepost.dev/api-keys](https://dashboard.letmepost.dev/api-keys), click "Create API key", copy the `lmp_live_...` value.
Profile-scoped keys are supported — the MCP tools only see accounts under the key's profile.
## Configuration
| Variable | Required | Default | Description |
| - | - | - | - |
| `LMP_API_KEY` | yes (local) | | Bearer token from the dashboard or CLI mint |
| `LMP_API_BASE` | no | `https://api.letmepost.dev` | Override when pointing at a self-hosted instance |
## Tools (autogenerated)
The tool surface is generated from the canonical OpenAPI spec at startup. Tool names follow `{method}_{path}` with `{param}` segments rewritten as `by_{param}`. Today's surface — 21 tools across every documented endpoint:
| Group | Tools |
| - | - |
| Posts | `post_v1_posts`, `get_v1_posts`, `get_v1_posts_by_id` |
| Media | `get_v1_media` |
| Accounts | `get_v1_accounts`, `get_v1_accounts_by_id`, `delete_v1_accounts_by_id`, `post_v1_accounts_connect_by_platform`, `post_v1_accounts_connect_by_platform_complete` |
| Pinterest boards | `get_v1_accounts_by_id_pinterest_boards`, `post_v1_accounts_by_id_pinterest_boards`, `patch_v1_accounts_by_id_pinterest_default_board` |
| API keys | `post_v1_api_keys`, `get_v1_api_keys`, `delete_v1_api_keys_by_id` |
| Webhooks | `post_v1_webhook_endpoints`, `get_v1_webhook_endpoints`, `get_v1_webhook_endpoints_by_id`, `patch_v1_webhook_endpoints_by_id`, `delete_v1_webhook_endpoints_by_id`, `post_v1_webhook_endpoints_by_id_test` |
Each tool's `inputSchema` is a flat JSON schema with path / query / body fields merged into one object — the agent never has to think about which slot a field lives in. Idempotency keys are auto-injected on writes.
When endpoints are added to the API, they appear as tools on the next server restart. No code change.
## Example: cross-post
Once installed, the agent composes a multi-target post in plain English:
> "Post 'Shipping the MCP server today' to X, Bluesky, and Threads."
Claude calls `post_v1_posts` with:
```json theme={"system"}
{
"text": "Shipping the MCP server today",
"targets": [
{ "platform": "twitter" },
{ "platform": "bluesky" },
{ "platform": "threads" }
]
}
```
…and renders the batch envelope back. One call, three platforms, identical error contract across all of them.
## Publish to a specific profile
When an org has multiple profiles, pass `profileId` to scope the publish:
```json theme={"system"}
{
"text": "Per-client announcement",
"profileId": "prof_01HX...",
"targets": [{ "platform": "bluesky" }]
}
```
`profileId` is forbidden when the API key is already scoped to a different profile — that surfaces as a clean `validation_failed` with `rule: profile.scope_mismatch`.
## OAuth discovery
The hosted endpoint advertises full OAuth 2.1 metadata at the standard well-known URLs:
* `https://api.letmepost.dev/.well-known/oauth-authorization-server`
* `https://api.letmepost.dev/.well-known/oauth-protected-resource`
* `https://api.letmepost.dev/.well-known/openid-configuration`
Dynamic client registration (RFC 7591) is enabled. The CLI's `lmp login` uses this flow today; direct OAuth from MCP clients (Claude Desktop's "OAuth connector" UI) is supported on the discovery surface and will be a one-line wiring once their UI ships. For now, paste your API key as the Bearer header.
## Self-host
Self-host bypasses every platform approval gate. Bring your own credentials, every platform works the day you clone the repo. Point both the API and the MCP server at your deployment:
```bash theme={"system"}
LMP_API_KEY=lmp_live_... LMP_API_BASE=https://api.yourdomain.com npx @letmepost/mcp
```
See [self-host quick-start](/self-host/quick-start) for the full deployment walkthrough.
File issues or feature requests at [github.com/letmepost/letmepost.dev](https://github.com/letmepost/letmepost.dev).
# Create a Pinterest board
Source: https://docs.letmepost.dev/api-reference/accounts/create-a-pinterest-board
/api-reference/openapi.json post /v1/accounts/{id}/pinterest/boards
Creates a board on the user's Pinterest account without sending them off-site. Optional `setAsDefault` lifts the new board id into the account's default in one round-trip. Optional `upsert: true` returns the existing board (with `existing: true`) on duplicate-name instead of 409-ing.
# Disconnect (hard delete)
Source: https://docs.letmepost.dev/api-reference/accounts/disconnect-hard-delete
/api-reference/openapi.json delete /v1/accounts/{id}
Dashboard session only — programmatic keys cannot disconnect accounts. Hard delete: the platform_accounts row is removed and any persisted token is destroyed.
# Fetch a connected account
Source: https://docs.letmepost.dev/api-reference/accounts/fetch-a-connected-account
/api-reference/openapi.json get /v1/accounts/{id}
# Finish a connect flow
Source: https://docs.letmepost.dev/api-reference/accounts/finish-a-connect-flow
/api-reference/openapi.json post /v1/accounts/connect/{platform}/complete
Dashboard session only. Completes the handshake started by `POST /v1/accounts/connect/{platform}` — for OAuth platforms this is invoked from the redirect callback flow; for credentials platforms (Bluesky) it carries the credentials in the body. Upserts the platform_accounts row (re-connecting the same account rotates the stored token rather than 409-ing).
# List connected accounts
Source: https://docs.letmepost.dev/api-reference/accounts/list-connected-accounts
/api-reference/openapi.json get /v1/accounts
Lists every connected platform account in the caller's organization. No secrets ever leak — display name, platform identifiers, and lifecycle timestamps only.
# List Pinterest boards on a connected account
Source: https://docs.letmepost.dev/api-reference/accounts/list-pinterest-boards-on-a-connected-account
/api-reference/openapi.json get /v1/accounts/{id}/pinterest/boards
Proxy to Pinterest's `/v5/boards` using the stored token. Used by the dashboard's default-board picker so a user can see their actual boards.
# Set the default Pinterest board for publishes
Source: https://docs.letmepost.dev/api-reference/accounts/set-the-default-pinterest-board-for-publishes
/api-reference/openapi.json patch /v1/accounts/{id}/pinterest/default-board
Persists the supplied `boardId` as the account's default — used when a `POST /v1/posts` body omits `options.boardId` for a Pinterest target. The board is validated against the account's own boards list before persisting.
# Start a connect flow
Source: https://docs.letmepost.dev/api-reference/accounts/start-a-connect-flow
/api-reference/openapi.json post /v1/accounts/connect/{platform}
Dashboard session only. Returns a `ConnectDescriptor` describing how to finish the connect — an OAuth authorization URL for OAuth platforms, or a credentials form schema for Bluesky. Programmatic API keys cannot mint OAuth state.
# List API keys
Source: https://docs.letmepost.dev/api-reference/api-keys/list-api-keys
/api-reference/openapi.json get /v1/api-keys
Dashboard session only. Plaintext is never returned again — only `last4` and metadata.
# Mint an API key
Source: https://docs.letmepost.dev/api-reference/api-keys/mint-an-api-key
/api-reference/openapi.json post /v1/api-keys
Dashboard session only. The plaintext `key` is returned **once** in this response and never again — store it securely. Optional `profileId` pins the key to a single profile; cross-profile reads/writes 404 under that key.
# Revoke an API key
Source: https://docs.letmepost.dev/api-reference/api-keys/revoke-an-api-key
/api-reference/openapi.json delete /v1/api-keys/{id}
Soft-revoke. The key is invalidated immediately; the row stays for audit. Already-revoked keys 404.
# List uploaded media
Source: https://docs.letmepost.dev/api-reference/media/list-uploaded-media
/api-reference/openapi.json get /v1/media
Cursor-paginated. Scoped to the caller's organization, and (when the key is profile-scoped) to that profile. Org-wide keys may narrow with `?profileId=`.
# Upload media (multipart)
Source: https://docs.letmepost.dev/api-reference/media/upload-media-multipart
/api-reference/openapi.json post /v1/media
Stream a single `file` part to S3 and get back a `med_…` id to reference from post bodies as `media: [{ kind, mediaId }]`.
The per-file ceiling is 200 MB. Optional `?profileId=` lands the media inside that profile (org-wide keys only — profile-scoped keys are pinned to their profile).
# Cancel a queued scheduled post
Source: https://docs.letmepost.dev/api-reference/posts/cancel-a-queued-scheduled-post
/api-reference/openapi.json delete /v1/posts/{id}
Cancel a scheduled post before it fires. Only allowed when `status=queued` AND `scheduledAt` is still in the future — same window as `PATCH`. Removes the BullMQ job and transitions the row to `status=canceled`. A `post.canceled` webhook fires on success.
If a worker is already processing the job when cancellation lands, the worker's conditional `UPDATE … WHERE status IN ('queued','validated')` short-circuits and the publish never happens.
# Fetch a single post with its publish attempts
Source: https://docs.letmepost.dev/api-reference/posts/fetch-a-single-post-with-its-publish-attempts
/api-reference/openapi.json get /v1/posts/{id}
Returns the post row plus every publish attempt (start/finish, success flag, upstream response, error code). Use the `postId` returned in `CreatePostResponse.results[]` as `{id}`.
# List posts (post log)
Source: https://docs.letmepost.dev/api-reference/posts/list-posts-post-log
/api-reference/openapi.json get /v1/posts
Cursor-paginated post log scoped to the caller's organization (and profile, when the key is profile-scoped). Filters compose. Array filters (`platform`, `status`, `errorCode`) accept either repeated query params or comma-separated single values.
# Publish or schedule a multi-target post
Source: https://docs.letmepost.dev/api-reference/posts/publish-or-schedule-a-multi-target-post
/api-reference/openapi.json post /v1/posts
Publish a single body to up to 25 connected accounts in one call, or schedule the whole batch for a future time.
- **Multi-target only:** the v1 wire shape requires `targets[]`. The legacy `account: {}` single-target shape is no longer accepted.
- **Idempotent:** include `Idempotency-Key`. Replays within 24 hours return the original response.
- **Preflight:** every documented platform constraint runs locally before the upstream call. Cheap shape preflight is atomic across the batch — if any target fails the synchronous checks the whole call is rejected as `400 preflight_failed`. Deep checks (URL reachability, MIME sniffing, byte caps) run per target and surface inside `results[i].error`, producing a `partial_failed` batch rather than a 400.
- **Errors:** failures use the [stable error envelope](https://letmepost.dev/docs/errors/) — `code`, `rule`, `platformResponse`, `remediation`, `docUrl`, `ruleUrl`.
For per-platform payload differences, see the [platforms guides](https://letmepost.dev/docs/platforms/bluesky/).
# Reschedule a queued post
Source: https://docs.letmepost.dev/api-reference/posts/reschedule-a-queued-post
/api-reference/openapi.json patch /v1/posts/{id}
Move a scheduled post to a new firing time. Only allowed when `status=queued` AND `scheduledAt` is still in the future — once a post enters `publishing` or any terminal state, the window closes and the request returns `409`.
The operation is atomic: the API replaces the BullMQ job (removes the old delayed job, enqueues a new one at the updated delay) and only persists the new `scheduledAt` if the queue swap succeeded. A `post.rescheduled` webhook fires on success, carrying both the old and new timestamps.
# Delete a webhook endpoint
Source: https://docs.letmepost.dev/api-reference/webhooks/delete-a-webhook-endpoint
/api-reference/openapi.json delete /v1/webhook-endpoints/{id}
Hard delete — the row is removed. Use `PATCH … { active: false }` instead if you want to pause delivery while preserving history.
# Fetch a webhook endpoint
Source: https://docs.letmepost.dev/api-reference/webhooks/fetch-a-webhook-endpoint
/api-reference/openapi.json get /v1/webhook-endpoints/{id}
# List webhook endpoints
Source: https://docs.letmepost.dev/api-reference/webhooks/list-webhook-endpoints
/api-reference/openapi.json get /v1/webhook-endpoints
# Register a webhook endpoint
Source: https://docs.letmepost.dev/api-reference/webhooks/register-a-webhook-endpoint
/api-reference/openapi.json post /v1/webhook-endpoints
Authenticated with an API key or dashboard session. The `signingSecret` is returned **once** at creation and is used to verify the HMAC-SHA256 `X-Letmepost-Signature` header on every delivery. An empty `events` array subscribes to every event type.
# Send a synthetic test delivery
Source: https://docs.letmepost.dev/api-reference/webhooks/send-a-synthetic-test-delivery
/api-reference/openapi.json post /v1/webhook-endpoints/{id}/test
Fires a synthetic event at the endpoint URL synchronously and returns what the consumer responded — bypasses the BullMQ delivery queue on purpose so operators get immediate feedback. The real signing secret is used; the consumer's HMAC verification path is identical to production.
# Update a webhook endpoint
Source: https://docs.letmepost.dev/api-reference/webhooks/update-a-webhook-endpoint
/api-reference/openapi.json patch /v1/webhook-endpoints/{id}
Partial update. Setting `active: false` pauses delivery without losing the endpoint; flipping back to `true` re-enables it.
# Authentication
Source: https://docs.letmepost.dev/authentication
API keys, scoping, and rotation. Bearer tokens, scoped per organization.
Every request to the API requires a Bearer token in the `Authorization` header.
**`lmp_test_` keys are not sandboxed.** The prefix is a label for your own bookkeeping — nothing more. A `lmp_test_…` key has identical permissions to a `lmp_live_…` key: it publishes to your real connected accounts and burns real quota. Sandbox isolation is on the roadmap; until it ships, do not point an integration test suite at a test key expecting it to be inert.
## Headers
```
Authorization: Bearer lmp_live_a1b2c3d4...
Idempotency-Key: 01HY6X4AWBJM2K9F2PTQMRD9JQ (optional, recommended on writes)
```
The dashboard's **API keys** page mints, lists, and revokes keys. The secret is shown exactly once — at create time. After that, rotate to replace it.
## Key shape
| Prefix | Label | Behaviour |
| - | - | - |
| `lmp_live_…` | live | hits `api.letmepost.dev`, writes to real accounts |
| `lmp_test_…` | test | hits `api.letmepost.dev`, writes to real accounts |
Both rows are identical on purpose — that is the current behaviour. The prefix is chosen at mint time and stored on the key so you can tell two keys apart in the dashboard; the API does not branch on it. There is no separate sandbox base URL, no sandbox account pool, and no write rejection for test keys.
The hash, not the secret, is stored at rest. Your secret is unrecoverable after creation.
## Scoping
A key carries two scopes:
* **Organization** — every key is bound to a single organization. There is no cross-org key.
* **Profile** *(optional)* — a key may be pinned to a single profile within the org. Cross-profile access by a profile-scoped key returns `404 not_found`, never `403`, so the key cannot probe for the existence of other profiles.
Set the profile scope at creation time. To widen scope, mint a new key — keys are immutable.
## Read vs write
Read endpoints (`GET /v1/posts`, `GET /v1/posts/:id`, `GET /v1/media`, `GET /v1/accounts`) accept either a Bearer key or a dashboard session cookie. Writes accept Bearer keys only.
## Rotation
There is no automatic key rotation. To rotate:
1. Mint a new key in the dashboard.
2. Switch your application secret to the new key.
3. Revoke the old key.
Revocation is immediate; in-flight requests on the old key fail with `unauthenticated`.
## OAuth tokens are different
The `Authorization: Bearer …` header authenticates *you* to letmepost. The OAuth tokens we hold for upstream platforms (Bluesky session, LinkedIn access token, etc.) are managed entirely server-side, AES-256-GCM encrypted at rest, and refreshed on the platform's schedule. You never see them.
See [accounts](/guides/connect-account) for the connection lifecycle.
## Errors
| code | when |
| - | - |
| [`unauthenticated`](/errors/unauthenticated) | Header missing, malformed, or key unknown/revoked. |
| [`unauthorized`](/errors/unauthorized) | Authenticated, but the key isn't allowed to perform this action. |
| [`not_found`](/errors/not_found) | Often a profile-scope mismatch — see scoping above. |
| [`rate_limited`](/errors/rate_limited) | Per-key rate limit exhausted. |
# Changelog
Source: https://docs.letmepost.dev/changelog
Reverse-chronological release notes covering API surface, schema, breaking changes, and new platforms.
The canonical record lives in the git history; this page summarises the milestones a caller needs to know about. Subscribe to [GitHub releases](https://github.com/rosekamallove/letmepost.dev/releases) for tagged cuts, or follow [the open source repo](https://github.com/rosekamallove/letmepost.dev) for the running history.
## 2026-10
### TikTok is live on the upload track
`tiktok` moves from `pending` to `live`. Connect works and
`POST /v1/posts` publishes — the video uploads to the creator's TikTok inbox
and they confirm it in the TikTok app.
* **What a post actually does.** letmepost calls
`/v2/post/publish/inbox/video/init/` (push\_by\_file), chunk-uploads the bytes,
and polls `/v2/post/publish/status/fetch/`. `SEND_TO_USER_INBOX` is treated as
terminal-published: TikTok gives no signal for the creator's final tap.
* **Direct Post is approved but not implemented.** TikTok audits Upload
(`video.upload`) and Direct Post (`video.publish`) separately. The Direct Post
audit has cleared, but it is a different endpoint
(`/v2/post/publish/video/init/`) and letmepost does not call it yet, nor
request the scope. Publishing without the confirmation tap is still to come.
* **`privacy` is always rewritten to `self_only`, and now always says so.**
The upload-inbox endpoint carries no privacy level. Preflight previously
only warned when the *account* looked unaudited, which meant that once the
app passed audit a caller could ask for `public_to_everyone`, receive no
warning, and get a private draft. The `tiktok.audit.self_only` warning now
fires whenever the requested privacy is rewritten, whatever the account
reports. The code is unchanged for compatibility; only its trigger widened.
* **Retired: `tiktok.privacy.not_allowed`.** Every account permits
`SELF_ONLY`, so once privacy is always coerced this rule can no longer fire.
It returns when Direct Post ships.
## 2026-08
### Fixed: scheduled posts stayed queued and never published
Scheduled publishing was broken end to end. Every `scheduledAt` post failed at
the moment it was handed to the queue, on every platform. Immediate publishes
(no `scheduledAt`) were unaffected throughout.
* **Root cause.** Publish jobs were written with `jobId: publish:`.
BullMQ rejects a custom job id containing `:` — it uses the colon as its
internal key separator — unless the id happens to split into exactly three
parts. `publish:` splits into two, so `queue.add()` threw every time.
Job ids are now dash-joined (`publish-`).
* **Why it was silent.** The `posts` row is committed before the enqueue, so
each failure left a row durably `queued` with no job behind it: no delivery
attempt, no error on the row, no lifecycle event, and a `500` to the caller.
`POST /v1/posts` now rolls the whole batch back — every accepted target has
its job removed, its row canceled, and its quota refunded — rather than
returning an error over a partly-scheduled batch.
* **Recovery.** A new reconciliation sweep runs every 5 minutes and re-drives
`queued` / `validated` posts that are past due with no live job. Posts more
than 24h past `scheduledAt` are failed with [`post.failed`](/webhooks/post-failed)
instead of being sent, so a recovered backlog can't fire days-old posts at
once. Rows stranded in `publishing` by an interrupted worker are closed out
the same way.
### Fixed: X (Twitter) connections dying after a token refresh
* **Refresh tokens are no longer dropped.** X is not obliged to return a
`refresh_token` on every refresh response. Metadata was rebuilt from the
response alone and persisted wholesale, so the first such response erased the
stored refresh token; the next refresh failed with "no refresh token stored",
emitted [`token.revoked`](/webhooks/token-revoked), and the account stayed
dead until manually reconnected. The previous value now carries forward.
* **Tokens are refreshed inline at publish time** when they're at or past
expiry, so a gap in the scheduled refresh chain no longer costs a post. This
is a best-effort backstop and never fails a publish on its own.
Accounts disconnected by the old bug can't be restored server-side — the stored
credential is gone. Reconnect the X account once to clear it.
### Fixed: valid media rejected on mime type
* **Media type is detected from the bytes**, not from the declared value. The
base64 path hard-coded `image/jpeg`, the URL path trusted the origin's
`Content-Type`, and the `mediaId` path trusted the stored row — so a valid
JPEG served as `application/octet-stream` was rejected by preflight before it
ever reached the platform. Detection covers JPEG, PNG, GIF, WebP and ISOBMFF
containers, discriminated by brand so HEIC and AVIF aren't mistaken for MP4.
* **`POST /v1/media` sniffs on upload**, so the stored `contentType` and the S3
object's `Content-Type` are correct at the source. Rows written earlier with a
placeholder type are recovered on read.
* Formats we can't identify still fall back to the declared type, so genuinely
unsupported media is still rejected with the same rule.
### Fixed: failures that reported nothing
* **`rate_limited` now dispatches [`post.failed`](/webhooks/post-failed).** It
previously marked the row failed and dispatched no event at all, so a post
could disappear from an integrator's point of view. It is also terminal now
rather than consuming retries against a 30-day window.
* **`platformResponse` always carries something.** When an upstream answered
with an empty body we recorded no detail and discarded the HTTP status,
producing errors whose own remediation pointed at a field that wasn't there.
Those now carry `{ httpStatus }` at minimum. Responses with a body are
unchanged.
### Changed: X launch cap counts calls that reached X
The per-account launch cap (`twitter.launch_cap.per_account`) now counts posts
that actually reached X — published tweets, plus failures X itself returned.
Locally-produced failures such as preflight rejections no longer consume a slot.
Previously any failure counted, which made the cap self-reinforcing: a run of
failures could exhaust a 30-day allowance without a single tweet being created.
The window is also measured from publication rather than creation.
## 2026-06
### Added: Bluesky reply threading
* **Reply to any Bluesky post** via per-target `options.replyToUri` + `options.replyToCid`, the parent's AT Proto strong ref (both come back in the publish response under `results[]`). The pair must be sent together; one without the other is a `validation_failed`.
* **Multi-post threads.** Pin the thread's original post with `options.replyRootUri` + `options.replyRootCid` for replies deeper than the first. Omit the root and it defaults to the parent, which is correct for a reply to a top-level post.
* **Scheduled Bluesky replies are rejected** with `validation_failed` (rule `scheduledAt.no_bluesky_reply`). The queued row carries only text + media, so a scheduled reply would publish un-threaded; publish synchronously by dropping `scheduledAt`. Same stance the scheduled path already takes for `firstComment`.
* `@letmepost/sdk` exposes the new fields on the Bluesky `TargetOptions` variant; the publish response already carries `uri`/`cid` for chaining. Full reference at [/platforms/bluesky](/platforms/bluesky).
### Added: media on scheduled posts
* **Scheduled posts now carry media.** Media refs persist on `posts.mediaRefs` and the worker reads them back at fire time, so a `scheduledAt` post can include images or video. Previously media on a scheduled post was rejected outright.
* **Breaking (error rule).** The old `scheduledAt.text_only` rejection is gone. First-comment is still unsupported on scheduled posts, now under the narrower rule `scheduledAt.no_first_comment`. If you matched on `scheduledAt.text_only`, switch to the new rule.
### Added: API-key auth on `/v1/webhook-endpoints`
* **Manage webhook endpoints programmatically.** The `/v1/webhook-endpoints` CRUD routes now accept an API key (Bearer), not just a dashboard session, so you can register and rotate delivery endpoints from your own code like the rest of v1.
## 2026-05
### Added: TikTok (in App Review)
* **TikTok provider** with OAuth 2.0 PKCE, push\_by\_file upload to the user's TikTok inbox, and a status-poll worker with bucketed backoff (5s → 30s → 120s up to a 30-minute deadline).
* **State: `pending`.** The dashboard greys the tile and the API rejects connect with `platform_not_enabled` until App Review clears. The publisher itself is fully built; flipping the state in `packages/schemas/src/platform-state.ts` (or via the `PLATFORM_STATE_OVERRIDES` env) is the only step left once approval lands.
* **Sandbox / audit accounts**: posts go to the inbox with `privacy=SELF_ONLY` and the user confirms publish in the TikTok app. Direct Post (`video.publish`) unlocks the moment review approves. No code change needed.
* New queue: `tiktok-publish-status-poll` with deterministic per-attempt delay (`tiktokPublishStatusPollDelayMs`).
* Docs at [/platforms/tiktok](/platforms/tiktok) covering caps, scopes, audit gating, and the inbox flow.
### Added: scheduling lifecycle endpoints
* **`PATCH /v1/posts/:id`**: reschedule a queued post. Body `{ scheduledAt }`. Atomic: removes the existing BullMQ job, enqueues a new one at the updated delay, then persists the new time. Window-gated to `status=queued AND scheduledAt > now`. A `409` lands once the worker has the row. Dispatches [`post.rescheduled`](/webhooks/post-rescheduled) on success with both old and new timestamps.
* **`DELETE /v1/posts/:id`**: cancel a queued post before it fires. Same window. Removes the BullMQ job and transitions the row to the new terminal state `canceled`. Dispatches [`post.canceled`](/webhooks/post-canceled).
* **Stable BullMQ jobIds.** `POST /v1/posts` writes scheduled jobs with a deterministic per-post job id so the cancel/reschedule endpoints can find and replace by post id without tracking BullMQ-assigned ids on the row. (The colon-joined form shipped here was invalid and broke scheduled publishing outright — see [2026-08](#2026-08).)
* **Worker race-safe transition.** The publish worker's queued→publishing step is now a conditional `UPDATE … WHERE status IN ('queued','validated')`. If a `DELETE` lands between the worker's SELECT and UPDATE, the transition matches zero rows and the worker bails out cleanly. No more canceled posts going out by accident.
* **New post status:** `canceled`. Terminal. Set exclusively via `DELETE`.
* **New webhook events:** [`post.canceled`](/webhooks/post-canceled), [`post.rescheduled`](/webhooks/post-rescheduled).
### Added: dashboard restructure
* **`/posts` is now the compose surface.** Grid + list views of scheduled and published posts, with a Create Post modal modeled on Zernio's two-column shape (content left, profile + accounts + Schedule / Now / Queue / Draft tabs right). Queue + Draft are selectable but show a coming-soon empty state with a vote button, which captures demand signal via the `feature.requested` analytics event.
* **`/logs` (renamed from old `/posts`)**: same log-viewer behavior, more honest URL. The page was always a "did my post land?" log, not a "what's coming up?" management surface.
* **`/calendar`**: month grid, scheduled posts as chips on day cells. Click a chip → side drawer with reschedule (datetime picker) and cancel buttons wired through the new endpoints.
* **`/settings`** is tabbed: Usage (current cycle, profiles + posts progress, reset date), Profile (read-only name + email), Danger Zone (account deletion via support). Billing kept at the dedicated `/billing` route.
* **`/analytics`**: placeholder with a "prioritize this" vote button.
* Sidebar reorganized with new routes + matching Phosphor icons.
### Added: observability + onboarding emails
* **Sentry on api, worker, and dashboard.** Node init via a separate `instrument.mjs` loaded with `node --import` so OpenTelemetry auto-instrumentation works under ESM. Dashboard wired via `@sentry/nextjs`. DSN configurable per service.
* **Founder-voice onboarding email sequence.** Five emails over seven days: D0 welcome, D1 first-post nudge, D3 stuck-check, D5 webhooks nudge, D7 Sean Ellis PMF question. Gated on email verification so a bad actor can't burn sender reputation with throwaway addresses. Each email is a pure function of the user's state; D1/D3/D5 skip themselves when their gating condition is already met (e.g. D3 skips users who already connected an account).
* **Resend transport** with `Idempotency-Key` deduplication and svix-signed webhook for delivery + complaint events.
* **Suppression list.** Hard bounces and complaints from the Resend webhook write to `email_suppressions` (PK on lowercased email). The onboarding worker checks this list before sending. Best-effort cancellation of queued onboarding jobs for newly-suppressed addresses.
* **RFC 2369 mailto `List-Unsubscribe` header** on every transactional onboarding email. RFC 8058 one-click was skipped pending the HTTPS endpoint.
### Fixed: auth blockers
* **401 on email signup.** With `requireEmailVerification: true` (production), `signUp.email` succeeded but no session landed until the user clicked the verification link. The dashboard's signup form then immediately called `organization.create` and 401'd. Fixed by probing `getSession()` after signup; if no session, the requested org name is stashed in localStorage and the user routes to a new `/verify-email` screen that polls for verification and forwards to `/onboarding` once the session arrives. `/onboarding` picks up the stashed name and creates the org with what the user originally typed.
* **API key plaintext now auto-copies to clipboard** on creation in the onboarding flow. The dedicated copy button lives below the fold on short viewports and new users were missing it on the only render where the plaintext is visible. Falls back to the button if clipboard permission is denied.
* **OAuth callback `returnTo`.** `POST /v1/accounts/connect/:platform` now accepts `returnTo` (validated against `DASHBOARD_URL` + `TRUSTED_ORIGINS` to block open-redirect) and carries it through the signed state. Callback redirects there instead of the static `/accounts` page, so dashboard users return to the home dashboard and marketing-site demo connections can return to the landing page. Shared `useConnectCallback` hook surfaces the success/error toast on whatever page the user lands on.
### Added: billing (Lemon Squeezy)
* **Three tiers: Free (50 posts/mo), Pro ($79/mo for 5k), Business ($299/mo for 25k).** Self-host is unlimited; enterprise was dropped from the tier ladder until a sales path exists.
* **`POST /v1/billing/checkout`** mints a Lemon Squeezy checkout URL via their Checkouts API (not the legacy `/buy/` URL).
* **Webhook handler** at `POST /v1/lemonsqueezy/webhook`: CSRF-hardened, status-aware (respects LS's order/subscription state), event-id derived from body hash since Lemon Squeezy has no `X-Event-Id` header. Dispatches `subscription.activated`, `subscription.cancelled`, `subscription.tier_changed`, `billing.payment_failed`, `billing.delinquent`, `billing.recovered`, `quota.warning`, `quota.exceeded`.
* **Quota gate on `POST /v1/posts`.** Idempotent replays skip the counter (the idempotency middleware short-circuits before the handler). Infinity-quota tiers (self\_host, grandfather) bypass the cap entirely.
* **Dashboard billing surface**: plan card, usage meter (animated counter + bar, polled every 60s), upgrade flow, invoices list, eager-written cancel + reactivate state.
* **Dunning + retention jobs** run on the billing queue: hourly past\_due → delinquent sweep, nightly per-org log cleanup respecting tier retention windows.
* **`BILLING_ENABLED` env gate** so self-host instances can skip the entire surface without a code branch.
### Added: official SDKs
* **`@letmepost/sdk` (TypeScript)** on npm. Hand-written client mirroring every `/v1/*` endpoint, with first-class types pulled from the same `@letmepost/schemas` package the API uses.
* **Python (`letmepost`) and Go (`github.com/letmepost/letmepost-go`)** generated from the OpenAPI spec via openapi-generator-cli, published from CI. The spec is down-converted from OpenAPI 3.1 → 3.0 before Go codegen for `oneOf` compatibility.
### Added: GitHub + Google OAuth, account linking, first-touch attribution
* **Social sign-in** on the dashboard for Google and GitHub. OAuth identities auto-link to existing email-password accounts when the verified email matches (Google and GitHub are on the `trustedProviders` allowlist; both verify emails server-side before issuing tokens, so auto-link is safe).
* **`requireLocalEmailVerified: false`** on the linking path: a verified-OAuth email matches an unverified local email cleanly, because the OAuth provider's verification is what we actually rely on.
* **First-touch attribution snapshot** at signup: `signupSource`, `signupUtmSource`, `signupUtmMedium`, `signupUtmCampaign`, `signupUtmContent`, `signupUtmTerm`, `signupReferrer`, `signupLandingPath`. Captured from URL params + localStorage at the moment the form is focused or an OAuth flow begins. Persisted on the `user` row.
### Added: Notion-backed blog
* `letmepost.dev/blog` is rendered from a Notion database via a daily Vercel cron. Reading-view design: single column, large type, syntax-highlighted code. Each post has alt text on the cover image and breadcrumbs back to the index. Fonts self-hosted for FCP.
### Changed: marketing site redesigned
* Receipt-themed visual identity across landing, pricing, blog, tools, `/platforms`, `/api`, `/agents`, `/about`, `/llms.txt`. New brand mark + wordmark synced to docs and dashboard. SEO upgrades: FAQ + Product schema, `/about`, `/llms.txt`, latest-writing strip on the landing, breadcrumbs on the blog.
### Added: Pinterest live
* Pinterest Standard Access approval cleared. Pinterest is now connectable for every signup. Image pins, video pins, board picker, default-board setting, and the v5 API version pinned in the platform-versions registry.
### Added: hosted MCP server + CLI
* **[@letmepost/mcp](https://www.npmjs.com/package/@letmepost/mcp) on npm.** `npx @letmepost/mcp@latest` runs the stdio binary for Claude Desktop, Cursor, Claude Code, opencode, and any MCP-aware client. The tool surface is generated from the OpenAPI spec at startup, so the hosted server and the stdio binary expose an identical set of tools (21 today).
* **Hosted MCP at `https://api.letmepost.dev/mcp`.** Streamable HTTP transport, stateless mode. Accepts both `Authorization: Bearer lmp_live_...` API keys and OAuth 2.1 bearer tokens.
* **[@letmepost/cli](https://www.npmjs.com/package/@letmepost/cli) on npm.** `npm i -g @letmepost/cli` installs the `lmp` binary. Commands: `login`, `logout`, `whoami`, `version`, `accounts`, `posts`, `post`, `profiles`. Config persists at `~/.letmepost/config.json`.
* **Profile scoping wired through both.** `lmp post --profile ` on the CLI, top-level `profileId` on the OpenAPI request body, and auto-resolution when an API key is profile-scoped. Mismatch surfaces as `profile.scope_mismatch`.
### Added: OAuth 2.1 provider
* **Dynamic Client Registration (RFC 7591).** MCP clients self-register at install with no out-of-band config.
* **PKCE-only flow.** No client secrets needed in fat clients.
* **Path-suffixed well-known endpoints.** `/.well-known/oauth-authorization-server` and `/.well-known/oauth-protected-resource` per RFC 8414 and RFC 9728, with `/api/auth` and `/mcp` suffixed variants for clients that walk the issuer path.
* **RFC 8707 resource indicators.** The `aud` claim is the MCP endpoint URL, validated on every tool call.
* **Hosted login + consent pages on the dashboard.** Standard better-auth session, then a scope-grant screen before the redirect back to the loopback callback.
* **`WWW-Authenticate: Bearer resource_metadata="..."` on `/mcp`** so MCP clients can discover the OAuth surface from a single header.
* **`POST /v1/oauth-exchange`.** Trades a verified OAuth JWT for a plaintext API key. Used by the CLI's `lmp login` flow so the resulting key works against every `/v1/*` endpoint, not just `/mcp`.
* **`/mcp` accepts both shapes.** Keys with the `lmp_live_` / `lmp_test_` prefix pass through to the standard auth path. JWTs are verified against JWKS and mint a per-user `letmepost-mcp` API key on first use, cached in-process keyed by JWT `sub`.
### Added: top-level `profileId` on `POST /v1/posts`
* Sits alongside `targets` so an MCP or CLI caller can scope a whole batch to a profile without rotating to a profile-scoped API key.
* Validates against the connected account's profile. Mismatches surface as `profile.scope_mismatch`.
### Changed: agent docs live under `/agents/`
* The MCP and CLI guides moved to [docs.letmepost.dev/agents/mcp](https://docs.letmepost.dev/agents/mcp) and [docs.letmepost.dev/agents/cli](https://docs.letmepost.dev/agents/cli). The old `/mcp` route is reserved by Mintlify for its own docs MCP server, so the local pages were being shadowed.
### Changed: v1 wire shape
* **`POST /v1/posts` is now multi-target.** Body shape: `{ targets: [{ accountId | platform, text?, media?, firstComment?, options? }], text?, media?, firstComment?, publishNow?, scheduledAt? }`. One request fans out to up to 25 connected accounts in parallel.
* **Response is a batch envelope:** `{ id, status, createdAt, results: [{ accountId, platform, postId, status, uri?, cid?, error? }] }`. Batch `status` is `published`, `partial_failed`, `failed`, or `queued`.
* **Explicit modes:** `publishNow: true` or `scheduledAt`. Mutually exclusive: the route returns `400 mode_conflict` if both are set.
* **Atomic shape preflight:** if any target fails the synchronous shape checks (text length, media count, alt-text length, options sanity) the whole batch is rejected before any persistence. Deep preflight (URL reachability, MIME sniffing, byte caps) runs per-target inside the publisher and surfaces in `results[i].error`.
* **Removed** the legacy single-target `{ account: { platform, id }, text, … }` body. Migration is a one-line rewrite to `{ targets: [{ accountId }], text, … }`.
### Added: error envelope upgrades
* Every error response now carries `docUrl` (always) and `ruleUrl` (when `rule` is set). Both are stable URLs that land directly on the canonical docs page for the code / rule.
* New error code `platform_not_enabled`: surfaces when a platform isn't yet approved for the calling org (review pending, credentials missing, or admin-disabled).
* New validation rules: `targets.required`, `targets.max`, `target.account.not_connected`, `target.account.ambiguous`, `targets.account.platform_mismatch`, `targets.options.platform_mismatch`.
### Added: single-account auto-resolution
* `targets[i]` may omit `accountId` when only `platform` is set; the API resolves the org's unique connected account for that platform (scoped to the api key's profile, so cross-profile leak is prevented). Ambiguous (2+) and not-connected (0) cases surface as structured 400s.
### Added: rate-limit ceiling header
* `X-RateLimit-Limit` on every response (per-route static ceiling). The IETF-draft `RateLimit-*` headers (no `X-` prefix) carry the real per-window state on routes that enforce it.
### Added: Twitter / X live
* Approval cleared; X is now connectable for every signup. Premium-tier 25,000-char support included. Token refresh and PKCE state are signed and survive the redirect.
### Added: Instagram Login (standalone OAuth)
* Instagram now has its own OAuth flow at `/v1/accounts/connect/instagram`, separate from the Facebook Login fan-out. Lets a user connect just their Instagram Business / Creator account without authoring a Page.
### Added: docs surface
* Mintlify docs site at [docs.letmepost.dev](https://docs.letmepost.dev) with 7 platform pages (each with Quick Reference, Common Errors, Wisdom callouts, "What you can't do"), the full preflight rule catalog, and a Best Practices guide for the error contract.
* Migration guides for [Postiz](/guides/migrate-from-postiz), [Ayrshare](/guides/migrate-from-ayrshare), and [Buffer](/guides/migrate-from-buffer): field rename tables + side-by-side curl + cutover playbook.
* [Self-host section](/self-host/quick-start) with 5 detailed pages (quick-start, environment, platform-credentials, deploying, troubleshooting), written against the real `apps/api/.env.example`.
* OpenAPI spec rewritten end-to-end to match the v1 wire shape. Spec size went from 1,413 lines to 3,313 lines. Every error references a unified `ApiError` schema with `docUrl` / `ruleUrl`, every write documents the `Idempotency-Key` parameter, and the previously-undocumented Pinterest sub-resources and webhook endpoint detail routes are now in the spec.
### Added: landing surface
* Vercel Analytics on the landing alongside PostHog.
* New free tools at [letmepost.dev/tools](https://letmepost.dev/tools): grapheme counter, idempotency-key generator, YouTube quota calculator, LinkedIn scope checker, LinkedIn API-version tracker, PKCE / OAuth scope debugger, cron expression builder.
* Blog scaffold at [letmepost.dev/blog](https://letmepost.dev/blog).
* Canonical logo SVG at `letmepost.dev/logo.svg` (text-as-paths, no font dependency), referenced from both the landing and the docs.
### Fixed
* Instagram provider persists the IG-scoped user id from `GET /me?fields=id` instead of the token-response `user_id`. The token-response value isn't what the Content Publishing API expects in `/{ig-user-id}/media`. Sending it produced an opaque `OAuthException code:2 is_transient:true` on every publish.
* Dashboard QuickStart toast branches on the batch status (`published` / `partial_failed` / `failed` / `queued`) instead of always saying "Post queued."
* Dashboard error toast now reads `body.error.message` (the real envelope path) instead of the always-undefined `body.message`.
* Post detail error contract surfaces `docUrl` and `ruleUrl` as clickable links, the same URLs the API stamps into live error responses.
## 2026-04
### Added: first wave of platforms
* **Bluesky** publisher live end-to-end: text, single image + multi-image, video, alt text, first-comment reply.
* **LinkedIn** MVP publisher (personal-only, `w_member_social`); org posting awaits the Marketing Developer Platform review.
* **Pinterest** publisher: image + video pins, board picker, sandbox-flip via `PINTEREST_API_BASE` while Standard Access is in review.
* **Twitter / X** publisher: text, 4-up images + alt-text, video chunked upload, reply chains, quote tweets, PKCE PKCE-state signing.
* **Threads** publisher: text + image + multi-image + video + carousel up to 20 items, mixed image / video allowed.
* **Facebook Pages + Instagram (FBLB fan-out)** publishers, one OAuth grant.
### Added: primitives
* **Posts**: hybrid sync + scheduled flow with lifecycle webhook events (`post.queued`, `post.validated`, `post.published`, `post.rejected`, `post.failed`).
* **Media** service: `POST /v1/media` multipart streaming, `mediaId` resolver path on every publish.
* **Webhooks**: HMAC-signed delivery, endpoint registration CRUD, synchronous test-event endpoint, event types `post.queued`, `post.validated`, `post.published`, `post.rejected`, `post.failed`, `token.expiring`, `token.revoked`, `version.deprecated`.
* **API keys**: scoped Bearer tokens; per-profile scoping; rotation surface.
* **Profiles**: workspace primitive inside an organization; api keys can be profile-scoped so a single org can isolate per-client publishing.
* **Post log**: `GET /v1/posts` (filterable, cursor-paginated) and `GET /v1/posts/:id` (full record + attempts).
* **Idempotency**: `Idempotency-Key` middleware on every write, with body-hash dedupe so retries replay the original response.
* **Rate limiting**: per-route middleware on `/v1/posts` and `/v1/api-keys`.
* **Encryption at rest**: AES-256-GCM envelope encryption for every stored OAuth token; `KEK_MASTER` env-rotatable.
* **Queue**: BullMQ worker for scheduled publishes + token refresh + webhook delivery.
* **Auth**: better-auth integration with email/password + optional Google / GitHub OAuth.
### Added: dashboard
* Onboarding checklist: API key → connect platform → first post, with inline live-send.
* Post log with TanStack-Table filters (time range, error code, status, platform).
* Accounts page with per-platform brand styling.
* Webhook endpoints page with test dialog (synthetic event, editable JSON, delivery result).
* Profiles CRUD + profile switcher in sidebar.
* TanStack-Query throughout for cache-shared lists, optimistic mutations, and refetch on focus.
* Three-state theme picker (light / dark / system) aligned with the landing palette.
## 2026-04 (early)
* Project scaffolded. Bluesky integration as the first publisher.
* Drizzle schemas + repositories.
* DB integration test harness.
* Initial migration.
* API key auth + `/v1/api-keys`.
## How we communicate breaking changes
* Stable error code rename → bumps the API version (none have happened yet).
* New required field on a request → bumps the API version.
* New optional field on a request, or a new field on a response → non-breaking.
* New error code → non-breaking; consumers must default-case unknown codes.
* Removing a webhook event type → bumps the API version.
The current path is `/v1/`. When `/v2/` ships we'll keep `/v1/` available for at least 12 months.
## Upstream platform versions
We pin upstream versions and upgrade internally when a platform announces a sunset. The current pins are exposed via `GET /v1/platform-versions`. When we change one, we publish a `version.deprecated` webhook for any account on the outgoing version and run the migration internally, so your code keeps working.
# idempotency_conflict
Source: https://docs.letmepost.dev/errors/idempotency_conflict
Same Idempotency-Key reused with a different request body. We surface this rather than silently publish twice.
You sent a second request with an `Idempotency-Key` we've already seen — but the body hash is different from the first request. We refuse to publish, because that's almost always a bug.
## What triggers it
* A retry path that mutates the body before retrying. Common when the body is built from a "current state" object that changes between attempts.
* Re-using a cached UUID for a logically distinct call.
* A worker that retries the same job after the post was edited.
## Response shape
```json idempotency_conflict.json theme={"system"}
{
"error": {
"code": "idempotency_conflict",
"message": "An Idempotency-Key was reused with a different request body.",
"remediation": "Generate a new Idempotency-Key for this distinct request, or reuse the original body byte-for-byte.",
"docUrl": "https://docs.letmepost.dev/errors/idempotency_conflict",
"requestId": "req_..."
}
}
```
## Pick one
1. **The bodies should be identical.** Whatever made them differ is the bug. Find it (often a timestamp or a randomized field) and either freeze it or strip it.
2. **The bodies are intentionally different.** Generate a new key. Two distinct requests should never share a key.
## Replay window
The replay cache keeps the original response for 24 hours, then evicts it. Beyond 24 h the same key is treated as fresh — so you won't get this error from a stale key after a day. Don't lean on the 24 h window for application-level dedup; that's what your own logic is for.
## Related
* [Idempotency](/idempotency) — the full contract.
# Errors
Source: https://docs.letmepost.dev/errors/index
The thirteen error codes, the response shape, and what to do about each one.
Every failure response from the API uses the same envelope:
```json error.json theme={"system"}
{
"error": {
"code": "preflight_failed",
"message": "Post text is 312 graphemes; Bluesky allows at most 300.",
"rule": "bluesky.text.max_graphemes",
"platform": "bluesky",
"platformVersion": "atproto-2026-04",
"platformResponse": null,
"remediation": "Shorten the post to 300 graphemes or fewer.",
"docUrl": "https://docs.letmepost.dev/errors/preflight_failed",
"ruleUrl": "https://docs.letmepost.dev/preflight/bluesky-text-max_graphemes",
"requestId": "req_01HY6X4AWBJM2K9F2PTQMRD9JQ"
}
}
```
| field | always set | meaning |
| - | - | - |
| `code` | yes | one of the thirteen values below |
| `message` | yes | human-readable explanation |
| `rule` | when known | preflight rule id or validation field path |
| `platform` | when known | which upstream platform was involved |
| `platformVersion` | when known | the pinned upstream API version we targeted |
| `platformResponse` | when known | raw upstream body, untouched |
| `remediation` | usually | actionable next step |
| `docUrl` | always | absolute link to this code's docs page |
| `ruleUrl` | when `rule` is set | absolute link to the rule's preflight page |
| `requestId` | always | echoed in the `x-request-id` response header |
| `traceId` | when on | OTel trace id when tracing is active |
The shape never collapses to `{ body: {}, message: "" }`. If the upstream platform returned nothing meaningful, we still attach a `code`, `message`, and `requestId`.
## The thirteen codes
| code | typical HTTP | what it means |
| - | - | - |
| [`validation_failed`](/errors/validation_failed) | 400 | Request body or query failed schema validation. |
| [`preflight_failed`](/errors/preflight_failed) | 400 | A documented platform constraint failed before the upstream call. |
| [`platform_auth_failed`](/errors/platform_auth_failed) | 401, 403 | The connected account's token is missing, expired, or revoked. |
| [`platform_rejected`](/errors/platform_rejected) | 4xx (502 if mapped from an upstream 5xx) | Upstream rejected the call after preflight passed. |
| [`platform_unavailable`](/errors/platform_unavailable) | 502, 503 | Upstream is down or rate-limited; safe to retry later. |
| [`platform_not_enabled`](/errors/platform_not_enabled) | 403 | Platform is approval-gated upstream and not yet connectable for your org. |
| [`internal_error`](/errors/internal_error) | 500 | Unexpected server-side issue. Includes a `requestId` to file. |
| [`unauthenticated`](/errors/unauthenticated) | 401 | Bearer header missing, malformed, or key revoked. |
| [`unauthorized`](/errors/unauthorized) | 403 | Authenticated, but not allowed to perform this action. |
| [`not_found`](/errors/not_found) | 404 | Resource doesn't exist or is out of scope. |
| [`idempotency_conflict`](/errors/idempotency_conflict) | 409 | Same `Idempotency-Key` reused with a different body. |
| [`rate_limited`](/errors/rate_limited) | 429 | Per-key rate limit exhausted. Honor `Retry-After`. |
| [`quota_exceeded`](/errors/quota_exceeded) | 429 | Monthly plan post quota reached. Upgrade the plan or wait for the reset. |
## Reading errors in code
The pattern is the same regardless of language:
```ts handle.ts theme={"system"}
const res = await fetch(url, init);
if (!res.ok) {
const body = await res.json();
// body.error is the envelope above.
if (body.error.code === "preflight_failed") {
// body.error.rule is the canonical rule id.
// Look it up at /docs/preflight// for context.
throw new PreflightError(body.error.rule, body.error.message);
}
if (body.error.code === "rate_limited") {
const retry = Number(res.headers.get("retry-after") ?? 5);
await sleep(retry * 1000);
return retryOnce();
}
throw new Error(\`\${body.error.code}: \${body.error.message}\`);
}
```
## Why this matters
Every code on this page is narrow, stable, and documented. If we discover a new failure shape, we add a new code with its own page rather than fold it into an existing one — broad error codes that mask multiple underlying causes are the failure mode this design exists to defeat.
## Best practices
Error messages are tuned for humans and can change between releases. The `code` field is part of the API contract — it's stable. Always switch on `code` (and `rule` for preflight failures), never on substring match against `message`.
```ts theme={"system"}
// Stable
if (err.code === "preflight_failed" && err.rule === "instagram.media.required") {
attachDefaultImage();
}
// Brittle
if (err.message.includes("Instagram requires media")) { /* … */ }
```
On `rate_limited` responses, the `Retry-After` header tells you how many seconds to wait. The `X-RateLimit-Limit` header is on **every** response (not just 429s) and carries the static per-route ceiling — useful to size your client-side concurrency without first probing a 429. We deliberately do **not** publish `RateLimit-Remaining` or `RateLimit-Reset` in v1: until per-key counting lands, those values would be misleading across tenants. Honor `Retry-After` on 429 and you'll never need them.
Platform `4xx` responses (mapped to letmepost `platform_rejected` with the upstream `platformResponse`) usually mean the post itself violated platform rules — character count, missing media, banned URL pattern. These can be fixed by editing the post.
Platform `5xx` responses (mapped to `platform_unavailable`) are upstream issues. Retry with backoff; check the [status page](https://status.letmepost.dev) if it persists.
Every error response includes a `docUrl` that points at the corresponding code's docs page, and a `ruleUrl` when `rule` is present. If you hit an error code you don't recognize, the `docUrl` value will take you to its remediation page directly.
Every response carries a `requestId` in the body and the `x-request-id` response header. When you open a support issue or check the logs, include it — it indexes the full request log for that operation across the entire pipeline.
## See also
* Each code links to its own page above with reproduction, response shape, and remediation.
* [`preflight`](/preflight) for the full rule catalog when `code` is `preflight_failed`.
# internal_error
Source: https://docs.letmepost.dev/errors/internal_error
Unexpected server-side issue. Includes a requestId you can file with us.
Something broke on our side that we didn't anticipate. Every `internal_error` is a bug. The `requestId` field is the link between you and our logs.
## What triggers it
* Unhandled exceptions on a code path we haven't covered.
* Database / Redis connectivity loss.
* Storage layer (S3) refused the call for a reason we can't classify.
* A feature flag is in an inconsistent state.
If a third-party platform is down, you'll see [`platform_unavailable`](/errors/platform_unavailable) instead — `internal_error` is exclusively about us.
## Response shape
```json internal_error.json theme={"system"}
{
"error": {
"code": "internal_error",
"message": "An unexpected error occurred.",
"remediation": "Retry with the same Idempotency-Key. If the issue persists, share the requestId with support.",
"docUrl": "https://docs.letmepost.dev/errors/internal_error",
"requestId": "req_01HY6X4AWBJM2K9F2PTQMRD9JQ",
"traceId": "abc123..."
}
}
```
The `message` field is intentionally generic — internal errors don't leak server-side details. The `requestId` is the way to reference this specific failure.
## How to handle
1. Retry once with the same `Idempotency-Key`.
2. If it fails again, file an issue or open a support thread with the `requestId`.
3. We do not return server stack traces; please don't try to parse `message` for diagnosis.
## Related
* [Idempotency](/idempotency) — every retry is safe with a key.
* [`platform_unavailable`](/errors/platform_unavailable) — third-party outage, not ours.
# not_found
Source: https://docs.letmepost.dev/errors/not_found
Resource doesn't exist or is out of scope. We 404 instead of 403 to avoid leaking existence.
The id you referenced doesn't exist, was deleted, or belongs to an organization or profile your key can't see.
This is also the response for **scope mismatches**: a profile-scoped key requesting a post on a different profile gets `404`, not `403`. We do that on purpose — `403` would leak the fact that the resource exists somewhere.
## What triggers it
* `POST /v1/posts` with a `targets[].accountId` that doesn't belong to your org.
* `GET /v1/posts/:id` with an id that's been deleted or belongs to another org.
* `GET /v1/accounts/:id` against a profile-scoped key with a `profileId` mismatch.
* A typo in any UUID parameter.
## Response shape
```json not_found.json theme={"system"}
{
"error": {
"code": "not_found",
"message": "Platform account not found.",
"remediation": "Verify the account id and that it belongs to your organization.",
"docUrl": "https://docs.letmepost.dev/errors/not_found",
"requestId": "req_..."
}
}
```
## Why we 404 instead of 403
Imagine a profile-scoped key that probes account ids across the org until one of them returns `403` instead of `404`. That `403` would leak which ids exist. So every cross-profile, cross-org, or scope-mismatch path returns the same `404` response — no observable difference between "doesn't exist" and "exists but you can't see it."
## Related
* [Authentication](/authentication) — profile-scoping rules.
* [`unauthorized`](/errors/unauthorized) — when role-level permission is the issue, not scope.
# platform_auth_failed
Source: https://docs.letmepost.dev/errors/platform_auth_failed
The connected account's platform token is missing, expired, or revoked. The user must reconnect.
The credentials we hold for the connected upstream account are no longer accepted by the platform. This is distinct from [`unauthenticated`](/errors/unauthenticated), which is about your letmepost API key — `platform_auth_failed` is about the OAuth token (or app password) for the *connected platform account*.
## What triggers it
* The user revoked your app's access from the platform's settings page.
* A LinkedIn token aged past its 60-day lifetime and the user hasn't re-authenticated.
* A Bluesky app password was deleted from `bsky.app/settings/app-passwords`.
* A Threads token was rotated faster than our refresh schedule (rare, but the upstream allows it).
* Facebook user removed your business asset from their account.
## Response shape
```json platform_auth_failed.json theme={"system"}
{
"error": {
"code": "platform_auth_failed",
"message": "Bluesky rejected the session token.",
"platform": "bluesky",
"platformVersion": "atproto-2026-04",
"platformResponse": { "error": "AuthMissing", "message": "..." },
"remediation": "Reconnect the Bluesky account.",
"docUrl": "https://docs.letmepost.dev/errors/platform_auth_failed",
"requestId": "req_..."
}
}
```
## How to handle
1. Mark the connected account as needing re-auth in your UI.
2. Send the user through the connect flow again — see [accounts](/guides/connect-account).
3. Subscribe to [`token.expiring`](/webhooks/token-expiring) and [`token.revoked`](/webhooks/token-revoked) so this doesn't surprise you mid-publish.
The connected `platform_account` row stays in place; reconnecting upserts new tokens onto the same row, so post history and idempotency keys are preserved.
## Related
* [`token.expiring`](/webhooks/token-expiring) — fires before this happens.
* [`token.revoked`](/webhooks/token-revoked) — fires when we detect the token is dead.
* [`unauthenticated`](/errors/unauthenticated) — your API key is the problem, not the platform token.
# platform_not_enabled
Source: https://docs.letmepost.dev/errors/platform_not_enabled
The platform exists in the API enum but isn't switched on for your org yet.
You tried to connect or publish to a platform that the API knows about but hasn't been enabled for your organization. Every platform on the hosted API is live today, so you should not see this there. It fires when a platform has been put into `pending` deliberately — on a self-hosted instance whose upstream review hasn't cleared, or during a rollback.
## What triggers it
* `POST /v1/accounts/connect/:platform` against a platform whose developer-portal review hasn't been approved for your app.
* `POST /v1/posts` with a `targets[].platform` that points at a not-yet-enabled platform.
* An admin toggled a platform off for the org without disconnecting existing accounts (rare).
## Response shape
The status is `403`.
```json platform_not_enabled.json theme={"system"}
{
"error": {
"code": "platform_not_enabled",
"platform": "tiktok",
"message": "tiktok is pending platform approval and not yet connectable.",
"remediation": "Subscribe to changelog updates for launch notifications, or self-host with your own platform credentials.",
"docUrl": "https://docs.letmepost.dev/errors/platform_not_enabled",
"requestId": "req_..."
}
}
```
## What to do
* **Check the current state first.** Every platform letmepost supports is `live` or `trial` today, so the hosted API does not return this error for any of them. It fires when a platform has been put into `pending` deliberately — on a self-hosted instance, or during a rollback. `POST /v1/accounts/connect/:platform` carries a `state` field (`live`, `trial`, or `pending`); the [platforms overview](/platforms-overview) tracks the same thing, and the [changelog](/changelog) announces changes.
* **For self-host deployments** — check that the platform's OAuth credentials are present in your environment (e.g. `META_APP_ID`, `TWITTER_CLIENT_ID`). The platform stays disabled if its credentials are unset. You can also flip a platform live yourself with `PLATFORM_STATE_OVERRIDES` (e.g. `PLATFORM_STATE_OVERRIDES=tiktok:live`).
* **For the meantime** — Bluesky has no upstream review gate and works for every signup today. The API surface (`POST /v1/posts`) is identical, so any code you write against Bluesky carries over.
## Related
* [Platforms overview](/platforms-overview) — current support status.
* [Self-host environment](/self-host/environment) — environment variables that gate each platform.
# platform_rejected
Source: https://docs.letmepost.dev/errors/platform_rejected
Preflight passed but the upstream platform still rejected the call. Carries the raw platform response.
The request passed every preflight rule we know about, but the upstream platform still said no. This is rare — when it happens we treat it as a gap in preflight coverage and either add a rule or sharpen an existing one.
## What triggers it
* A platform rule we don't yet enforce locally — e.g., a content moderation rejection, an account-state issue (rate-limited at the platform level, suspended), or a constraint introduced after our last update.
* An async media-validation failure on Meta's container flow (the file passed our size + mime checks but failed deeper inspection).
* A platform-specific rejection we haven't translated into a preflight rule yet.
## Response shape
```json platform_rejected.json theme={"system"}
{
"error": {
"code": "platform_rejected",
"message": "Threads rejected the create-container call: media URL not reachable.",
"rule": "threads.container.error",
"platform": "threads",
"platformVersion": "graph-2026-04",
"platformResponse": {
"error": {
"message": "Container creation failed",
"code": 100,
"fbtrace_id": "..."
}
},
"remediation": "Verify the media URL returns 200 anonymously, or upload via POST /v1/media first.",
"docUrl": "https://docs.letmepost.dev/errors/platform_rejected",
"ruleUrl": "https://docs.letmepost.dev/preflight/threads-container-error",
"requestId": "req_..."
}
}
```
`platformResponse` carries the raw upstream body, untouched. That's the most useful signal when this code surfaces — we don't summarize it away.
## How to handle
1. Inspect `platformResponse` for the upstream code / subcode.
2. If it's a known opaque rejection (e.g., Instagram's `OAuthException 2207052`), check the matching preflight rule page and follow the remediation there.
3. File an issue: every `platform_rejected` we see in production is a candidate for a new preflight rule. The dashboard's post log makes these easy to spot.
## HTTP status
* `4xx` (the platform's status, passed through) when the upstream said the call is invalid.
* `502 Bad Gateway` when the upstream returned a 5xx — we map upstream 5xx to our 502 + `platform_unavailable` instead of `platform_rejected`. See [`platform_unavailable`](/errors/platform_unavailable) for that case.
## Related
* [`platform_unavailable`](/errors/platform_unavailable) — upstream returned 5xx; transient.
* [`preflight_failed`](/errors/preflight_failed) — we caught it before the platform did.
# platform_unavailable
Source: https://docs.letmepost.dev/errors/platform_unavailable
Upstream is down, throttled, or returning 5xx. Transient — retry later.
The upstream platform is unreachable, throttled, or returning 5xx. Treat as transient. The post is **not** retried automatically by `POST /v1/posts` — that's the caller's job, with idempotency.
For scheduled posts the worker retries internally per the queue policy.
## What triggers it
* The platform is down (e.g., Meta Graph 5xx outage).
* The platform throttled us at the network layer (rare; per-app rate limit usually surfaces as `platform_rejected`).
* DNS / TLS error reaching the platform.
* The platform took longer than our request timeout to respond.
## Response shape
```json platform_unavailable.json theme={"system"}
{
"error": {
"code": "platform_unavailable",
"message": "Bluesky API returned 503 Service Unavailable.",
"platform": "bluesky",
"platformResponse": "",
"remediation": "Retry with the same Idempotency-Key after a short backoff.",
"docUrl": "https://docs.letmepost.dev/errors/platform_unavailable",
"requestId": "req_..."
}
}
```
## How to handle
Retry with **the same `Idempotency-Key`**. Our replay cache holds for 24 hours; sending the same request again within that window is safe — if the upstream succeeded the first time despite the 5xx, you'll get the cached `201` back.
Standard exponential backoff is fine: 1s, 2s, 4s, 8s, …, up to your tolerance.
## Related
* [Idempotency](/idempotency) — why retries are safe.
* [`rate_limited`](/errors/rate_limited) — letmepost's own rate limit.
* [`platform_rejected`](/errors/platform_rejected) — upstream said no for content reasons.
# preflight_failed
Source: https://docs.letmepost.dev/errors/preflight_failed
A documented platform constraint failed before we touched the upstream API. The rule field carries the canonical rule id.
The request was syntactically valid but failed a platform-specific rule we check locally before the upstream call. This is the canonical "we caught it before the platform did" error.
## What triggers it
Anything in the [preflight rule catalog](/preflight). A few high-frequency examples:
* Bluesky post text > 300 graphemes ([`bluesky.text.max_graphemes`](/preflight/bluesky-text-max_graphemes))
* Instagram post with no media ([`instagram.media.required`](/preflight/instagram-media-required))
* Instagram media URL not publicly fetchable ([`instagram.media.reachable`](/preflight/instagram-media-reachable) — the canonical `OAuthException 2207052` translator)
* Pinterest pin without a board id and no default board on the connected account ([`pinterest.board.required`](/preflight/pinterest-board-required))
## Response shape
```json preflight_failed.json theme={"system"}
{
"error": {
"code": "preflight_failed",
"message": "Post text is 312 graphemes; Bluesky allows at most 300.",
"rule": "bluesky.text.max_graphemes",
"platform": "bluesky",
"remediation": "Shorten the post to 300 graphemes or fewer.",
"docUrl": "https://docs.letmepost.dev/errors/preflight_failed",
"ruleUrl": "https://docs.letmepost.dev/preflight/bluesky-text-max_graphemes",
"requestId": "req_..."
}
}
```
`rule` is always set on `preflight_failed`. The `ruleUrl` is the absolute link to that rule's docs page — same path as `/docs/preflight//` — so a CLI can `open` it directly without parsing.
## Why preflight matters
Without preflight you'd see this same constraint failure as an opaque platform response — a generic IG `code 100`, a LinkedIn 422 with no field path, a Threads HTTP 200 carrying a status code in the body. Preflight surfaces the rule id directly, with a remediation, before the upstream call ever happens.
## Remediation
Open the rule's page and follow the remediation. Every preflight rule has one.
## Related
* [Preflight](/preflight) — the full rule catalog.
* [`platform_rejected`](/errors/platform_rejected) — preflight passed but the platform still rejected. We treat this as a preflight gap and add a rule.
# quota_exceeded
Source: https://docs.letmepost.dev/errors/quota_exceeded
Your organization hit its monthly post quota. Upgrade the plan or wait for the reset.
Your organization has reached the monthly post quota for its plan. Each target in a `POST /v1/posts` request counts against the quota, so a fan-out to three accounts costs three. Upgrade the plan or wait until the quota resets at the start of next month.
This is distinct from [`rate_limited`](/errors/rate_limited): rate limiting is a short-term per-key burst control, while `quota_exceeded` is your plan's total monthly allowance. Both return `429`.
## What triggers it
* A `POST /v1/posts` request whose target count would push the organization past its monthly post cap.
* A single request whose target count alone exceeds the entire monthly quota (rejected up front before any target is persisted).
The check is atomic — two concurrent requests can't double-spend the last slot under the cap. Self-hosted and unlimited tiers skip the gate entirely.
## Response shape
```json quota_exceeded.json theme={"system"}
{
"error": {
"code": "quota_exceeded",
"message": "Monthly post quota of 500 has been reached for this organization.",
"rule": "billing.posts.monthly_cap",
"remediation": "Upgrade your plan at https://dashboard.letmepost.dev/billing or wait until the quota resets at the start of next month.",
"platformResponse": {
"period": "2026-07",
"quota": 500,
"resetAt": "2026-08-01T00:00:00.000Z"
},
"docUrl": "https://docs.letmepost.dev/errors/quota_exceeded",
"requestId": "req_..."
}
}
```
`platformResponse` carries the billing `period` (`YYYY-MM`), the `quota`, and the `resetAt` timestamp — the exact moment the counter rolls over. When a single request is larger than the whole quota, `platformResponse` also includes the request `cost`.
## How to handle
```ts handle.ts theme={"system"}
const res = await fetch("https://api.letmepost.dev/v1/posts", { /* ... */ });
if (res.status === 429) {
const body = await res.json();
if (body.error.code === "quota_exceeded") {
// Retrying won't help until resetAt — surface an upgrade prompt instead.
const resetAt = body.error.platformResponse?.resetAt;
throw new QuotaError(body.error.message, resetAt);
}
// rate_limited — honor Retry-After and retry.
}
```
Unlike `rate_limited`, retrying a `quota_exceeded` response before `resetAt` will keep failing. Branch on `code` and route users to an upgrade rather than a backoff loop.
## Get ahead of it with webhooks
Subscribe to the quota webhooks so you're warned before you hit the wall:
* `quota.warning` — fires once when usage crosses 80% of the monthly cap.
* `quota.exceeded` — fires once per period when the cap is reached.
Both carry `period`, `postsCount`, `quota`, and `resetAt` in `data`. See [Webhooks](/webhooks).
## Related
* [`rate_limited`](/errors/rate_limited) — short-term per-key burst limit, not the monthly plan cap.
* [Pricing](/pricing) — per-tier monthly quotas.
* [Webhooks](/webhooks) — `quota.warning` and `quota.exceeded` events.
# rate_limited
Source: https://docs.letmepost.dev/errors/rate_limited
Per-key rate limit exhausted. Honor the Retry-After header.
You've sent more requests than the rate limit allows for this API key. Slow down and retry after the `Retry-After` header's value.
## What triggers it
* Burst exceeded the per-key write limit on `POST /v1/posts`.
* Burst exceeded the per-key media-upload limit on `POST /v1/media`.
* Suspicious patterns flagged by the rate limiter.
The exact limits aren't published as a contract — they're tuned by environment and may move. Honor `Retry-After` and you'll never need the number.
## Response shape
```json rate_limited.json theme={"system"}
{
"error": {
"code": "rate_limited",
"message": "Rate limit exceeded.",
"remediation": "Wait the number of seconds in the Retry-After header before retrying.",
"docUrl": "https://docs.letmepost.dev/errors/rate_limited",
"requestId": "req_..."
}
}
```
`Retry-After` is set on the response in seconds.
Every response (not just 429s) also carries `X-RateLimit-Limit` — the static per-route ceiling — so a client can size concurrency without first probing a 429. We deliberately do not publish `Remaining` or `Reset` until per-key counting lands; those values would be misleading across tenants in v1.
## How to handle
```ts handle.ts theme={"system"}
async function postWithBackoff(body: object) {
for (let i = 0; i < 5; i++) {
const res = await fetch("https://api.letmepost.dev/v1/posts", { /* ... */ });
if (res.status !== 429) return res;
const retry = Number(res.headers.get("retry-after") ?? 1);
await sleep(retry * 1000);
}
throw new Error("rate limited five times in a row");
}
```
## Distinct from upstream rate limits
This error is letmepost's own per-key limit. When the *upstream* platform rate-limits us you'll see [`platform_rejected`](/errors/platform_rejected) or [`platform_unavailable`](/errors/platform_unavailable) with the upstream's headers attached.
## Related
* [Authentication](/authentication) — keys are the unit of rate limiting.
* [`platform_unavailable`](/errors/platform_unavailable) — upstream is the limiter, not us.
# unauthenticated
Source: https://docs.letmepost.dev/errors/unauthenticated
The Authorization header is missing, malformed, or the API key is revoked.
The request didn't carry a valid letmepost API key. This is about *your* key — not the upstream platform token. For platform token issues see [`platform_auth_failed`](/errors/platform_auth_failed).
## What triggers it
* No `Authorization` header.
* Header doesn't start with `Bearer `.
* Key is unknown (typo, revoked, deleted org).
* Token doesn't start with `lmp_live_` or `lmp_test_` — anything else isn't recognised as a key at all.
The prefix is not an environment check. `lmp_live_` and `lmp_test_` take the same code path against the same base URL, so a test key will never be rejected for being "the wrong environment" — it just publishes. See [authentication](/authentication).
## Response shape
```json unauthenticated.json theme={"system"}
{
"error": {
"code": "unauthenticated",
"message": "Missing or invalid Authorization header.",
"remediation": "Send 'Authorization: Bearer lmp_live_…' (or lmp_test_…) on every request.",
"docUrl": "https://docs.letmepost.dev/errors/unauthenticated",
"requestId": "req_..."
}
}
```
## Remediation
1. Mint or rotate a key in the dashboard's **API keys** page.
2. Confirm you're sending the whole secret, prefix included.
3. Confirm the key wasn't revoked — revocation is immediate.
## Related
* [Authentication](/authentication) — full key shape + scoping rules.
* [`unauthorized`](/errors/unauthorized) — authenticated but not allowed.
* [`platform_auth_failed`](/errors/platform_auth_failed) — upstream platform token issue.
# unauthorized
Source: https://docs.letmepost.dev/errors/unauthorized
The API key is authentic but not allowed to perform this action.
Your key is valid; it just can't do this. The two most common causes are role mismatch (e.g., a member key trying to manage org settings) and write attempts on a read-only endpoint shape.
For most cross-organization or cross-profile attempts you'll see [`not_found`](/errors/not_found) instead — we 404 to avoid leaking resource existence to a key that shouldn't see it.
## What triggers it
* An owner-only action (e.g., deleting an organization, rotating an org-wide secret) attempted by a member key.
* A read-only key on a write endpoint.
* A profile-scoped key trying to modify org-wide settings.
## Response shape
```json unauthorized.json theme={"system"}
{
"error": {
"code": "unauthorized",
"message": "This action requires an owner-scoped key.",
"remediation": "Mint an owner-scoped key from the dashboard, or have an owner perform the action.",
"docUrl": "https://docs.letmepost.dev/errors/unauthorized",
"requestId": "req_..."
}
}
```
## Related
* [Authentication](/authentication) — scoping rules and key shape.
* [`not_found`](/errors/not_found) — what you get for cross-org/cross-profile attempts.
# validation_failed
Source: https://docs.letmepost.dev/errors/validation_failed
Request body or query failed schema or batch-level validation. The rule field carries the specific check that failed.
The request body, headers, or query parameters didn't match the documented schema, or the request violated a batch-level rule (target count, mode conflict, account resolution). This is rejection at the route boundary — platform-specific preflight (which checks platform constraints) only runs after validation passes.
## What triggers it
* Required field missing (e.g. no `targets[]`, no `text`).
* Type mismatch (e.g. `text: 42` instead of a string).
* Out-of-range value (e.g. `limit: -1` on a list endpoint).
* Bad pattern match (e.g. `mediaId: "foo"` — must match `^med_[0-9A-Za-z]{22}$`).
* Constraint violation (e.g. `scheduledAt` parses but is in the past).
* Batch-level rules listed below.
## Batch and target rules
These rules fire on `POST /v1/posts` after schema parsing but before preflight. They map directly to the validation layer in `apps/api/src/routes/posts.ts`.
| `rule` | meaning | remediation |
| - | - | - |
| `targets.required` | `targets[]` was empty | Send at least one target. |
| `targets.max` | `targets[]` exceeded 25 entries | Split the publish into batches of at most 25 targets. |
| `target.account.not_connected` | A target's `platform` matched zero connected accounts in the org+profile scope | Connect the platform first, or pass an explicit `accountId`. |
| `target.account.ambiguous` | A target's `platform` matched 2+ connected accounts; we won't guess | Pass an explicit `accountId` (the `remediation` field lists the candidate ids). |
| `targets.options.platform_mismatch` | A target's `options.platform` doesn't match the resolved account's platform | Drop `options`, or set `options.platform` to match. |
| `targets.account.platform_mismatch` | A target carried both `accountId` and `platform` and they disagreed | Drop `platform`, or set it to match the account. |
| `mode_conflict` | `publishNow: true` AND `scheduledAt` both set | Drop one of the two — they're mutually exclusive. |
| `scheduledAt.future` | `scheduledAt` was less than 1 second in the future | Send a timestamp at least 1 second ahead. |
| `scheduledAt.text_only` | Scheduled post carried `media` or `firstComment` (not supported in v1) | Publish synchronously, or drop media/firstComment. |
| `body.json` | Request body wasn't valid JSON | Send a JSON body with `Content-Type: application/json`. |
## Response shape
```json validation_failed.json theme={"system"}
{
"error": {
"code": "validation_failed",
"message": "A single request may not fan out to more than 25 targets.",
"rule": "targets.max",
"remediation": "Split the publish into batches of at most 25 targets.",
"docUrl": "https://docs.letmepost.dev/errors/validation_failed",
"requestId": "req_..."
}
}
```
For pure schema failures the `rule` is the dot-joined Zod path and `platformResponse` carries the full Zod issues array — every issue, not just the first — so a UI can render them inline next to the right field:
```json zod-issues.json theme={"system"}
{
"error": {
"code": "validation_failed",
"message": "Required",
"rule": "targets",
"platformResponse": [
{ "code": "invalid_type", "expected": "array", "received": "undefined", "path": ["targets"], "message": "Required" }
],
"remediation": "Check the request body matches the documented schema.",
"docUrl": "https://docs.letmepost.dev/errors/validation_failed",
"requestId": "req_..."
}
}
```
## Reproducing it
```bash reproduce.sh theme={"system"}
# Missing 'targets' — should fail with rule: "targets"
curl -X POST https://api.letmepost.dev/v1/posts \
-H "Authorization: Bearer $LMP_KEY" \
-H "Content-Type: application/json" \
-d '{ "text": "shipped" }'
```
## Remediation
Read the `rule` field and either fix the field at that path, or follow the batch-rule table above. For pure Zod failures, `rule` is the path of the **first** issue; check `platformResponse` for the full list when you're rendering form errors to a user.
## Related
* [`preflight_failed`](/errors/preflight_failed) — passes schema, fails platform constraints.
* [`idempotency_conflict`](/errors/idempotency_conflict) — same key, different body.
* [API reference](/api-reference) — every endpoint's request schema.
# Accounts
Source: https://docs.letmepost.dev/guides/connect-account
Connect, refresh, fan-out. The OAuth lifecycle for each platform.
A "platform account" is a single connected identity on an upstream platform — one Bluesky handle, one Facebook Page, one Twitter user. Posts are sent against an account `id`, never against bare credentials.
## Two connect shapes
| platform | shape | flow |
| - | - | - |
| Bluesky | `credentials` | submit handle + app password, no OAuth round-trip |
| everything else | `oauth` | redirect → upstream consent → callback |
Both end with a row in `platform_accounts` and an `id` you reference from post bodies as `targets[].accountId`.
## OAuth: connect → complete
```bash connect.sh theme={"system"}
# 1. Start the flow.
curl -X POST https://api.letmepost.dev/v1/accounts/connect/linkedin \\
-H "Authorization: Bearer $LMP_KEY"
# Response: { "redirectUrl": "https://www.linkedin.com/oauth/...", "state": "..." }
# 2. Send your user to redirectUrl. After they consent, LinkedIn
# redirects to https://api.letmepost.dev/v1/accounts/connect/linkedin/complete
# with a ?code= and ?state= the API exchanges for tokens.
```
The dashboard's **Accounts** page wraps both ends of this for you. Programmatically, you initiate the flow yourself and your user comes back to the API's complete endpoint.
## Credentials: Bluesky
Bluesky doesn't have OAuth; it uses scoped app passwords. Generate one at [bsky.app/settings/app-passwords](https://bsky.app/settings/app-passwords), then submit:
```bash bluesky.sh theme={"system"}
curl -X POST https://api.letmepost.dev/v1/accounts/connect/bluesky/complete \\
-H "Authorization: Bearer $LMP_KEY" \\
-H "Content-Type: application/json" \\
-d '{ "identifier": "you.bsky.social", "appPassword": "abcd-efgh-ijkl-mnop" }'
```
We never store your main account password. Revoke an app password at any time on bsky.app and the connection breaks cleanly with `platform_auth_failed`.
## Meta surfaces each connect separately
Facebook Pages, Instagram Business, and Threads each have their own connect flow — one OAuth per `platform_accounts` row. Connecting `facebook` discovers Pages via `GET /me/accounts`; connecting `instagram` runs the standalone Instagram Login OAuth and uses `/me?fields=id` for the Content Publishing API user id; connecting `threads` runs Threads' own OAuth at `threads.net`. No silent fan-out — every connected identity is one explicit OAuth.
## Token lifecycle
We refresh access tokens on each platform's published schedule and AES-256-GCM encrypt them at rest:
| platform | access token lifetime | strategy |
| - | - | - |
| Bluesky | minutes | refresh on 401; the AT Proto session is short-lived |
| Twitter / X | 2 hours | refresh \~5 min before expiry |
| Threads | \~60 days | refresh long-lived token before expiry |
| Facebook / IG | \~60 days | exchange short-lived for long-lived; refresh on schedule |
| LinkedIn | 60 days, no refresh | re-auth required; we fire `token.expiring` 7 days ahead |
| Pinterest | 30 days | refresh \~24 h before expiry |
Subscribe to [`token.expiring`](/webhooks/token-expiring) and [`token.revoked`](/webhooks/token-revoked) to know when a connection needs user attention.
## Listing and disconnecting
```
GET /v1/accounts — list connected accounts (no secrets)
GET /v1/accounts/:id — single account detail
DELETE /v1/accounts/:id — disconnect (hard delete)
```
Secrets never come back from a read. Only the public profile (display name, platform user id), the token expiry timestamp, and platform-specific public extensions (Pinterest's default board, etc.) are exposed.
## Errors during connect
| code | when |
| - | - |
| [`platform_rejected`](/errors/platform_rejected) | Upstream OAuth flow failed (consent denied, bad scopes, missing business asset). |
| [`platform_auth_failed`](/errors/platform_auth_failed) | A subsequent post hit a revoked or expired token. |
| [`facebook.pages.none`](/preflight/facebook-pages-none) | The Facebook user has no Pages they can manage; can't connect. |
## See also
* [Authentication](/authentication) — letmepost API keys (separate from OAuth tokens).
* [Per-platform pages](/platforms/bluesky) — exact scope sets and quirks.
# Migrate from Ayrshare
Source: https://docs.letmepost.dev/guides/migrate-from-ayrshare
Map your existing Ayrshare publishing flow to letmepost.dev.
## Why migrate
[Ayrshare](https://ayrshare.com) is a closed-source SaaS social media API that has been around for years; it works well for plenty of teams. letmepost is an alternative for teams that want the same kind of API surface with three properties Ayrshare doesn't offer: open source under Apache-2.0 with a self-host path as an escape hatch, a structured error envelope with stable rule ids and remediation, and request-level idempotency. If those properties matter to you, the migration is mostly a field rename.
## What changes
Ayrshare's `POST /post` takes a `post` string, a `platforms` array, and per-platform options as siblings. letmepost takes a `targets` array of connected account ids, a top-level `text`, and a structured `media` array.
| Ayrshare concept | letmepost concept | Notes |
| - | - | - |
| User Profile (multi-tenant) | API key per workspace | letmepost workspaces map to one set of accounts and one set of keys. |
| `platforms: ["twitter", "instagram"]` | `targets[].accountId` | Ayrshare resolves the connected account from the profile; letmepost addresses it directly. |
| `post` (string) | `text` (string) | Same content. |
| `mediaUrls` (array of URLs) | `media[].url` | letmepost media also supports `mediaId` (server-side upload) and `bytesBase64` (inline). |
| `scheduleDate` | `scheduledAt` | Both ISO-8601. letmepost requires the timezone offset (`Z` or `+HH:MM`). |
| Per-platform `instagramOptions`, `twitterOptions`, etc. | per-target overrides on `targets[]` | letmepost places platform-specific fields on the target, not as top-level siblings. |
| Error message string | Envelope: `code`, `rule`, `remediation`, `platformResponse`, `requestId` | See [errors](/errors). |
## API shape side-by-side
```bash theme={"system"}
curl -X POST https://api.ayrshare.com/api/post \
-H "Authorization: Bearer $AYRSHARE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"post": "Hello",
"platforms": ["twitter", "instagram"],
"mediaUrls": ["https://cdn.example.com/hero.jpg"],
"scheduleDate": "2026-05-20T12:00:00Z"
}'
```
```bash theme={"system"}
curl -s -X POST https://api.letmepost.dev/v1/posts \
-H "Authorization: Bearer $LMP_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data @- <" },
{ "accountId": "" }
],
"text": "Hello",
"media": [{ "kind": "image", "url": "https://cdn.example.com/hero.jpg" }],
"scheduledAt": "2026-05-20T12:00:00Z"
}
EOF
```
The biggest shape difference: Ayrshare implicitly picks the right connected account from the profile based on the platform names you list. letmepost has you pass the account `id` directly. That's a deliberate choice — it makes one workspace with multiple accounts on the same platform (e.g. two Instagram Business accounts) addressable without ambiguity.
## Cutover playbook
* Create an account at [dashboard.letmepost.dev](https://dashboard.letmepost.dev). If you want a self-host escape hatch, see [self-host](/self-host/quick-start) — same image, same contracts.
* Mint an API key (Settings → API keys).
* Connect each platform you currently publish to. letmepost uses each platform's OAuth flow; you'll re-authorize the same accounts against the letmepost app.
* Copy each connected account's `id` from the dashboard — these become your `targets[].accountId` values.
* Translate `platforms: ["twitter", "instagram"]` into `targets[]` with the corresponding account ids.
* Rename `post` → `text`, `mediaUrls` → `media[].url` (with a `kind` of `"image"` or `"video"`).
* Rename `scheduleDate` → `scheduledAt`. letmepost rejects naive datetimes — include the `Z` or an explicit offset.
* Move per-platform options (Instagram captions, Twitter alt text, etc.) onto the matching `targets[]` entry. See [platforms](/platforms-overview) for the per-platform field set.
* Run a few candidate posts through [preflight](/preflight) — letmepost catches platform-specific issues (grapheme caps, mime allow-lists, media counts, IG reachability) before the upstream call so you don't waste a publish attempt.
letmepost dedupes retries via [`Idempotency-Key`](/idempotency) — generate one UUID per logical post and replay safely on network errors. The replay cache returns the original response (same status, same body) for 24 hours. Reusing the same key with a different body returns `idempotency_conflict`, which catches accidental drift in your client. Ayrshare does not document a request-level idempotency contract, so if you currently dedupe at all you're likely doing it in your own code — you can stop.
Every letmepost failure carries `code`, `message`, `rule`, `remediation`, `platformResponse`, `platformVersion`, and `requestId`. Branch on `code` and `rule`, not message text — they're stable. The raw upstream response is attached when the platform rejected the call, so you don't lose information when something goes wrong. See [errors](/errors) for the eleven codes and [preflight](/preflight) for the rule catalog.
* For about a week, publish each post through both Ayrshare and letmepost.
* Reconcile: same content lands on the same platforms, with the same timestamps and the same media.
* Watch `requestId` — every log line, webhook, and error envelope carries it, so a single id traces the whole pipeline end-to-end.
* Once you're confident, switch traffic over.
Cancel your Ayrshare plan once parallel runs match. We're glad to have you.
## Feature parity
| Capability | Ayrshare | letmepost.dev |
| - | - | - |
| Hosted REST API | ✓ | ✓ |
| Open source | — | ✓ (Apache-2.0) |
| Self-hostable | — | ✓ ([guide](/self-host/quick-start)) |
| Multi-target post in one request | ✓ (`platforms[]`) | ✓ (`targets[]`) |
| Scheduled posts | ✓ | ✓ |
| Preflight catalog with stable rule ids | — | ✓ ([preflight](/preflight)) |
| Structured error envelope (raw `platformResponse` attached) | — | ✓ ([errors](/errors)) |
| Request-level idempotency (`Idempotency-Key`, body-hash conflict) | — | ✓ ([idempotency](/idempotency)) |
| Pinned platform versions, `version.deprecated` webhook | — | ✓ |
| Webhooks for post lifecycle | ✓ | ✓ ([webhooks](/webhooks)) |
| Analytics endpoints | ✓ | not in v1 |
| Inbox / comments / DMs | ✓ | not in v1 (publisher only) |
| AI content generation | ✓ | not in scope |
| Official SDKs (TypeScript, Python, Go) | ✓ (varies) | ✓ ([SDKs](/sdks/typescript)) |
| CLI + MCP server | — | ✓ ([CLI](/agents/cli), [MCP](/agents/mcp)) |
letmepost is a publisher in v1 — analytics, inbox, and DMs are out of scope. If those are central to your Ayrshare usage, the migration won't be one-for-one yet. For pure publishing, the mapping above covers it. See [pricing](/pricing) for the current plan.
## Need help
Open an issue on [GitHub](https://github.com/letmepost/letmepost.dev) or email [rose@letmepost.dev](mailto:rose@letmepost.dev). If you're migrating from Ayrshare, we'll prioritize debugging help.
# Migrate from Buffer
Source: https://docs.letmepost.dev/guides/migrate-from-buffer
Map your existing Buffer publishing flow to letmepost.dev.
## Why migrate
[Buffer](https://buffer.com) is a UI-first scheduling product with a long history. Its public API exists but is a secondary surface — limited in scope, with channel-by-channel publish semantics. letmepost is API-first by design: one HTTP call publishes to one or many platforms, errors are structured, idempotency is built in, and the API is the same contract that powers the dashboard. If you're building publishing into your own product or agent and Buffer's API has been the bottleneck, the move is a good fit. If you're a content team that lives in Buffer's UI, the migration story is weaker — letmepost's dashboard is intentionally minimal.
## What changes
Buffer's model is channel-centric: you create an update against one connected channel at a time. letmepost lets you express the same idea — same content, multiple destinations — in a single request with a `targets[]` array.
| Buffer concept | letmepost concept | Notes |
| - | - | - |
| Channel | Platform account | Connect via OAuth. Each has a stable `id`. |
| Update | `POST /v1/posts` request | One request, one or many targets. |
| One update per channel | One request with multiple `targets[]` | This is the headline change. N Buffer calls collapse into 1. |
| Scheduled time on an update | `scheduledAt` (ISO-8601) | Omit for immediate publish. |
| Channel-specific overrides | per-target overrides on `targets[]` | Platform-specific fields live on the target entry. |
| Error message string | Envelope: `code`, `rule`, `remediation`, `platformResponse`, `requestId` | See [errors](/errors). |
## API shape side-by-side
Buffer's API creates an update against a specific connected channel ("profile"). To post the same content to three channels, you make three calls.
```bash theme={"system"}
# One call per channel — repeat for each profile_id you want to publish to.
curl -X POST https://api.bufferapp.com/1/updates/create.json \
-H "Authorization: Bearer $BUFFER_ACCESS_TOKEN" \
--data-urlencode "profile_ids[]=" \
--data-urlencode "text=Hello" \
--data-urlencode "scheduled_at=2026-05-20T12:00:00Z"
```
```bash theme={"system"}
# One call, multiple targets.
curl -s -X POST https://api.letmepost.dev/v1/posts \
-H "Authorization: Bearer $LMP_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data @- <" },
{ "accountId": "" },
{ "accountId": "" }
],
"text": "Hello",
"media": [{ "kind": "image", "url": "https://cdn.example.com/hero.jpg" }],
"scheduledAt": "2026-05-20T12:00:00Z"
}
EOF
```
Buffer's `profile_ids[]` parameter does accept multiple ids in a single create call, so a fan-out is possible there too. The bigger difference is what comes back: letmepost returns one consolidated response with per-target outcomes, the same `requestId` traces the whole pipeline (preflight, publish, webhook), and any failure is the same envelope shape regardless of which platform rejected it.
## Cutover playbook
* Create an account at [dashboard.letmepost.dev](https://dashboard.letmepost.dev), or [self-host](/self-host/quick-start).
* Mint an API key (Settings → API keys).
* Connect each platform you currently publish to from Buffer. letmepost uses each platform's OAuth flow — re-authorize the same accounts against the letmepost app.
* Copy each connected account's `id` from the dashboard — these become your `targets[].accountId` values.
If your current Buffer integration loops over channels and calls `updates/create.json` per channel, that loop goes away. Build a single request body with one entry in `targets[]` per channel and send once. Same content, one network round-trip, one response to reconcile.
* Rename `text` → `text` (same).
* Rename `scheduled_at` → `scheduledAt`. Include the `Z` or an explicit offset; naive datetimes are rejected.
* Translate channel-specific overrides into per-target fields. See [platforms](/platforms-overview) for the field set per platform.
* For media, prefer [`POST /v1/media`](/guides/upload-media) once, then reference by `mediaId` on every post. URL passthrough and inline base64 are also supported.
* Run a few candidate posts through [preflight](/preflight) — letmepost catches platform-specific issues (grapheme caps, mime allow-lists, media counts) before the upstream call.
letmepost dedupes retries via [`Idempotency-Key`](/idempotency) — generate one UUID per logical post and replay safely on network errors. The replay cache returns the original response (same status, same body) for 24 hours. Reusing the same key with a different body returns `idempotency_conflict`, which catches accidental drift in your client. Buffer does not document a request-level idempotency contract, so anything you'd been doing in your own code to dedupe retries can move to the header.
Every letmepost failure carries `code`, `message`, `rule`, `remediation`, `platformResponse`, `platformVersion`, and `requestId`. Branch on `code` and `rule`, not message text. The raw upstream response is attached when the platform rejected the call, so you keep the original signal instead of a flattened message string. See [errors](/errors) for the eleven codes and [preflight](/preflight) for the rule catalog.
letmepost pins each upstream platform to a known API version (e.g. `Linkedin-Version` for LinkedIn). When a platform deprecates the version we're on, you get a [`version.deprecated`](/webhooks/version-deprecated) webhook with a window to migrate — your live traffic doesn't break silently. This is the kind of platform-churn buffering that Buffer's API doesn't expose to callers.
* For about a week, publish each post through both Buffer and letmepost.
* Reconcile: same content lands on the same platforms, with the same timestamps and the same media.
* Once you're confident, switch traffic over.
Many teams will keep Buffer for the human scheduling workflow and use letmepost for programmatic publishing — they don't conflict. If Buffer's UI was the primary value, keep it. If the API was, the move is clean.
## Feature parity
| Capability | Buffer | letmepost.dev |
| - | - | - |
| Hosted publishing API | ✓ | ✓ |
| Open source | — | ✓ (Apache-2.0) |
| Self-hostable | — | ✓ ([guide](/self-host/quick-start)) |
| Multi-target post in one request | ✓ (`profile_ids[]`) | ✓ (`targets[]`) |
| Scheduled posts | ✓ | ✓ |
| Preflight catalog with stable rule ids | — | ✓ ([preflight](/preflight)) |
| Structured error envelope (raw `platformResponse` attached) | — | ✓ ([errors](/errors)) |
| Request-level idempotency (`Idempotency-Key`, body-hash conflict) | — | ✓ ([idempotency](/idempotency)) |
| Pinned platform versions, `version.deprecated` webhook | — | ✓ |
| Webhooks for post lifecycle | limited | ✓ ([webhooks](/webhooks)) |
| Scheduling / queue UI for end users | ✓ (mature) | minimal |
| Analytics dashboards | ✓ | not in v1 |
| Engagement / reply management | ✓ | not in v1 |
| AI assistant / drafting | ✓ | not in scope |
| Official SDKs (TypeScript, Python, Go) | — | ✓ ([SDKs](/sdks/typescript)) |
| CLI + MCP server | — | ✓ ([CLI](/agents/cli), [MCP](/agents/mcp)) |
Buffer is a mature scheduling product with strong UI workflows and analytics. letmepost is a publishing API with a minimal dashboard. If you're building publishing into your own product, the migration is straightforward. If your team works inside Buffer's UI, plan to keep using it for that. See [pricing](/pricing) for the current letmepost plan.
## Need help
Open an issue on [GitHub](https://github.com/letmepost/letmepost.dev) or email [rose@letmepost.dev](mailto:rose@letmepost.dev). If you're migrating from Buffer, we'll prioritize debugging help.
# Migrate from Postiz
Source: https://docs.letmepost.dev/guides/migrate-from-postiz
Map your existing Postiz publishing flow to letmepost.dev.
## Why migrate
[Postiz](https://postiz.com) and letmepost are both open-source (Postiz under AGPL-3.0, letmepost under Apache-2.0). The difference is shape: Postiz is a UI-first scheduling app that you self-host as a product for your team, while letmepost is an API-first publishing service for embedding into your own product or agent workflow. If what you actually want is `POST /v1/posts` with a stable contract, a preflight catalog, structured errors, and SDKs — that's the surface letmepost ships.
Both can run on your own infrastructure. Hosted is available too, if you don't want to operate either.
## What changes
Postiz's data model is built around posts authored in its scheduler and dispatched to one or more "integrations" (connected accounts). letmepost's model is a single request that carries one or more `targets`.
| Postiz concept | letmepost concept | Notes |
| - | - | - |
| Integration | Platform account | Connect via OAuth in the dashboard. Each account has a stable `id`. |
| Post (UI-created) | `POST /v1/posts` request | The API is the primary surface; the dashboard is built on top of it. |
| Selected integrations on a post | `targets[].accountId` | Multi-target in one request — same as picking multiple integrations in Postiz. |
| Scheduled publish date | `scheduledAt` (ISO-8601) | Omit for immediate publish. |
| Media library | `POST /v1/media` then `mediaId` | Upload once, reference on every post. |
| Provider-level error in the UI | Structured envelope with `code`, `rule`, `remediation`, `platformResponse`, `requestId` | See [errors](/errors). |
## API shape side-by-side
Postiz exposes a public REST API for posts when you self-host or use their hosted tier. A typical create-post call schedules content across selected integrations.
```bash theme={"system"}
curl -X POST https://api.postiz.com/public/v1/posts \
-H "Authorization: $POSTIZ_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "schedule",
"date": "2026-05-20T12:00:00Z",
"shortLink": false,
"posts": [
{
"integration": { "id": "" },
"value": [{ "content": "Hello", "image": [] }]
},
{
"integration": { "id": "" },
"value": [{ "content": "Hello", "image": [] }]
}
]
}'
```
```bash theme={"system"}
curl -s -X POST https://api.letmepost.dev/v1/posts \
-H "Authorization: Bearer $LMP_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data @- <" },
{ "accountId": "" }
],
"text": "Hello from letmepost.dev",
"media": [{ "kind": "image", "url": "https://cdn.example.com/hero.jpg" }],
"scheduledAt": "2026-05-20T12:00:00Z"
}
EOF
```
The shapes differ but the intent maps cleanly: Postiz's `posts[].integration.id` becomes a `targets[].accountId` entry on a single letmepost request.
## Cutover playbook
* Create an account at [dashboard.letmepost.dev](https://dashboard.letmepost.dev), or [self-host](/self-host/quick-start) the same image.
* Mint an API key (Settings → API keys).
* Connect each platform you currently publish to from Postiz. letmepost uses each platform's OAuth flow — same accounts, just re-authorized against letmepost's app.
* Copy each connected account's `id` from the dashboard; these become your `targets[].accountId` values.
* For every Postiz post that fanned out to N integrations, send ONE letmepost request with N `targets` entries instead.
* Replace `value[].content` with top-level `text`, and `value[].image` with `media[]`.
* Move `date` to `scheduledAt`. Omit it for immediate publish.
* Run the request through [preflight](/preflight) — letmepost checks platform-specific constraints (grapheme caps, mime allow-lists, media counts) before the upstream call.
letmepost dedupes retries via [`Idempotency-Key`](/idempotency) — generate one UUID per logical post and replay safely on network errors. The replay cache returns the original response (same status, same body) for 24 hours. Reusing the same key with a different body returns `idempotency_conflict` so you catch accidental drift.
Postiz surfaces provider errors mostly as messages in the UI. letmepost returns a structured envelope on every failure with `code`, `message`, `rule`, `remediation`, `platformResponse`, `platformVersion`, and `requestId`. Branch on `code` and `rule`, not message text. See [errors](/errors) for the eleven codes and [preflight](/preflight) for the rule catalog.
* For about a week, publish each post through both Postiz and letmepost.
* Reconcile: same content lands on the same platforms, with the same timestamps and the same media.
* Once you're confident, switch traffic over.
If you were self-hosting Postiz, you can spin it down (or keep it running for the team's UI workflow alongside letmepost's API — they don't conflict). Both projects are open source; nothing is locked in either direction.
## Feature parity
| Capability | Postiz | letmepost.dev |
| - | - | - |
| Open source | ✓ (AGPL-3.0) | ✓ (Apache-2.0) |
| Self-hostable | ✓ | ✓ ([guide](/self-host/quick-start)) |
| Hosted option | ✓ | ✓ |
| Scheduling UI | ✓ | dashboard ships scheduling; UI is intentionally minimal |
| Public REST API | ✓ | ✓ (the primary surface) |
| Preflight catalog with stable rule ids | — | ✓ ([preflight](/preflight)) |
| Structured error envelope (`code` / `rule` / `remediation` / raw `platformResponse`) | — | ✓ ([errors](/errors)) |
| Request-level idempotency (`Idempotency-Key`, body-hash conflict) | — | ✓ ([idempotency](/idempotency)) |
| Pinned platform versions (e.g. `Linkedin-Version` header, `version.deprecated` webhook) | — | ✓ |
| Multi-target post in one request | ✓ (multiple integrations on one post) | ✓ (`targets[]`) |
| Official SDKs (TypeScript, Python, Go) | community | ✓ ([SDKs](/sdks/typescript)) |
| CLI + MCP server | — | ✓ ([CLI](/agents/cli), [MCP](/agents/mcp)) |
| Built-in AI assistance for drafting | ✓ | not in scope for v1 |
| Team/workspace UI features | ✓ | minimal — v1 is API-first |
| Analytics dashboards | ✓ | not in v1 |
Postiz is a strong fit if you want an end-user scheduling product. letmepost is a fit if you're shipping publishing as a capability inside your own app or agent. Many teams will run both.
## Need help
Open an issue on [GitHub](https://github.com/letmepost/letmepost.dev) or email [rose@letmepost.dev](mailto:rose@letmepost.dev). If you're migrating from Postiz, we'll prioritize debugging help.
# Publish a post
Source: https://docs.letmepost.dev/guides/publish-post
From key to published — the synchronous path, the scheduled path, and what to subscribe to in between.
Publishing is one HTTP call. The same `POST /v1/posts` endpoint handles immediate, scheduled, and multi-target publishes; the response status code and the lifecycle differ.
## Immediate publish
A request body without `scheduledAt` runs synchronously. Preflight runs, the upstream platform call fires, and the response is `200` with a batch envelope:
```bash immediate.sh theme={"system"}
curl -X POST https://api.letmepost.dev/v1/posts \
-H "Authorization: Bearer $LMP_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
--data @- <" },
{ "accountId": "" },
{ "accountId": "" }
],
"text": "Hello from three platforms at once"
}
EOF
```
Each target can override `text`, `media`, `firstComment`, and per-platform `options` independently — anything you omit on a target inherits from the top-level defaults. Mix per-target overrides to send tailored copy in one call:
```json per-target-overrides.json theme={"system"}
{
"targets": [
{
"accountId": "",
"text": "shipped: 280-char version with emoji"
},
{
"accountId": "",
"text": "Shipped — the long-form version with paragraphs and a CTA."
},
{ "accountId": "" }
],
"text": "shipped — default that bluesky picks up"
}
```
### Batch status semantics
The top-level `status` summarizes the per-target outcomes:
| `status` | when |
| - | - |
| `published` | every target succeeded |
| `partial_failed` | at least one succeeded, at least one failed |
| `failed` | every target failed |
| `queued` | scheduled batch accepted (returned with `202`) |
The batch itself returns `200` even on `partial_failed` — per-target errors live inside `results[].error`. Loop the array and branch on each entry's `status`; a mixed batch is a normal, expected shape, not an exception path.
### Atomic shape preflight
Cheap shape checks (text length, media counts + exclusivity, alt-text length, per-platform option sanity) run **atomically across the batch** before any persistence: if any target fails a shape rule the whole batch is rejected as `400 preflight_failed` and nothing is published. Deep checks (URL reachability, MIME sniffing, byte caps) run inside each publisher and surface per-target — a deep-check failure lands as `partial_failed`, not a `400`.
### Auto-resolution by platform
If your org has exactly one connected account for a platform, you can omit `accountId` and let the API resolve it:
```json platform-only.json theme={"system"}
{
"targets": [{ "platform": "bluesky" }],
"text": "shipped"
}
```
When two or more accounts match, you get `validation_failed` with `rule: "target.account.ambiguous"` and the candidate ids in `remediation` — pass an explicit `accountId` to disambiguate.
## Scheduled publish
Add `scheduledAt` and every target in the batch goes onto the publish queue. Response is `202 Accepted` with `status: "queued"`:
```bash scheduled.sh theme={"system"}
curl -X POST https://api.letmepost.dev/v1/posts \
-H "Authorization: Bearer $LMP_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
--data @- <" }
],
"text": "Friday at noon UTC",
"scheduledAt": "2026-05-08T12:00:00Z"
}
EOF
```
See [schedule a post](/guides/schedule-post) for the full lifecycle and the v1 text-only constraint.
## What to subscribe to
Webhooks fire **per target**, not per batch. A successful immediate publish on three targets fires three `post.published` events. A scheduled batch fires `post.queued` per target on accept, then `post.published` / `post.rejected` / `post.failed` per target when the worker runs. Subscribe via [`POST /v1/webhook-endpoints`](/api-reference) and verify signatures per [webhooks](/webhooks).
## Idempotency
`Idempotency-Key` applies to the whole batch. Reuse the same key on a retry and you get the original `CreatePostResponse` (same batch id, same per-target results) replayed verbatim — no double publishing on any target. Change any byte of the body (different `targets[]`, different `text`, anything) and you get `idempotency_conflict`. See [idempotency](/idempotency) for the full contract.
## When it fails
The error envelope is the same shape across every failure mode — `code`, `message`, `rule`, `platformResponse`, `remediation`, `docUrl`, `ruleUrl`, `requestId`. See [errors](/errors) for the eleven codes.
## See also
Defer publishes via `scheduledAt`.
Three media-source modes; when to use each.
OAuth flows per platform.
Stop polling; subscribe instead.
# Scheduled posts
Source: https://docs.letmepost.dev/guides/schedule-post
Pass scheduledAt to queue a batch for the future. 202 instead of 200, queued status per target, post.queued webhook per target.
Add `scheduledAt` to a `POST /v1/posts` body and every target in the batch is persisted with `status: "queued"`. A delayed publish job is enqueued per target at the requested time.
## Request
```bash scheduled.sh theme={"system"}
curl -X POST https://api.letmepost.dev/v1/posts \
-H "Authorization: Bearer $LMP_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
--data @- <" },
{ "accountId": "" },
{ "accountId": "" }
],
"text": "Coordinated drop on Friday at noon UTC",
"scheduledAt": "2026-05-08T12:00:00Z"
}
EOF
```
## Lifecycle
A scheduled target moves through these statuses:
```
queued → publishing → published
↓ ↘ rejected
↓ ↘ failed
↓
canceled (terminal — set by DELETE before the job fires)
```
Subscribe to webhooks for each transition. Events fire **per target**, not per batch:
* [`post.queued`](/webhooks/post-queued) — fired per target immediately on accept.
* [`post.published`](/webhooks/post-published) — successful publish on a target.
* [`post.rejected`](/webhooks/post-rejected) — preflight or platform rejected; not retried.
* [`post.failed`](/webhooks/post-failed) — transient failure; the worker may retry per the queue policy.
* [`post.rescheduled`](/webhooks/post-rescheduled) — operator moved the firing time via `PATCH`.
* [`post.canceled`](/webhooks/post-canceled) — operator canceled the post before it fired.
## Reschedule
Move a queued post to a new time with `PATCH /v1/posts/:id`. The window is `status=queued AND scheduledAt > now` — once the worker picks the row up, the window closes and the API returns `409`.
```bash reschedule.sh theme={"system"}
curl -X PATCH https://api.letmepost.dev/v1/posts/ \
-H "Authorization: Bearer $LMP_KEY" \
-H "Content-Type: application/json" \
-d '{"scheduledAt":"2026-05-09T15:00:00Z"}'
```
The operation is atomic: the API removes the existing BullMQ job, enqueues a new one at the updated delay, then persists the new `scheduledAt`. If the queue swap fails the row stays as-is — no orphaned rows pointing at a job that never fires.
A `post.rescheduled` webhook fires on success with both the old and new timestamps.
## Cancel
Cancel a queued post with `DELETE /v1/posts/:id`. Same window as reschedule.
```bash cancel.sh theme={"system"}
curl -X DELETE https://api.letmepost.dev/v1/posts/ \
-H "Authorization: Bearer $LMP_KEY"
```
```json 200.json theme={"system"}
{ "id": "post_…", "status": "canceled" }
```
The BullMQ job is removed and the row transitions to `status: "canceled"`. A `post.canceled` webhook fires on success.
**Race-safety:** if a worker has already started running the job when the cancel lands, the worker's conditional transition (`UPDATE … WHERE status IN ('queued','validated')`) sees `canceled` and short-circuits — the publish never happens.
## Idempotency on scheduled posts
`Idempotency-Key` works the same way as on immediate posts and applies to the whole batch — the replay cache returns the original `202` envelope (same batch id, same per-target rows) if you retry within 24 h with the same body. Each queued job is enqueued exactly once.
## Time zones
`scheduledAt` is ISO-8601 with a timezone offset. Always include the `Z` (or an explicit `+HH:MM`); naive datetimes are rejected. We do not interpret a "local" timezone for you.
## See also
* [`POST /v1/posts` reference](/api-reference/#post-/v1/posts)
* [`PATCH /v1/posts/{id}` reference](/api-reference/#patch-/v1/posts/\{id})
* [`DELETE /v1/posts/{id}` reference](/api-reference/#delete-/v1/posts/\{id})
* [Idempotency](/idempotency)
* [`post.queued` webhook](/webhooks/post-queued)
* [`post.rescheduled` webhook](/webhooks/post-rescheduled)
* [`post.canceled` webhook](/webhooks/post-canceled)
# Media
Source: https://docs.letmepost.dev/guides/upload-media
Three ways to attach media. Why mediaId is the production answer.
A post body's `media` array accepts three sources per item. Pick exactly one.
| field | what it is | when to use |
| - | - | - |
| `mediaId` | reference to a `POST /v1/media` upload | production. Preferred — bytes already on our CDN. |
| `url` | passthrough; we fetch from your CDN | you host your own assets and want zero copies. |
| `bytesBase64` | inline base64 | tiny images, scripts, tests. Don't use for video. |
## The mediaId path
The two-step flow: upload bytes once, then reference them on every post that uses them. Latency at publish time stays low because the platform fetches from our CDN (or we already have the bytes).
```bash upload.sh theme={"system"}
# Step 1 — upload.
curl -X POST https://api.letmepost.dev/v1/media \\
-H "Authorization: Bearer $LMP_KEY" \\
-F file=@./hero.jpg
# Response: { "id": "med_…", "url": "https://media.letmepost.dev/...", ... }
```
```json post-with-media.json theme={"system"}
{
"targets": [
{ "accountId": "00000000-0000-0000-0000-000000000000" }
],
"text": "shipped",
"media": [
{ "kind": "image", "mediaId": "med_a1b2c3d4e5f6g7h8i9j0k1", "altText": "Status page screenshot" }
]
}
```
Top-level `media` applies to every target in the batch that doesn't carry its own `media`. Pass a different `media` on a specific target to override just that target.
`mediaId` matches the regex `^med_[0-9A-Za-z]{22}$` — anything else fails request validation.
## The url path
Send a public URL we can `GET` anonymously:
```json url.json theme={"system"}
{
"media": [
{ "kind": "image", "url": "https://cdn.example.com/hero.jpg", "altText": "..." }
]
}
```
The URL **must be publicly reachable**. Google Drive share links, Dropbox preview URLs, and S3 buckets with object ACLs locked down will all fail — see [`instagram.media.reachable`](/preflight/instagram-media-reachable) for the canonical version of that failure (Instagram's `OAuthException 2207052`).
When in doubt, upload via `POST /v1/media` and use `mediaId`.
## The bytesBase64 path
Inline base64. Convenient for tests and tiny assets:
```json inline.json theme={"system"}
{
"media": [
{ "kind": "image", "bytesBase64": "iVBORw0KGgoAAAANSUhEUgAAA...", "altText": "..." }
]
}
```
A few platforms refuse inline bytes outright (URL-only ingest paths). When that happens the response is [`media.bytes_inline_unsupported`](/preflight/media-bytes_inline_unsupported) — upload via `POST /v1/media` and switch to `mediaId`.
## Per-platform constraints
Each platform has its own ceilings. The full set is enforced in preflight; here's the headline shape:
| platform | image max | video max | mime allowed | media count |
| - | - | - | - | - |
| Bluesky | 976 KB | 100 MB | jpeg, png, webp, gif / mp4 | up to 4 imgs OR 1 vid |
| Threads | 8 MB | 1 GB | jpeg, png, webp / mp4, mov | 2–20 mixed |
| Instagram | 8 MB | 1 GB | **jpeg only** / mp4, mov | 2–10 mixed |
| Facebook | 4 MB | 4 GB | jpeg, png / mp4, mov | up to 10 |
| Pinterest | 20 MB | 2 GB | jpeg, png / mp4, mov, m4v | 1 image OR 1 video pin |
| Twitter / X | 5 MB | 512 MB | jpeg, png, webp / mp4 (gif: 15 MB) | up to 4 imgs OR 1 vid OR 1 GIF |
| LinkedIn | (uploads handled per-asset by LinkedIn) | | | |
The exact constants live in `packages/schemas/src/post.ts`. Each ceiling has a preflight rule with its own page; e.g. [`instagram.media.image_size_max`](/preflight/instagram-media-image_size_max).
## Notes per platform
* **Instagram is JPEG-only on the photo path.** PNG, WebP, HEIC, and GIF all surface as opaque IG `code 100` errors upstream — preflight catches them with [`instagram.media.mime_allowed`](/preflight/instagram-media-mime_allowed) and points you at the remediation.
* **Bluesky enforces image-vs-video exclusivity.** A single post can have images *or* a video, not both. See [`bluesky.media.image_video_exclusive`](/preflight/bluesky-media-image_video_exclusive). Video uploads route to `video.bsky.app`, NOT `com.atproto.repo.uploadBlob` — handled transparently.
* **Pinterest video pins need a cover image URL.** Pinterest mandates a still-frame URL for the pre-play poster. Pass `pinterest.coverImageUrl` on the request body — preflight catches missing covers via [`pinterest.video.cover_required`](/preflight/pinterest-video-cover_required) instead of letting Pinterest 400 with a vague reason.
* **Twitter video uploads are chunked.** 4 MiB segments via INIT/APPEND/FINALIZE/STATUS. The publisher polls until X reports `succeeded`; failures surface as [`twitter.media.video_processing_failed`](/preflight/twitter-media-video_processing_failed) with the upstream reason intact.
* **Threads is the most permissive carousel.** 2–20 children, mixed images and videos.
## Alt text
Always provide `altText`. Most platforms cap it (Bluesky 2000, Instagram 2200, Threads 1000); see the per-rule pages for the exact ceiling.
# Idempotency
Source: https://docs.letmepost.dev/idempotency
Every write accepts an Idempotency-Key. Retries are safe. No double-posting.
Sending the same `POST /v1/posts` request twice should publish exactly one post per target. That's what the `Idempotency-Key` header buys you — and on multi-target batches it covers every target in the batch, not just the first.
## How it works
Pass a unique key — typically a UUID v4 — on every write:
```bash header.txt theme={"system"}
Idempotency-Key: 01HY6X4AWBJM2K9F2PTQMRD9JQ
```
The first request with that key executes normally. Any subsequent request with the same key, the same route, and a matching body hash returns the **first response** verbatim — same status code, same batch envelope, same per-target `results[]`. None of the underlying platform calls run a second time.
If the body hash differs from the first request, you get back [`idempotency_conflict`](/errors/idempotency_conflict) so you don't accidentally publish two different posts under one key.
## Multi-target batches
The key applies to the whole batch. If you send a request with `targets: [A, B, C]` and retry with the same key, you replay the same batch response — A, B, and C each publish at most once, even if the first call partially succeeded. Change any target (add D, remove C, swap an `accountId`) and the body hash changes; you'll get `idempotency_conflict` on the retry.
```bash retry-safe.sh theme={"system"}
# First call — fans out to three targets.
curl -X POST https://api.letmepost.dev/v1/posts \
-H "Authorization: Bearer $LMP_KEY" \
-H "Idempotency-Key: 01HY6X4AWBJM2K9F2PTQMRD9JQ" \
-H "Content-Type: application/json" \
--data @- <" },
{ "accountId": "" },
{ "accountId": "" }
],
"text": "shipped"
}
EOF
# Network blew up before the response landed? Retry with the same key,
# byte-identical body. You get back the original batch envelope; no
# target publishes twice.
```
## When to use a key
* Every retry path. Network blip, lambda re-invocation, Temporal workflow restart — pin them to a key the caller computes deterministically.
* Every webhook handler that publishes. Webhooks are at-least-once; keys make idempotent processing a one-line addition.
* Every CI / cron job that posts on a schedule.
## What counts as the "same request"
The replay cache keys on:
* API key
* HTTP method + path
* The exact `Idempotency-Key` header
* A SHA-256 hash of the request body
Change the body and the second request returns `idempotency_conflict` — that's a feature, not a bug. It catches the case where you reused a key by mistake but actually meant to publish something different.
## TTL
Replay records are kept for **24 hours** after the first request, then evicted. Beyond 24h the same key is treated as fresh, so don't rely on long-lived dedup; that's what idempotent application logic is for.
## Generating keys
UUID v4 from any standard library is fine. For deterministic retries, hash the upstream event id (e.g., your Temporal workflow run id):
```ts key-strategies.ts theme={"system"}
// Random per-call — fine for ad-hoc scripts.
const key = crypto.randomUUID();
// Deterministic from your own correlation id — best for workflows.
const key = sha256(\`workflow:\${workflowId}:step:\${stepName}\`);
```
## What this is *not*
Idempotency keys do not deduplicate based on content. Two different keys, same body, will publish two posts. That's the contract — the key is the unit of "this is the same request"; we don't second-guess.
## See also
* [`POST /v1/posts` reference](/api-reference/#tag/posts/post/v1/posts) for the parameter on the endpoint itself.
* [`idempotency_conflict`](/errors/idempotency_conflict) for the error you'll see on a key-with-different-body collision.
# Platforms overview
Source: https://docs.letmepost.dev/platforms-overview
Seven networks, one API. Status, features, and connect flow per platform.
Every platform letmepost.dev supports goes through the same API surface — `POST /v1/posts` with `targets: [...]`. Per-platform quirks (caps, mime types, OAuth shapes) live in [preflight rules](/preflight) so failures surface locally with a rule id instead of an opaque upstream error.
**All eight platforms are live.** Bluesky, X, Pinterest, LinkedIn, Instagram, Facebook Pages, and Threads publish end-to-end. TikTok publishes on its upload track, where the video lands in the creator's TikTok inbox for them to confirm in the app. The API surface is identical across all of them — same `POST /v1/posts`, same error envelope. Live status is also queryable at `GET /v1/platform-versions`.
v2 API · OAuth 2.0 PKCE · 4-up images, video, reply chains, quote tweets, Premium 25k chars.
Meta Graph · Standalone Instagram Login · Photos, Reels, carousels (≤10) for Business/Creator.
Versioned REST · Personal posting in v1; org posting needs Marketing Developer Platform.
Meta Graph · Facebook Login for Business · Page text, photos, videos.
Threads Graph · Standalone OAuth at threads.net · Text + carousel (2–20, mixed image/video).
v5 API · OAuth 2.0 · Image + video pins · Cover image required for video.
Content Posting API · OAuth 2.0 PKCE · Upload-inbox path. Live — posts land in the creator's TikTok inbox for them to confirm in the app.
AT Proto · App-password connect (no OAuth) · Text + media + first comment.
## What's the same everywhere
* **One request shape.** `POST /v1/posts` with `targets: [{ accountId, ... }]`, optional `media`, optional `scheduledAt`.
* **One error envelope.** `code` + `rule` + `platformResponse` + `remediation` + `docUrl` + `ruleUrl` — no per-platform `body: {}` surprises.
* **Idempotency keys.** Every write accepts `Idempotency-Key`; replays return the original response.
* **Preflight, not postflight.** Documented platform rules run locally before the upstream call.
* **Multi-target fan-out.** One request can publish to N targets in parallel with per-target overrides.
## What's different per platform
| concern | varies by |
| - | - |
| Connect flow | OAuth shape (Meta Login for Business, OAuth 2.0 PKCE, etc.); Bluesky uses app passwords |
| Token lifetime | Bluesky: minutes · X: \~2h · Threads/IG/FB: \~60d · LinkedIn: 60d (no refresh) · Pinterest: 30d |
| Media constraints | mime allowlist, size cap, count cap, mixed image+video acceptance |
| Text caps | Twitter 280 · Bluesky 300 · Threads 500 · Instagram 2200 · LinkedIn 3000 · Facebook 63206 |
| Required body shape | Instagram requires media; Facebook accepts text-only; Pinterest needs board id |
| Scope set | minimum scope to publish + optional extended scopes for read access |
The per-platform pages above link to the exact constraints and the OAuth scope set.
## Out of v1
Deliberately cut:
* **Reddit, Telegram, Discord, Snapchat, Google Business, WhatsApp** — long-tail platforms with fewer than 1k accounts each in the dataset that drove platform priority decisions.
TikTok was originally cut for the same audit-complexity reason; it is now live on the upload-inbox track, where a post arrives as a draft the creator confirms in the TikTok app.
## Build order
Platform priority was decided from the 90-day post-volume / failure-rate corpus (see [PRODUCT.md](https://github.com/rosekamallove/letmepost.dev/blob/main/PRODUCT.md) for the data):
1. **Bluesky** — first to ship. Simple AT Proto, no app review, minutes-long JWT lifecycle is a good forcing function for the token-refresh architecture.
2. **LinkedIn** — the wedge platform. #1 complaint volume in the 150-citation research corpus, cleanest API of the major networks, no brutal approval gauntlet.
3. **Twitter / X** — table-stakes for the automation-builder ICP.
4. **Instagram + Facebook + Threads** — Meta Graph trio, built together because they share auth (well, Threads is its own).
5. **Pinterest** — cheapest integration, fastest-growing network in the dataset (+1369% over 90 days).
Watch the [changelog](/changelog) for live status flips.
# Bluesky
Source: https://docs.letmepost.dev/platforms/bluesky
AT Proto. Live end-to-end. App-password connect, no OAuth.
**Before you start.** Bluesky doesn't have OAuth — you connect with an app password generated at [bsky.app/settings/app-passwords](https://bsky.app/settings/app-passwords). Don't paste a main account password; the platform's session endpoint accepts it but the publisher refuses by design.
## Quick reference
| Limit / capability | Value |
| - | - |
| Character limit | 300 graphemes |
| Images per post | 4 |
| Image formats | jpeg, png, webp, gif |
| Image size | 1,000,000 bytes (\~976 KB) |
| Video formats | mp4 |
| Video size | 100,000,000 bytes (100 MB) |
| Video duration | platform-enforced (no client-side cap) |
| Mixed image + video | Rejected — pick one per post |
| Alt text per item | 2000 graphemes |
| Post types supported | text, single image, multi-image, video |
| Scheduling | Yes (via `scheduledAt`) |
| Reply / threads | Yes — reply to any post via `options.replyToUri` + `replyToCid` |
| First comment | Yes (`firstComment.text`) |
| Inbox / DM | Not supported in v1 |
| Analytics | Not supported in v1 (AT Proto exposes no impressions data) |
## Connect an account
Generate an app password at [bsky.app/settings/app-passwords](https://bsky.app/settings/app-passwords). The format is 19 characters with hyphens (e.g. `abcd-efgh-ijkl-mnop`). Submit it via the dashboard's **Accounts** page or programmatically:
```bash connect.sh theme={"system"}
curl -X POST https://api.letmepost.dev/v1/accounts/connect/bluesky/complete \
-H "Authorization: Bearer $LMP_KEY" \
-H "Content-Type: application/json" \
-d '{ "identifier": "you.bsky.social", "appPassword": "abcd-efgh-ijkl-mnop" }'
```
The `complete` response carries the `id` you'll send posts against. App passwords can be revoked at any time on bsky.app — when that happens the connection breaks cleanly with [`platform_auth_failed`](/errors/platform_auth_failed).
App passwords don't have OAuth scopes; they grant the same permissions as the password itself. AT Protocol JWTs are short-lived (minutes); the publisher refreshes on every 401 so you don't manage tokens. Subscribe to [`token.revoked`](/webhooks/token-revoked) for upstream revocations.
## Post types
### Text post
```json text.json theme={"system"}
{
"targets": [{ "accountId": "..." }],
"text": "Shipping letmepost.dev"
}
```
### Single image
```json single-image.json theme={"system"}
{
"targets": [{ "accountId": "..." }],
"text": "Look at this",
"media": [
{ "kind": "image", "mediaId": "med_…", "altText": "Status page screenshot" }
]
}
```
### Multi-image
Up to four images per post. Mixing images and video on the same post is rejected by preflight ([`bluesky.media.image_video_exclusive`](/preflight/bluesky-media-image_video_exclusive)).
```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" }
]
}
```
### Video
```json video.json theme={"system"}
{
"targets": [{ "accountId": "..." }],
"text": "demo clip",
"media": [
{ "kind": "video", "mediaId": "med_…", "altText": "..." }
]
}
```
The publisher runs Bluesky's documented video flow transparently: mint a service-auth JWT scoped to `app.bsky.video.uploadVideo`, probe `getUploadLimits` so quota exhaustion fails loudly with [`bluesky.video.quota_exhausted`](/preflight/bluesky-video-quota_exhausted), stream bytes to `uploadVideo`, poll `getJobStatus` until COMPLETED, then embed the blob ref as `app.bsky.embed.video`. Videos do **not** go through `com.atproto.repo.uploadBlob` — that's the most common reason naive integrations break.
### First comment
```json first-comment.json theme={"system"}
{
"targets": [{ "accountId": "..." }],
"text": "Main post",
"firstComment": { "text": "Reply that drops in seconds later" }
}
```
The reply posts as a child of the main record under the same DID. If the parent publishes but the comment fails, the parent stays — the response carries a `warnings[]` entry with `code: "first_comment_failed"` instead of failing the whole call.
### Reply (threading)
Thread a post under any existing Bluesky post by passing the parent's strong ref — its `uri` **and** `cid` — in the target's `options`. Both come straight from the parent post's publish response.
```json reply.json theme={"system"}
{
"targets": [
{
"accountId": "...",
"options": {
"platform": "bluesky",
"replyToUri": "at://did:plc:abc/app.bsky.feed.post/3kr…",
"replyToCid": "bafyrei…"
}
}
],
"text": "2/ …continuing the thread"
}
```
`replyToUri` and `replyToCid` must be sent together. To build a multi-post thread: publish post 1, read its `uri` + `cid` from the response, and pass them as post 2's `replyToUri`/`replyToCid`. For replies deeper than the first, also pin the thread's original post with `replyRootUri` + `replyRootCid` so every reply stays anchored to the root — omit the root and it defaults to the parent (correct for a reply to a top-level post).
## Wisdom (platform-specific things that bite)
* Mentions, hashtags, and URLs are AT Proto **facets** — letmepost auto-detects them from plain text and builds the facet ranges. Don't construct facet byte offsets manually; the post will index correctly without them.
* Bluesky's image blob ceiling is 976 KB, not 1 MB — a 1,000,000-byte file is at the edge and re-encoded JPEGs around 950 KB are the safe zone.
* Videos count against a **daily** account quota that lives entirely on Bluesky's side. The first preflight call to `getUploadLimits` surfaces remaining headroom before bytes are sent — exhausted callers fail with [`bluesky.video.quota_exhausted`](/preflight/bluesky-video-quota_exhausted) and no upload happens.
* Bluesky deduplicates videos by content hash, so re-uploading the same clip can short-circuit `JOB_STATE_COMPLETED` without re-transcoding.
* The `uri` on the response is the AT Proto record URI (`at://did:plc:abc/app.bsky.feed.post/3kr…`), not a bsky.app URL. The public URL is derived from the DID + record key.
* Bluesky has no scheduled-publish API; `scheduledAt` is handled by letmepost's queue — the post fires from our worker when the time comes.
## Common errors
| Error rule | What it means | How to fix |
| - | - | - |
| [`bluesky.text.max_graphemes`](/preflight/bluesky-text-max_graphemes) | Post exceeded the 300-grapheme cap | Trim to ≤300 graphemes. |
| [`bluesky.text.non_empty`](/preflight/bluesky-text-non_empty) | Text field was empty or whitespace | Provide non-whitespace text — Bluesky has no media-only post shape. |
| [`bluesky.media.image_video_exclusive`](/preflight/bluesky-media-image_video_exclusive) | Attached both images and a video | Split into two posts; AT Proto records hold one media kind per record. |
| [`bluesky.media.count_max`](/preflight/bluesky-media-count_max) | More than 4 images or more than 1 video | Reduce to 4 images max, or 1 video. |
| [`bluesky.media.image_size_max`](/preflight/bluesky-media-image_size_max) | Image exceeded 976 KB | Re-encode under 1,000,000 bytes. |
| [`bluesky.media.mime_allowed`](/preflight/bluesky-media-mime_allowed) | Unsupported image/video mime | Images: jpeg/png/webp/gif. Videos: mp4. |
| [`bluesky.video.quota_exhausted`](/preflight/bluesky-video-quota_exhausted) | Daily video upload quota hit | Wait for the quota window to roll over; letmepost reads the live limit from `getUploadLimits` so the check is current. |
| [`bluesky.video.job_failed`](/preflight/bluesky-video-job_failed) | Bluesky's transcoder rejected the clip | Inspect `platformResponse` for the upstream reason — usually codec or duration. |
## What you can't do (yet)
* Starter packs, custom feeds, pin-to-profile.
* Direct messages and DM attachments (AT Proto chat lives on a separate XRPC surface not exposed in v1).
* Analytics — AT Proto records carry no impression count by design.
* Polls, quote posts, and labeler interactions.
* Editing or backdating a published record.
## API reference
* [`POST /v1/posts`](/api-reference) — primary publish.
* [`POST /v1/media`](/api-reference) — upload images/videos for `mediaId` references.
* [`POST /v1/accounts/connect/bluesky/complete`](/api-reference) — finish a Bluesky connect.
# Facebook Pages
Source: https://docs.letmepost.dev/platforms/facebook
Facebook Pages via the Meta Graph API. Page Access Tokens, three endpoints (/feed, /photos, /videos), no IG fan-out.
**Before you start.** Facebook publishing happens against a **Page** (not a personal profile). The connect flow uses Facebook Login for Business, then discovers Pages via `GET /me/accounts` — one letmepost row per Page. If the user manages no Pages, the connect fails with [`facebook.pages.none`](/preflight/facebook-pages-none).
## Quick reference
| Limit / capability | Value |
| - | - |
| Character limit | 63,206 graphemes |
| Images per post | up to 10 (multi-photo via `attached_media`) |
| Image formats | jpeg, png, gif, webp |
| Image size | 4,000,000 bytes (4 MB) |
| Video formats | mp4, mov |
| Video size | 4,000,000,000 bytes (4 GB) |
| Mixed image + video | Rejected — one media kind per post |
| Post types supported | text, single image, multi-image, single video, link share |
| Scheduling | Yes (via `scheduledAt`) |
| Reply / thread support | Not supported in v1 |
| First comment | Not supported in v1 |
| Inbox / DM | Not supported in v1 |
| Analytics | Not supported in v1 |
## Connect an account
`POST /v1/accounts/connect/facebook`. Standard Facebook Login for Business OAuth. After complete, the provider calls `GET /me/accounts` and creates one `platform_accounts` row per Page the user manages, each carrying its own **Page Access Token** (the non-expiring token used for publishing, distinct from the User Access Token).
If the user manages no Pages: [`facebook.pages.none`](/preflight/facebook-pages-none). Personal profiles cannot publish via the Graph API and are out of scope for v1.
### Scopes
```
pages_show_list — list Pages the user manages
pages_manage_posts — create posts on Pages
pages_read_engagement — pre-req for posts on some apps
business_management — Pages connected via Business
```
`extended` adds insights / engagement scopes (`pages_read_user_content`, `pages_manage_engagement`).
### Token lifecycle
The User Access Token from the OAuth handshake is short-lived; the provider swaps it for a long-lived (\~60-day) user token, then derives a **Page Access Token** per Page via `/me/accounts`. Page Access Tokens are non-expiring as long as the user token stays fresh; refresh runs on schedule. `token.expiring` fires before expiry on the user token.
## Post types
### Text post
```json text.json theme={"system"}
{
"targets": [{ "accountId": "..." }],
"text": "Page update"
}
```
Internally this routes to `POST /{page-id}/feed` with `message`.
### Single image
```json single-image.json theme={"system"}
{
"targets": [{ "accountId": "..." }],
"text": "with a photo",
"media": [{ "kind": "image", "mediaId": "med_…" }]
}
```
Internally: `POST /{page-id}/photos` with caption.
### Multi-image (up to 10)
```json multi-image.json theme={"system"}
{
"targets": [{ "accountId": "..." }],
"text": "three photos",
"media": [
{ "kind": "image", "mediaId": "med_a…" },
{ "kind": "image", "mediaId": "med_b…" },
{ "kind": "image", "mediaId": "med_c…" }
]
}
```
The publisher stages each photo as `published=false` via `POST /{page-id}/photos`, then creates the wall post with `POST /{page-id}/feed` carrying `attached_media: [{ media_fbid }]`.
### Single video
```json video.json theme={"system"}
{
"targets": [{ "accountId": "..." }],
"text": "demo clip",
"media": [{ "kind": "video", "mediaId": "med_…" }]
}
```
Routes to `POST /{page-id}/videos` with the media URL. Only one video per post; mixing image + video on the same post is rejected upfront.
## Wisdom (platform-specific things that bite)
* The **Page Access Token** is what publishes — not the User Access Token. The provider derives it at connect; revoking the user's session in Facebook also kills the Page token even though the Page token itself has no documented expiry.
* Multi-photo posts on Pages are a two-step dance: upload N photos unpublished, then attach the resulting `media_fbid`s to one `/feed` call. The publisher hides this; if any single photo upload fails, the whole post fails (no half-posted state).
* FB's hard cap is 63,206 codeunits; we pre-check at 63,206 **graphemes**, which is strictly tighter — a payload that passes preflight never trips Meta's server-side check.
* The response `id` comes back as `{page-numeric-id}_{post-numeric-id}`; the publisher derives a clean `https://www.facebook.com/{pageId}/posts/{postId}` permalink for the response `uri`.
* Link previews are auto-rendered by Facebook when a URL appears in `message` — no separate field is required; passing `link` is for explicit-share semantics only and is honored only on text-only posts.
* Page-level rate limits scale with Page size and prior engagement; bursty publish patterns trigger [`platform_unavailable`](/errors/platform_unavailable) (mapped from Meta `code: 4`).
## Common errors
| Error rule | What it means | How to fix |
| - | - | - |
| [`facebook.text.required`](/preflight/facebook-text-required) | Empty text and no media | Provide text or attach `media[]`. |
| [`facebook.text.max_graphemes`](/preflight/facebook-text-max_graphemes) | Message > 63,206 graphemes | Trim under the cap. |
| [`facebook.media.image_video_exclusive`](/preflight/facebook-media-image_video_exclusive) | Attached both images and a video | Split into separate posts. |
| [`facebook.media.count_max`](/preflight/facebook-media-count_max) | More than one video, or more than 10 photos | One video per post; ≤10 photos per multi-image post. |
| [`facebook.media.mime_allowed`](/preflight/facebook-media-mime_allowed) | Unsupported image/video mime | Images: jpeg/png/gif/webp. Videos: mp4/mov. |
| [`facebook.media.image_size_max`](/preflight/facebook-media-image_size_max) | Image > 4 MB | Re-encode under 4,000,000 bytes. |
| [`facebook.media.video_size_max`](/preflight/facebook-media-video_size_max) | Video > 4 GB | Compress under 4,000,000,000 bytes. |
| [`facebook.pages.none`](/preflight/facebook-pages-none) | Connect-time: user manages no Pages | The user must own/manage a Page in Business Manager before connecting. |
## What you can't do (yet)
* Posting to a personal profile (Meta's Graph API doesn't expose write access to user feeds).
* First comment / threaded replies as the Page.
* Story posts, Reels (Facebook Reels live behind a separate publishing surface not in v1).
* Tagging users or co-authors.
* Targeted post audience (Facebook geo/demographic targeting).
* Reading comments, reactions, or insights (those scopes are `extended` and the read APIs aren't wired in v1).
## API reference
* [`POST /v1/posts`](/api-reference) — primary publish.
* [`POST /v1/media`](/api-reference) — upload images/videos for `mediaId` references.
* [`POST /v1/accounts/connect/facebook`](/api-reference) — start a Facebook OAuth flow.
# Instagram
Source: https://docs.letmepost.dev/platforms/instagram
Instagram Business / Creator via the Content Publishing API. Standalone Instagram Login — no Facebook required.
**Before you start.** Instagram requires a **Business** or **Creator** account. Personal accounts cannot publish via the Content Publishing API regardless of scopes granted — the connect flow rejects them with `instagram.account_type.requires_professional` and a remediation pointing at the Switch to professional account toggle in the IG app.
## Quick reference
| Limit / capability | Value |
| - | - |
| Caption character limit | 2,200 graphemes |
| Items per carousel | 2 – 10 (mixed image + video allowed) |
| **Image format** | **JPEG only** (PNG, WebP, HEIC, GIF rejected by Meta) |
| Image size | 8,000,000 bytes (8 MB) |
| Video formats | mp4, mov |
| Video size | 1,000,000,000 bytes (1 GB) |
| Reels duration | ≤ 90 s (platform-enforced) |
| Alt text per item | 2,200 graphemes |
| Post types supported | single image, single video (Reels), carousel |
| Text-only post | **Not supported** — every post requires media |
| Scheduling | Yes (via `scheduledAt`) |
| Reply / thread support | Not supported in v1 |
| First comment | Not supported in v1 |
| Story | Not supported in v1 |
| Inbox / DM | Not supported in v1 |
| Analytics | Not supported in v1 |
## Connect an account
Instagram has its own OAuth (since the 2024 Instagram Login product) — distinct from Facebook Login. There is no FB fan-out anymore; connecting Instagram does **not** also connect Facebook.
`POST /v1/accounts/connect/instagram` produces an authorize URL at `instagram.com/oauth/authorize`. After the user grants, the callback runs three Graph calls:
1. Exchange `code` → short-lived token (1 h).
2. Swap short-lived → long-lived (60 d).
3. `GET /me?fields=id,user_id,username,account_type` — the **Content Publishing API expects `id` (the IG-scoped user id), not `user_id`** — the provider persists `id` as `platformAccountId`. If `account_type === "PERSONAL"` the connect is rejected.
### Scopes
```
instagram_business_basic — read /me + identity
instagram_business_content_publish — create + publish containers
```
`extended` scopes (off by default) cover comment + insight reads — not required for publishing.
### Token lifecycle
Long-lived tokens last 60 days and are refreshable any time after the first 24 hours via `GET /refresh_access_token`. The provider refreshes on schedule; `token.expiring` fires before expiry.
## Post types
### Single image
```json single-image.json theme={"system"}
{
"targets": [{ "accountId": "..." }],
"text": "Caption is optional",
"media": [
{ "kind": "image", "mediaId": "med_…", "altText": "..." }
]
}
```
### Single video (Reels)
Single-video posts route through the `REELS` container — Instagram retired the legacy `VIDEO` product surface in 2024.
```json reels.json theme={"system"}
{
"targets": [{ "accountId": "..." }],
"text": "Reels caption",
"media": [
{ "kind": "video", "mediaId": "med_…" }
]
}
```
### Carousel (2 – 10, mixed)
Carousel children can mix image and video items.
```json carousel.json theme={"system"}
{
"targets": [{ "accountId": "..." }],
"text": "Mixed carousel of three",
"media": [
{ "kind": "image", "mediaId": "med_a…" },
{ "kind": "video", "mediaId": "med_b…" },
{ "kind": "image", "mediaId": "med_c…" }
]
}
```
The publisher creates each child container (`is_carousel_item=true`), polls each to `FINISHED`, then creates a `CAROUSEL` parent with the child ids and publishes the parent.
## Wisdom (platform-specific things that bite)
* **JPEG only** for images. PNG, WebP, HEIC, and GIF will be rejected with `code 100, error_subcode 2207003`. Preflight catches this locally so you don't lose a roundtrip — re-encode to JPEG via your image pipeline (or upload via [`POST /v1/media`](/api-reference) which preserves the source mime).
* Media URLs **must be publicly reachable** by Meta's CDN — anonymous HTTPS, no auth, no Drive links, no signed S3 URLs that expire before the container finishes. Unreachable URLs surface as `OAuthException 2207052`, the canonical opaque-rejection.
* The first **125 characters** of the caption show before the "more" fold in feed. Front-load anything important.
* Container expiry: Instagram's media containers live for **24 hours** after creation. The publisher creates + publishes in one synchronous flow so this rarely matters, but a scheduled post that ages out triggers `instagram.container.expired`.
* The Content Publishing API uses `id` (the IG-scoped user id) on `/{ig-user-id}/media` — **not** `user_id` from the token-exchange response. The provider stores the right one; if you ever rebuild this manually, sending `user_id` returns `code:2`.
* Reels duration is platform-enforced. We size-check at preflight, but the 90 s ceiling is checked by Instagram at container-finalize and surfaces as a platform rejection.
* The publisher's response carries a permalink fetched best-effort via `GET /{media-id}?fields=permalink`. If that call 404s, the publish still succeeds and the response carries the id alone.
## Common errors
| Error rule | What it means | How to fix |
| - | - | - |
| [`instagram.media.required`](/preflight/instagram-media-required) | No media on the post | Attach at least one image or video; Instagram has no text-only feed surface. |
| [`instagram.text.max_graphemes`](/preflight/instagram-text-max_graphemes) | Caption > 2,200 graphemes | Trim under 2,200. |
| [`instagram.media.count_max`](/preflight/instagram-media-count_max) | More than 10 items in a carousel | Reduce to ≤10, or split into multiple posts. |
| [`instagram.media.mime_allowed`](/preflight/instagram-media-mime_allowed) | Image isn't JPEG, or video isn't mp4/mov | Re-encode to JPEG (images) or mp4/mov (video). |
| [`instagram.media.image_size_max`](/preflight/instagram-media-image_size_max) | Image > 8 MB | Compress under 8,000,000 bytes. |
| [`instagram.media.video_size_max`](/preflight/instagram-media-video_size_max) | Video > 1 GB | Compress under 1,000,000,000 bytes. |
| [`instagram.container.expired`](/preflight/instagram-container-expired) | Container aged past Instagram's 24 h window | Re-create and publish in one flow. |
| [`instagram.account_type.requires_professional`](/preflight/instagram-account_type-requires_professional) | Connect attempted with a PERSONAL account | Switch the IG account to Business or Creator in-app, then reconnect. |
## What you can't do (yet)
* Stories (Stories use a separate `STORIES` container type not in v1).
* Direct messages.
* First comment as the author.
* Tagging users or product tags.
* Editing or deleting a published post.
* Posting from a **Personal** Instagram account (Meta's API restriction — switch to Business or Creator).
* Reading comments, replies, or insights (requires `extended` scopes plus read endpoints not in v1).
## API reference
* [`POST /v1/posts`](/api-reference) — primary publish.
* [`POST /v1/media`](/api-reference) — upload images/videos for `mediaId` references.
* [`POST /v1/accounts/connect/instagram`](/api-reference) — start an Instagram OAuth flow.
# LinkedIn
Source: https://docs.letmepost.dev/platforms/linkedin
Personal posting via LinkedIn's Versioned REST API. Org/Company posting ships with MDP approval.
**Before you start.** v1 supports **personal posting only** (`urn:li:person:*`). Organization / Company Page posting requires LinkedIn's Marketing Developer Platform (MDP) approval plus the `w_organization_social` scope — passing an `urn:li:organization:*` author URN today fails preflight with [`linkedin.author.org_not_supported`](/preflight/linkedin-author-org_not_supported).
## Quick reference
| Limit / capability | Value |
| - | - |
| Character limit | 3,000 graphemes (emoji-aware) |
| Author URN format | `urn:li:person:{id}` (org URNs gated behind MDP) |
| Visibility | `PUBLIC` or `CONNECTIONS` |
| Post types supported | text |
| Images / video / document | Not supported in v1 (media slice TBD) |
| Scheduling | Yes (via `scheduledAt`) |
| Reply / thread support | Not supported in v1 |
| First comment | Not supported in v1 |
| Inbox / DM | Not supported in v1 |
| Analytics | Not supported in v1 |
## Connect an account
`POST /v1/accounts/connect/linkedin`. OAuth 2.0, three-legged. After the callback the provider calls `/v2/userinfo` to mint a stable `urn:li:person:{id}` author URN, stored on `tokenMetadata.authorUrn`. That URN is what `posts.author` carries on every publish.
### Scopes
| scope | requested |
| - | - |
| `w_member_social` | always — required to post on a personal account. |
| `openid`, `profile` | always — needed to mint the URN via `/v2/userinfo`. |
| `email` | extended only. |
| `r_organization_social`, `w_organization_social` | extended only — gated behind MDP approval. |
Until MDP clears for your app, `extended` org scopes are off by default and any attempt to post against an org URN fails preflight.
### Version pinning
LinkedIn sunset five API versions in six months from 2024 – 2025. letmepost pins a single version on the `Linkedin-Version` header and upgrades internally; the public version tracker (`GET /v1/platform-versions`) shows the current pin and any in-flight migration. When LinkedIn announces a sunset, [`version.deprecated`](/webhooks/version-deprecated) fires on the affected accounts.
### Token lifecycle
LinkedIn tokens last **60 days** with **no refresh** — re-auth is mandatory at expiry. letmepost fires [`token.expiring`](/webhooks/token-expiring) seven days ahead so you can prompt the user.
## Post types
### Text post
```json text.json theme={"system"}
{
"targets": [{ "accountId": "..." }],
"text": "Personal LinkedIn post"
}
```
The author URN is resolved from the connected account; passing an explicit author URN on the request body is not required (and is overridden by the connect-time URN).
## Wisdom (platform-specific things that bite)
* **External URLs in the post body materially reduce LinkedIn's organic reach.** LinkedIn's distribution algorithm de-prioritizes posts that link off-platform. The standard remediation is to keep the body link-free and put the URL in the first comment — but letmepost does not yet support a `firstComment` field on LinkedIn (the v2 comment API isn't wired). For now, post without the link and drop it manually, or post the link in the body and accept the reach hit.
* LinkedIn tokens have **no refresh token**. They die at 60 days, hard. Subscribe to [`token.expiring`](/webhooks/token-expiring) and prompt the user before they lose the connection — the publisher returns [`platform_auth_failed`](/errors/platform_auth_failed) the moment the token aged out.
* The post `id` and `uri` come back as the LinkedIn **URN** itself (`urn:li:share:…`), not a clickable URL — LinkedIn's create response doesn't include a web permalink. Construct the public URL with the URN's numeric tail if you need one.
* Org / Company posting needs both MDP approval and the `w_organization_social` extended scope. Posting against an `urn:li:organization:*` URN fails preflight with a remediation that points at the post-MDP follow-up slice.
* LinkedIn's character counter is grapheme-aware: emoji + ZWJ sequences count as one grapheme, matching the publisher's preflight counter.
* The version tracker is the canonical source for "what LinkedIn API version letmepost is running against right now" — when LinkedIn ships a versioned change, our migration runs centrally and you see the rollover via the [`version.deprecated`](/webhooks/version-deprecated) webhook.
## Common errors
| Error rule | What it means | How to fix |
| - | - | - |
| [`linkedin.text.non_empty`](/preflight/linkedin-text-non_empty) | Empty post body | Provide non-whitespace text. |
| [`linkedin.text.max_graphemes`](/preflight/linkedin-text-max_graphemes) | Body > 3,000 graphemes | Trim under 3,000. |
| [`linkedin.author.urn_format`](/preflight/linkedin-author-urn_format) | Author URN doesn't match `urn:li:person:*` or `urn:li:organization:*` | Use the URN persisted at connect (the publisher does this for you). |
| [`linkedin.author.org_not_supported`](/preflight/linkedin-author-org_not_supported) | Tried to publish under an `urn:li:organization:*` URN | Use a person URN. Org posting requires MDP approval and lands in a follow-up slice. |
| [`linkedin.author.unresolved`](/preflight/linkedin-author-unresolved) | Account row missing the cached author URN | Disconnect and reconnect — the URN is minted at connect from `/v2/userinfo`. |
| [`linkedin.visibility.enum`](/preflight/linkedin-visibility-enum) | Visibility wasn't `PUBLIC` or `CONNECTIONS` | Pass one of those two values, or omit (defaults to PUBLIC). |
## What you can't do (yet)
* Organization / Company Page posts (requires MDP).
* Image, video, multi-image, or document posts (media slice deferred).
* Polls.
* First comment / threaded replies as the author.
* Mentions of other LinkedIn members (requires the v2 entity resolution surface).
* Reading post engagement, comments, or impressions.
* Scheduling via LinkedIn's own scheduled-post API — `scheduledAt` runs in letmepost's queue instead.
## API reference
* [`POST /v1/posts`](/api-reference) — primary publish.
* [`POST /v1/accounts/connect/linkedin`](/api-reference) — start a LinkedIn OAuth flow.
* [`GET /v1/platform-versions`](/api-reference) — current pinned LinkedIn API version + migration state.
# Pinterest
Source: https://docs.letmepost.dev/platforms/pinterest
Pinterest v5 API. Image pins via direct URL, video pins via register-media + S3 multipart + status poll.
**Before you start.** Pinterest gates pin creation on production (`api.pinterest.com`) until your app passes **Standard Access** review — Trial Access apps must point at `api-sandbox.pinterest.com`. letmepost reads `PINTEREST_API_BASE` from env, so during the trial window you set it to the sandbox host and clear it back to default the moment your app goes Standard.
## Quick reference
| Limit / capability | Value |
| - | - |
| Description (text) | mapped to Pinterest `description`; no documented grapheme cap (Pinterest enforces a soft 800-char display limit) |
| Title | optional, free-form |
| Media items per pin | **1** (image or video; no carousel in v1) |
| Image formats | jpeg, png, webp |
| Image size | 20,000,000 bytes (20 MB) |
| Video formats | mp4, mov, m4v |
| Video size | 2,000,000,000 bytes (\~2 GB) |
| Board | required (per-post `pinterest.boardId` or account default) |
| Destination URL | optional click-through; defaults to the image URL |
| Cover image URL | **required for video pins** (Pinterest mandates a still-frame URL) |
| Post types supported | image pin, video pin |
| Scheduling | Yes (via `scheduledAt`) |
| Reply / first comment | Not supported in v1 |
| Inbox / DM | Not supported in v1 |
| Analytics | Not supported in v1 |
## Connect an account
`POST /v1/accounts/connect/pinterest`. OAuth 2.0. After the callback the provider calls `GET /v5/user_account` to pin `platformAccountId` to the real Pinterest user id.
### Scopes
```
boards:read — read the caller's boards
boards:write — create boards from the dashboard
pins:read — required alongside pins:write (some endpoints echo the pin back)
pins:write — create pins
user_accounts:read — required to mint the platform_account_id at connect
```
Extended: `pins:read_secret` (off by default) for callers that publish to secret boards.
### Token lifecycle
30-day access tokens, refreshable. The provider refreshes \~24 h before expiry; subscribe to [`token.expiring`](/webhooks/token-expiring) to know when a re-auth window is coming.
## Post types
### Image pin
```json image-pin.json theme={"system"}
{
"targets": [
{
"accountId": "...",
"options": {
"platform": "pinterest",
"boardId": "",
"destinationUrl": "https://example.com/blog-post",
"title": "Pin title"
}
}
],
"text": "Pin description",
"media": [{ "kind": "image", "mediaId": "med_…" }]
}
```
The `options` extension is optional. If you set a default board on the account, `options.boardId` can be omitted; if you don't supply `destinationUrl` we fall back to the image's letmepost URL.
### Video pin
`coverImageUrl` is **required** — Pinterest mandates a still-frame URL for the pre-play poster. Preflight rejects video pins without it ([`pinterest.video.cover_required`](/preflight/pinterest-video-cover_required)) so you don't lose the upload to a vague upstream 400.
```json video-pin.json theme={"system"}
{
"targets": [
{
"accountId": "...",
"options": {
"platform": "pinterest",
"boardId": "",
"destinationUrl": "https://example.com/walkthrough",
"coverImageUrl": "https://cdn.example.com/cover.jpg"
}
}
],
"text": "Walkthrough",
"media": [{ "kind": "video", "mediaId": "med_…" }]
}
```
Behind the scenes the publisher runs four steps for video:
1. `POST /v5/media` — register an upload slot. Failures: [`pinterest.media.register_failed`](/preflight/pinterest-media-register_failed).
2. Multipart POST to the presigned S3 endpoint. Failures: [`pinterest.video.upload_failed`](/preflight/pinterest-video-upload_failed).
3. Poll `GET /v5/media/{id}` every 2 s up to a 5-minute deadline. Failures: [`pinterest.video.transcode_failed`](/preflight/pinterest-video-transcode_failed), [`pinterest.video.transcode_timeout`](/preflight/pinterest-video-transcode_timeout).
4. `POST /v5/pins` with `media_source: { source_type: "video_id", media_id, cover_image_url }`.
## Wisdom (platform-specific things that bite)
* **Video pins need a cover image URL.** Pinterest will 400 createPin with a vague message if it's missing. Preflight catches the absence before any upload starts.
* **Destination URL and image URL must be publicly reachable.** Both go through a HEAD probe at preflight ([`pinterest.destination_url.reachable`](/preflight/pinterest-destination_url-reachable), [`pinterest.image_url.reachable`](/preflight/pinterest-image_url-reachable)). Signed S3 URLs that expire mid-flight will fail — use stable public URLs.
* **One media item per pin.** Carousel pins (multi-image) are not in v1 — preflight rejects with [`pinterest.media.single_only`](/preflight/pinterest-media-single_only).
* **Board id is required.** Either pass `pinterest.boardId` per-post, or set a default board on the connected account; missing both fails preflight with [`pinterest.board.required`](/preflight/pinterest-board-required).
* **Trial vs Standard access.** Pinterest's Trial Access apps must point at the sandbox host (`api-sandbox.pinterest.com`); production calls return 403 until your app clears review.
* Image content-type is checked **from the URL response headers**, not from the file extension — a `.jpg` URL serving `image/webp` will trip [`pinterest.image.mime_allowed`](/preflight/pinterest-image-mime_allowed).
* The publisher uses `GET` for the reachability probe (not `HEAD`) because several Pinterest-hosted CDNs reject HEAD or skip `content-length` on it.
## Common errors
| Error rule | What it means | How to fix |
| - | - | - |
| [`pinterest.board.required`](/preflight/pinterest-board-required) | No `boardId` per-post and no account default | Pass `pinterest.boardId` or set a default board on the account. |
| [`pinterest.media.required`](/preflight/pinterest-media-required) | No media on the pin | Attach `media: [{ kind: "image" / "video", … }]`. |
| [`pinterest.media.single_only`](/preflight/pinterest-media-single_only) | More than one media item | Send a single media item; carousel pins aren't in v1. |
| [`pinterest.video.cover_required`](/preflight/pinterest-video-cover_required) | Video pin without `coverImageUrl` | Pass `pinterest.coverImageUrl` pointing at a public still frame. |
| [`pinterest.image.mime_allowed`](/preflight/pinterest-image-mime_allowed) | Image URL served an unsupported mime | Serve jpeg/png/webp from the URL. |
| [`pinterest.image.size_max`](/preflight/pinterest-image-size_max) | Image > 20 MB | Compress under 20,000,000 bytes. |
| [`pinterest.image_url.reachable`](/preflight/pinterest-image_url-reachable) | Pinterest couldn't fetch the image URL | Verify it's publicly reachable and returns 2xx. |
| [`pinterest.cover_image_url.reachable`](/preflight/pinterest-cover_image_url-reachable) | Cover image URL unreachable | Verify the cover URL is publicly reachable + serves an image mime. |
| [`pinterest.video.transcode_failed`](/preflight/pinterest-video-transcode_failed) | Pinterest's transcoder rejected the video | Re-encode the source (H.264 + AAC in an MP4 container is the safe path). |
| [`pinterest.video.transcode_timeout`](/preflight/pinterest-video-transcode_timeout) | Transcode poll exceeded 5 minutes | Retry; large videos occasionally exceed the SLA. |
## What you can't do (yet)
* Carousel pins (multi-image).
* Idea pins.
* Tagging products on a pin.
* Reading pin engagement (saves, impressions).
* Comments on pins.
* Posting to secret boards without the extended `pins:read_secret` scope.
## API reference
* [`POST /v1/posts`](/api-reference) — primary publish.
* [`POST /v1/media`](/api-reference) — upload images/videos for `mediaId` references.
* [`POST /v1/accounts/connect/pinterest`](/api-reference) — start a Pinterest OAuth flow.
# Threads
Source: https://docs.letmepost.dev/platforms/threads
Meta's Threads via the standalone Threads Graph API. Two-step container publish.
**Before you start.** Threads uses Meta's Threads Graph API with a **separate** OAuth at threads.net — distinct from Facebook Login. Connecting Threads does not connect Facebook or Instagram, and vice versa.
## Quick reference
| Limit / capability | Value |
| - | - |
| Character limit | 500 graphemes |
| Items per carousel | 2 – 20 (mixed image + video allowed) |
| Image formats | jpeg, png, webp |
| Image size | 8,000,000 bytes (8 MB) |
| Video formats | mp4, mov |
| Video size | 1,000,000,000 bytes (1 GB) |
| Video duration | ≤ 5 min (platform-enforced) |
| Alt text per item | 1000 graphemes |
| Post types supported | text, single image, single video, carousel, reply |
| Scheduling | Yes (via `scheduledAt`) |
| Reply support | Yes (`threads.replyToId`) |
| First comment | Not supported |
| Inbox / DM | Not supported in v1 |
| Analytics | Not supported in v1 |
## Connect an account
OAuth 2.0 against threads.net. Start at `POST /v1/accounts/connect/threads`, send the user through the redirect, and the callback completes the handshake.
### Scopes
| scope | requested |
| - | - |
| `threads_basic` | always — required to call `GET /me`. |
| `threads_content_publish` | always — unlocks create-container + publish. |
| `threads_manage_replies` | only when the caller opts into `extended` scopes. |
| `threads_read_replies` | only when the caller opts into `extended` scopes. |
The publisher uses neither replies scope — Threads supports replying via `replyToId` on the request body, which is a write and needs no extra scope.
### Token lifecycle
Threads issues long-lived tokens (\~60 days, refreshable after the first 24 h). The publisher refreshes ahead of expiry; subscribe to [`token.expiring`](/webhooks/token-expiring) to know when a user-prompt re-auth is on the horizon.
## Post types
### Text post
```json text.json theme={"system"}
{
"targets": [{ "accountId": "..." }],
"text": "Threads-only thought"
}
```
### Single image
```json single-image.json theme={"system"}
{
"targets": [{ "accountId": "..." }],
"text": "Caption is optional",
"media": [
{ "kind": "image", "mediaId": "med_…", "altText": "..." }
]
}
```
### Single video
```json single-video.json theme={"system"}
{
"targets": [{ "accountId": "..." }],
"text": "Demo clip",
"media": [
{ "kind": "video", "mediaId": "med_…" }
]
}
```
### Carousel (mixed)
Threads accepts 2 – 20 items per carousel, and unlike most platforms it allows **mixed image + video** in a single carousel.
```json carousel.json theme={"system"}
{
"targets": [{ "accountId": "..." }],
"text": "Mixed carousel",
"media": [
{ "kind": "image", "mediaId": "med_…", "altText": "first" },
{ "kind": "video", "mediaId": "med_…", "altText": "second" },
{ "kind": "image", "mediaId": "med_…", "altText": "third" }
]
}
```
### Reply
Replies to an existing thread under the same account.
```json reply.json theme={"system"}
{
"targets": [
{
"accountId": "...",
"options": { "platform": "threads", "replyToId": "" }
}
],
"text": "Replying to my own post"
}
```
## Wisdom (platform-specific things that bite)
* Threads is a two-step container API: create-container → poll until `FINISHED` → publish. The publisher hides this entirely; one `POST /v1/posts` blocks until the publish lands. Image containers usually FINISH inside 1 – 2 s; video can take a minute.
* Containers expire after **24 hours** without a publish. If a scheduled post's container ages out, the dispatcher creates a fresh one — see [`threads.container.expired`](/preflight/threads-container-expired).
* Carousels allow mixed image + video — this is the only platform in letmepost where that's true.
* Threads has no first-comment surface; what looks like "first comment" on the app is just another reply you'd post via `threads.replyToId`.
* A 700-char caption on an image post is rejected upstream with the same 500-grapheme rule as text-only — preflight catches it locally before any container is created.
* The publish response carries the post id but not a permalink; the publisher follows up with `GET /{thread-id}?fields=permalink` best-effort, and falls back to the id if Threads returns nothing.
## Common errors
| Error rule | What it means | How to fix |
| - | - | - |
| [`threads.text.max_graphemes`](/preflight/threads-text-max_graphemes) | Caption exceeded 500 graphemes | Trim to ≤500 graphemes. |
| [`threads.text.required`](/preflight/threads-text-required) | Text-only post had no text | Provide text or attach `media[]`. |
| [`threads.media.count_max`](/preflight/threads-media-count_max) | More than 20 items in a carousel | Reduce to ≤20, or split into multiple posts. |
| [`threads.media.mime_allowed`](/preflight/threads-media-mime_allowed) | Unsupported image/video mime | Images: jpeg/png/webp. Videos: mp4 or mov. |
| [`threads.media.image_size_max`](/preflight/threads-media-image_size_max) | Image > 8 MB | Re-encode under 8,000,000 bytes. |
| [`threads.media.video_size_max`](/preflight/threads-media-video_size_max) | Video > 1 GB | Compress under 1,000,000,000 bytes. |
| [`threads.container.expired`](/preflight/threads-container-expired) | Container aged past Threads' 24 h window | Re-create and publish in one flow (the publisher does this for scheduled posts automatically). |
| [`threads.container.error`](/preflight/threads-container-error) | Threads transcoder or policy filter rejected the container | Inspect `platformResponse.error_message` for the upstream reason. |
## What you can't do (yet)
* First comment (Threads has no comment-as-author surface distinct from a reply).
* Quote posts, polls, edits.
* Reading replies / engagement (would require `threads_read_replies` + the inbox feature, neither in v1).
* DMs — Threads has no DM API.
* Cross-posting fan-out to Instagram in a single call; Threads and Instagram are separate connects.
## API reference
* [`POST /v1/posts`](/api-reference) — primary publish.
* [`POST /v1/media`](/api-reference) — upload images/videos for `mediaId` references.
* [`POST /v1/accounts/connect/threads`](/api-reference) — start a Threads OAuth flow.
# TikTok
Source: https://docs.letmepost.dev/platforms/tiktok
TikTok Content Posting API. OAuth 2.0 PKCE + push_by_file upload to the creator's inbox, confirmed in the TikTok app.
**Before you start.** Every TikTok post currently goes to the creator's **upload inbox** as a draft: the video uploads, TikTok notifies the creator, and they confirm the publish in the TikTok app. letmepost resolves your requested `privacy` to `self_only` and reports that back, but does not send a privacy level upstream at all — the upload-inbox request has no such field, and the creator picks one when they confirm.
TikTok's Direct Post (`video.publish`) App Review has cleared, but Direct Post is a different endpoint (`/v2/post/publish/video/init/`) and is **not wired up yet**, so public posting still requires that confirmation tap. Passing review does not change this on its own.
## Quick reference
| Limit / capability | Value |
| - | - |
| Caption (text) | ≤ 2,200 characters |
| Hashtag count | ≤ 100 |
| Media items per post | **1 video** (no photo posts on unaudited apps) |
| Video formats | mp4, mov, mpeg, avi, flv, webm |
| Video size | ≤ 4,000,000,000 bytes (4 GB push\_by\_file) |
| Video duration | 3 s minimum, 10 min maximum (audit accounts typically capped to 60 s) |
| Video resolution | ≥ 540 px on short edge (1080 x 1920 recommended) |
| Aspect ratio | 9:16 preferred; 1:1 and 16:9 accepted with a warning |
| Privacy | Accepted on the request, but always rewritten to `self_only` — the inbox draft carries no privacy; the creator sets it on confirm |
| Branded content | `brandContentToggle` OR `brandOrganicToggle` (mutually exclusive) |
| Post types supported | single video |
| Scheduling | Yes (via `scheduledAt`) |
| Reply / first comment | Not supported in v1 |
| Inbox / DM | Not supported in v1 |
| Analytics | Not supported in v1 |
## Connect an account
`POST /v1/accounts/connect/tiktok`. OAuth 2.0 with PKCE. After the callback the provider:
1. Calls `GET /v2/user/info/` to pin `platformAccountId` to the real TikTok `open_id`.
2. Calls `POST /v2/post/publish/creator_info/query/` to snapshot the privacy allowlist; accounts with only `SELF_ONLY` are flagged as `auditState: "audit"` and the publisher forces `SELF_ONLY` on every post until that flips.
### Scopes
```
user.info.basic — required to mint platform_account_id at connect
video.upload — upload-inbox path (push_by_file)
```
Extended (not requested by default): `video.publish` (Direct Post), `video.list`.
### Token lifecycle
24-hour access tokens; 365-day refresh tokens that roll on every use. The provider refreshes 1 hour before expiry; subscribe to [`token.expiring`](/webhooks/token-expiring) to know when a re-auth window is coming.
## Post a video
```json tiktok-video-post.json theme={"system"}
{
"targets": [
{
"accountId": "...",
"options": {
"platform": "tiktok",
"privacy": "self_only",
"disableComment": false,
"disableDuet": false,
"disableStitch": false
}
}
],
"text": "Walkthrough #demo",
"media": [{ "kind": "video", "mediaId": "med_…" }]
}
```
### Privacy
`privacy` accepts three values: `public_to_everyone`, `mutual_follow_friend`, `self_only`. All three are currently rewritten to `SELF_ONLY`, whatever the account's audit state, because posts route through the upload inbox and that endpoint takes no privacy level. letmepost attaches a `tiktok.audit.self_only` warning every time it rewrites your intent, so a request for a public post is never silently downgraded. The value becomes meaningful once Direct Post is wired up.
### Branded content
`brandContentToggle: true` flags the post as a paid partnership. `brandOrganicToggle: true` flags it as content for your own brand. TikTok rejects requests that set both — preflight catches it with `tiktok.branded_content.mutual_exclusive`.
## Code samples
```bash curl theme={"system"}
curl https://api.letmepost.dev/v1/posts \
-H "Authorization: Bearer lmp_live_…" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"targets": [
{
"accountId": "acc_…",
"options": { "platform": "tiktok", "privacy": "self_only" }
}
],
"text": "Walkthrough #demo",
"media": [{ "kind": "video", "mediaId": "med_…" }]
}'
```
```ts TypeScript theme={"system"}
import { Letmepost } from "@letmepost/sdk";
const lmp = new Letmepost({ apiKey: process.env.LETMEPOST_API_KEY! });
await lmp.posts.create({
targets: [
{
accountId: "acc_…",
options: { platform: "tiktok", privacy: "self_only" },
},
],
text: "Walkthrough #demo",
media: [{ kind: "video", mediaId: "med_…" }],
});
```
```python Python theme={"system"}
from letmepost import Letmepost
lmp = Letmepost(api_key=os.environ["LETMEPOST_API_KEY"])
lmp.posts.create(
targets=[
{
"accountId": "acc_…",
"options": {"platform": "tiktok", "privacy": "self_only"},
}
],
text="Walkthrough #demo",
media=[{"kind": "video", "mediaId": "med_…"}],
)
```
```go Go theme={"system"}
client := letmepost.New(os.Getenv("LETMEPOST_API_KEY"))
client.Posts.Create(ctx, letmepost.CreatePostRequest{
Targets: []letmepost.PostTarget{{
AccountID: "acc_…",
Options: &letmepost.TargetOptions{
Platform: "tiktok",
Privacy: "self_only",
},
}},
Text: "Walkthrough #demo",
Media: []letmepost.MediaInput{{Kind: "video", MediaID: "med_…"}},
})
```
## How publishing works under the hood
TikTok's Content Posting API is asynchronous. The publisher:
1. Calls `POST /v2/post/publish/inbox/video/init/` with `source: "FILE_UPLOAD"`, `video_size`, `chunk_size`, `total_chunk_count`. Returns `upload_url` + `publish_id`.
2. PUTs the video bytes to `upload_url`. Files under 64 MiB go in one request; larger files chunk at 10 MiB with explicit `Content-Range` headers.
3. Returns `status: "publishing"` with the `publish_id` stamped on `cid` so the caller can correlate by id.
4. A BullMQ worker polls `POST /v2/post/publish/status/fetch/` on a bucketed schedule (5 s → 30 s → 2 min) until TikTok reaches a terminal state.
5. Terminal `PUBLISH_COMPLETE` → `post.published` webhook with the public TikTok URL. Terminal `FAILED` → `post.failed` with the upstream reason. The 30-minute deadline turns into `post.failed` with `tiktok.publish.pending`.
The `SEND_TO_USER_INBOX` state is also treated as terminal-published — TikTok's upload-inbox flow IS the documented end-state for audit-mode posts. The user confirms publish in their TikTok app; we have no API signal for that final tap.
## Wisdom (platform-specific things that bite)
* **Every post is rewritten to SELF\_ONLY.** The upload-inbox endpoint accepts no privacy level, so whatever you pass is rewritten and a `tiktok.audit.self_only` warning fires. This is true on audited and unaudited accounts alike — the constraint is the endpoint, not the account.
* **Direct Post is approved but not implemented.** TikTok's `video.publish` review has cleared, but letmepost still posts through the inbox endpoint and does not yet request the `video.publish` scope. Posts land for manual confirmation. There's no API signal for the user tapping publish, so the worker treats `SEND_TO_USER_INBOX` as terminal-published.
* **ffprobe is needed for duration / resolution preflight.** Without ffprobe installed locally, letmepost emits a `tiktok.video.probe_unavailable` warning and lets the upload proceed; TikTok's own preflight will catch resolution issues at publish time.
* **Aspect ratio 9:16 is strongly preferred.** 1:1 and 16:9 work but emit `tiktok.video.aspect_non_vertical`. Anything else trips `tiktok.video.aspect_unusual`.
* **Refresh tokens roll on every use.** TikTok issues a new `refresh_token` on each refresh; letmepost stores it. If you bypass the refresh worker and call the upstream endpoint yourself, persist the new refresh token — discarding it silently shortens the next refresh window.
* **`push_by_file` only.** `pull_by_url` requires media-domain verification on developer.tiktokapis.com which letmepost has not completed; v1 uploads always go through the chunked PUT path.
## Common errors
| Error rule | What it means | How to fix |
| - | - | - |
| `tiktok.media.required` | No media on the post | Attach `media: [{ kind: "video", … }]`. |
| `tiktok.media.single_only` | More than one media item | Send a single video item. |
| `tiktok.media.video_required` | Non-video media (e.g. image) | TikTok unaudited apps only support video posts. |
| `tiktok.text.max_chars` | Caption > 2,200 characters | Shorten the caption. |
| `tiktok.text.hashtag_max` | More than 100 hashtags in caption | Reduce hashtag count. |
| `tiktok.file_format.invalid` | Video mime not in TikTok's allowlist | Use mp4, mov, mpeg, avi, flv, or webm. |
| `tiktok.video.size_max` | Video > 4 GB | Compress under 4 GB. |
| `tiktok.video.too_short` | Duration \< 3 s (detected via ffprobe) | Use a video at least 3 s long. |
| `tiktok.video.too_long` | Duration > 10 min | Trim to 10 min or under. |
| `tiktok.resolution.unsupported` | Short edge \< 540 px | Use 1080 x 1920 (or any 540+ short-edge resolution). |
| `tiktok.audit.self_only` (warning) | Privacy rewritten to SELF\_ONLY — posts go to the inbox as a draft | Expect the creator to confirm the post in the TikTok app. Returns to meaning "audit account only" once Direct Post ships. |
| `tiktok.branded_content.mutual_exclusive` | Both brand toggles set | Set exactly one of `brandContentToggle` / `brandOrganicToggle`. |
| `tiktok.publish.failed` | TikTok rejected the upload async | Check `fail_reason` on the webhook payload. |
| `tiktok.publish.pending` | Poll deadline exceeded (30 min) | The upload may still finish in the user's TikTok inbox; re-check via the app. |
## What you can't do (yet)
* Photo carousels (unaudited apps).
* Direct Post (public publishing without user confirmation). The scope is approved; the endpoint is not implemented yet.
* pull\_by\_url uploads (domain verification gate).
* Reading post engagement, comments, or analytics.
* TikTok Live, Shop, DMs.
## API reference
* [`POST /v1/posts`](/api-reference) — primary publish.
* [`POST /v1/media`](/api-reference) — upload video bytes for `mediaId` references.
* [`POST /v1/accounts/connect/tiktok`](/api-reference) — start a TikTok OAuth flow.
# Twitter / X
Source: https://docs.letmepost.dev/platforms/twitter
Twitter / X v2 API. OAuth 2.0 PKCE. Up to 4 images, video via chunked upload, reply chains, quote tweets.
**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.
## 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)
* **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.
## 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.
# bluesky.first_comment.max_graphemes
Source: https://docs.letmepost.dev/preflight/bluesky-first_comment-max_graphemes
First-comment text is also capped at 300 graphemes — same as the parent post.
## What it checks
`countGraphemes(firstComment.text) <= 300`. Same counter and same cap as the post itself, because a first comment is a reply post — Bluesky doesn't distinguish.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "bluesky.first_comment.max_graphemes",
"platform": "bluesky",
"message": "First comment text is 312 graphemes; Bluesky allows at most 300."
}
}
```
## Remediation
Same as for the parent post — see [`bluesky.text.max_graphemes`](/preflight/bluesky-text-max_graphemes) for grapheme-aware truncation guidance.
# bluesky.first_comment.non_empty
Source: https://docs.letmepost.dev/preflight/bluesky-first_comment-non_empty
When firstComment is set, its text must be non-empty.
## What it checks
If `firstComment` is present in the request body, `firstComment.text.trim().length > 0`.
## Why
A first comment is a separate Bluesky reply post; it has the same non-empty constraint as any post.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "bluesky.first_comment.non_empty",
"platform": "bluesky",
"message": "First comment text cannot be empty.",
"remediation": "Provide non-empty firstComment.text, or omit firstComment entirely."
}
}
```
## Remediation
Either provide non-whitespace text or drop the `firstComment` field. There's no semantic difference between an empty first comment and no first comment.
# bluesky.media.alt_text_max_graphemes
Source: https://docs.letmepost.dev/preflight/bluesky-media-alt_text_max_graphemes
Alt text caps at 2000 graphemes per media item.
## What it checks
`countGraphemes(item.altText) <= 2000` for every media item that supplies `altText`.
## Why
Bluesky's `app.bsky.embed.images#image` schema's `alt` field is documented at 2000 graphemes. Same Unicode segmentation as the post-text rule.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "bluesky.media.alt_text_max_graphemes",
"platform": "bluesky",
"message": "Alt text is 2400 graphemes; Bluesky allows at most 2000.",
"remediation": "Shorten alt text to 2000 graphemes or fewer."
}
}
```
## Remediation
Tighten the description. Alt text is for accessibility — it should describe the image's content concisely, not act as a hidden caption.
## Related
* [`bluesky.text.max_graphemes`](/preflight/bluesky-text-max_graphemes) — same Unicode counter.
# bluesky.media.count_max
Source: https://docs.letmepost.dev/preflight/bluesky-media-count_max
Up to 4 images per post, or 1 video. No exceptions.
## What it checks
`images.length <= 4` AND `videos.length <= 1`. Mixed image+video is rejected separately by [`bluesky.media.image_video_exclusive`](/preflight/bluesky-media-image_video_exclusive).
## Why
Bluesky's `app.bsky.embed.images` schema caps `images` at 4 entries. Video posts use a different embed (`app.bsky.embed.video`) which holds exactly one.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "bluesky.media.count_max",
"platform": "bluesky",
"message": "Attached 6 images; Bluesky allows at most 4 per post.",
"remediation": "Reduce to 4 images or fewer."
}
}
```
## Remediation
Trim to 4. For longer galleries, post them as a thread — each reply can attach 4 more.
## Related
* [`bluesky.media.image_video_exclusive`](/preflight/bluesky-media-image_video_exclusive)
* [Platforms / Bluesky](/platforms/bluesky)
# bluesky.media.image_size_max
Source: https://docs.letmepost.dev/preflight/bluesky-media-image_size_max
Each image must be ≤ 976 KB (1,000,000 bytes). Bluesky's blob limit.
## What it checks
`byteLength <= 1_000_000` per image item.
## Why
Bluesky's PDS blob upload caps at \~976 KB (1,000,000 bytes — Bluesky uses base-10, not base-2). Larger blobs are rejected with `BlobTooLarge`. The 976 KB number is the "post" you want; the constant is the byte limit you should compare to.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "bluesky.media.image_size_max",
"platform": "bluesky",
"message": "Image is 1450000 bytes; Bluesky allows at most 1000000.",
"remediation": "Re-encode the image under 1000000 bytes (Bluesky's blob limit is ~976 KB)."
}
}
```
## Remediation
Re-encode at lower quality or smaller dimensions. For JPEGs, quality 80–85 + max-dimension 2048 px is usually enough.
```sh theme={"system"}
# ImageMagick
magick input.jpg -quality 82 -resize 2048x2048\> output.jpg
# sharp (Node)
await sharp(input).resize({ width: 2048, withoutEnlargement: true }).jpeg({ quality: 82 }).toFile("output.jpg");
```
## Related
* [`bluesky.media.video_size_max`](/preflight/bluesky-media-video_size_max)
* [`bluesky.media.mime_allowed`](/preflight/bluesky-media-mime_allowed)
# bluesky.media.image_video_exclusive
Source: https://docs.letmepost.dev/preflight/bluesky-media-image_video_exclusive
A single Bluesky post is either images-only or video-only — not mixed.
## What it checks
If the `media` array contains both `kind: "image"` and `kind: "video"` items, preflight rejects.
## Why
Bluesky's record schema (`app.bsky.embed.images` vs `app.bsky.embed.video`) is exclusive — a single post embeds *either* images *or* a video, never both. There's no embed shape that holds mixed media.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "bluesky.media.image_video_exclusive",
"platform": "bluesky",
"message": "Cannot attach both images and video to the same Bluesky post; pick one.",
"remediation": "Split the post: images in one post, video in another. Bluesky does not support mixed media in a single record."
}
}
```
## Remediation
Send two posts. The second can quote-post or thread-reply to the first if you want them visually linked. If you need mixed media in a single visual, [Threads](/platforms/threads) is the platform that supports that — same `media` shape, just send it against a Threads `accountId`.
## Related
* [`bluesky.media.count_max`](/preflight/bluesky-media-count_max) — also caps at 4 images / 1 video.
# bluesky.media.mime_allowed
Source: https://docs.letmepost.dev/preflight/bluesky-media-mime_allowed
Bluesky accepts jpeg/png/webp/gif images and mp4 video. Other formats are rejected.
## What it checks
For images: `mimeType ∈ { image/jpeg, image/png, image/webp, image/gif }`.
For videos: `mimeType === video/mp4`.
The mime is sniffed from the resolved bytes, not trusted from the file extension.
## Why
The PDS (Personal Data Server) blob endpoint enforces the same allowlist. Sending HEIC, BMP, MOV, etc. produces a `BlobUnsupportedMimeType` error from Bluesky — opaque, async, hard to debug. We catch it locally.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "bluesky.media.mime_allowed",
"platform": "bluesky",
"message": "Image mime type 'image/heic' is not allowed on Bluesky.",
"remediation": "Use one of: image/jpeg, image/png, image/webp, image/gif."
}
}
```
## Remediation
Re-encode the asset. ImageMagick, sharp, ffmpeg — any of them. For video, `ffmpeg -i input.mov -c:v libx264 -c:a aac output.mp4` produces a Bluesky-compatible mp4.
## Related
* [`bluesky.media.image_size_max`](/preflight/bluesky-media-image_size_max)
* [`bluesky.media.video_size_max`](/preflight/bluesky-media-video_size_max)
# bluesky.media.video_size_max
Source: https://docs.letmepost.dev/preflight/bluesky-media-video_size_max
Each video must be ≤ 100 MB.
## What it checks
`byteLength <= 100_000_000` for video items.
## Why
Bluesky's video service caps individual video blobs at 100 MB. Beyond that the upload is rejected before transcoding.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "bluesky.media.video_size_max",
"platform": "bluesky",
"message": "Video is 156000000 bytes; Bluesky allows at most 100000000.",
"remediation": "Compress the video under 100000000 bytes (100 MB)."
}
}
```
## Remediation
Re-encode at a lower bitrate. For 1080p H.264, \~5 Mbps is plenty:
```sh theme={"system"}
ffmpeg -i input.mp4 -c:v libx264 -b:v 5M -c:a aac -b:a 128k -movflags +faststart output.mp4
```
If the video is long, consider a short clip + linking to the full version on a host that doesn't have a 100 MB cap.
## Related
* [`bluesky.media.mime_allowed`](/preflight/bluesky-media-mime_allowed) — mp4 only.
# bluesky.text.max_graphemes
Source: https://docs.letmepost.dev/preflight/bluesky-text-max_graphemes
Bluesky caps post text at 300 graphemes. Emoji and ZWJ sequences count as 1.
## What it checks
`countGraphemes(text) <= 300`. The grapheme counter uses Unicode segmentation, so `👨👩👧👦` is one grapheme, not seven codepoints.
## Why 300
The cap matches Bluesky's `app.bsky.feed.post` schema constant, which is itself defined in graphemes — not bytes, not codepoints, not UTF-16 codeunits. We use the same counter the platform uses, so what passes preflight passes upstream.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "bluesky.text.max_graphemes",
"platform": "bluesky",
"message": "Post text is 312 graphemes; Bluesky allows at most 300.",
"remediation": "Shorten the post to 300 graphemes or fewer."
}
}
```
## Remediation
Trim the post. If you're truncating programmatically, **use a grapheme-aware splitter**. JavaScript's `string.length` counts UTF-16 codeunits and will under-count emoji; use `Intl.Segmenter` or `graphemer`.
```ts theme={"system"}
import Graphemer from "graphemer";
const g = new Graphemer();
const truncated = g.splitGraphemes(text).slice(0, 300).join("");
```
## Related
* [`bluesky.text.non_empty`](/preflight/bluesky-text-non_empty)
* [Platforms / Bluesky](/platforms/bluesky)
# bluesky.text.non_empty
Source: https://docs.letmepost.dev/preflight/bluesky-text-non_empty
Bluesky posts cannot be empty. Whitespace-only text is rejected.
## What it checks
`text.trim().length > 0`. Bluesky requires a non-empty post body — there's no "image-only with no caption" surface like there is on Instagram.
## Why
Bluesky's `app.bsky.feed.post` record requires a `text` field. Sending an empty string or whitespace produces a record that's syntactically valid but semantically broken; Bluesky's appview hides empty posts from feeds.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "bluesky.text.non_empty",
"platform": "bluesky",
"message": "Post text cannot be empty.",
"remediation": "Provide a non-empty text body."
}
}
```
## Remediation
Send any non-whitespace `text`. If you want a media-only experience, attach the image and use a brief caption like the file name.
# bluesky.video.job_failed
Source: https://docs.letmepost.dev/preflight/bluesky-video-job_failed
Bluesky's video pipeline rejected the file during transcode or scanning.
## What it checks
After `uploadVideo`, the publisher polls `app.bsky.video.getJobStatus`. If the response carries `state: "JOB_STATE_FAILED"`, the upload made it onto the service but transcoding (or scanning) decided not to keep it.
## Why
Bluesky's video service runs an encode pass + a scanning pass. Either can reject a file. Common causes:
* Codec other than h.264 video / AAC audio
* Corrupt mp4 container (often happens when re-muxing without `+faststart`)
* Clip exceeds Bluesky's 60 s duration cap (caught here, not in size preflight, because duration isn't in the byte length)
* Content scanning rejected the file
## Failure response
```json theme={"system"}
{
"error": {
"code": "platform_rejected",
"rule": "bluesky.video.job_failed",
"platform": "bluesky",
"message": "bluesky rejected the post: Unsupported codec.",
"remediation": "Bluesky's video pipeline failed to encode the file. Common causes: unsupported codec, corrupted mp4 container, video > 60s. Re-encode and retry.",
"platformResponse": {
"jobId": "job-…",
"did": "did:plc:…",
"state": "JOB_STATE_FAILED",
"error": "TranscodeError",
"message": "Unsupported codec."
}
}
}
```
## Remediation
Re-encode to a known-good profile:
```sh theme={"system"}
ffmpeg -i input.mov \
-c:v libx264 -profile:v high -pix_fmt yuv420p -crf 23 \
-c:a aac -b:a 128k \
-movflags +faststart \
-t 60 \
output.mp4
```
If the file still fails after a clean re-encode, the upstream message in `platformResponse.message` is your best lead — it's the verbatim string from Bluesky's transcoder.
## Related
* [`bluesky.media.video_size_max`](/preflight/bluesky-media-video_size_max)
* [`bluesky.video.transcode_timeout`](/preflight/bluesky-video-transcode_timeout)
# bluesky.video.quota_exhausted
Source: https://docs.letmepost.dev/preflight/bluesky-video-quota_exhausted
The Bluesky account is out of daily video upload quota.
## What it checks
Before streaming the bytes, the publisher calls `app.bsky.video.getUploadLimits` on the video service. If `canUpload` comes back `false`, we fail loudly here instead of POSTing to `uploadVideo` and watching the job state flip to FAILED minutes later.
## Why
Bluesky enforces a per-user daily cap on video uploads + total bytes. The exact ceilings shift over time and aren't documented as constants — the upload-limits endpoint is the source of truth. Hitting the cap silently is one of the canonical opaque-failure patterns we built the preflight surface to avoid.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "bluesky.video.quota_exhausted",
"platform": "bluesky",
"message": "Daily video upload limit reached.",
"remediation": "Bluesky enforces a per-user daily cap on video uploads + total bytes. Wait for the quota to reset or use a different account.",
"platformResponse": {
"canUpload": false,
"remainingDailyVideos": 0,
"remainingDailyBytes": 0
}
}
}
```
## Remediation
* Wait for the quota window to reset (rolling 24 h).
* Connect a different Bluesky account.
* Trim the post — text-only or image-only posts don't draw from the video quota.
## Related
* [`bluesky.media.video_size_max`](/preflight/bluesky-media-video_size_max) — per-file cap
* [`bluesky.video.job_failed`](/preflight/bluesky-video-job_failed) — terminal-state failure during transcode
# bluesky.video.transcode_timeout
Source: https://docs.letmepost.dev/preflight/bluesky-video-transcode_timeout
Bluesky's transcoder didn't finish within our 6-minute deadline.
## What it checks
The publisher polls `app.bsky.video.getJobStatus` every 2 seconds for up to 6 minutes. If the job is still in a non-terminal state (CREATED / ENCODING\_IN\_PROGRESS / SCANNING) when the deadline hits, we surface this rule rather than holding the request open indefinitely.
## Why
Most Bluesky video transcodes finish in seconds. Long clips, heavy-bitrate sources, or service-side congestion can push past the deadline. Holding a synchronous publish open for 10+ minutes is worse UX than failing fast and letting the caller retry — Bluesky deduplicates by content hash, so a retry skips re-uploading the bytes.
## Failure response
```json theme={"system"}
{
"error": {
"code": "platform_unavailable",
"rule": "bluesky.video.transcode_timeout",
"platform": "bluesky",
"message": "Bluesky video job job-… did not finish within 360000ms.",
"remediation": "The transcode is still running upstream; retry the publish in a minute or two, or shorten the clip."
}
}
```
## Remediation
* Retry the publish. The upload is content-addressed; the second attempt skips the bytes phase.
* If timeouts are persistent, shorten the clip (Bluesky caps duration at 60 s anyway) or reduce bitrate.
## Related
* [`bluesky.video.job_failed`](/preflight/bluesky-video-job_failed)
* [`bluesky.media.video_size_max`](/preflight/bluesky-media-video_size_max)
# facebook.media.count_max
Source: https://docs.letmepost.dev/preflight/facebook-media-count_max
Facebook Page photo posts accept up to 10 images.
## What it checks
`media.length <= 10` for image posts. Video posts are single-video.
## Remediation
Cap at 10 images. For longer galleries, post a Page album link or split across multiple posts.
# facebook.media.image_size_max
Source: https://docs.letmepost.dev/preflight/facebook-media-image_size_max
Each Facebook Page image must be ≤ 4 MB.
## What it checks
`byteLength <= 4_000_000` per image, on the Pages `/photos` endpoint.
## Remediation
Re-encode at lower quality or smaller dimensions:
```sh theme={"system"}
magick input.jpg -quality 82 -resize 2048x2048\> output.jpg
```
# facebook.media.image_video_exclusive
Source: https://docs.letmepost.dev/preflight/facebook-media-image_video_exclusive
A Facebook Page post is either images-only or video-only. No mixing.
## What it checks
`media` cannot contain both image and video items. Pages's `/photos` and `/videos` endpoints are mutually exclusive surfaces.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "facebook.media.image_video_exclusive",
"platform": "facebook",
"message": "Cannot attach both images and video to the same Facebook post."
}
}
```
## Remediation
Split into two posts.
# facebook.media.mime_allowed
Source: https://docs.letmepost.dev/preflight/facebook-media-mime_allowed
Facebook Pages accept jpeg/png images and mp4/mov video.
## What it checks
For images: `mimeType ∈ { image/jpeg, image/png }`.
For videos: `mimeType ∈ { video/mp4, video/quicktime }`.
WebP, HEIC, GIF on the photo path will fail upstream with opaque `code 100`.
## Remediation
Re-encode to JPEG or PNG for images; mp4 (H.264 + AAC) for video.
# facebook.media.video_size_max
Source: https://docs.letmepost.dev/preflight/facebook-media-video_size_max
Each Facebook Page video must be ≤ 4 GB.
## What it checks
`byteLength <= 4_000_000_000` per video, on the Pages `/videos` endpoint.
## Remediation
Re-encode at a lower bitrate. Most Page videos are well under 4 GB; if you're hitting this you're probably uploading uncompressed.
# facebook.pages.none
Source: https://docs.letmepost.dev/preflight/facebook-pages-none
The connecting Facebook user has no Pages they can manage. Connection cannot complete.
## What it checks
After OAuth completes, we call `GET /me/accounts` to discover Pages the user can publish to. If the response is empty, this rule fires — there's no Page to attach the connection to.
## Why this happens
* The user authenticated with a personal account that doesn't manage any Page.
* The user manages Pages but didn't grant the `pages_show_list` scope.
* The user manages Pages via Business Manager but didn't grant `business_management`.
## Failure response
```json theme={"system"}
{
"error": {
"code": "platform_rejected",
"rule": "facebook.pages.none",
"platform": "facebook",
"message": "No Pages were returned for this Facebook user.",
"remediation": "Ensure the user manages at least one Facebook Page and granted the pages_show_list and business_management scopes."
}
}
```
## Remediation
Re-run the connect flow and ensure all requested scopes are granted. If the user truly doesn't manage a Page, this is a wrong-account issue — they need to either be added to a Page or sign in as a user who manages one.
## Related
* [Accounts](/guides/connect-account) — full connect flow.
# facebook.text.max_graphemes
Source: https://docs.letmepost.dev/preflight/facebook-text-max_graphemes
Facebook Page posts cap at 63,206 graphemes. Hard server-side limit.
## What it checks
`countGraphemes(text) <= 63_206`.
## Why 63,206
Facebook's hard server-side cap on a Page post is 63,206 characters — an oddly specific value chosen by Meta. Above it, Graph API rejects with `code 100`. We pre-check against graphemes; since Facebook counts UTF-16 codeunits but graphemes ≤ codeunits, we never over-permit a payload that would 100 upstream.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "facebook.text.max_graphemes",
"platform": "facebook",
"message": "Post text exceeds Facebook's 63206-character cap."
}
}
```
## Remediation
If you're hitting this, the post is probably better as a Note or a link to a longer-form surface. Trim to under 63,206 characters.
# facebook.text.required
Source: https://docs.letmepost.dev/preflight/facebook-text-required
Facebook posts without media require non-empty text.
## What it checks
If `media` is empty or absent, `text.trim().length > 0`. Facebook supports text-only Page posts but not empty ones.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "facebook.text.required",
"platform": "facebook",
"message": "Facebook posts without media require non-empty text."
}
}
```
## Remediation
Provide non-whitespace `text` or attach media.
# Preflight
Source: https://docs.letmepost.dev/preflight/index
Every documented platform constraint is checked locally before we touch the upstream API. Here are all the rules.
Before the API forwards a post to a platform, it runs every documented constraint locally — character count, media format, alt-text length, URL reachability, scope checks. When something fails the response is an [`preflight_failed`](/errors/preflight_failed) error carrying the **rule id** that fired, plus a `ruleUrl` field with the absolute link to *this* page set — so a client doesn't need to know the slug convention to deep-link a failure straight to its docs.
## Why preflight
The most-cited developer complaint about social media APIs is that constraints are documented somewhere, but the runtime error doesn't say which constraint you tripped. Examples from the research corpus that drove this product:
* Instagram Reels rejecting Google Drive URLs as `OAuthException 2207052`.
* LinkedIn returning generic 422s for URN format mismatches.
* Threads returning HTTP 200 with a status code embedded in the body.
Preflight runs every documented rule **before** the upstream call. If we know it'll fail, we surface the rule id immediately rather than wait for the platform to reject opaquely.
## Reading a rule id
Rule ids look like `..`:
```
bluesky.text.max_graphemes
threads.media.alt_text_max_graphemes
instagram.media.reachable
pinterest.image.size_max
```
Each one has its own page below — the rule id is the canonical link.
## Rule catalog
### Bluesky
* [`bluesky.text.non_empty`](/preflight/bluesky-text-non_empty)
* [`bluesky.text.max_graphemes`](/preflight/bluesky-text-max_graphemes)
* [`bluesky.media.image_video_exclusive`](/preflight/bluesky-media-image_video_exclusive)
* [`bluesky.media.count_max`](/preflight/bluesky-media-count_max)
* [`bluesky.media.alt_text_max_graphemes`](/preflight/bluesky-media-alt_text_max_graphemes)
* [`bluesky.media.mime_allowed`](/preflight/bluesky-media-mime_allowed)
* [`bluesky.media.image_size_max`](/preflight/bluesky-media-image_size_max)
* [`bluesky.media.video_size_max`](/preflight/bluesky-media-video_size_max)
* [`bluesky.video.quota_exhausted`](/preflight/bluesky-video-quota_exhausted)
* [`bluesky.video.job_failed`](/preflight/bluesky-video-job_failed)
* [`bluesky.video.transcode_timeout`](/preflight/bluesky-video-transcode_timeout)
* [`bluesky.first_comment.non_empty`](/preflight/bluesky-first_comment-non_empty)
* [`bluesky.first_comment.max_graphemes`](/preflight/bluesky-first_comment-max_graphemes)
### Threads
* [`threads.text.required`](/preflight/threads-text-required)
* [`threads.text.max_graphemes`](/preflight/threads-text-max_graphemes)
* [`threads.media.count_max`](/preflight/threads-media-count_max)
* [`threads.media.alt_text_max_graphemes`](/preflight/threads-media-alt_text_max_graphemes)
* [`threads.media.mime_allowed`](/preflight/threads-media-mime_allowed)
* [`threads.media.image_size_max`](/preflight/threads-media-image_size_max)
* [`threads.media.video_size_max`](/preflight/threads-media-video_size_max)
* [`threads.container.expired`](/preflight/threads-container-expired)
* [`threads.container.error`](/preflight/threads-container-error)
### Instagram
* [`instagram.media.required`](/preflight/instagram-media-required)
* [`instagram.text.max_graphemes`](/preflight/instagram-text-max_graphemes)
* [`instagram.media.count_max`](/preflight/instagram-media-count_max)
* [`instagram.media.alt_text_max_graphemes`](/preflight/instagram-media-alt_text_max_graphemes)
* [`instagram.media.mime_allowed`](/preflight/instagram-media-mime_allowed)
* [`instagram.media.image_size_max`](/preflight/instagram-media-image_size_max)
* [`instagram.media.video_size_max`](/preflight/instagram-media-video_size_max)
* [`instagram.media.reachable`](/preflight/instagram-media-reachable) — the canonical `OAuthException 2207052` translator.
* [`instagram.media.aspect_ratio`](/preflight/instagram-media-aspect_ratio)
### Facebook
* [`facebook.text.required`](/preflight/facebook-text-required)
* [`facebook.text.max_graphemes`](/preflight/facebook-text-max_graphemes)
* [`facebook.media.image_video_exclusive`](/preflight/facebook-media-image_video_exclusive)
* [`facebook.media.count_max`](/preflight/facebook-media-count_max)
* [`facebook.media.mime_allowed`](/preflight/facebook-media-mime_allowed)
* [`facebook.media.image_size_max`](/preflight/facebook-media-image_size_max)
* [`facebook.media.video_size_max`](/preflight/facebook-media-video_size_max)
* [`facebook.pages.none`](/preflight/facebook-pages-none)
### LinkedIn
* [`linkedin.text.non_empty`](/preflight/linkedin-text-non_empty)
* [`linkedin.text.max_graphemes`](/preflight/linkedin-text-max_graphemes)
* [`linkedin.author.urn_format`](/preflight/linkedin-author-urn_format)
* [`linkedin.author.org_not_supported`](/preflight/linkedin-author-org_not_supported)
* [`linkedin.author.unresolved`](/preflight/linkedin-author-unresolved)
* [`linkedin.visibility.enum`](/preflight/linkedin-visibility-enum)
### Pinterest
* [`pinterest.board.required`](/preflight/pinterest-board-required)
* [`pinterest.media.required`](/preflight/pinterest-media-required)
* [`pinterest.media.single_only`](/preflight/pinterest-media-single_only)
* [`pinterest.image.mime_allowed`](/preflight/pinterest-image-mime_allowed)
* [`pinterest.image.size_max`](/preflight/pinterest-image-size_max)
* [`pinterest.image_url.reachable`](/preflight/pinterest-image_url-reachable)
* [`pinterest.destination_url.reachable`](/preflight/pinterest-destination_url-reachable)
* [`pinterest.url.reachable`](/preflight/pinterest-url-reachable)
* [`pinterest.video.cover_required`](/preflight/pinterest-video-cover_required)
* [`pinterest.video.mime_allowed`](/preflight/pinterest-video-mime_allowed)
* [`pinterest.video.size_max`](/preflight/pinterest-video-size_max)
* [`pinterest.cover_image_url.reachable`](/preflight/pinterest-cover_image_url-reachable)
* [`pinterest.cover_image.mime_allowed`](/preflight/pinterest-cover_image-mime_allowed)
* [`pinterest.media.register_failed`](/preflight/pinterest-media-register_failed)
* [`pinterest.video.upload_failed`](/preflight/pinterest-video-upload_failed)
* [`pinterest.video.transcode_failed`](/preflight/pinterest-video-transcode_failed)
* [`pinterest.video.transcode_timeout`](/preflight/pinterest-video-transcode_timeout)
### Twitter / X
* [`twitter.text.non_empty`](/preflight/twitter-text-non_empty)
* [`twitter.text.max_graphemes`](/preflight/twitter-text-max_graphemes)
* [`twitter.media.count_max`](/preflight/twitter-media-count_max)
* [`twitter.media.image_video_exclusive`](/preflight/twitter-media-image_video_exclusive)
* [`twitter.media.alt_text_max_graphemes`](/preflight/twitter-media-alt_text_max_graphemes)
* [`twitter.media.mime_allowed`](/preflight/twitter-media-mime_allowed)
* [`twitter.media.image_size_max`](/preflight/twitter-media-image_size_max)
* [`twitter.media.gif_size_max`](/preflight/twitter-media-gif_size_max)
* [`twitter.media.video_size_max`](/preflight/twitter-media-video_size_max)
* [`twitter.media.video_processing_failed`](/preflight/twitter-media-video_processing_failed)
* [`twitter.media.processing_timeout`](/preflight/twitter-media-processing_timeout)
### Cross-platform
* [`media.unknown`](/preflight/media-unknown) — referenced media id doesn't exist or is out of scope.
* [`media.bytes_inline_unsupported`](/preflight/media-bytes_inline_unsupported) — `bytesBase64` not allowed for this surface.
* [`meta.container.expired`](/preflight/meta-container-expired) — IG/Threads container ttl exceeded.
## Validation without publishing
When `POST /v1/posts/validate` ships, you'll be able to run the same preflight without the upstream call — useful in CI or before queueing a scheduled post. Today, the same validation runs synchronously on `POST /v1/posts` and returns the rule id on the first failure.
# instagram.account_type.requires_professional
Source: https://docs.letmepost.dev/preflight/instagram-account_type-requires_professional
Connect attempted with a PERSONAL Instagram account. Instagram's Graph API requires Business or Creator.
Instagram's Graph publishing API only accepts **Business** or **Creator** accounts. A **Personal** account cannot be used for API posting — Meta returns `OAuthException` with a sub-code indicating the account type mismatch the moment our connect callback inspects the account.
## How to fix
The user needs to switch their Instagram account from Personal to Business or Creator, then reconnect:
1. Open the Instagram app on mobile.
2. **Settings → Account → Switch to Professional Account**.
3. Pick **Business** or **Creator**.
4. Return to the dashboard and re-run the Instagram connect flow.
Personal accounts cannot publish via API — there's no workaround on the platform side.
## Related
* [Instagram platform docs](/platforms/instagram)
* [Preflight overview](/preflight)
# instagram.container.expired
Source: https://docs.letmepost.dev/preflight/instagram-container-expired
An IG media container aged past Instagram's 24-hour window before publish.
Instagram's Graph API requires a two-step publish: create a media container, then publish it. The container stays valid for **24 hours**. If the publish step happens after that window, Instagram rejects with `code 100, error_subcode 2207003` and the container is unrecoverable — you have to start over.
## Why this happens
* A scheduled post was queued more than 24 h after creation.
* The publish job retried so many times that the cumulative delay exceeded 24 h.
* An idempotency replay landed against a stale container.
## How to fix
Re-create the container and publish in one flow. letmepost.dev does this automatically on retry — the worker rebuilds the container if the previous one is older than 24 h, so the only time you see `instagram.container.expired` surfaced to your app is when the post body itself is unrecoverable (e.g., the source media URL is now 404).
## Related
* [Instagram platform docs](/platforms/instagram)
* [Preflight overview](/preflight)
# instagram.media.alt_text_max_graphemes
Source: https://docs.letmepost.dev/preflight/instagram-media-alt_text_max_graphemes
Instagram alt text caps at 2200 graphemes — same as the caption.
## What it checks
`countGraphemes(item.altText) <= 2200`.
## Why 2200
Instagram doesn't publish a distinct alt-text cap in the Graph API; in practice the field accepts up to the same 2200-grapheme limit as the caption. We use the same number rather than guessing tighter.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "instagram.media.alt_text_max_graphemes",
"platform": "instagram",
"message": "Instagram alt text is capped at 2200 characters."
}
}
```
## Remediation
Tighten the description.
## Related
* [`instagram.text.max_graphemes`](/preflight/instagram-text-max_graphemes)
# instagram.media.aspect_ratio
Source: https://docs.letmepost.dev/preflight/instagram-media-aspect_ratio
Instagram rejects media that's outside its supported aspect-ratio range. Caught at container-finalize time.
## What it checks
The IG container reports an aspect-ratio rejection when the media's width/height ratio is outside `0.8..1.91` for feed posts, or not `9:16` for Reels. Detection happens at the container status-poll step — preflight surfaces the rule when Meta reports it.
## Why we don't check it statically
Verifying the aspect ratio requires reading image dimensions or ffprobe-style video introspection. We don't do that in the API hot path; instead we let Meta run its own check and translate the response into this rule.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "instagram.media.aspect_ratio",
"platform": "instagram",
"message": "Instagram rejected the container — media aspect ratio is outside the accepted range.",
"remediation": "Crop or pad to a 4:5..1.91:1 ratio for feed posts, or 9:16 for Reels."
}
}
```
## Remediation
For a feed image, the safest target is **1:1** (1080×1080). Reels: **9:16** (1080×1920).
```sh theme={"system"}
# Pad a wide image to 1:1
magick input.jpg -gravity center -background white -extent 1080x1080 output.jpg
# Crop a 16:9 video to 9:16
ffmpeg -i input.mp4 -vf "crop=ih*9/16:ih,scale=1080:1920" -c:a copy output.mp4
```
## Related
* [`instagram.media.mime_allowed`](/preflight/instagram-media-mime_allowed)
* [`instagram.media.reachable`](/preflight/instagram-media-reachable) — the 2207052 sibling.
# instagram.media.count_max
Source: https://docs.letmepost.dev/preflight/instagram-media-count_max
Instagram carousels cap at 10 mixed media items.
## What it checks
`media.length <= 10`. Carousels can mix images and videos (since IG Graph API v15).
## Why 10
Meta's Instagram Content Publishing API documents `CAROUSEL` containers at 2–10 children. Above that the create-container call returns an opaque `code 100`.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "instagram.media.count_max",
"platform": "instagram",
"message": "Attached 12 media items; Instagram allows at most 10 per carousel.",
"remediation": "Reduce to 10 items or fewer."
}
}
```
## Remediation
Trim to 10. If you have more, post the rest as a follow-up — IG doesn't support carousel-of-carousels.
## Related
* [`threads.media.count_max`](/preflight/threads-media-count_max) — Threads caps higher (20).
# instagram.media.image_size_max
Source: https://docs.letmepost.dev/preflight/instagram-media-image_size_max
Each Instagram image must be ≤ 8 MB.
## What it checks
`byteLength <= 8_000_000` per image item.
## Why
Meta's Instagram container endpoint caps photo containers at 8 MB. Larger payloads are rejected at container creation.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "instagram.media.image_size_max",
"platform": "instagram",
"message": "Image is 9500000 bytes; Instagram allows at most 8000000."
}
}
```
## Remediation
Re-encode at lower quality or smaller dimensions:
```sh theme={"system"}
magick input.jpg -quality 85 -resize 2048x2048\> output.jpg
```
## Related
* [`instagram.media.mime_allowed`](/preflight/instagram-media-mime_allowed) — JPEG-only.
# instagram.media.mime_allowed
Source: https://docs.letmepost.dev/preflight/instagram-media-mime_allowed
Instagram is JPEG-only for photos. PNG, WebP, HEIC, and GIF will all fail with opaque IG errors.
## What it checks
For images: `mimeType === "image/jpeg"`. **JPEG only**, no exceptions.
For videos: `mimeType ∈ { video/mp4, video/quicktime }`.
The mime is sniffed from the resolved bytes, not trusted from the file extension.
## Why JPEG-only for images
Meta's Instagram container endpoint will accept a `image/png` URL but produces a `code 100, error subcode 2207003` (or similar) at container creation time. Same for WebP, HEIC, GIF. The only mime IG actually publishes through the photo path is JPEG.
Catching this locally with a clear rule + remediation saves you the round-trip and the opaque error.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "instagram.media.mime_allowed",
"platform": "instagram",
"message": "Image mime 'image/png' is not accepted by Instagram (JPEG only).",
"remediation": "Re-encode to JPEG. Instagram's container endpoint rejects PNG, WebP, HEIC, and GIF on the photo path."
}
}
```
## Remediation
Re-encode to JPEG:
```sh theme={"system"}
# ImageMagick
magick input.png -quality 85 output.jpg
# sharp (Node)
await sharp(input).jpeg({ quality: 85 }).toFile("output.jpg");
```
If your asset is a transparent PNG, IG won't render the transparency anyway — flatten onto a background of your choice.
For videos, re-encode to mp4 with H.264 + AAC (the codec subset IG accepts inside an mp4 container):
```sh theme={"system"}
ffmpeg -i input.mov -c:v libx264 -c:a aac -movflags +faststart output.mp4
```
## Upstream documentation
The 2207003 / 2207004 subcode family is undocumented in Meta's developer docs but consistently observable. The empirical mapping is what we encode.
## Related
* [`instagram.media.image_size_max`](/preflight/instagram-media-image_size_max)
* [`instagram.media.video_size_max`](/preflight/instagram-media-video_size_max)
* [`threads.media.mime_allowed`](/preflight/threads-media-mime_allowed) — Threads is more permissive (jpeg/png/webp).
# instagram.media.reachable
Source: https://docs.letmepost.dev/preflight/instagram-media-reachable
Instagram fetches media URLs from its own servers. Drive/Dropbox/auth-gated URLs fail as OAuthException 2207052.
This is the canonical translator for Instagram's notorious `OAuthException 2207052`. The upstream message is one of the most-cited opaque-rejection examples in the research that drove this product. We catch the platform's response and remap to a useful rule + remediation.
## What it checks
The `media_url` you sent (or the URL we built from your `mediaId`) must be **publicly reachable from Meta's servers**. Specifically:
* It must respond `200` to an anonymous `GET` (no cookies, no auth headers).
* It must redirect cleanly (Meta does follow redirects, but private session-bound redirect URLs break this).
* It must serve the bytes — HTML preview pages don't count.
Failure modes from the wild:
* **Google Drive share links.** Drive returns an HTML preview page, not the bytes. Even "anyone with the link can view" links fail.
* **Dropbox share URLs.** Same shape — HTML page, not the asset.
* **S3 buckets with object ACLs locked.** Anonymous reads denied → 403 to Meta.
* **CDNs requiring an auth token.** Meta isn't going to send your token.
## Why it's caught here, not in static preflight
We can't verify URL reachability without an HTTP request — and we don't HEAD third-party URLs from the API hot path. Instead we run an active probe inside the meta client: when Meta returns `OAuthException 2207052`, we map it to this rule and surface the remediation. The user-visible flow is identical — same rule id, same remediation — but the check happens at upstream-call time rather than statically.
## Reproducing it
```json reproduce.json theme={"system"}
{
"targets": [
{ "accountId": "00000000-0000-0000-0000-000000000000" }
],
"text": "this will fail",
"media": [
{ "kind": "image", "url": "https://drive.google.com/file/d/.../view" }
]
}
```
Response:
```json error.json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "instagram.media.reachable",
"platform": "instagram",
"message": "Instagram couldn't fetch the media URL. Most often this is a Google Drive 'share' link, a Dropbox preview URL, or an S3 object with restricted ACLs.",
"remediation": "Instagram fetches media URLs from its own servers. The URL must be on a public CDN — not Google Drive, Dropbox 'share' links, S3 with object ACLs locked, or any auth-gated host. Upload via POST /v1/media first, then reference the returned id.",
"docUrl": "https://docs.letmepost.dev/errors/preflight_failed",
"ruleUrl": "https://docs.letmepost.dev/preflight/instagram-media-reachable",
"requestId": "req_..."
}
}
```
## Remediation
The reliable path is to upload the file via [`POST /v1/media`](/guides/upload-media) and reference the returned `mediaId`:
```bash remediation.sh theme={"system"}
# 1. Upload the bytes to letmepost media storage.
curl -X POST https://api.letmepost.dev/v1/media \\
-H "Authorization: Bearer $LMP_KEY" \\
-F file=@./hero.jpg
# Response: { "id": "med_a1b2c3d4...", ... }
# 2. Reference it from the post.
curl -X POST https://api.letmepost.dev/v1/posts \\
-H "Authorization: Bearer $LMP_KEY" \\
-H "Idempotency-Key: $(uuidgen)" \\
-H "Content-Type: application/json" \\
-d '{
"targets": [{ "accountId": "00000000-0000-0000-0000-000000000000" }],
"text": "shipped",
"media": [{ "kind": "image", "mediaId": "med_a1b2c3d4..." }]
}'
```
If you're committed to using your own URL, verify with `curl -I` from a clean machine — no cookies, no auth headers — and confirm `Content-Type` is the actual image and the response is `200`, not a redirect to an HTML preview.
## Upstream documentation
The 2207052 subcode is documented in Meta's [Instagram Content Publishing API](https://developers.facebook.com/docs/instagram-api/guides/content-publishing) error reference. Meta's documented remediation is "ensure the URL is publicly accessible," with no further detail. We translate that to the specific failure modes we've seen in production.
## Related
* [`instagram.media.required`](/preflight/instagram-media-required) — IG has no text-only post surface.
* [`instagram.media.mime_allowed`](/preflight/instagram-media-mime_allowed) — JPEG-only on the photo path.
* [Media](/guides/upload-media) — the full guide on the three media-source modes.
# instagram.media.required
Source: https://docs.letmepost.dev/preflight/instagram-media-required
Instagram has no text-only post surface. Every IG post needs at least one media item.
## What it checks
`media.length >= 1` on Instagram posts. The caption (`text` field) is optional, but media is mandatory.
## Why
Instagram's Graph API has no "text-only feed post" endpoint. Every IG publish goes through the create-container flow, and the container requires either an `image_url` or a `video_url`. Without one, there's no API to call.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "instagram.media.required",
"platform": "instagram",
"message": "Instagram does not support text-only posts. Attach at least one image or video via `media: [...]`.",
"remediation": "Provide a media item — Instagram's API has no text-only feed surface."
}
}
```
## Remediation
Add at least one image or video. If you want a text-first post, [Threads](/platforms/threads) is where it goes; the same content with a Threads `accountId` on the target will publish text-only.
## Related
* [`instagram.media.mime_allowed`](/preflight/instagram-media-mime_allowed) — JPEG-only on the photo path.
* [`threads.text.required`](/preflight/threads-text-required) — Threads's mirror constraint.
# instagram.media.video_size_max
Source: https://docs.letmepost.dev/preflight/instagram-media-video_size_max
Instagram videos and Reels must be ≤ 1 GB.
## What it checks
`byteLength <= 1_000_000_000` per video item. Reels also have a 90-second duration cap that's enforced at platform level (preflight doesn't probe duration without ffprobe).
## Why
Meta's Instagram Reels documentation caps individual videos at 1 GB. Beyond that the upload is rejected.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "instagram.media.video_size_max",
"platform": "instagram",
"message": "Video is 1450000000 bytes; Instagram allows at most 1000000000.",
"remediation": "Compress the video under 1000000000 bytes (Instagram Reels accepts up to 1 GB)."
}
}
```
## Remediation
Re-encode at a lower bitrate. For a 60 s Reel at 1080×1920, \~6 Mbps is plenty:
```sh theme={"system"}
ffmpeg -i input.mp4 -c:v libx264 -b:v 6M -c:a aac -b:a 128k -movflags +faststart output.mp4
```
## Related
* [`instagram.media.mime_allowed`](/preflight/instagram-media-mime_allowed)
# instagram.text.max_graphemes
Source: https://docs.letmepost.dev/preflight/instagram-text-max_graphemes
Instagram captions cap at 2200 graphemes. Same counter as Bluesky and Threads.
## What it checks
`countGraphemes(text) <= 2200`.
## Why 2200
Meta's Instagram Graph API documents a 2200-character cap on the `caption` field of a media container. The cap is in user-perceived characters; we count graphemes to match.
Empty captions are allowed — see [`instagram.media.required`](/preflight/instagram-media-required), which is the constraint that *does* apply when there's no media.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "instagram.text.max_graphemes",
"platform": "instagram",
"message": "Caption is 2400 graphemes; Instagram allows at most 2200."
}
}
```
## Remediation
Trim the caption. Use grapheme-aware splitting — see [`bluesky.text.max_graphemes`](/preflight/bluesky-text-max_graphemes) for an example.
# linkedin.author.org_not_supported
Source: https://docs.letmepost.dev/preflight/linkedin-author-org_not_supported
Org/Company posting requires LinkedIn's Marketing Developer Platform (MDP). Not in v1.
## What it checks
If the connected LinkedIn account is bound to a `urn:li:organization:*` author, this rule fires. v1 of the publisher only supports personal posting (`urn:li:person:*`).
## Why
Org posting requires LinkedIn's Marketing Developer Platform (MDP) approval and the `w_organization_social` scope, which we request only as `extended` (off by default). MDP approval ships in a follow-up slice after the personal-posting wedge clears review.
## Remediation
For now, post from a personal account. Org/Company posting lands once MDP approval clears. Watch the [changelog](/changelog).
## Related
* [`linkedin.author.urn_format`](/preflight/linkedin-author-urn_format)
* [Platforms / LinkedIn](/platforms/linkedin)
# linkedin.author.unresolved
Source: https://docs.letmepost.dev/preflight/linkedin-author-unresolved
We don't have a usable LinkedIn author URN for this account. Re-auth needed.
## What it checks
The connected `platform_account` row has no `platformAccountId` (LinkedIn URN) set. Connect should always populate this; if it didn't, the account row is in a broken state.
## Remediation
Disconnect and reconnect the LinkedIn account. The connect flow calls LinkedIn's `/userinfo` to mint a stable URN; that will repopulate the field.
# linkedin.author.urn_format
Source: https://docs.letmepost.dev/preflight/linkedin-author-urn_format
The author URN must match urn:li:person:{id}. We surface the format check before LinkedIn's opaque 422.
## What it checks
The platform-account row's stored URN must match `^urn:li:person:[A-Za-z0-9_-]+$`. If we couldn't mint a valid URN at connect time (or the row was edited), this rule fires before the upstream call.
## Why this is a separate check
LinkedIn's `/rest/posts` returns a generic 422 with no field path when the URN format is wrong. Catching it locally surfaces the actual issue.
## Remediation
Disconnect and reconnect the account. The connect flow re-runs `/userinfo` to mint a fresh URN. If the issue persists, it's a sign of an upstream LinkedIn bug — open a support thread with the request id.
# linkedin.text.max_graphemes
Source: https://docs.letmepost.dev/preflight/linkedin-text-max_graphemes
LinkedIn caps post commentary at 3000 graphemes. Emoji-aware.
## What it checks
`countGraphemes(text) <= 3000`.
## Why 3000
LinkedIn's `/rest/posts` commentary cap is 3000 graphemes — emoji and ZWJ sequences count as 1 character, matching our counter. The cap is documented in LinkedIn's Versioned API docs.
## Remediation
Trim the post. Use grapheme-aware splitting — see [`bluesky.text.max_graphemes`](/preflight/bluesky-text-max_graphemes) for example code.
# linkedin.text.non_empty
Source: https://docs.letmepost.dev/preflight/linkedin-text-non_empty
LinkedIn posts cannot be empty. Whitespace-only text is rejected.
## What it checks
`text.trim().length > 0` on LinkedIn `/rest/posts` calls.
## Remediation
Provide non-empty text. LinkedIn doesn't have a media-only post shape on the standard publishing API.
# linkedin.visibility.enum
Source: https://docs.letmepost.dev/preflight/linkedin-visibility-enum
visibility must be PUBLIC or CONNECTIONS. Other values are rejected.
## What it checks
When the request body sets a LinkedIn-specific `visibility`, it must be one of `PUBLIC` or `CONNECTIONS`. Other values (LOGGED\_IN, CONTAINER, etc.) require LinkedIn's MDP and aren't supported in v1.
## Remediation
Use `PUBLIC` (the default) or `CONNECTIONS`. The other visibility modes ship with org-posting in a follow-up slice.
# media.bytes_inline_unsupported
Source: https://docs.letmepost.dev/preflight/media-bytes_inline_unsupported
Inline base64 isn't accepted on this platform's URL-only ingest path. Upload via POST /v1/media first.
## What it checks
The platform's publish path requires a URL — Meta's create-container flow, Pinterest's pin creation — and the request supplied `bytesBase64` instead.
We could buffer + upload to a temp URL on the fly, but that path has had enough surprise failures (timeouts, signed URL expiry) that we make the caller do it explicitly.
## Remediation
Upload via [`POST /v1/media`](/guides/upload-media) and use the returned `mediaId`. Inline bytes work fine for Bluesky and Twitter; the URL-only platforms route through media first.
# media.unknown
Source: https://docs.letmepost.dev/preflight/media-unknown
A mediaId on the request body doesn't resolve to a media record we can see.
## What it checks
The `mediaId` referenced from `media: [{ mediaId: "med_..." }]` doesn't match a media row in your organization's profile scope.
Causes:
* Typo in the id.
* The media row was deleted.
* A profile-scoped key referencing media uploaded under a different profile.
* The `med_…` id format is correct (`/^med_[0-9A-Za-z]{22}$/`) but no row matches.
## Remediation
Re-upload via [`POST /v1/media`](/guides/upload-media) and use the new id, or verify the id exists with `GET /v1/media`.
# meta.container.expired
Source: https://docs.letmepost.dev/preflight/meta-container-expired
Instagram or Threads media container exceeded its 24-hour TTL. Re-create.
## What it checks
Meta's two-step flow (create container → publish) holds the container for 24 hours. If our polling sees the container in `EXPIRED` state at publish time, this rule fires.
In practice this only matters for long-deferred publishes — synchronous `POST /v1/posts` runs end-to-end in seconds.
## Remediation
Retry the publish with the same `Idempotency-Key`. The dispatcher creates a fresh container on each attempt.
## Related
* [`threads.container.expired`](/preflight/threads-container-expired) — Threads-specific shape.
* [`threads.container.error`](/preflight/threads-container-error) — non-expiry container failures.
# pinterest.board.required
Source: https://docs.letmepost.dev/preflight/pinterest-board-required
A Pinterest pin needs a board id — either explicit or set as the account default.
## What it checks
The pin must have a `boardId` — either provided in the request as `pinterest.boardId`, or set as the account's default board on the `platform_accounts` row.
## Remediation
Either:
1. Pass `pinterest.boardId` on the request body, or
2. Set a default board on the connected account (in the dashboard's **Accounts** page, or via the account update endpoint).
If the connected account has no boards, the user needs to create one on Pinterest first. The `boards:write` scope lets the dashboard offer a "create your first board" flow.
# pinterest.cover_image.mime_allowed
Source: https://docs.letmepost.dev/preflight/pinterest-cover_image-mime_allowed
Cover image content-type must be jpeg, png, or webp.
## What it checks
The reachability probe of `pinterest.coverImageUrl` records the response's `Content-Type` header. If it isn't one of the allowlisted image mimes, preflight fails before the video upload runs.
Allowed: `image/jpeg`, `image/png`, `image/webp`.
## Why
Pinterest's pin endpoint expects the cover URL to serve a real image — HEIC, GIF, AVIF, or `application/octet-stream` from a misconfigured CDN all surface upstream as opaque pin failures. Preflight maps that to a clear remediation.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "pinterest.cover_image.mime_allowed",
"platform": "pinterest",
"message": "Cover image mime type 'image/heic' is not allowed on Pinterest.",
"remediation": "Use one of: image/jpeg, image/png, image/webp."
}
}
```
## Remediation
Re-encode the cover image to JPEG:
```sh theme={"system"}
sips -s format jpeg cover.heic --out cover.jpg
# or
ffmpeg -i cover.heic cover.jpg
```
Then host the JPEG (or PNG / WebP) URL.
## Related
* [`pinterest.cover_image_url.reachable`](/preflight/pinterest-cover_image_url-reachable)
* [`pinterest.video.cover_required`](/preflight/pinterest-video-cover_required)
# pinterest.cover_image_url.reachable
Source: https://docs.letmepost.dev/preflight/pinterest-cover_image_url-reachable
The cover image URL on a video pin must return 2xx and a supported image mime.
## What it checks
Before registering the video upload, the publisher GETs `pinterest.coverImageUrl`. The response must:
* Return 2xx
* Carry a content-type in `image/jpeg`, `image/png`, or `image/webp` (mime check fires under [`pinterest.cover_image.mime_allowed`](/preflight/pinterest-cover_image-mime_allowed) when present)
## Why
Pinterest fetches the cover image on its end during pin creation. If the URL is unreachable, returns 4xx/5xx, or serves something that isn't an image, `POST /v5/pins` fails with a generic upstream error. Catching it locally points at the URL specifically.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "pinterest.cover_image_url.reachable",
"platform": "pinterest",
"message": "URL returned 404: https://cdn.example.com/cover.jpg",
"remediation": "Pinterest must be able to fetch the cover image URL; verify it is public and returns 2xx."
}
}
```
## Remediation
* Verify the URL is publicly reachable from outside your network (no auth, no IP allowlist).
* Don't use Google Drive share links, Dropbox preview URLs, or signed S3 URLs that have expired — same general failure mode as [`instagram.media.reachable`](/preflight/instagram-media-reachable) (`OAuthException 2207052`).
* When in doubt, upload the cover via `POST /v1/media` and use the returned public URL.
## Related
* [`pinterest.cover_image.mime_allowed`](/preflight/pinterest-cover_image-mime_allowed)
* [`pinterest.video.cover_required`](/preflight/pinterest-video-cover_required)
# pinterest.destination_url.reachable
Source: https://docs.letmepost.dev/preflight/pinterest-destination_url-reachable
The pin's click-through URL must be reachable.
## What it checks
If you provide `pinterest.destinationUrl`, we probe it during preflight. It must respond 2xx anonymously.
## Remediation
Verify the URL serves a 2xx anonymously. Pinterest will follow redirects, but a 404 destination is the kind of thing that happens when you ship a pin pointing at a not-yet-deployed page.
# pinterest.image.mime_allowed
Source: https://docs.letmepost.dev/preflight/pinterest-image-mime_allowed
Pinterest accepts jpeg/png images.
## What it checks
`mimeType ∈ { image/jpeg, image/png }`. WebP, HEIC, GIF are rejected.
## Remediation
Re-encode to JPEG or PNG.
# pinterest.image.size_max
Source: https://docs.letmepost.dev/preflight/pinterest-image-size_max
Pinterest images cap at 20 MB.
## What it checks
`byteLength <= 20_000_000`. The most permissive image cap among the v1 platforms.
## Remediation
Re-encode if you're over 20 MB. That's a lot of pixels — most pins should be well under.
# pinterest.image_url.reachable
Source: https://docs.letmepost.dev/preflight/pinterest-image_url-reachable
Pinterest fetches the pin image from the URL you provide. It must respond 200 anonymously.
## What it checks
Active probe — we HEAD the image URL during preflight. If the response isn't a 2xx that resolves to image bytes, the rule fires.
Same failure modes as [`instagram.media.reachable`](/preflight/instagram-media-reachable): Drive share links, Dropbox previews, S3 with restricted ACLs.
## Remediation
Use [`POST /v1/media`](/guides/upload-media) and reference the returned `mediaId`. The result URL is on our public CDN and Pinterest fetches it cleanly.
# pinterest.media.register_failed
Source: https://docs.letmepost.dev/preflight/pinterest-media-register_failed
Pinterest refused to register a video upload slot.
## What it checks
`POST /v5/media` is the first step of Pinterest's video flow. If it returns non-2xx, no presigned URL came back, or the response shape is malformed, this rule fires.
## Why
Most often this is a missing scope on the connected account: the OAuth grant predates `pins:write` / `boards:write`, or the user revoked the scope on Pinterest's side. The upstream message is opaque ("not authorized") — preflight maps it to a remediation that mentions the scope set explicitly.
## Failure response
```json theme={"system"}
{
"error": {
"code": "platform_rejected",
"rule": "pinterest.media.register_failed",
"platform": "pinterest",
"message": "pinterest rejected the post: insufficient_scope.",
"remediation": "Pinterest refused to register a video upload slot — most commonly missing the boards:write / pins:write scope on the connected account."
}
}
```
## Remediation
* Disconnect and reconnect the Pinterest account from the dashboard. The reconnect re-requests the current scope set, including any added since the original grant.
* Confirm the account isn't on Trial Access pointing at production. See the platform notes — Trial Access apps must use `https://api-sandbox.pinterest.com/v5` until Standard Access clears.
## Related
* [Pinterest platform docs](/platforms/pinterest) — full scope list
# pinterest.media.required
Source: https://docs.letmepost.dev/preflight/pinterest-media-required
Every pin needs media. Pinterest is media-first.
## What it checks
`media.length >= 1`. Pinterest pins always carry exactly one media item — image or video.
## Remediation
Attach an image, or a video with a cover image URL:
```json image-pin.json theme={"system"}
{
"media": [{ "kind": "image", "mediaId": "med_…" }]
}
```
```json video-pin.json theme={"system"}
{
"media": [{ "kind": "video", "mediaId": "med_…" }],
"pinterest": { "coverImageUrl": "https://cdn.example.com/cover.jpg" }
}
```
Single-pin-per-request still applies — see [`pinterest.media.single_only`](/preflight/pinterest-media-single_only).
## Related
* [`pinterest.video.cover_required`](/preflight/pinterest-video-cover_required) — video pins need a cover URL
* [`pinterest.media.single_only`](/preflight/pinterest-media-single_only)
# pinterest.media.single_only
Source: https://docs.letmepost.dev/preflight/pinterest-media-single_only
v1 Pinterest support is single-image only. Multi-image pins are deferred.
## What it checks
`media.length <= 1`.
## Why
Pinterest's API supports multi-image pins (carousel pins), but the v1 publisher targets the single-image surface. Carousel and video pins ship in a follow-up slice.
## Remediation
Attach exactly one image, or split into multiple pins.
# pinterest.url.reachable
Source: https://docs.letmepost.dev/preflight/pinterest-url-reachable
Generic URL-reachable failure on Pinterest pin creation.
## What it checks
Pinterest's create-pin endpoint returns a URL-not-reachable error in some edge cases that don't match the more specific [`pinterest.image_url.reachable`](/preflight/pinterest-image_url-reachable) or [`pinterest.destination_url.reachable`](/preflight/pinterest-destination_url-reachable) rules. We surface those as this generic rule.
## Remediation
Inspect `platformResponse` for the specific URL Pinterest couldn't fetch. The fix is the same — make it publicly reachable, or upload via [`POST /v1/media`](/guides/upload-media).
# pinterest.video.cover_required
Source: https://docs.letmepost.dev/preflight/pinterest-video-cover_required
Pinterest video pins must include a publicly reachable cover image URL.
## What it checks
If `media[0].kind === "video"` and `pinterest.coverImageUrl` is missing or empty, preflight fails before any upload work runs.
## Why
Pinterest mandates a still-frame URL on every video pin — it's the poster shown before play. Without one, `POST /v5/pins` returns a vague 400 ("missing cover\_image\_url") with no indication that the failure was preventable. Catching it locally surfaces a clear rule + a `pinterest.coverImageUrl` field to point at.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "pinterest.video.cover_required",
"platform": "pinterest",
"message": "Pinterest video pins require a cover image — the still frame shown before play.",
"remediation": "Pass `options.coverImageUrl` (with `options.platform: \"pinterest\"`) on the target.",
"docUrl": "https://docs.letmepost.dev/errors/preflight_failed",
"ruleUrl": "https://docs.letmepost.dev/preflight/pinterest-video-cover_required",
"requestId": "req_..."
}
}
```
## Remediation
Generate a still frame and host it somewhere publicly reachable. Quick option with `ffmpeg`:
```sh theme={"system"}
ffmpeg -i input.mp4 -ss 1 -vframes 1 -q:v 2 cover.jpg
```
Then upload the cover via `POST /v1/media` and reference its public URL — or pass any public CDN URL you already host:
```json video-pin.json theme={"system"}
{
"targets": [
{
"accountId": "00000000-0000-0000-0000-000000000000",
"options": {
"platform": "pinterest",
"boardId": "",
"coverImageUrl": "https://cdn.example.com/cover.jpg"
}
}
],
"text": "Walkthrough",
"media": [{ "kind": "video", "mediaId": "med_…" }]
}
```
## Related
* [`pinterest.cover_image_url.reachable`](/preflight/pinterest-cover_image_url-reachable) — the URL must return 2xx
* [`pinterest.cover_image.mime_allowed`](/preflight/pinterest-cover_image-mime_allowed) — must be jpeg / png / webp
# pinterest.video.mime_allowed
Source: https://docs.letmepost.dev/preflight/pinterest-video-mime_allowed
Video bytes must be mp4, mov, or m4v.
## What it checks
After bytes resolve, the publisher inspects the resolved `mimeType` and rejects anything outside Pinterest's video allowlist.
Allowed: `video/mp4`, `video/quicktime`, `video/m4v`.
## Why
Pinterest's video upload pipeline accepts these container types only. Anything else surfaces from `POST /v5/media` as a vague registration failure or fails later during transcode — preflight catches it locally.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "pinterest.video.mime_allowed",
"platform": "pinterest",
"message": "Video mime type 'video/webm' is not allowed on Pinterest.",
"remediation": "Use one of: video/mp4, video/quicktime, video/m4v."
}
}
```
## Remediation
Re-mux to mp4:
```sh theme={"system"}
ffmpeg -i input.webm -c:v libx264 -c:a aac -movflags +faststart output.mp4
```
## Related
* [`pinterest.video.size_max`](/preflight/pinterest-video-size_max)
# pinterest.video.size_max
Source: https://docs.letmepost.dev/preflight/pinterest-video-size_max
Pinterest video pins are capped at ~2 GB per upload.
## What it checks
`byteLength <= 2_000_000_000` for resolved video bytes.
## Why
Pinterest's video upload spec puts the per-upload ceiling at \~2 GB. Going over surfaces as a registration failure or an S3 upload reject — neither maps cleanly to an actionable error.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "pinterest.video.size_max",
"platform": "pinterest",
"message": "Video is 2400000000 bytes; Pinterest allows at most 2000000000.",
"remediation": "Compress under 2000000000 bytes (~2 GB)."
}
}
```
## Remediation
Drop the bitrate. For 1080p H.264, 5 Mbps is typically enough:
```sh theme={"system"}
ffmpeg -i input.mp4 -c:v libx264 -b:v 5M -c:a aac -b:a 128k -movflags +faststart output.mp4
```
For very long clips, split into multiple pins or trim the video.
## Related
* [`pinterest.video.mime_allowed`](/preflight/pinterest-video-mime_allowed)
# pinterest.video.transcode_failed
Source: https://docs.letmepost.dev/preflight/pinterest-video-transcode_failed
Pinterest's video pipeline rejected the file during transcoding.
## What it checks
After uploading bytes to Pinterest's presigned S3 endpoint, the publisher polls `GET /v5/media/{id}` until status reaches a terminal state. `failed` surfaces with this rule.
## Why
Pinterest validates the uploaded video — codec, container, duration ≤ 5 min, aspect ratio. Failures don't come back from the upload itself (S3 returns 204 either way); they only show up on the status poll. Catching the terminal-failed state as a structured rule lets the dashboard's Post Log filter on it.
## Failure response
```json theme={"system"}
{
"error": {
"code": "platform_rejected",
"rule": "pinterest.video.transcode_failed",
"platform": "pinterest",
"message": "pinterest rejected the post.",
"remediation": "Pinterest's video transcode failed. Common causes: codec other than h.264 + AAC, mp4 container errors, video > 5 min. Re-encode and retry.",
"platformResponse": {
"media_id": "vid-…",
"media_type": "video",
"status": "failed"
}
}
}
```
## Remediation
Re-encode to a known-good profile:
```sh theme={"system"}
ffmpeg -i input.mov \
-c:v libx264 -profile:v high -pix_fmt yuv420p -crf 23 \
-c:a aac -b:a 128k \
-movflags +faststart \
-t 300 \
output.mp4
```
If the same file works elsewhere, it's most often a duration > 5 min that Pinterest rejects.
## Related
* [`pinterest.video.size_max`](/preflight/pinterest-video-size_max)
* [`pinterest.video.transcode_timeout`](/preflight/pinterest-video-transcode_timeout)
# pinterest.video.transcode_timeout
Source: https://docs.letmepost.dev/preflight/pinterest-video-transcode_timeout
Pinterest didn't finish transcoding within our 5-minute deadline.
## What it checks
After uploading bytes, the publisher polls `GET /v5/media/{id}` every 2 s for up to 5 minutes. If the status is still `processing` (or `registered`) at the deadline, this rule fires.
## Why
Pinterest's video transcode is usually fast (under a minute for short clips), but the upstream queue depth varies. Holding a synchronous publish open longer than 5 minutes is worse UX than failing fast — Pinterest keeps the registered media id around, so a follow-up retry can short-circuit.
## Failure response
```json theme={"system"}
{
"error": {
"code": "platform_unavailable",
"rule": "pinterest.video.transcode_timeout",
"platform": "pinterest",
"message": "Pinterest media vid-… did not finish transcoding within 300000ms.",
"remediation": "Pinterest is still processing the video; retry the publish in a minute or two."
}
}
```
## Remediation
* Retry the publish in a few minutes.
* For consistent timeouts, shorten the clip or drop the bitrate.
## Related
* [`pinterest.video.transcode_failed`](/preflight/pinterest-video-transcode_failed)
# pinterest.video.upload_failed
Source: https://docs.letmepost.dev/preflight/pinterest-video-upload_failed
The S3 multipart upload Pinterest handed us back rejected the bytes.
## What it checks
After `POST /v5/media` returns a presigned S3 endpoint + form-field bag, the publisher streams the video bytes to that endpoint as `multipart/form-data`. S3 returns 204 on success — anything else surfaces here verbatim.
## Why
The most common causes are signed-URL clock skew, a mime mismatch between what we declared and what we sent, or the registered slot expiring before the upload finished. S3 returns XML on errors; we pass the body through so the AWS-style error code is visible.
## Failure response
```json theme={"system"}
{
"error": {
"code": "platform_rejected",
"rule": "pinterest.video.upload_failed",
"platform": "pinterest",
"message": "pinterest rejected the post: ...",
"remediation": "Pinterest's S3 upload rejected the bytes. Common causes: signed-URL clock skew, file mime mismatch, or the file size exceeded the registered slot. Retry; if it persists the registered slot has expired.",
"platformResponse": {
"status": 403,
"body": "SignatureDoesNotMatch..."
}
}
}
```
## Remediation
* Retry the publish. The publisher re-registers a fresh slot on each attempt, so an expired one self-heals.
* If `SignatureDoesNotMatch` repeats, check your server's clock — significant drift makes presigned URLs reject.
* If the body mentions an "EntityTooLarge" or similar size error, the file may be larger than what was registered; preflight should already catch this via [`pinterest.video.size_max`](/preflight/pinterest-video-size_max).
## Related
* [`pinterest.media.register_failed`](/preflight/pinterest-media-register_failed)
* [`pinterest.video.size_max`](/preflight/pinterest-video-size_max)
# threads.container.error
Source: https://docs.letmepost.dev/preflight/threads-container-error
Threads's media container transitioned to ERROR during preparation. Carries the upstream error.
## What it checks
The Threads publish worker polls the container status until it reports `FINISHED`. If the status transitions to `ERROR` instead, this rule fires with the upstream message attached on `platformResponse`.
## Common causes
* The provided media URL became unreachable mid-fetch.
* Threads's transcoder rejected the asset (corrupt mp4, unsupported codec inside an otherwise mp4 container).
* Account-level temporary block.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "threads.container.error",
"platform": "threads",
"platformResponse": { "error_message": "...", "error_code": 100 },
"remediation": "Inspect platformResponse for the upstream message; usually a transcoder rejection — re-encode the asset and retry."
}
}
```
## Remediation
Inspect `platformResponse.error_message`. The most common cause is an mp4 container that has unsupported codec inside it (e.g., HEVC inside `.mp4`). Re-encode with `ffmpeg -c:v libx264 -c:a aac` and retry.
## Related
* [`threads.container.expired`](/preflight/threads-container-expired) — 24 h TTL exceeded.
* [`threads.media.mime_allowed`](/preflight/threads-media-mime_allowed)
# threads.container.expired
Source: https://docs.letmepost.dev/preflight/threads-container-expired
A media container exceeded its 24-hour TTL before publish. Re-create the container.
## What it checks
Threads's two-step publish flow creates a media container, polls until it transitions to `FINISHED`, then publishes. Containers expire 24 hours after creation. If our polling sees status `EXPIRED`, this rule fires.
In practice this only fires on long-deferred publishes (the worker fell behind for >24h). Live `POST /v1/posts` calls publish within seconds.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "threads.container.expired",
"platform": "threads",
"message": "Threads media container expired before publish (24 h ttl).",
"remediation": "Retry the publish — a new container is created on each attempt."
}
}
```
## Remediation
Retry the publish with the same `Idempotency-Key`. The dispatcher creates a fresh container each attempt; an expired one from a previous run isn't carried forward.
## Related
* [`meta.container.expired`](/preflight/meta-container-expired) — the cross-platform shape (IG + Threads).
* [`threads.container.error`](/preflight/threads-container-error) — non-expiry container failures.
# threads.media.alt_text_max_graphemes
Source: https://docs.letmepost.dev/preflight/threads-media-alt_text_max_graphemes
Threads alt text caps at 1000 graphemes per media item.
## What it checks
`countGraphemes(item.altText) <= 1000`.
## Why 1000
Meta's Threads API docs don't publish an explicit alt-text cap, so we use the nearest analogue we trust — half of Instagram's 2200-cap, sized down because Threads alt text is rendered in narrower contexts. The 1000 number is conservative: anything below it has been accepted upstream in our testing.
If Meta publishes an explicit cap that differs, this rule will follow.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "threads.media.alt_text_max_graphemes",
"platform": "threads",
"message": "Alt text is 1240 graphemes; Threads allows at most 1000."
}
}
```
## Remediation
Tighten the description. Alt text should describe the image's content; if you have more to say, it belongs in the post body.
## Related
* [`bluesky.media.alt_text_max_graphemes`](/preflight/bluesky-media-alt_text_max_graphemes) — Bluesky caps at 2000.
* [`instagram.media.alt_text_max_graphemes`](/preflight/instagram-media-alt_text_max_graphemes) — IG caps at 2200.
# threads.media.count_max
Source: https://docs.letmepost.dev/preflight/threads-media-count_max
Threads carousels accept 2–20 mixed media items. Beyond 20 is rejected.
## What it checks
`media.length <= 20`.
Threads is the most permissive carousel of the platforms supported here — 20 children, mixed images and videos. The minimum carousel size is 2; below that, single-image or single-video shapes apply (see [`threads.text.required`](/preflight/threads-text-required) for the post-shape decision tree).
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "threads.media.count_max",
"platform": "threads",
"message": "Attached 25 media items; Threads allows at most 20 per carousel.",
"remediation": "Reduce to 20 items or fewer, or split into multiple posts."
}
}
```
## Remediation
Cap at 20. For longer galleries, split into a thread — Threads's reply UX is solid, and each reply can hold 20 more.
## Related
* [`threads.media.alt_text_max_graphemes`](/preflight/threads-media-alt_text_max_graphemes)
* [`instagram.media.count_max`](/preflight/instagram-media-count_max) — IG caps the same shape at 10.
# threads.media.image_size_max
Source: https://docs.letmepost.dev/preflight/threads-media-image_size_max
Each image on Threads must be ≤ 8 MB.
## What it checks
`byteLength <= 8_000_000` per image.
## Why
Meta's Threads API documents an 8 MB per-image cap on the create-container endpoint. Larger images are rejected upstream with `code 100, error subcode 2207003` or similar.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "threads.media.image_size_max",
"platform": "threads",
"message": "Image is 9500000 bytes; Threads allows at most 8000000.",
"remediation": "Re-encode the image under 8000000 bytes (Threads's per-image limit is 8 MB)."
}
}
```
## Remediation
Re-encode at lower quality or smaller dimensions. JPEG q=85 + max 2048 px is a sensible default:
```sh theme={"system"}
magick input.jpg -quality 85 -resize 2048x2048\> output.jpg
```
## Related
* [`threads.media.video_size_max`](/preflight/threads-media-video_size_max)
* [`threads.media.mime_allowed`](/preflight/threads-media-mime_allowed)
# threads.media.mime_allowed
Source: https://docs.letmepost.dev/preflight/threads-media-mime_allowed
Threads accepts jpeg/png/webp images and mp4/mov video.
## What it checks
For images: `mimeType ∈ { image/jpeg, image/png, image/webp }`.
For videos: `mimeType ∈ { video/mp4, video/quicktime }`.
The mime is sniffed from the resolved bytes, not trusted from the file extension.
## Why
Meta's Threads create-container endpoint rejects HEIC, BMP, AVI, etc. with opaque `code 100` errors. Catching the mime mismatch locally surfaces a useful rule + remediation.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "threads.media.mime_allowed",
"platform": "threads",
"message": "Image mime type 'image/heic' is not allowed on Threads.",
"remediation": "Use one of: image/jpeg, image/png, image/webp."
}
}
```
## Remediation
Re-encode the asset:
```sh theme={"system"}
# HEIC → JPEG
magick input.heic output.jpg
# AVI → MP4
ffmpeg -i input.avi -c:v libx264 -c:a aac output.mp4
```
## Related
* [`threads.media.image_size_max`](/preflight/threads-media-image_size_max)
* [`threads.media.video_size_max`](/preflight/threads-media-video_size_max)
* [`instagram.media.mime_allowed`](/preflight/instagram-media-mime_allowed) — IG is stricter (JPEG-only).
# threads.media.video_size_max
Source: https://docs.letmepost.dev/preflight/threads-media-video_size_max
Each video on Threads must be ≤ 1 GB.
## What it checks
`byteLength <= 1_000_000_000` per video.
## Why
Meta's Threads docs cap per-video at 1 GB on the create-container endpoint. Beyond that the upstream rejects before the post becomes visible.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "threads.media.video_size_max",
"platform": "threads",
"message": "Video is 1450000000 bytes; Threads allows at most 1000000000.",
"remediation": "Compress the video under 1000000000 bytes (Threads's per-video limit is 1 GB)."
}
}
```
## Remediation
Re-encode at a lower bitrate or split the post. For 1080p, 6 Mbps is a generous ceiling:
```sh theme={"system"}
ffmpeg -i input.mp4 -c:v libx264 -b:v 6M -c:a aac -b:a 128k -movflags +faststart output.mp4
```
## Related
* [`threads.media.image_size_max`](/preflight/threads-media-image_size_max)
# threads.text.max_graphemes
Source: https://docs.letmepost.dev/preflight/threads-text-max_graphemes
Threads caps post text at 500 graphemes, including image captions.
## What it checks
`countGraphemes(text) <= 500` regardless of post shape (text-only, image, video, carousel).
## Why 500
Meta's published Threads API documents the 500-character cap. We use grapheme-aware counting so a single emoji counts as 1, matching Meta's own behavior on `/threads` create-container calls.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "threads.text.max_graphemes",
"platform": "threads",
"message": "Post text is 612 graphemes; Threads allows at most 500."
}
}
```
## Remediation
Trim the post. Use the same grapheme-aware approach as for Bluesky — see [`bluesky.text.max_graphemes`](/preflight/bluesky-text-max_graphemes) for code.
## Related
* [`threads.text.required`](/preflight/threads-text-required)
* [Platforms / Threads](/platforms/threads)
# threads.text.required
Source: https://docs.letmepost.dev/preflight/threads-text-required
Threads text-only posts require non-empty text. Media-less + empty-text combinations are rejected.
## What it checks
If `media` is empty or absent, `text.trim().length > 0`.
Threads supports four post shapes:
* **Text-only**: text required, no media.
* **Single image**: text optional, one image.
* **Single video**: text optional, one video.
* **Carousel (2–20)**: text optional, mixed images + videos.
This rule covers the first row.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "threads.text.required",
"platform": "threads",
"message": "Threads text-only posts require non-empty text — pass `media: [...]` for an image/video/carousel.",
"remediation": "Provide non-whitespace `text`, or include at least one media item."
}
}
```
## Remediation
Add either non-whitespace `text` or at least one `media` item. Threads doesn't have an "image with no caption" surface that's also empty-text — there must be either text or media.
## Related
* [`threads.text.max_graphemes`](/preflight/threads-text-max_graphemes) — 500 graphemes max.
* [Platforms / Threads](/platforms/threads)
# twitter.launch_cap.per_account
Source: https://docs.letmepost.dev/preflight/twitter-launch_cap-per_account
The per-account X launch cap of 50 billable posts per rolling 30 days is exhausted.
To keep cost predictable during the X Pay Per Use launch window, letmepost enforces a soft cap of **50 billable posts** (`published` + `rejected` + `failed`) per connected X account per rolling 30 days. When the cap is hit, further `POST /v1/posts` calls for that account return `rate_limited` (HTTP 429) with a `Retry-After` header derived from the oldest billable post in the window.
## How to fix
* **Wait for the cap window to roll over.** The `Retry-After` header tells you how many seconds until the oldest billable post falls out of the 30-day window.
* **Reduce volume** if you're hitting the cap by accident — every retry on a transient failure still counts as billable.
* **Lift the cap.** Self-host instances can bump `TWITTER_LAUNCH_CAP_PER_ACCOUNT` via env. Hosted users on the Business tier with sustained X volume should reach out; the cap is a launch backstop, not a permanent policy.
## Why we cap
X's pay-per-use pricing kicked in for new developers in Feb 2026 — every API call burns budget from the first request. The launch cap is a safety net so a runaway loop on one account doesn't drain the hosted X budget before we have signal on real usage patterns.
## Related
* [X / Twitter platform docs](/platforms/twitter)
* [Preflight overview](/preflight)
# twitter.media.alt_text_max_graphemes
Source: https://docs.letmepost.dev/preflight/twitter-media-alt_text_max_graphemes
Alt text on a tweet's media is capped at 1000 graphemes.
## What it checks
For every media item that carries `altText`: `countGraphemes(altText) <= 1000`.
Counted with `Intl.Segmenter`, so a compound emoji (`👨👩👧👦`) is one grapheme — same counter the platform uses.
## Why
X's v1.1 metadata endpoint (`/1.1/media/metadata/create`) caps alt text at 1000 characters. Beyond that the metadata write fails — and because alt-text writes are best-effort by design (they live on a separate deprecation track from `/2/tweets`), a silent failure means the tweet posts without accessibility metadata you intended to attach. Preflight makes that explicit.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "twitter.media.alt_text_max_graphemes",
"platform": "twitter",
"message": "Alt text is 1432 graphemes; X allows at most 1000.",
"remediation": "Shorten alt text to 1000 graphemes or fewer."
}
}
```
## Remediation
Trim the alt text. If you need more context, put it in the tweet body — alt text is for screen readers, the body is for everyone.
## Related
* [`twitter.text.max_graphemes`](/preflight/twitter-text-max_graphemes)
# twitter.media.count_max
Source: https://docs.letmepost.dev/preflight/twitter-media-count_max
Tweets accept up to 4 images, OR 1 video, OR 1 GIF.
## What it checks
* `images.length <= 4`
* `videos.length <= 1`
Counted before bytes resolve, so 5 image URLs fail before any fetch fires.
## Why
X's `/2/tweets` endpoint accepts at most 4 image media ids on a single tweet, and at most 1 video. Going over the cap surfaces upstream as a media validation error with no clean way to map back to which item was the offender — preflight catches it locally with the count surfaced in the message.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "twitter.media.count_max",
"platform": "twitter",
"message": "Attached 5 images; X allows at most 4 per tweet.",
"remediation": "Reduce to 4 images or fewer."
}
}
```
## Remediation
* Trim the array to 4 images (or 1 video, or 1 GIF).
* Multi-tweet threads carry separate media — split the post into a reply chain via `twitter.replyToTweetId` if you need more than 4 attachments overall.
## Related
* [`twitter.media.image_video_exclusive`](/preflight/twitter-media-image_video_exclusive) — can't mix images with a video on one tweet
# twitter.media.gif_size_max
Source: https://docs.letmepost.dev/preflight/twitter-media-gif_size_max
Animated GIFs cap at 15 MB on Twitter / X.
## What it checks
`byteLength <= 15_000_000` for `image/gif` media.
## Remediation
Re-encode at fewer frames or lower quality. For long animations, an mp4 video is usually a better fit anyway — it'll render with native video controls.
# twitter.media.image_size_max
Source: https://docs.letmepost.dev/preflight/twitter-media-image_size_max
Static images cap at 5 MB on Twitter / X.
## What it checks
`byteLength <= 5_000_000` for non-GIF images.
## Remediation
Re-encode at lower quality. JPEG q=82 + max 2048 px is usually well under 5 MB.
# twitter.media.image_video_exclusive
Source: https://docs.letmepost.dev/preflight/twitter-media-image_video_exclusive
A tweet can carry images OR a video, not both.
## What it checks
If the same `media` array contains at least one item with `kind: "image"` and at least one with `kind: "video"`, preflight fails before bytes resolve.
## Why
X enforces this at the API level — `/2/tweets` rejects mixed-media payloads. Catching it locally surfaces a clean rule id instead of a generic 400 from upstream.
## Failure response
```json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "twitter.media.image_video_exclusive",
"platform": "twitter",
"message": "Cannot attach both images and video to the same tweet — X requires picking one.",
"remediation": "Split into separate tweets: one with images, one with the video."
}
}
```
## Remediation
Two tweets in a reply chain — first one with images, second one with the video, threaded via `twitter.replyToTweetId`:
```json theme={"system"}
{ "media": [{ "kind": "image", "mediaId": "med_…" }] }
```
```json theme={"system"}
{
"media": [{ "kind": "video", "mediaId": "med_…" }],
"twitter": { "replyToTweetId": "" }
}
```
## Related
* [`twitter.media.count_max`](/preflight/twitter-media-count_max)
# twitter.media.mime_allowed
Source: https://docs.letmepost.dev/preflight/twitter-media-mime_allowed
Twitter accepts jpeg/png/webp images, gif animations, and mp4 video.
## What it checks
For images: `mimeType ∈ { image/jpeg, image/png, image/webp, image/gif }`.
For videos: `mimeType === "video/mp4"`.
## Remediation
Re-encode unsupported formats.
# twitter.media.processing_timeout
Source: https://docs.letmepost.dev/preflight/twitter-media-processing_timeout
X's STATUS endpoint didn't reach succeeded/failed within our 5-minute deadline.
## What it checks
After FINALIZE returns `processing_info` indicating async transcode, the publisher polls `?command=STATUS&media_id=…` honoring the upstream-provided `check_after_secs`. If the state is still `pending` or `in_progress` when our 5-minute deadline lapses, we surface this.
## Why
Real X transcodes finish in seconds for short clips, occasionally a few minutes for long ones. Holding the publish open longer than that is worse for the caller than failing fast — the bytes are already uploaded, so a retry can short-circuit on dedupe rather than re-streaming.
## Failure response
```json theme={"system"}
{
"error": {
"code": "platform_unavailable",
"rule": "twitter.media.processing_timeout",
"platform": "twitter",
"message": "X did not finish processing media 1700000000 within 300000ms.",
"remediation": "X's video transcoder is occasionally slow; retry the publish or shorten the clip."
}
}
```
## Remediation
* Retry the publish — X's pipeline may have finished by then.
* If timeouts repeat, shorten the clip or reduce bitrate.
## Related
* [`twitter.media.video_processing_failed`](/preflight/twitter-media-video_processing_failed) — terminal-failed state
# twitter.media.video_processing_failed
Source: https://docs.letmepost.dev/preflight/twitter-media-video_processing_failed
X's chunked upload finalize step or status poll reported a failed transcode.
## What it checks
After the FINALIZE leg of the chunked upload (`command=FINALIZE` on `/1.1/media/upload.json`), X may return `processing_info.state: "failed"` either inline or on a subsequent STATUS poll. We surface that terminal state with the upstream error message intact.
## Why
X's video pipeline does its own validation: codec must be H.264, audio AAC, the mp4 container must be playable, duration ≤ 140 s for the `tweet_video` category, etc. None of these are byte-counted preflight checks — only the upload pipeline can verify them. When it rejects, this rule fires.
## Failure response
```json theme={"system"}
{
"error": {
"code": "platform_rejected",
"rule": "twitter.media.video_processing_failed",
"platform": "twitter",
"message": "twitter rejected the post: The video is invalid.",
"remediation": "X rejected the video during processing — check codec (h.264 + AAC), duration (≤140s for tweet_video), and aspect ratio.",
"platformResponse": {
"state": "failed",
"error": {
"code": 3,
"name": "InvalidMedia",
"message": "The video is invalid."
}
}
}
}
```
## Remediation
Re-encode to a known-good profile:
```sh theme={"system"}
ffmpeg -i input.mov \
-c:v libx264 -profile:v high -pix_fmt yuv420p -crf 23 \
-c:a aac -b:a 128k \
-movflags +faststart \
-t 140 \
output.mp4
```
Read the upstream `error.message` field — that's the verbatim reason from X's transcoder. "Invalid video" usually means codec or container; "duration too long" means trim under 140 s.
## Related
* [`twitter.media.video_size_max`](/preflight/twitter-media-video_size_max) — file-size cap
* [`twitter.media.processing_timeout`](/preflight/twitter-media-processing_timeout) — STATUS poll deadline
# twitter.media.video_size_max
Source: https://docs.letmepost.dev/preflight/twitter-media-video_size_max
Videos cap at 512 MB on Twitter / X.
## What it checks
`byteLength <= 512_000_000` for `video/mp4` media.
## Remediation
Re-encode at lower bitrate. For 1080p, 5 Mbps is plenty for any reasonable Twitter video.
# twitter.text.max_graphemes
Source: https://docs.letmepost.dev/preflight/twitter-text-max_graphemes
Tweets cap at 280 graphemes — with t.co URL wrapping accounted for.
## What it checks
`countGraphemes(text) <= 280`, with URLs counted as 23 characters regardless of their real length (t.co wraps every URL).
## Why 280 + 23
Twitter's `tweet.create` enforces 280 graphemes. URLs are wrapped by t.co to a fixed 23-character length, so a 100-character URL still counts as 23. We subtract the real URL length and add 23 for each URL.
## Remediation
Trim the tweet, or break it into a thread.
# twitter.text.non_empty
Source: https://docs.letmepost.dev/preflight/twitter-text-non_empty
Tweets cannot be empty. Whitespace-only text is rejected.
## What it checks
`text.trim().length > 0`.
## Remediation
Provide non-empty text.
# Pricing
Source: https://docs.letmepost.dev/pricing
Two tiers. Free for evaluation, Pro at $79/mo for real workloads. Self-host always free under Apache 2.0.
letmepost.dev is in alpha. The hosted SaaS is **free for everyone today** while we shake out platform-review timing. The tier shape below is locked for launch and what you'll pay when alpha ends. The canonical surface is [letmepost.dev/pricing](https://letmepost.dev/pricing).
## Pricing principles
* **Flat rate, not per-profile.** The most-cited complaint about every incumbent is per-profile or per-seat pricing. We don't tax you for connecting more accounts.
* **A real free tier, not a trial.** Persistent, indefinite, sized so an indie dev or hobby project never has to upgrade.
* **Self-host stays free, forever.** The OSS image is the same code as the hosted SaaS. No feature gate, no open-core trick. [Self-host docs](/self-host/quick-start).
* **No contracts, no auto-renewal abuse.** Month-to-month, cancel anytime, downgrade anytime.
## Tiers
Pricing is per org, not per account. The number on the card is what your whole org pays.
| tier | price | what you get |
| - | - | - |
| **Free** | \$0 / mo | 50 posts / month. All 7 platforms, all API surface. MCP server + CLI. Community Discord support. |
| **Pro** | \$79 / mo | 5,000 posts / month. All 7 platforms, all API surface. MCP server + CLI. All webhook events. 30-day publish logs. Email support, 24h response. |
| **Business** | \$299 / mo | 25,000 posts / month. All 7 platforms, all API surface. MCP server + CLI. White-label OAuth. 99.9% uptime SLA. 180-day publish logs. Priority Slack + email, 4h response. |
| **Self-host** | \$0 / mo | Same code as hosted. Bring your own Postgres + Redis. No license server, no telemetry. |
Annual billing saves 2 months on Pro when it turns on.
**Founder pricing.** $40/mo locked for life on Pro if you sign up in the next 30 days. Normally $79/mo. Forfeit on cancel.
Need more volume, SSO, a custom SLA, or a DPA? Email us and we'll set up a custom plan. No marketplace tier-stacking games.
We intentionally start with two tiers. Once we have real usage data we'll add tiers where the breakpoints actually are. More tiers right now is just decision friction for you and complexity for us.
## What's actually live
* **Bluesky**, **X**, **Pinterest**, **Instagram**, **LinkedIn**, **Facebook Pages**, and **Threads** are live for every signup today.
* **TikTok** is live on its upload track: the video lands in the creator's TikTok inbox and they confirm the publish in the app.
* **LinkedIn** is personal posting only — Company-Page posting needs `w_organization_social` and is not supported yet.
* Live status is queryable at `GET /v1/platform-versions` and tracked in the [changelog](/changelog).
## What's free during alpha
Everything. While we're in alpha, the hosted SaaS is free for everyone, there's no quota, no rate-limit ceiling beyond what's enforced for abuse prevention, and no card on file.
In return, you should expect:
* **No SLA today.** We're stable enough for production workflows but not promising one.
* **Migration help when billing turns on.** You'll know the prices, get advance notice, and have time to decide. Any workflow that connected during alpha gets a 60-day grace window after announcement before charges begin.
* **Every platform is live.** All eight publish end-to-end through our reviewed apps, so there is nothing for you to file. TikTok is the one with a wrinkle: posts land in the creator's TikTok inbox for them to confirm in the app.
## What we don't charge for
* **Connected accounts.** Connect every platform, every account, every Page. No per-row tax.
* **Profiles.** A single org can carve into N profiles for per-client isolation. Profiles are free.
* **Team members.** No per-seat ladder.
* **Webhook endpoints.** Register as many as you need, we'll deliver to all of them.
* **API keys.** Mint a key per service, per script, per cron. Keys are free.
* **Webhook deliveries / retries.** Built into the contract, not a billable line item.
## Updates
The instant pricing changes, this page updates and the [changelog](/changelog) gets a note. Subscribe to that or watch [GitHub releases](https://github.com/rosekamallove/letmepost.dev/releases).
# Quickstart
Source: https://docs.letmepost.dev/quickstart
Publish your first tweet via curl in 90 seconds.
In 90 seconds you'll publish to X (Twitter) from your terminal. The same `POST /v1/posts` shape works for every platform we support — Twitter is the entry-point because most developers already think in tweets.
Every platform is live today, with the same API, error envelope and idempotency contract across all of them. The examples below are text-only, so they carry over directly to any platform that accepts a text post. Pinterest, Instagram, and TikTok always require media, and TikTok requires video specifically — see their [platform pages](/platforms-overview) for the extra fields.
## 1. Get an API key
Sign in at [dashboard.letmepost.dev](https://dashboard.letmepost.dev) and mint a key from the **API keys** page. Keys look like `lmp_live_…` or `lmp_test_…`. Treat them like passwords; the dashboard only shows the secret once.
The `lmp_test_` prefix is a naming convention, not a sandbox. Test keys publish to your real accounts and consume real quota exactly like live keys. Everything below posts for real — use a throwaway account if you don't want the tweet.
```bash theme={"system"}
export LMP_KEY=lmp_live_...
```
## 2. Connect Twitter
Open the dashboard's **Accounts** page and click **Connect X / Twitter**. The OAuth 2.0 PKCE flow is fully handled for you — you grant scopes once and we manage refresh transparently afterwards. When you come back, the account row will show the connected handle and an `id` you'll send posts against.
Each platform has its own connect flow (OAuth, PKCE, or app password for Bluesky). They're all documented per platform under [Platforms](/platforms-overview), but the dashboard is the easiest path for the first connection — it manages redirects and stores tokens encrypted.
Copy the connected account's `id` from the dashboard. You'll need it in the next step.
## 3. Send a tweet
```bash cURL theme={"system"}
curl -X POST https://api.letmepost.dev/v1/posts \
-H "Authorization: Bearer $LMP_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
--data @- <
Replace the `accountId` with the id you copied from the dashboard. The active language tab is sticky — picking TypeScript here will follow you across every code sample on this site.
## 4. Read the response
A successful immediate publish returns `200 OK` with a batch envelope:
```json 200.json theme={"system"}
{
"id": "batch_01HY6X4AWBJM2K9F2PTQMRD9JQ",
"status": "published",
"createdAt": "2026-05-16T12:00:00.000Z",
"results": [
{
"accountId": "00000000-0000-0000-0000-000000000000",
"platform": "twitter",
"postId": "post_01HY6X4...",
"status": "published",
"uri": "https://x.com/yourhandle/status/1788..."
}
]
}
```
The `results[].uri` is the public URL of the tweet. Save `id` (the batch id) and `results[].postId` — that's how you fetch the record later via `GET /v1/posts/:id`.
## 5. Fan out to multiple platforms
Same call, more `targets`. One request, N publishes, atomic shape preflight, structured per-target results:
```bash theme={"system"}
curl -X POST https://api.letmepost.dev/v1/posts \
-H "Authorization: Bearer $LMP_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
--data @- <" },
{ "accountId": "" },
{ "accountId": "" }
],
"text": "Hello from three platforms at once"
}
EOF
```
The batch `status` summarizes the outcomes: `published` when every target succeeded, `partial_failed` on mixed outcomes, `failed` when none did. Each `results[]` entry tells you what happened on that platform.
## 6. What happens when it fails
Send a 400-character tweet and you'll get this back instead:
```json 400.json theme={"system"}
{
"error": {
"code": "preflight_failed",
"rule": "twitter.text.max_graphemes",
"platform": "twitter",
"message": "Post text is 400 graphemes; Twitter (free tier) allows at most 280.",
"remediation": "Shorten the post to 280 graphemes, or switch to a Premium-tier account (25,000 cap).",
"docUrl": "https://docs.letmepost.dev/errors/preflight_failed",
"ruleUrl": "https://docs.letmepost.dev/preflight/twitter-text-max_graphemes",
"requestId": "req_01HY6X4AWBJM2K9F2PTQMRD9JQ"
}
}
```
Every error carries the same envelope: stable `code`, the `rule` that fired, a `remediation`, a `requestId` you can grep, and `docUrl` / `ruleUrl` links straight to the right docs page. See [errors](/errors) for the full catalog and [error best practices](/errors#best-practices) for the contract.
## Next
Twitter, Instagram, LinkedIn, Facebook, Threads, Pinterest, Bluesky — caps, scope sets, and per-platform wisdom.
Why every retry is safe.
The twelve codes and the rule catalog.
`POST /v1/posts` parameter by parameter.
# Deploying
Source: https://docs.letmepost.dev/self-host/deploying
Two-service split, managed Postgres + Redis, custom domain, zero-downtime migrations.
The hosted service runs on Railway with a managed Neon (Postgres) + Upstash (Redis) stack. The same `apps/api/Dockerfile` is the artifact you ship — there is no separate "OSS" image. Whether you use Railway, Fly, Render, ECS, or plain Kubernetes, the moving parts are the same.
## Architecture: two services, one image
The API container has two entry points and you run them as **two separate services** in production:
| Service | Start command | Public port | What it does |
| - | - | - | - |
| **api** | `pnpm --filter @letmepost/api start:api` | `:3000` | HTTP, runs migrations on boot, then `node dist/server.js` |
| **worker** | `pnpm --filter @letmepost/api start:worker` | none | BullMQ consumer: publish, validate, refresh-token, webhook-deliver queues. `node dist/queue/worker.js` |
Both share the same image and the same env vars. Only the start command and the public-networking flag differ.
Why split: a queue backlog (Twitter rate limit, Instagram container processing) keeps the worker busy. If the worker shared a process with the HTTP listener, that backlog would starve `POST /v1/posts`. Splitting lets you scale them independently and ensures the HTTP path stays responsive under queue pressure.
## What you need
* A container host (Railway, Fly, Render, ECS, Kubernetes — anywhere Docker runs)
* Managed Postgres — Neon, RDS, Supabase, or a Postgres you operate
* Managed Redis — Upstash, ElastiCache, or a Redis you operate
* An S3 bucket configured per the [media spec](/self-host/environment#media-s3)
* DNS control for the hostname your API will live on
* Your own platform OAuth credentials (see [platform credentials](/self-host/platform-credentials))
## Build the image
The repo-root `apps/api/Dockerfile` builds a single image from the monorepo:
```bash theme={"system"}
docker build -f apps/api/Dockerfile -t letmepost-api:latest .
```
**The build context must be the monorepo root**, not `apps/api/`. The Dockerfile copies `pnpm-lock.yaml`, every workspace `package.json`, and the `packages/schemas/` source — none of which exist inside `apps/api/`.
## Environment variables
Set these on **both** services (api and worker):
| Variable | Value |
| - | - |
| `DATABASE_URL` | Managed Postgres connection string |
| `REDIS_URL` | Managed Redis connection string |
| `KEK_MASTER` | `openssl rand -base64 32` — **same value on both services**. Rotating without re-wrapping all encrypted tokens locks every connected account out. |
| `BETTER_AUTH_SECRET` | `openssl rand -base64 32` |
| `BETTER_AUTH_URL` | `https://api.your-domain.example` |
| `PUBLIC_API_BASE_URL` | `https://api.your-domain.example` |
| `TRUSTED_ORIGINS` | `https://dashboard.your-domain.example` |
| `CORS_ORIGINS` | `https://dashboard.your-domain.example` |
| `COOKIE_DOMAIN` | `.your-domain.example` — leading dot, so the session cookie is readable by both the api and dashboard subdomains. |
| `DOCS_BASE_URL` | Default `https://docs.letmepost.dev`. Override only if you host a separate docs site. |
| `AWS_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `MEDIA_PUBLIC_BASE_URL`, `MEDIA_ENV_PREFIX` | Media bucket config — see [environment](/self-host/environment#media-s3). |
| Platform OAuth | Add `LINKEDIN_CLIENT_ID/SECRET`, `TWITTER_CLIENT_ID/SECRET`, etc. as the developer apps come back from review. |
The dashboard hosts elsewhere (Vercel, Cloudflare Pages, your own static host) and needs `NEXT_PUBLIC_API_URL=https://api.your-domain.example` on *its* environment.
## Health check
The API exposes `GET /health`:
```bash theme={"system"}
curl https://api.your-domain.example/health
# {"status":"ok"}
```
Configure your platform's health check to hit `/health` on the api service with a 30s timeout. The worker doesn't bind a port — disable its health check.
## Migrations
The api service runs migrations on boot (`node dist/db/migrate.js && node dist/server.js`). The worker does **not** run migrations — the api owns that responsibility so two replicas don't race.
For a schema change in your fork:
```bash theme={"system"}
pnpm --filter @letmepost/api db:generate # writes a new SQL file under apps/api/drizzle/
git add apps/api/drizzle/
git commit -m "db: "
```
Push — the api service applies the new migration on the way up, then the new code starts serving.
For zero-downtime deploys, follow the standard pattern: **additive in one release, destructive in a follow-up**. Add the new column / table, ship the code that writes to both old and new, deploy. Once you've verified the migration backfilled and the new code is healthy, ship a follow-up that drops the old column.
## Reference: Railway
Step-by-step setup for our production deploy (api + worker on Railway, Neon for Postgres, Upstash for Redis) lives in [`DEPLOY.md`](https://github.com/rosekamallove/letmepost.dev/blob/main/DEPLOY.md) in the repo. The two non-obvious things to know if you mirror it:
1. **Root Directory: blank / `/`** on both services. Not `/apps/api`. The Dockerfile needs the monorepo root as its build context.
2. **Same `KEK_MASTER` on both services.** They're separate Railway services, so it's two env-var copies — but the value must be identical or the worker can't decrypt tokens the api wrote.
## Rollback
Container platforms handle rollback by redeploying a previous image. Migrations are forward-only — if you need to roll back across a schema change, restore the database from a point-in-time snapshot first, then redeploy the previous image.
## Smoke test after deploy
```bash theme={"system"}
# 1. Health
curl https://api.your-domain.example/health
# 2. Sign up + dashboard handshake (in a browser, on your dashboard host)
# - Create an org
# - Mint an API key (copy the plaintext — only shown once)
# - Connect Bluesky (no OAuth review required)
# - Send a test post from the dashboard
# - Watch the post log fill in
```
If the worker is wired up correctly, the post moves from `queued` → `published` within a few seconds. If it stays `queued`, the worker isn't consuming — see [troubleshooting](/self-host/troubleshooting#worker-not-picking-up-jobs).
## Self-host parity
There is no feature gate between the OSS image and `api.letmepost.dev`. Same code, same API surface, same error envelope. The hosted tier's only differentiation is managed OAuth apps (you don't have to register your own with each platform) and managed infrastructure.
# Environment variables
Source: https://docs.letmepost.dev/self-host/environment
Every variable the API reads, what defaults to what, and which ones are required to boot.
The canonical source is [`apps/api/.env.example`](https://github.com/rosekamallove/letmepost.dev/blob/main/apps/api/.env.example) in the repo — it's the file the API actually reads, with comments next to each variable. This page is the same set of variables organized by purpose so you can scan it before a deploy.
## Core (required to boot)
These four are non-negotiable. The API refuses to start without them.
| Variable | What it is |
| - | - |
| `DATABASE_URL` | Postgres connection string. NeonDB pooler URL recommended for hosted use; local Postgres also works. |
| `REDIS_URL` | Redis URL. Powers BullMQ (publish / validate / refresh-token / webhook-deliver queues) and the idempotency cache. |
| `KEK_MASTER` | AES-256-GCM master key for the token envelope. Base64 of exactly 32 bytes. Generate with `openssl rand -base64 32`. Must be rotated via a re-wrap script — replacing the value locks every connected account. |
| `BETTER_AUTH_SECRET` | better-auth signing secret. 32 bytes minimum. `openssl rand -base64 32`. |
## HTTP server
| Variable | Default | What it is |
| - | - | - |
| `PORT` | `3000` | HTTP port the API listens on. |
| `HOST` | `0.0.0.0` | Bind address. The default is reachable from outside the container/host. Override to `127.0.0.1` if you only want local reads. |
| `PUBLIC_API_BASE_URL` | `http://localhost:3000` | Public origin the API is reachable on. Used by platform providers to build OAuth redirect URIs at runtime. Must match exactly what each platform's developer portal has on file. |
| `BETTER_AUTH_URL` | `http://localhost:3000` | Public origin where better-auth callbacks land. Must match the URL the dashboard hits. |
| `CORS_ORIGINS` | *empty* | Extra CORS origins, comma-separated. The dashboard at `http://localhost:3001` is always allowed. Add your prod dashboard origin here. |
| `TRUSTED_ORIGINS` | *empty* | Origins better-auth trusts for cross-origin auth flows. Local `:3001` is always allowed; add the prod dashboard origin. |
| `DASHBOARD_URL` | `http://localhost:3001` | Where the OAuth callback redirects after a successful (or failed) connect — typically the dashboard's `/accounts` page. |
| `COOKIE_DOMAIN` | *empty* | Set in production when API and dashboard live on different subdomains of the same parent — e.g. `.letmepost.dev` (note the leading dot). Configures the session cookie with `Domain=; SameSite=None; Secure`. Leave blank in dev. |
## Documentation links
| Variable | Default | What it is |
| - | - | - |
| `DOCS_BASE_URL` | `https://docs.letmepost.dev` | Stamped on every error envelope as `error.docUrl` (and `error.ruleUrl` when a preflight rule fires), so callers land on the canonical remediation page in one click. Override for staging or a self-hosted docs site. |
If you mirror the docs at, say, `https://docs.example.internal`, set `DOCS_BASE_URL=https://docs.example.internal` and every error response will link there.
## Media (S3)
The API uses a **single bucket**, with environments separated by an in-bucket key prefix. The current build talks to AWS S3 directly — there is no endpoint override, so S3-compatible providers (R2, MinIO, Wasabi) need a follow-up PR to expose the AWS SDK's `endpoint` option. See [troubleshooting](/self-host/troubleshooting#s3-compatible-storage) for the workaround.
| Variable | What it is |
| - | - |
| `AWS_REGION` | AWS region, e.g. `us-east-1`. |
| `S3_BUCKET` | Bucket name. Layout: `${MEDIA_ENV_PREFIX}/${orgId}/${mediaId}.${ext}`. |
| `S3_ACCESS_KEY_ID` | IAM access key id. |
| `S3_SECRET_ACCESS_KEY` | IAM secret access key. |
| `MEDIA_PUBLIC_BASE_URL` | Public URL prefix concatenated with the S3 key. Direct S3 in v1 (`https://.s3..amazonaws.com`); swap to CloudFront / a custom CDN domain later without code changes. |
| `MEDIA_ENV_PREFIX` | Key prefix that namespaces objects per environment (e.g. `prod`, `dev`). Keeps prod / dev objects in separate key namespaces while sharing a bucket. |
The bucket itself needs **Block Public Access OFF** (objects are served by URL), **ACLs disabled** (Object Ownership: "Bucket owner enforced"), and an IAM user with `s3:PutObject`, `GetObject`, `DeleteObject`, `AbortMultipartUpload`, `ListMultipartUploadParts` on objects, plus `ListBucket` / `ListBucketMultipartUploads` on the bucket itself.
## Optional dashboard sign-in providers
Set both `*_CLIENT_ID` and `*_CLIENT_SECRET` to enable each provider; leave blank to disable. The default is email + password.
| Variable pair | Portal | Authorized callback |
| - | - | - |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | Cloud Console → Credentials | `${BETTER_AUTH_URL}/api/auth/callback/google` |
| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | GitHub → Developer settings → OAuth Apps | `${BETTER_AUTH_URL}/api/auth/callback/github` |
## Platform OAuth credentials
These are only required when you want users to connect that platform. The full per-platform setup (portal links, exact scope strings, app review status) lives in [platform credentials](/self-host/platform-credentials).
| Platform | Required vars | Optional overrides |
| - | - | - |
| LinkedIn | `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET` | — |
| Twitter / X | `TWITTER_CLIENT_ID`, `TWITTER_CLIENT_SECRET` | `TWITTER_LAUNCH_CAP_PER_ACCOUNT` (default `50`) |
| Pinterest | `PINTEREST_CLIENT_ID`, `PINTEREST_CLIENT_SECRET` | `PINTEREST_API_BASE` (Trial Access sandbox) |
| Facebook Pages | `META_APP_ID`, `META_APP_SECRET` | `META_GRAPH_VERSION` |
| Instagram (IG Login) | `INSTAGRAM_CLIENT_ID`, `INSTAGRAM_CLIENT_SECRET` | `INSTAGRAM_GRAPH_BASE`, `INSTAGRAM_OAUTH_AUTHORIZE_URL`, `INSTAGRAM_OAUTH_TOKEN_URL` (test-only) |
| Threads | `THREADS_CLIENT_ID`, `THREADS_CLIENT_SECRET` | `THREADS_API_BASE`, `THREADS_API_VERSION` |
| TikTok | `TIKTOK_CLIENT_KEY`, `TIKTOK_CLIENT_SECRET` | `TIKTOK_API_BASE`, `TIKTOK_OAUTH_AUTHORIZE_URL` (test-only) |
| Bluesky | — (uses app passwords) | — |
The Meta family runs on three independent OAuth apps in v1 — Facebook Pages, Instagram (Instagram API with Instagram Login), and Threads are all separate developer apps with separate client credentials. See [platform credentials](/self-host/platform-credentials#meta) for why.
## Operational toggles
| Variable | Default | What it is |
| - | - | - |
| `PLATFORM_STATE_OVERRIDES` | *empty* | Override the launch state of a platform without redeploying. Comma-separated `platform:state` pairs (`live`, `trial`, `pending`). Example: `pinterest:live,linkedin:trial`. |
| `TEST_DATABASE_URL` | *empty* | Used only by the repository test suite (per-test transaction rollback). Point at a disposable local Postgres or a Neon preview branch. |
## What's not in scope today
One pair appears in the example file but is not yet read by the source code. Don't carry it into your `.env` expecting it to do anything in v1:
* `YOUTUBE_CLIENT_ID` / `YOUTUBE_CLIENT_SECRET` — placeholders for a future YouTube provider.
`TIKTOK_CLIENT_KEY` / `TIKTOK_CLIENT_SECRET` used to be listed here as unread. They **are** read (`apps/api/src/platforms/tiktok/provider.ts`) and are required to connect TikTok accounts — see the platform table above.
Likewise, three variables that older self-host docs referenced **do not exist** in the current build:
* `MEDIA_S3_BUCKET`, `MEDIA_S3_REGION`, `MEDIA_S3_ENDPOINT`, `MEDIA_S3_ACCESS_KEY_ID`, `MEDIA_S3_SECRET_ACCESS_KEY` — superseded by the `AWS_REGION` + `S3_*` + `MEDIA_*` set above.
* `ENCRYPTION_KEY` — superseded by `KEK_MASTER` (AES-256-GCM, base64 of 32 bytes).
* `WEBHOOK_SIGNING_SECRET` — there is no global signing secret. Each webhook endpoint generates its own signing secret at registration time and returns it once in the create response.
# Platform credentials
Source: https://docs.letmepost.dev/self-host/platform-credentials
Register the developer apps that let your self-hosted instance talk to each social network.
The hosted SaaS at `api.letmepost.dev` ships with platform-approved OAuth apps for every network we support. When you self-host, you bring your own — each platform requires that the OAuth client id match an app you control, registered against your own developer account.
Bluesky is the exception: it uses [app passwords](https://bsky.app/settings/app-passwords) and doesn't need a developer app at all.
For each platform below: register the app, configure the redirect URI exactly as listed (substituting your own `${BETTER_AUTH_URL}` for the placeholder), paste the resulting client id + secret into your `.env`, and restart the API.
Throughout this page, `${BETTER_AUTH_URL}` means whatever you set [`BETTER_AUTH_URL`](/self-host/environment#http-server) to — typically `http://localhost:3000` in dev or `https://api.letmepost.dev` in prod. The redirect URI must match the platform's record character-for-character, including trailing slash (or lack thereof).
## LinkedIn
* **Portal:** [linkedin.com/developers/apps](https://www.linkedin.com/developers/apps)
* **Redirect URI:** `${BETTER_AUTH_URL}/v1/accounts/oauth/linkedin/callback`
* **Env vars:** `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`
Enable these two products on the app:
* **Sign In with LinkedIn using OpenID Connect** — gives `openid`, `profile`.
* **Share on LinkedIn** — gives `w_member_social`.
letmepost requests these scopes:
* **Write:** `openid`, `profile`, `w_member_social`
* **Extended:** `email`, `r_organization_social`, `w_organization_social`
Personal posting works out of the box. Org / Company-Page posting (`w_organization_social`) requires LinkedIn's **Marketing Developer Platform (MDP)** review — separate, slower process. The personal-posting flow doesn't need MDP.
## Twitter / X
* **Portal:** [developer.x.com](https://developer.x.com/en/portal/dashboard)
* **Redirect URI:** `${BETTER_AUTH_URL}/v1/accounts/oauth/twitter/callback`
* **Env vars:** `TWITTER_CLIENT_ID`, `TWITTER_CLIENT_SECRET`
OAuth 2.0 settings on the app:
* **Type of App:** Web App, Automated App or Bot
* **Authentication:** OAuth 2.0 with PKCE (Confidential client)
* **Scopes (write):** `tweet.read`, `tweet.write`, `users.read`, `offline.access`
* **Scopes (extended):** `like.read`, `follows.read`
As of Feb 2026 X killed tiered pricing for new signups — Basic / Pro tiers are closed; pay-per-use is the only option for new developers. Posting requires write access on the paid tier.
Optional billing safety net: `TWITTER_LAUNCH_CAP_PER_ACCOUNT` (default `50`) caps billable posts per rolling 30-day window per account, so an uncapped publish loop can't run up the PPU bill.
## Pinterest
* **Portal:** [developers.pinterest.com/apps](https://developers.pinterest.com/apps/)
* **Redirect URI:** `${BETTER_AUTH_URL}/v1/accounts/oauth/pinterest/callback`
* **Env vars:** `PINTEREST_CLIENT_ID`, `PINTEREST_CLIENT_SECRET`
Scopes:
* **Write:** `boards:read`, `boards:write`, `pins:read`, `pins:write`, `user_accounts:read`
* **Extended:** `pins:read_secret`
New apps start in **Trial Access** — pin creation on production (`api.pinterest.com`) is blocked with a hard "use sandbox" error until Pinterest approves Standard Access. While you wait:
```bash theme={"system"}
PINTEREST_API_BASE=https://api-sandbox.pinterest.com/v5
```
Clear `PINTEREST_API_BASE` once Standard Access is approved — it defaults back to `https://api.pinterest.com/v5`. The variable affects every `/v5/*` call *and* the OAuth token-exchange endpoint, since both live under the same host on Pinterest's API.
## Meta family
The Meta family used to be a single Facebook Login for Business app that fanned out to Facebook Pages, Instagram, and Threads. In the current build, **each of those three is an independent OAuth flow** with its own client credentials.
You still register all three on [developers.facebook.com/apps](https://developers.facebook.com/apps/) — Meta's developer portal — but the apps, scopes, redirect URIs, and review tracks are separate.
### Facebook Pages
* **Portal:** [developers.facebook.com/apps](https://developers.facebook.com/apps/)
* **Redirect URI:** `${BETTER_AUTH_URL}/v1/accounts/oauth/facebook/callback`
* **Env vars:** `META_APP_ID`, `META_APP_SECRET`
Products to add on the app:
* **Facebook Login for Business**
Scopes letmepost requests (write):
```
pages_manage_posts
pages_read_engagement
pages_show_list
business_management
```
The redirect URI ends in `/facebook/callback`, not `/meta/callback`. Earlier docs incorrectly said `/meta/callback`; if your Meta app has that registered, update it.
App Review takes 2–8 weeks per cycle and rejections are normal. Build against Development Mode + Tester accounts during the wait — same Graph API endpoints, same response shapes; review only lifts the testers-only gate. Business verification is required before submission.
Optional: `META_GRAPH_VERSION` pins the Graph API version (the provider has a default; bump it on each platform version cut and re-run the contract test suite).
### Instagram (Instagram API with Instagram Login)
* **Portal:** [developers.facebook.com/apps](https://developers.facebook.com/apps/) — same portal as Facebook, but you'll enable the **Instagram** product → **"Instagram API setup with Instagram Login"** path.
* **Redirect URI:** `${BETTER_AUTH_URL}/v1/accounts/oauth/instagram/callback`
* **Env vars:** `INSTAGRAM_CLIENT_ID`, `INSTAGRAM_CLIENT_SECRET`
Scopes letmepost requests (write):
```
instagram_business_basic
instagram_business_content_publish
```
This is **different** from the Instagram fan-out you used to get through Facebook Login for Business. Instagram Login is for users who want to connect with just their Instagram account — **Professional accounts only (Business or Creator)**, no Facebook Page required. The token is bound to the IG user and publishes hit `graph.instagram.com` instead of `graph.facebook.com`.
App Review is separate from the Facebook Login scopes — different scope namespace, different review cycle.
The optional `INSTAGRAM_GRAPH_BASE`, `INSTAGRAM_OAUTH_AUTHORIZE_URL`, `INSTAGRAM_OAUTH_TOKEN_URL` variables are test-only — they let the contract suite point at a mock server. Leave them blank in production.
### Threads
* **Portal:** [developers.facebook.com/apps](https://developers.facebook.com/apps/) — add the **Threads API** product.
* **Authorize URL:** `https://threads.net/oauth/authorize` (NOT `facebook.com/dialog/oauth`)
* **Redirect URI:** `${BETTER_AUTH_URL}/v1/accounts/oauth/threads/callback`
* **Env vars:** `THREADS_CLIENT_ID`, `THREADS_CLIENT_SECRET`
Scopes:
* **Write:** `threads_basic`, `threads_content_publish`
* **Extended:** `threads_manage_replies`, `threads_read_replies`
Threads has its **own OAuth flow + client credentials**, separate from Facebook Login for Business. The Threads API product on developers.facebook.com generates a distinct `THREADS_CLIENT_ID` / `THREADS_CLIENT_SECRET` — do not reuse `META_APP_ID`.
Tokens are short-lived (1h) on first exchange, swapped server-side for 60-day long-lived tokens which are what letmepost persists.
App Review for `threads_content_publish` is required for production; Dev Mode with Tester accounts is fully functional in the meantime — same API, same response shapes, review only lifts the testers-only gate.
Optional: `THREADS_API_BASE`, `THREADS_API_VERSION` let tests point at a mock server. Defaults: `https://graph.threads.net`.
## Bluesky
No registration. No client id. No secret. Bluesky's documented integration path for third-party tools is the user-generated **app password** flow.
The user creates an app password at [bsky.app/settings/app-passwords](https://bsky.app/settings/app-passwords), pastes it into the dashboard's Bluesky connect form alongside their handle, and the API stores it encrypted with `KEK_MASTER`. There's nothing to configure on a self-hosted instance — connecting Bluesky works the moment the API can reach `bsky.social`.
## Verifying a connection
Once you've set credentials and restarted the API, the connect flow runs from the dashboard at `/accounts`. Pick the platform, complete OAuth at the provider, land back on the dashboard with a connected account row.
If OAuth fails, the error envelope includes a stable `code` and a `docUrl` pointing at the troubleshooting page — see [`platform_auth_failed`](/errors/platform_auth_failed) for the most common cause (a redirect URI that doesn't match the platform's record character-for-character).
# Quick start
Source: https://docs.letmepost.dev/self-host/quick-start
Get the API, worker, and dashboard running locally against a Postgres + Redis you control.
This is the fastest path from a fresh clone to publishing your first post against a self-hosted API. It uses Docker Compose for Postgres + Redis only — the API, worker, and dashboard run via `pnpm` so you keep watch mode, fast iteration, and stack traces in your terminal.
For a production deploy (separate API + worker services, managed Postgres / Redis, custom domain), see [deploying](/self-host/deploying) once you've gotten this working locally.
## Prerequisites
* **Node** `>= 24`
* **pnpm** `10.33.0+` — `corepack enable` if you haven't already
* **Docker** + **Docker Compose** — only for the Postgres + Redis containers
* An S3-compatible bucket for media (you can defer this if you only need text posts to start with)
## 1. Clone and install
```bash theme={"system"}
git clone https://github.com/rosekamallove/letmepost.dev
cd letmepost.dev
pnpm install
```
## 2. Start Postgres and Redis
The repo ships a `docker-compose.dev.yml` that runs **only Postgres and Redis** — nothing else. The API, worker, and dashboard run from your shell via `pnpm`. This keeps reloads instant and stack traces native.
```bash theme={"system"}
docker compose -f docker-compose.dev.yml up -d
```
Ports are deliberately non-standard so they don't collide with whatever you already have running:
| Service | Host port | Container port |
| - | - | - |
| Postgres | `5433` | `5432` |
| Redis | `6380` | `6379` |
The Postgres database is seeded with user `letmepost`, password `letmepost`, db `letmepost`.
## 3. Configure environment
Copy the example file and fill in the bare minimum to boot:
```bash theme={"system"}
cp apps/api/.env.example apps/api/.env
```
The minimum required to start the server is:
```bash theme={"system"}
DATABASE_URL=postgres://letmepost:letmepost@localhost:5433/letmepost
REDIS_URL=redis://localhost:6380
KEK_MASTER=$(openssl rand -base64 32)
BETTER_AUTH_SECRET=$(openssl rand -base64 32)
```
`KEK_MASTER` is the AES-256-GCM master key that wraps every encrypted token at rest. **Keep it.** Rotating it without a re-wrap script locks every connected account.
Every other variable is either optional with a sensible default or only required when you connect a specific platform. See [environment](/self-host/environment) for the full list.
## 4. Run database migrations
```bash theme={"system"}
pnpm --filter @letmepost/api db:migrate
```
Schema lives in `apps/api/drizzle/`. Drizzle Kit applies the SQL files in order. Re-running is safe — already-applied migrations are skipped.
## 5. Start the stack
In separate terminal tabs:
```bash theme={"system"}
pnpm --filter @letmepost/api dev # API → http://localhost:3000
pnpm --filter @letmepost/dashboard dev # Dashboard → http://localhost:3001
```
Or run everything via Turbo from the repo root:
```bash theme={"system"}
pnpm dev
```
The worker is bundled into the API process in dev mode, so the same `pnpm --filter @letmepost/api dev` command runs the HTTP server and the BullMQ consumer in one process. In production you split them — see [deploying](/self-host/deploying).
## 6. Verify the API
```bash theme={"system"}
curl http://localhost:3000/health
# {"status":"ok"}
```
If you get `connection refused`, check that the Postgres and Redis containers are up: `docker compose -f docker-compose.dev.yml ps`.
## 7. Sign in and connect Bluesky
Open `http://localhost:3001` and sign up with email + password. Bluesky is the easiest platform to connect for a first smoke test — no OAuth app required, just an [app password](https://bsky.app/settings/app-passwords).
Once the account is connected, copy its `id` from the **Accounts** page and follow [quickstart](/quickstart) — every example works against your local API once you swap `https://api.letmepost.dev` for `http://localhost:3000`.
## Next
Every var the API reads, what defaults to what, and which ones are optional.
OAuth client setup for LinkedIn, X, Pinterest, Meta, Threads, Instagram.
Two-service split, managed Postgres + Redis, custom domain.
Common boot failures and how to read them.
# Troubleshooting
Source: https://docs.letmepost.dev/self-host/troubleshooting
Common boot failures, what they mean, and how to fix them.
If something's wrong, the API tends to fail loudly and early — a missing required env var crashes on boot with the variable name in the message, not a silent misroute. This page collects the recurring problems we've seen.
If your issue isn't here, [open one on GitHub](https://github.com/rosekamallove/letmepost.dev/issues) with the error message and your environment (host platform, Postgres + Redis providers, anything non-default).
## Boot failures
### `KEK_MASTER env var is required`
Generate one:
```bash theme={"system"}
openssl rand -base64 32
```
Paste it into `KEK_MASTER` in your `.env` (dev) or your platform's variable store (prod). Make sure the **api** and **worker** services have the **same** value — the worker decrypts tokens the api wrote.
If you change `KEK_MASTER` on an instance that already has connected accounts, every encrypted token becomes unreadable. There is no automatic re-wrap today; rotation requires a script that decrypts with the old key and re-encrypts with the new.
### `S3_BUCKET env var is required for the media upload service`
You hit a code path that needs media S3 before configuring the bucket. Set the full set in [environment](/self-host/environment#media-s3):
```bash theme={"system"}
AWS_REGION=us-east-1
S3_BUCKET=your-bucket
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
MEDIA_PUBLIC_BASE_URL=https://your-bucket.s3.us-east-1.amazonaws.com
MEDIA_ENV_PREFIX=prod
```
The S3 client is lazy — text-only posts don't require these vars to boot the server, but any media upload will.
### `connection refused` to Postgres or Redis on local dev
The dev compose maps containers to non-standard host ports so they don't collide with anything already running on `:5432` / `:6379`:
| Service | Host port | Container port |
| - | - | - |
| Postgres | `5433` | `5432` |
| Redis | `6380` | `6379` |
Your `DATABASE_URL` and `REDIS_URL` need to use the host port:
```bash theme={"system"}
DATABASE_URL=postgres://letmepost:letmepost@localhost:5433/letmepost
REDIS_URL=redis://localhost:6380
```
Verify the containers are up:
```bash theme={"system"}
docker compose -f docker-compose.dev.yml ps
```
### Migrations don't apply on deploy
Both `start:api` and `start:worker` are wired with very different boot sequences:
* `start:api` runs `node dist/db/migrate.js && node dist/server.js` — migrations then server.
* `start:worker` runs `node dist/queue/worker.js` — no migrations.
If you accidentally point both services at `start:worker`, neither will apply pending migrations and the API will fail on the first request that touches a new column. Check the worker's start command — it should be `pnpm --filter @letmepost/api start:worker` and **the api's** should be `pnpm --filter @letmepost/api start:api`.
## OAuth and connect flows
### `platform_auth_failed` immediately after redirecting from the provider
The most common cause is a redirect URI mismatch. The OAuth provider compares the `redirect_uri` you sent against the one registered on the app — they must match **character for character**, including scheme, port, trailing slash, and case.
Check that the redirect URI registered on the platform's developer portal matches:
```
${BETTER_AUTH_URL}/v1/accounts/oauth//callback
```
Where `${BETTER_AUTH_URL}` is whatever the API has set in env (e.g. `http://localhost:3000` in dev, `https://api.your-domain.example` in prod). The trailing path `/v1/accounts/oauth//callback` is the same on every platform — only the platform name changes.
See [platform credentials](/self-host/platform-credentials) for the exact redirect URI per platform. Common gotchas:
* **Facebook:** the path is `/facebook/callback`, **not** `/meta/callback`. Older docs were wrong about this.
* **Instagram:** uses a **separate** OAuth app from Facebook Pages — its own client id, its own redirect URI, its own scope namespace.
* **Threads:** authorize URL is `https://threads.net/oauth/authorize`, not `facebook.com/dialog/oauth`. It has its own client credentials too.
### Pinterest: "use sandbox" error on pin creation
Pinterest's developer apps start in **Trial Access** and pin creation against production (`api.pinterest.com`) is hard-blocked with an explicit "use sandbox" error until Standard Access is approved. While you wait:
```bash theme={"system"}
PINTEREST_API_BASE=https://api-sandbox.pinterest.com/v5
```
This affects every `/v5/*` call **and** the OAuth token-exchange endpoint, since both live under the same host. Clear the variable once Standard Access is approved — it defaults back to `https://api.pinterest.com/v5`.
### Meta App Review takes forever and posts fail
Meta App Review takes 2–8 weeks per cycle and rejections are normal. While you wait, build against **Development Mode + Tester accounts** — same Graph API endpoints, same response shapes; review only lifts the testers-only gate. Add your test users as Testers on the app, and the production Graph API will accept their tokens immediately.
Business verification is required before submitting for review.
## Runtime
### Worker not picking up jobs
Posts stay `queued` indefinitely. Check:
1. **The worker process is running.** A common cause is starting both services with `start:api`, leaving no consumer on the queues. The worker should be running `pnpm --filter @letmepost/api start:worker`.
2. **`REDIS_URL` is the same on both services.** They communicate exclusively through Redis — different URLs means no jobs flow between them.
3. **The worker can reach Redis.** Check its logs for `ECONNREFUSED` or BullMQ errors. Managed Redis providers sometimes need `rediss://` (TLS) instead of `redis://`.
### Posts publish but the dashboard log is empty
Check `CORS_ORIGINS` and `TRUSTED_ORIGINS` — the dashboard fetches the post log via the API, and a missing origin will block the request. The dashboard origin must be in both lists. In a cross-subdomain prod setup, `COOKIE_DOMAIN` also needs to be set to the parent domain with a leading dot (e.g. `.your-domain.example`) so the session cookie is readable by the dashboard host.
### Error `docUrl` and `ruleUrl` point at the wrong site
Set `DOCS_BASE_URL` to your docs origin. The default is `https://docs.letmepost.dev` — every error envelope stamps `error.docUrl` and `error.ruleUrl` (when a preflight rule fires) using this prefix.
```bash theme={"system"}
DOCS_BASE_URL=https://docs.your-domain.example
```
## Infrastructure
### S3-compatible storage
The current build instantiates the AWS S3 client without an `endpoint` override, so by default it talks only to AWS S3. Self-hosting with Cloudflare R2, MinIO, Wasabi, or any S3-compatible provider needs a small code change to pass `endpoint` into the `S3Client` constructor in `apps/api/src/media/s3.ts`. The fields you'd want from env: `S3_ENDPOINT` (or similar) wired through `getS3Client()`.
Drop an issue or PR on the repo if you need this — it's a known gap and the fix is small.
### Postgres + Redis must persist
The dev compose ships ephemeral volumes — fine for local development, **never** ship that to prod. Use managed Postgres (Neon, RDS, Supabase, Postgres you operate) and managed Redis (Upstash, ElastiCache, Redis you operate) with real durability. Both stores are load-bearing: Postgres holds every account, post, key, and webhook endpoint; Redis holds in-flight job state and the idempotency replay cache.
### Same `KEK_MASTER` on every replica
If you scale the api or worker horizontally, every replica needs the same `KEK_MASTER`. They're all decrypting the same tokens from the same database. A mismatch produces opaque crypto errors on the post path — usually `unsupported state or unable to authenticate data` from the AES-GCM verifier.
## Getting help
Still stuck:
* Re-read the error message. The API tries to make boot-time failures self-describing — the exact variable name or platform call is usually in the message.
* Check the `requestId` on any error envelope and grep your logs.
* Open an issue at [github.com/rosekamallove/letmepost.dev/issues](https://github.com/rosekamallove/letmepost.dev/issues) with the error, your host platform, and which step you were on.
# Webhooks
Source: https://docs.letmepost.dev/webhooks/index
HMAC-signed deliveries for post lifecycle, token, version, subscription, quota, and billing events.
Register an HTTPS URL once via `POST /v1/webhook-endpoints` and we'll deliver signed JSON for every event. No polling.
## Event types
### Post lifecycle
| event | when |
| - | - |
| [`post.queued`](/webhooks/post-queued) | scheduled post accepted; job enqueued |
| [`post.validated`](/webhooks/post-validated) | preflight passed (currently fired together with `post.queued` on schedule) |
| [`post.published`](/webhooks/post-published) | upstream returned success |
| [`post.rejected`](/webhooks/post-rejected) | preflight or platform rejected; not retried |
| [`post.failed`](/webhooks/post-failed) | transient failure; the worker may retry |
| [`post.canceled`](/webhooks/post-canceled) | queued scheduled post canceled via `DELETE` before it fired |
| [`post.rescheduled`](/webhooks/post-rescheduled) | queued scheduled post moved to a new firing time via `PATCH` |
### Tokens & versions
| event | when |
| - | - |
| [`token.expiring`](/webhooks/token-expiring) | platform token approaching expiry |
| [`token.revoked`](/webhooks/token-revoked) | platform token rejected by upstream |
| [`version.deprecated`](/webhooks/version-deprecated) | upstream platform announced an API version sunset |
### Subscription & billing
These carry billing state in `data` — see the shapes in `WEBHOOK_EVENT_TYPES`.
| event | when |
| - | - |
| `subscription.activated` | a plan was activated (carries `tier`, `previousTier`, `periodStart`, `periodEnd`) |
| `subscription.cancelled` | a plan was cancelled (carries `tier`, `cancelAtPeriodEnd`, `cancelledAt`, `effectiveAt`) |
| `subscription.tier_changed` | a plan moved between tiers (carries `previousTier`, `tier`, `periodStart`, `periodEnd`) |
| `quota.warning` | monthly post usage crossed 80% (carries `period`, `postsCount`, `quota`, `percent`, `resetAt`) |
| `quota.exceeded` | monthly post quota reached (carries `period`, `postsCount`, `quota`, `resetAt`) |
| `billing.payment_failed` | a subscription payment failed (carries `ls_subscription_id`, `failedAt`, `tier`) |
| `billing.delinquent` | a subscription is past due and downgraded to free (carries `ls_subscription_id`, `since`, `tier`) |
| `billing.recovered` | a previously-failed payment recovered (carries `ls_subscription_id`, `recoveredAt`, `tier`) |
This list is canonical — see `WEBHOOK_EVENT_TYPES` in `packages/schemas/src/webhook-events.ts`. Adding an event is non-breaking; removing one is breaking and shows up in the [changelog](/changelog).
## Envelope
Every delivery has the same outer shape:
```json envelope.json theme={"system"}
{
"id": "evt_01HY6X4AWBJM2K9F2PTQMRD9JQ",
"type": "post.published",
"createdAt": "2026-05-04T15:30:00.000Z",
"organizationId": "org_…",
"data": { /* event-specific shape — see per-event pages */ }
}
```
The `data` field is the only thing that varies between event types. The envelope is stable so consumers can write one verifier and one router.
## Delivery headers
Every delivery includes:
```
X-Letmepost-Signature: sha256=
X-Letmepost-Event: post.published
X-Letmepost-Event-Id: evt_01HY6X4AWBJM2K9F2PTQMRD9JQ
X-Letmepost-Delivery-Id: 5f3b0c2e-8a1d-4b6c-9e7f-2a1c3d4e5f60
Content-Type: application/json
```
| header | meaning |
| - | - |
| `X-Letmepost-Signature` | `sha256=` + the HMAC-SHA256 of the **raw request body**, hex-encoded. No timestamp. |
| `X-Letmepost-Event` | the event `type` (e.g. `post.published`). |
| `X-Letmepost-Event-Id` | the event id — **stable across retries** of the same event. Deduplicate on this. |
| `X-Letmepost-Delivery-Id` | a unique id for this one delivery attempt — changes on every retry. |
| `X-Letmepost-Request-Id` | set when the event was produced by an inbound API request; correlates back to that request. |
## Signature verification
The signature is `HMAC-SHA256(secret, raw_body)` — computed over the exact bytes of the request body, with no timestamp. The value is hex-encoded and prefixed with `sha256=`. Verify before parsing the body:
```ts verify.ts theme={"system"}
import crypto from "node:crypto";
function verify(rawBody: string, header: string, secret: string): boolean {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(header);
const b = Buffer.from(expected);
// Constant-time compare; length check first so timingSafeEqual can't throw.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```
Compute the HMAC over the raw body **before** JSON-parsing it — reserializing changes bytes and breaks the digest. Reject the request if verification fails.
## Idempotency
Delivery is **at-least-once**: a retried event arrives with the same `X-Letmepost-Event-Id` but a new `X-Letmepost-Delivery-Id`. Deduplicate on `X-Letmepost-Event-Id` and treat a repeat as a no-op. There is no timestamp on the signature, so there is no replay window to enforce — the signature and event-id dedup are the security boundary.
## Retries
Delivery is retried with exponential backoff on `5xx` responses and network errors (DNS, TCP, TLS, timeout). The budget is **8 attempts** with backoff starting at 5s and doubling each time — roughly 5s, 10s, 20s, 40s, 80s, 160s, 320s, 640s, about 21 minutes total. After the final attempt the delivery lands in the failed set and surfaces in the dashboard's webhook log.
A `4xx` response is treated as a **permanent failure and is never retried** — a `4xx` means the consumer deliberately rejected the payload (bad signature config, missing route, auth failure), and retrying can't fix a config error.
Each attempt times out after 10 seconds. Respond `2xx` quickly and defer your work to a background queue if it takes longer.
## See also
* Per-event pages link from the table above for the exact `data` shape.
* [`POST /v1/webhook-endpoints`](/api-reference/#post-/v1/webhook-endpoints) reference.
# post.canceled
Source: https://docs.letmepost.dev/webhooks/post-canceled
A queued scheduled post was canceled via DELETE /v1/posts/:id before it fired.
Fires when an operator cancels a queued scheduled post via `DELETE /v1/posts/:id`. The BullMQ job is removed and the row transitions to `status: "canceled"` before this event dispatches.
The event does **not** fire for posts that reached `rejected` or `failed` through the normal publish path — those carry their own events. `post.canceled` is specifically the operator-initiated cancellation signal.
## Payload
```json post.canceled.json theme={"system"}
{
"id": "evt_...",
"type": "post.canceled",
"createdAt": "2026-05-08T11:55:00Z",
"organizationId": "org_...",
"data": {
"id": "post_...",
"platform": "bluesky",
"accountId": "...",
"profileId": "...",
"scheduledAt": "2026-05-08T12:00:00Z",
"canceledAt": "2026-05-08T11:55:00Z"
}
}
```
`scheduledAt` is the time the post **would have fired** if it hadn't been canceled. `canceledAt` is when the cancellation was accepted.
## Use it for
* Reverting any pre-publish work you queued in anticipation (CMS draft state, scheduled announcements, attribution links).
* Notifying upstream tools that the planned post won't go out.
* Audit trail: pair with `post.queued` to reconstruct the full lifecycle of any scheduled item.
## Cancellation race
If you receive `post.canceled` **before** the platform-side post would have fired, that's the expected path. If you receive it **after** a `post.published` event for the same `post.id`, ignore the cancel — the worker had already finalized the publish and the cancellation arrived too late to stop it. The API's window gate (`status=queued AND scheduledAt > now`) makes this race vanishingly rare, but consumers should still handle it idempotently.
## Related
* [`DELETE /v1/posts/{id}`](/api-reference/#delete-/v1/posts/\{id}) — the endpoint that triggers this event.
* [Scheduled posts guide](/guides/schedule-post) — full lifecycle including cancel + reschedule.
* [`post.rescheduled` webhook](/webhooks/post-rescheduled) — sibling event for the move-the-time case.
# post.failed
Source: https://docs.letmepost.dev/webhooks/post-failed
Transient failure — internal error or upstream 5xx. The worker may retry.
Fires for transient failures: `internal_error` or `platform_unavailable`. For scheduled posts, the worker may retry per the queue policy before this event becomes terminal.
## Payload
```json theme={"system"}
{
"type": "post.failed",
"data": {
"id": "post_...",
"platform": "bluesky",
"accountId": "...",
"profileId": "...",
"error": {
"code": "platform_unavailable",
"message": "Bluesky API returned 503 Service Unavailable."
},
"rejectedAt": "2026-05-04T15:30:00Z"
}
}
```
## Use it for
* Surface "publish issue, will retry" state — distinct from `post.rejected` ("publish failed, won't retry").
* Capture metrics on platform-side reliability.
## Related
* [`post.rejected`](/webhooks/post-rejected) — terminal failure.
* [`platform_unavailable`](/errors/platform_unavailable)
# post.published
Source: https://docs.letmepost.dev/webhooks/post-published
The post was successfully published upstream.
Fires after a successful publish — both for immediate `POST /v1/posts` calls and for scheduled posts when the worker runs.
## Payload
```json post.published.json theme={"system"}
{
"id": "evt_...",
"type": "post.published",
"createdAt": "2026-05-08T12:00:01Z",
"organizationId": "org_...",
"data": {
"id": "post_...",
"platform": "bluesky",
"accountId": "...",
"profileId": "...",
"uri": "at://did:plc:abc/app.bsky.feed.post/3kr...",
"cid": "bafyrei...",
"firstCommentUri": null,
"firstCommentCid": null,
"publishedAt": "2026-05-08T12:00:01Z",
"warnings": []
}
}
```
`warnings` is non-empty when the parent post succeeded but a follow-up step (e.g., first-comment) failed. We don't fail the whole publish for that — we surface a warning.
## Use it for
* Mark posts as published in your UI.
* Capture the platform's native URI to link out to the post.
* Trigger downstream workflows (announcements, analytics, attribution).
## Related
* [`post.failed`](/webhooks/post-failed)
* [`post.rejected`](/webhooks/post-rejected)
# post.queued
Source: https://docs.letmepost.dev/webhooks/post-queued
A scheduled post was accepted and a delayed publish job was enqueued.
Fires the moment we accept a scheduled `POST /v1/posts` and enqueue the delayed job. Immediate posts (no `scheduledAt`) **do not** fire this event — they go straight to `post.published` or a failure event.
## Payload
```json post.queued.json theme={"system"}
{
"id": "evt_...",
"type": "post.queued",
"createdAt": "2026-05-04T15:30:00Z",
"organizationId": "org_...",
"data": {
"id": "post_...",
"platform": "bluesky",
"accountId": "...",
"profileId": "...",
"scheduledAt": "2026-05-08T12:00:00Z",
"queuedAt": "2026-05-04T15:30:00Z"
}
}
```
## Use it for
* Surface "scheduled" state in your UI as soon as the API accepts the post.
* Log the queueing for audit trails — `queuedAt` is the API-side timestamp, not your wall clock.
## Related
* [`post.published`](/webhooks/post-published) — fires when the queued post actually publishes.
* [Scheduled posts](/guides/schedule-post) — full scheduling guide.
# post.rejected
Source: https://docs.letmepost.dev/webhooks/post-rejected
Preflight failed, the platform rejected the post, or auth failed. Not retried.
Fires when the post will not be retried — preflight, platform rejection, or platform auth failure. Distinct from `post.failed`, which is a transient failure that the worker may retry.
## Payload
```json post.rejected.json theme={"system"}
{
"id": "evt_...",
"type": "post.rejected",
"data": {
"id": "post_...",
"platform": "instagram",
"accountId": "...",
"profileId": "...",
"error": {
"code": "preflight_failed",
"rule": "instagram.media.reachable",
"message": "Instagram couldn't fetch the media URL.",
"remediation": "Upload via POST /v1/media first..."
},
"rejectedAt": "2026-05-04T15:30:00Z"
}
}
```
The `error` shape mirrors the API's HTTP error body. `code` is one of `preflight_failed`, `platform_auth_failed`, or `platform_rejected`.
## Use it for
* Surface "rejected" state in the post log.
* Page the user with the remediation when their action is required.
* Capture metrics on rule frequency — the most-fired rules become candidates for tighter local validation.
## Related
* [Errors](/errors) — the full error envelope.
* [`post.failed`](/webhooks/post-failed) — the transient cousin.
# post.rescheduled
Source: https://docs.letmepost.dev/webhooks/post-rescheduled
An operator moved a queued post to a new scheduledAt via PATCH /v1/posts/:id.
Fires when `PATCH /v1/posts/:id` successfully moves a queued scheduled post to a new firing time. The BullMQ job is replaced (old removed, new enqueued at the updated delay) and the row's `scheduledAt` is persisted before this event dispatches.
## Payload
```json post.rescheduled.json theme={"system"}
{
"id": "evt_...",
"type": "post.rescheduled",
"createdAt": "2026-05-08T11:45:00Z",
"organizationId": "org_...",
"data": {
"id": "post_...",
"platform": "bluesky",
"accountId": "...",
"profileId": "...",
"previousScheduledAt": "2026-05-08T12:00:00Z",
"scheduledAt": "2026-05-09T15:00:00Z"
}
}
```
Both timestamps are present so consumers can update calendar UI, notification triggers, or analytics buckets without a separate lookup.
## Use it for
* Mirroring the schedule change in any external calendar or planning tool.
* Resetting notification timers tied to the post's original firing window.
* Audit trail: pair with `post.queued` and the eventual `post.published` (or `post.canceled`) to reconstruct the post's full lifecycle.
## Ordering
`post.rescheduled` fires after the row is updated. A consumer that subscribed to both `post.queued` and `post.rescheduled` will see them in this order:
1. `post.queued` (original `scheduledAt`)
2. `post.rescheduled` (any number of times — each PATCH emits one)
3. `post.published` / `post.canceled` / `post.failed` / `post.rejected` (terminal)
Idempotency on the consumer side is the same as for any other webhook — dedupe on event id, not on logical state.
## Related
* [`PATCH /v1/posts/{id}`](/api-reference/#patch-/v1/posts/\{id}) — the endpoint that triggers this event.
* [Scheduled posts guide](/guides/schedule-post) — full lifecycle including reschedule + cancel.
* [`post.canceled` webhook](/webhooks/post-canceled) — sibling event for the cancel-the-post case.
# post.validated
Source: https://docs.letmepost.dev/webhooks/post-validated
Preflight passed. Today fired together with post.queued; reserved for an explicit validate step in a future slice.
Reserved for the explicit `POST /v1/posts/validate` flow. In v1 the publish path runs preflight inline, so the event isn't emitted on the synchronous `POST /v1/posts` path.
This event is in the canonical type list so consumers can register for it now and have it just work when the validate endpoint ships.
## Payload (planned)
```json theme={"system"}
{
"type": "post.validated",
"data": {
"id": "post_...",
"platform": "bluesky",
"accountId": "...",
"profileId": "...",
"validatedAt": "2026-05-04T15:30:00Z",
"rules": [ /* every rule that ran, with pass/fail */ ]
}
}
```
## Related
* [`post.queued`](/webhooks/post-queued)
* [Webhooks overview](/webhooks)
# token.expiring
Source: https://docs.letmepost.dev/webhooks/token-expiring
A connected platform token is approaching expiry and cannot be auto-refreshed. The user must reconnect.
Fires before an unrefreshable platform token expires — at 7 days for LinkedIn (no refresh-token mechanism), and on equivalent windows for any other platform that doesn't support automatic refresh.
For platforms with refresh tokens (Twitter, Facebook, Instagram, Pinterest, Threads), refresh runs silently on schedule — you won't see this event in the normal flow, only if a refresh attempt itself fails.
## Payload
```json theme={"system"}
{
"type": "token.expiring",
"data": {
"accountId": "...",
"profileId": "...",
"platform": "linkedin",
"platformAccountId": "...",
"displayName": "...",
"expiresAt": "2026-05-11T12:00:00Z",
"daysUntilExpiry": 7
}
}
```
## Use it for
* Email or in-app notification: "Your LinkedIn account needs to reconnect by Tuesday."
* Trigger a re-auth flow before publishing breaks.
## Related
* [`token.revoked`](/webhooks/token-revoked) — already revoked.
* [`platform_auth_failed`](/errors/platform_auth_failed) — the publish-time error if reconnect doesn't happen.
# token.revoked
Source: https://docs.letmepost.dev/webhooks/token-revoked
A connected platform token was rejected by the upstream — the user revoked, expired, or rotated it.
Fires when we detect that a connected account's token is no longer accepted by the upstream platform. Either:
* The user revoked the app's access from the platform's settings page.
* A refresh attempt failed because the refresh token is expired or invalid.
* A Bluesky app password was deleted from `bsky.app/settings/app-passwords`.
## Payload
```json theme={"system"}
{
"type": "token.revoked",
"data": {
"accountId": "...",
"profileId": "...",
"platform": "facebook",
"platformAccountId": "...",
"displayName": "...",
"revokedAt": "2026-05-04T15:30:00Z",
"reason": "refresh_failed"
}
}
```
## Use it for
* Mark the connected account as needing re-auth in your UI.
* Stop scheduling new posts against the account until reconnected.
## Related
* [`token.expiring`](/webhooks/token-expiring) — the heads-up event.
* [`platform_auth_failed`](/errors/platform_auth_failed)
# version.deprecated
Source: https://docs.letmepost.dev/webhooks/version-deprecated
An upstream platform announced an API version sunset. We're migrating internally; this is informational.
Fires when an upstream platform announces a sunset for an API version we're pinned to. Most-cited example: LinkedIn sunset five API versions in six months from 2024–2025.
This event is informational. **You do not need to act on it.** We pin the new version internally and migrate before the sunset. The event exists so integrators can render an "upcoming change" banner in their own UIs if they want — and so we have a public audit trail of every migration.
## Payload
```json theme={"system"}
{
"type": "version.deprecated",
"data": {
"platform": "linkedin",
"currentPin": "202504",
"nextPin": "202505",
"sunsetAt": "2026-08-01T00:00:00Z",
"announcedAt": "2026-05-04T15:30:00Z",
"migrationStatus": "in_flight",
"noticeUrl": "https://learn.microsoft.com/..."
}
}
```
## Why we surface this
The whole product principle of "API version abstraction" rests on the idea that *we* handle the migration, not you. This event makes that work legible from the outside — you can see when a migration is in flight and confirm your account isn't affected.
## Related
* `GET /v1/platform-versions` — the public version tracker, with current pins and pending migrations.
* [Changelog](/changelog)