Social Posting API Checker
Compare what each platform’s posting API supports: auth, token refresh, media and limits.
| Auth model | Token refresh | Media | Video | Publishes | |||
|---|---|---|---|---|---|---|---|
| X | OAuth 1.0a (your own keys) | Never expires | We upload | 4 | Yes | 280 | Live |
| Bluesky | App password | Refreshes itself | We upload | 4 | Yes | 300 | Live |
| OAuth 2.0 | Reconnect required | We upload | 9 | Dropped | 3,000 | Live | |
| OAuth 2.0 | Reconnect required | They fetch | 10 | Yes | 2,200 | Live | |
| OAuth 2.0 | Reconnect required | They fetch | 10 | Dropped | 63,206 | Live | |
| TikTok | OAuth 2.0 | Refreshes itself | We upload | 0 | Required | not sent | Draft only |
| Threads | OAuth 2.0 | Refreshes itself | They fetch | 1 | Dropped | 500 | Live |
| OAuth 2.0 | Refreshes itself | They fetch | 1 | Dropped | 800 | Live | |
| Mastodon | OAuth 2.0 | Never expires | We upload | 4 | Yes | 500 | Live |
| Discord | Webhook URL | Never expires | They fetch | 4 | Dropped | 2,000 | Live |
| Telegram | Bot token | Never expires | They fetch | 1 | Dropped | 4,096 | Live |
| Tumblr | OAuth 2.0 | Refreshes itself | They fetch | 1 | Dropped | 4,096 | Live |
| Slack | Webhook URL | Never expires | They fetch | 1 | Dropped | 3,000 | Live |
| WordPress | API key | Never expires | They fetch | 1 | Dropped | 250,000 | Live (article) |
| Ghost | API key | Never expires | They fetch | 1 | Dropped | 250,000 | Live (article) |
| Dev.to | API key | Never expires | They fetch | 1 | Dropped | 250,000 | Live (article) |
| Hashnode | API key | Never expires | They fetch | 1 | Dropped | 250,000 | Live (article) |
| Medium | API key | Never expires | They fetch | 1 | Dropped | 250,000 | Live (article) |
| Lemmy | App password | Never expires | They fetch | 1 | Dropped | 40,000 | Live (article) |
| Nostr | Private key | Never expires | They fetch | no cap | Yes | 10,000 | Live |
| Webhook | Webhook URL | Never expires | They fetch | no cap | Yes | 250,000 | Live |
“Media” is about who moves the bytes: some APIs take an upload, others take a URL and fetch it themselves — which means your media host has to stay reachable. “Images” is what the adapter actually attaches, not the platform’s documented maximum.
X
OAuth 1.0a (your own keys)Never expiresYou paste your own X app’s four OAuth 1.0a keys. Every request is signed with them (buildOAuth1Header).
OAuth 1.0a access tokens do not expire. The adapter’s OAuth 2.0 refresh grant exists but is unreachable — X connections are BYOK-only.
We fetch the asset and POST the bytes to upload.twitter.com/1.1/media/upload.json, then attach media_ids.
280 characters.
4 attached; anything beyond that is dropped.
Supported.
Gotchas (4)
- Posting costs money. A 402, or any error body mentioning “credit”, is X telling you the developer account needs API credits — that is X’s pricing, not a seenpaid limit.
- Video cannot use the single-shot upload: it has to go through chunked INIT / APPEND / FINALIZE, then a STATUS poll while X transcodes. The simple endpoint answers “media type unrecognized”.
- The 5th image onward is dropped with no error (slice(0, 4)).
- A pre-BYOK OAuth 2.0 connection is rejected before the API call — X answers OAuth 2.0 app-only auth on /2/tweets with a 403.
Read off x.adapter.ts — publish, uploadMedia, uploadVideoChunked, describeXPublishError; jobs/publish-target.job.ts
Bluesky
App passwordRefreshes itselfNo OAuth redirect at all. Handle + app password go straight to com.atproto.server.createSession, which returns accessJwt/refreshJwt.
com.atproto.server.refreshSession. Session JWTs are short (~2h), so the sweep refreshes eagerly at 90 minutes.
com.atproto.repo.uploadBlob — raw bytes with the asset’s Content-Type; the returned blob ref goes in the post record.
300 characters.
4 attached; anything beyond that is dropped.
Supported.
Gotchas (3)
- A URL in the text is dead plain text unless the record carries richtext facets, and facet offsets must be UTF-8 BYTE ranges — one emoji before a link shifts a character-index facet off target.
- An embed is exactly one of images / video / external, never mixed, and video uses a single blob rather than an array. A video blob placed in images.image is refused: “Expected image/* got video/mp4”.
- A 4xx from the PDS means the app password is gone (reconnect); a 5xx is a blip worth retrying. Collapsing both used to disconnect healthy accounts.
Read off bluesky.adapter.ts — loginWithAppPassword, refreshTokens, uploadBlob, publish, linkFacets
Plain OAuth 2.0 authorization code — no PKCE. Scopes: openid, profile, w_member_social.
refreshTokens() throws by design: the standard 3-legged flow issues no refresh token. The 60-day token expires and the account flips to reauth_required.
POST /rest/images?action=initializeUpload for an upload URL, then we PUT the bytes and attach the returned asset URN.
3,000 characters.
9 attached; anything beyond that is dropped.
Not supported. The adapter filters video out, so a video-only post loses the video.
Gotchas (4)
- Every request needs a LinkedIn-Version: YYYYMM header, and versions are supported for roughly 12 months. A sunset value fails with 426 NONEXISTENT_VERSION — this has already bitten once in production.
- One image must use the `media` content type. `multiImage` requires 2–20 images, so a single-image multiImage post 422s with “size of images, found 1, is out of range 2...20”.
- The created post URN comes back in the x-restli-id response header, not the body.
- The adapter filters to type === image, so a video attached to a LinkedIn post is silently not posted.
Read off linkedin.adapter.ts — getAuthorizeUrl, refreshTokens, uploadImage, publish
Facebook Login (Meta app, Graph v22.0). We persist the PAGE access token; the account id is the linked IG Business account.
refreshTokens() throws by design — Page tokens do not self-refresh. Reconnecting mints a new long-lived (60-day) token.
Instagram fetches our media URL: image_url / video_url on POST /{ig-user-id}/media, then media_publish.
2,200 characters.
10 attached; anything beyond that is dropped.
Supported.
Gotchas (6)
- The publishing permission is instagram_content_publish. Meta’s own “API setup with Facebook login” guide prints instagram_content_publishing — a typo in their docs. The Permissions and Features catalog is the authority.
- A caption set on a carousel CHILD container is silently ignored. It belongs on the CAROUSEL parent.
- media_type REELS is rejected as a carousel child — child videos must be media_type VIDEO. REELS is only valid for a standalone single video.
- Carousel children are created one at a time, not in parallel: Meta rate-limits container creation, and a partial parallel failure leaves orphaned containers with no id trail.
- The numeric Graph media id is not a permalink. instagram.com/p/<media id> 404s on every post — the real /p/<shortcode> URL has to be fetched with fields=permalink afterwards.
- Container processing is polled for ~18s only, to stay inside the worker’s 25s publish timeout. A long video fails with a clear error instead of risking a duplicate post.
Read off instagram.adapter.ts — getAuthorizeUrl, refreshTokens, createContainer, createCarouselContainer, publish
Same Meta app as Instagram (Graph v22.0). Scopes: pages_manage_posts, pages_read_engagement, pages_show_list. We persist the Page token.
refreshTokens() throws by design — Page tokens do not self-refresh. Reconnecting mints a new long-lived (60-day) token.
Facebook fetches our media URL: `url` on POST /{page-id}/photos, or attached_media fbids on /feed.
63,206 characters.
10 attached; anything beyond that is dropped.
Not supported. The adapter filters video out, so a video-only post loses the video.
Gotchas (3)
- Three different shapes for one post: no image → /feed, one image → /photos with a caption, several → unpublished /photos uploads collected into attached_media on a /feed post.
- The adapter filters to type === image, so a video-only Facebook post publishes as text with the video quietly missing.
- Meta does not always return expires_in on the long-lived token exchange (observed live), so the adapter falls back to 60 days rather than storing an invalid date.
Read off facebook.adapter.ts — getAuthorizeUrl, refreshTokens, publish; meta-shared.ts
TikTok
OAuth 2.0Refreshes itselfDraft onlyMedia requiredOAuth 2.0 with client_key. Scopes: user.info.basic, video.upload — deliberately NOT video.publish, which is audit-gated.
POST /v2/oauth/token/ with grant_type=refresh_token really refreshes.
FILE_UPLOAD: we download the video and PUT the bytes to TikTok’s pre-signed URL. PULL_FROM_URL is not used — it would need domain verification we cannot do on an *.r2.dev host.
Not sent at all. The composer offers 2,200 characters, but this adapter never passes the caption to the API.
None. Images are never read by this adapter.
Required — a post without a video is refused.
Gotchas (6)
- Nothing goes live. The video lands in the creator’s TikTok drafts (/v2/post/publish/inbox/video/init/) and somebody has to open the app and tap Post. Direct Post needs TikTok’s content-posting audit.
- The caption never reaches TikTok. The inbox init request carries only source_info, so whatever you wrote has to be retyped in the app — despite the composer offering a 2,200-character limit.
- A dead refresh token comes back as HTTP 200 with an invalid_grant body, so a status check alone is no guard. Measured against the live endpoint on 2026-08-16.
- One chunk, so 64MB is the hard ceiling; over that the post fails loudly instead of truncating.
- Exactly one video is required. Images are never looked at.
- There is no public post URL until the creator publishes it, so the account profile is surfaced as a placeholder.
Read off tiktok.adapter.ts — getAuthorizeUrl, refreshTokens, publish
Threads
OAuth 2.0Refreshes itselfA SEPARATE Meta app from Facebook/Instagram, with its own THREADS_CLIENT_ID. Scopes: threads_basic, threads_content_publish.
grant_type=th_refresh_token trades the current long-lived token for a fresh 60-day one — the access token doubles as its own refresh token.
Threads fetches our media URL: image_url on the container create (POST /{user-id}/threads), then threads_publish.
500 characters.
1 attached; anything beyond that is dropped.
Not supported. The adapter filters video out, so a video-only post loses the video.
Gotchas (3)
- The token from the code exchange is short-lived (~1h) and must immediately be swapped via th_exchange_token, or every connection dies within the hour.
- A failed refresh must throw, not no-op. Returning a token set with no expiry once nulled the stored expiry, and the sweep only selects rows where expires_at is not null — the account went permanently invisible and read as “active” for 8 days after expiring.
- One image, chosen with find(type === image). Videos have no code path.
Read off threads.adapter.ts — getAuthorizeUrl, exchangeCodeForTokens, refreshTokens, publish
OAuth 2.0 (API v5); the token endpoint wants HTTP Basic client credentials. Scopes: user_accounts:read, boards:read, boards:write, pins:read, pins:write.
POST /v5/oauth/token with grant_type=refresh_token really refreshes.
Pinterest fetches our image URL: media_source { source_type: image_url }.
800 characters — truncated at title 100 chars, description 800 chars.
1 attached; anything beyond that is dropped.
Not supported. The adapter filters video out, so a video-only post loses the video.
Gotchas (4)
- pins:write is not enough. Pinterest classes POST /pins as writing to the board, so boards:write is also required — proven by a real 401 “Missing: ['boards:write']” on the first live pin.
- The Pin goes to whichever board the API returns first (GET /boards?page_size=1). There is no board picker.
- The caption is split: first line becomes the Pin title (capped at 100), the rest becomes the description (capped at 800).
- An image is mandatory — a Pin cannot exist without one.
Read off pinterest.adapter.ts — getAuthorizeUrl, refreshTokens, firstBoardId, publish
Mastodon
OAuth 2.0Never expiresOAuth 2.0 against ONE configured instance (MASTODON_INSTANCE_URL), scopes read + write. Per-instance app registration at connect time is not implemented.
Mastodon access tokens do not expire by default and no refresh token is issued; refreshTokens() hands the token straight back, so the sweep is a no-op.
Multipart POST /api/v2/media with the fetched bytes; the returned attachment id goes on the status.
500 characters.
4 attached; anything beyond that is dropped.
Supported.
Gotchas (3)
- A multipart file part with no filename is refused by Mastodon and many Fediverse servers, so the adapter derives one from the content type.
- Video and GIF uploads return 202 with url: null while the server transcodes. Attaching that id to a status too early 422s with “files that have not finished processing”, so the adapter polls GET /api/v1/media/:id (200 = done, 206 = still going) for up to 20s.
- Federated: the 500-character limit is this instance’s default, and instances are free to configure a different one. The adapter does not read the instance’s own limit.
Read off mastodon.adapter.ts — getAuthorizeUrl, refreshTokens, uploadMedia, publish
Discord
Webhook URLNever expiresNo OAuth. You create a channel webhook (Channel → Integrations → Webhooks) and paste the URL — the URL IS the credential.
Webhook URLs do not expire; refreshTokens() returns the same credential.
Images ride as embeds ({ image: { url } }) pointing at our media URL; the caption is the message content.
2,000 characters — truncated at 2,000 chars, by the adapter.
4 attached; anything beyond that is dropped.
Not supported. The adapter filters video out, so a video-only post loses the video.
Gotchas (3)
- The adapter hard-truncates the message at 2,000 characters rather than letting Discord reject it.
- Only type === image is attached — a video is dropped.
- The webhook URL is validated by GETting it, so a revoked webhook is caught at connect time.
Read off discord.adapter.ts — connectWebhook, refreshTokens, publish
Telegram
Bot tokenNever expiresNo OAuth. You create a bot with @BotFather, add it to the channel as an admin, and paste the bot token plus the channel id.
Bot tokens do not expire; refreshTokens() returns the same credential.
Telegram fetches our image URL: `photo` on sendPhoto. No image → sendMessage.
4,096 characters — truncated at 4,096 chars for text — but only 1,024 when a photo is attached.
1 attached; anything beyond that is dropped.
Not supported. The adapter filters video out, so a video-only post loses the video.
Gotchas (4)
- Attaching a photo cuts the usable caption from 4,096 characters to 1,024: sendPhoto’s caption is truncated at 1,024 by the adapter. The composer’s limit table only knows about 4,096.
- The bot must be an admin of the channel, checked with getChat at connect time.
- A post URL only exists if the channel has a public username; otherwise the published post has no link.
- Telegram answers errors with HTTP 200 and ok: false, so the adapter checks the body, not the status.
Read off telegram.adapter.ts — connectBot, refreshTokens, publish
Tumblr
OAuth 2.0Refreshes itselfOAuth 2.0 (API v2), scopes write + offline_access.
POST /v2/oauth2/token with grant_type=refresh_token really refreshes (offline_access is what earns the refresh token).
Tumblr fetches our image URL: an NPF image block with media [{ url }].
4,096 characters.
1 attached; anything beyond that is dropped.
Not supported. The adapter filters video out, so a video-only post loses the video.
Gotchas (3)
- Posts go to the PRIMARY blog only — the blog picked by find(primary) at connect time. There is no per-post blog choice.
- Neue Post Format, so the body is a content-block array: the image block is prepended, the text block follows.
- One image, chosen with find(type === image). Videos have no code path.
Read off tumblr.adapter.ts — getAuthorizeUrl, refreshTokens, fetchAccountProfile, publish
Slack
Webhook URLNever expiresNo OAuth. You create an Incoming Webhook for a channel and paste the URL — the URL IS the credential.
Incoming Webhook URLs do not expire; refreshTokens() returns the same credential.
A lead image is passed as a legacy attachment image_url pointing at our media URL.
3,000 characters.
1 attached; anything beyond that is dropped.
Not supported. The adapter filters video out, so a video-only post loses the video.
Gotchas (3)
- Incoming Webhooks are write-only: no message id and no permalink come back. The stored post id is synthesized and the post has no URL to link to.
- Because the endpoint is write-only there is nothing to GET, so the credential is validated only by URL shape at connect time.
- One image, chosen with find(type === image). Videos have no code path.
Read off slack.adapter.ts — connectWebhook, refreshTokens, publish
WordPress
API keyNever expiresPublishes an articleAn Application Password (WordPress 5.6+), sent as HTTP Basic. You paste site URL, username and the password; no OAuth, no app registration.
Application Passwords do not expire; refreshTokens() returns the same credential.
The image is inlined in the post HTML as <img src="…"> pointing at our media URL. Nothing is uploaded to the WordPress media library.
250,000 characters.
1 attached; anything beyond that is dropped.
Not supported. The adapter filters video out, so a video-only post loses the video.
Gotchas (3)
- This publishes an ARTICLE, not a status: the caption’s first line becomes the post title and the rest becomes the HTML body.
- Because the image is only linked, the post depends on our media URL staying reachable — it never becomes a WordPress attachment.
- The target is user-supplied, so the request goes through the self-hosted fetch guard: the site must resolve to a public address.
Read off wordpress.adapter.ts — connectAppPassword, refreshTokens, publish; devto.adapter.ts splitTitleBody
Ghost
API keyNever expiresPublishes an articleAn Admin API key shaped id:secret (Settings → Integrations → custom integration). The adapter mints a fresh 5-minute JWT from it for every request.
Admin API keys do not expire; refreshTokens() returns the same credential. The short-lived JWT is minted per call, never stored.
The image is inlined in the post HTML as a <figure><img src="…"> pointing at our media URL. Nothing is uploaded to Ghost’s image store.
250,000 characters.
1 attached; anything beyond that is dropped.
Not supported. The adapter filters video out, so a video-only post loses the video.
Gotchas (3)
- Publishes an ARTICLE: first line of the caption becomes the title, the rest becomes HTML.
- The key must be in id:secret form with a hex secret — anything else is rejected at connect time before a request is made.
- The target is user-supplied, so the request goes through the self-hosted fetch guard: the site must resolve to a public address.
Read off ghost.adapter.ts — ghostToken, connectAdminKey, refreshTokens, publish
Dev.to
API keyNever expiresPublishes an articleAn API key from dev.to/settings/extensions, sent as the api-key header. No OAuth and no developer app for anyone.
API keys do not expire; refreshTokens() returns the same credential.
The image is prepended to the markdown body as  pointing at our media URL.
250,000 characters — truncated at title 250 chars (first line); body untouched.
1 attached; anything beyond that is dropped.
Not supported. The adapter filters video out, so a video-only post loses the video.
Gotchas (2)
- Publishes an ARTICLE: first line of the caption becomes the title (capped at 250 characters), the rest is markdown.
- This is the lowest-friction API of the set — a pasted key posts immediately, with no app registration or review.
Read off devto.adapter.ts — connectApiKey, refreshTokens, splitTitleBody, publish
Hashnode
API keyNever expiresPublishes an articleA Personal Access Token (Settings → Developer) plus the target publication id. GraphQL API; no OAuth.
PATs do not auto-expire; refreshTokens() returns the same credential.
The image is prepended to contentMarkdown as  pointing at our media URL.
250,000 characters.
1 attached; anything beyond that is dropped.
Not supported. The adapter filters video out, so a video-only post loses the video.
Gotchas (3)
- Publishes an ARTICLE via the publishPost mutation: first line becomes the title.
- PublishPostInput.tags is typed non-null, so omitting tags fails GraphQL validation — an empty array has to be sent explicitly.
- The account is keyed by publication id, so a post always lands on the blog you connected.
Read off hashnode.adapter.ts — connectPat, refreshTokens, publish
Medium
API keyNever expiresPublishes an articleAn integration token (Settings → Security and apps → Integration tokens), sent as a bearer. No OAuth.
Integration tokens do not auto-expire; refreshTokens() returns the same credential.
The image is prepended to the markdown content as  pointing at our media URL.
250,000 characters.
1 attached; anything beyond that is dropped.
Not supported. The adapter filters video out, so a video-only post loses the video.
Gotchas (2)
- Medium deprecated the creation of NEW integration tokens. This works if you already hold one, and a new account may simply be unable to make one.
- Publishes an ARTICLE with contentFormat: markdown and publishStatus: public — first line becomes the title.
Read off medium.adapter.ts — connectToken, refreshTokens, publish
Lemmy
App passwordNever expiresPublishes an articleInstance URL + username + password, logged in once. We store the returned session JWT and the resolved community id — never the password.
There is no refresh endpoint; refreshTokens() returns the same session token. If the instance invalidates it you reconnect.
The image URL becomes the post’s `url` field — a LINK post pointing at our media, not an inline embed.
40,000 characters — truncated at title 200 chars (first line); body untouched.
1 attached; anything beyond that is dropped.
Not supported. The adapter filters video out, so a video-only post loses the video.
Gotchas (4)
- A Lemmy post needs a title, so the caption’s first line becomes it (capped at 200 characters) and the rest becomes the body.
- Attaching an image turns the post into a link post pointing at the image URL — it is not embedded in the body.
- The community is resolved to an id at connect time, so every post goes to that one community.
- The instance is user-supplied, so the request goes through the self-hosted fetch guard: it must resolve to a public address.
Read off lemmy.adapter.ts — connectLogin, refreshTokens, publish
Nostr
Private keyNever expiresThere is no server to authorize against. You paste an nsec1… (or 64-char hex) private key; each note is signed with it and broadcast to relays.
Keys do not expire; refreshTokens() returns the same credential and the sweep is a no-op.
kind:1 has no media field at all, so every media URL is appended to the note text — clients render trailing image URLs inline.
10,000 characters.
No cap — every asset is forwarded.
Supported.
Gotchas (4)
- No cap on attachments, but also no attachment mechanism: media is just URLs pasted at the end of the text, and whether they render is up to the reader’s client.
- A publish succeeds if at least ONE relay accepts the event; each relay is raced against an 8-second timeout, because a relay that accepts the socket and never answers would otherwise hold a worker slot forever.
- There is no canonical post URL — the adapter builds an njump.me link from the nevent id.
- Relay connections need a WebSocket implementation injected on Node 20; the global only exists from Node 22 up.
Read off nostr.adapter.ts — connectKey, refreshTokens, publish
Webhook
Webhook URLNever expiresAny https:// URL you own, plus an optional signing secret. Not a social network — an escape hatch into Zapier, Make, n8n, or your own endpoint.
A URL does not expire; refreshTokens() returns the same credential.
The POST body is JSON: { platform, caption, media: [{ url, type }], postedAt }. Media is passed as URLs, never as bytes.
250,000 characters.
No cap — every asset is forwarded.
Supported.
Gotchas (3)
- With a secret set, each request carries X-Seenpaid-Signature: an HMAC-SHA256 of the exact raw body, so the receiver can verify it came from us.
- “Any https URL” is not any address. The target is checked at connect time AND on every publish: private, loopback and cloud-metadata ranges are refused and redirects are not followed.
- Every media asset is forwarded, with no cap and no filtering by type.
Read off webhook.adapter.ts — connectUrl, refreshTokens, publish; self-hosted-fetch.ts
Where this stops
Everything above is read off the code that calls these APIs, so it describes what a real publisher does — not everything a platform’s API can theoretically do. Rate limits, file-size ceilings and aspect-ratio rules mostly are not in there, so they are not claimed here: the two exceptions are TikTok’s 64MB single-chunk ceiling and Instagram’s processing window, which the code does enforce. Character limits come from the same table the publisher checks before sending, not from each platform’s API responses. And a platform that offers something we have not built — LinkedIn video, Threads carousels — reads as “dropped” or “1” here, because that is what happens when you post.
About this tool
One table for every posting API: how you authenticate, whether tokens refresh on their own, who uploads the media, how many images attach, and whether the post actually goes live. Each row is read from the adapter seenpaid uses to publish to that platform and names the file it came from, so it reflects what the API does rather than what its docs say. Useful if you are building your own integration, or deciding not to.
Related tools
Social Media Post Preview
See how your caption and images will look on each platform before you post.
Cross-Post Checker
Paste one caption and see where it runs over the limit on each platform.
Social Media Image Sizes
Image, video, profile and header sizes for each platform, with the source for every number.
seenpaid is a social media scheduler for 21 platforms. These tools came out of building it, and they stay free.