Not every platform is publishable at every moment. Each one carries a
server-controlled availability state, which you can read from
GET /api/platforms:
| State | New connections | Existing channels | Meaning |
|---|---|---|---|
on |
✅ | ✅ publish normally | Fully available. |
connect_off |
❌ | ✅ publish normally | New connections are paused — typically while a platform app review is pending. Customers already connected are unaffected. |
off |
❌ | ⏸ posts held | Kill switch. Posts are held, not failed, and publish automatically once the platform is re-enabled. |
Disabled platforms are always included in the response with enabled: false
and a reason — never omitted — so you can distinguish "switched off right now"
from "not supported".
{
"platforms": [
{
"platform": "tumblr",
"displayName": "Tumblr",
"enabled": true,
"state": "on",
"reason": "enabled",
"canConnect": true,
"canPublish": true,
"message": null
}
]
}Creating or publishing a post that targets an off platform returns 403 with
code: "PLATFORM_DISABLED":
{
"error": {
"message": "LinkedIn is temporarily unavailable. Scheduled posts are on hold and will publish once it's back.",
"code": "PLATFORM_DISABLED",
"platform": "linkedin",
"state": "off",
"reason": "flag_off"
}
}This is distinct from FEATURE_DISABLED, which means the platform is not
included in the organization's plan. PLATFORM_DISABLED is temporary and
resolves without any action from you; FEATURE_DISABLED requires an upgrade.
Posts already scheduled when a platform goes off need no intervention — do not
delete and recreate them. They remain scheduled and publish on their own once
the platform returns.
Some platforms are two integrations under one name, approved separately by the
vendor. Those report a variants object keyed by the channel accountType it
covers, with the same fields as the platform itself:
{
"platform": "linkedin",
"state": "on",
"canConnect": true,
"canPublish": true,
"variants": {
"organization": {
"label": "LinkedIn Company Pages",
"enabled": false,
"state": "connect_off",
"reason": "flag_connect_off",
"canConnect": false,
"canPublish": true,
"message": "New LinkedIn Company Pages connections are paused. Pages you've already connected keep working."
}
}
}Today the only variant is linkedin.organization — company pages run on a
separate LinkedIn app (Community Management API) with its own review, so pages
can be paused while personal-profile posting is fully live. The platform-level
state describes personal profiles; check the variant before offering a
company-page connect.
A variant is never more permissive than its parent: a platform in state off
means every variant is off too. When a variant is what blocks a write, the 403
PLATFORM_DISABLED error carries an extra accountType field naming it:
{
"error": {
"message": "LinkedIn Company Pages are temporarily unavailable. Scheduled posts are on hold and will publish once they're back.",
"code": "PLATFORM_DISABLED",
"platform": "linkedin",
"accountType": "organization",
"state": "off",
"reason": "flag_off"
}
}Use the postTypeOverrides field to set a specific post type per platform:
{
"postTypeOverrides": {
"instagram": "reel",
"facebook": "story",
"youtube": "short"
}
}| Platform | Available Post Types |
|---|---|
feed_photo, feed_video, reel, story, carousel |
|
post, reel, story |
|
| TikTok | video, photo_slideshow |
| YouTube | video, short |
post, multi_image, pdf_carousel, article |
|
pin, video_pin, carousel |
|
| Threads | text, image, video, carousel |
| X (Twitter) | tweet (use postFormat: "thread" for threads) |
| Bluesky | post (use postFormat: "thread" for threads) |
| Mastodon | post (use postFormat: "thread" for threads) |
| Google Business | standard, event, offer |
| Tumblr | post |
| Snapchat | story (default), saved_story, spotlight |
post |
|
| Discord | post |
| Telegram | post |
If not specified, the platform's default post type is used based on the attached media.
Each platform has unique features and requirements. BulkPublish lets you configure per-platform options through the platformSpecific field when creating or updating a post.
The platformSpecific object is keyed by platform name. Include only the platforms you want to customize:
{
"content": "New product launch!",
"channels": [
{"channelId": 1, "platform": "instagram"},
{"channelId": 2, "platform": "youtube"},
{"channelId": 3, "platform": "x"}
],
"platformSpecific": {
"instagram": {
"collaborators": "@partner1, @partner2"
},
"youtube": {
"title": "New Product Launch - Full Demo",
"privacyStatus": "public",
"categoryId": "28"
},
"x": {
"replySettings": "following"
}
},
"status": "scheduled",
"scheduledAt": "2026-04-10T12:00:00Z"
}If you need different text for different platforms, use platformContent instead of (or alongside) platformSpecific:
{
"content": "Default text for all platforms.",
"platformContent": {
"x": "Short version for X (280 chars max)",
"linkedin": "Longer, more professional version for LinkedIn..."
},
"channels": [
{"channelId": 1, "platform": "x"},
{"channelId": 2, "platform": "linkedin"},
{"channelId": 3, "platform": "facebook"}
],
"status": "draft"
}Platforms listed in platformContent use that text. All others fall back to the content field.
Most platforms support _firstComment in platformSpecific. After the post is published, BulkPublish automatically posts a comment on the published content:
{
"content": "Check out our new feature!",
"channels": [{"channelId": 1, "platform": "instagram"}],
"platformSpecific": {
"_firstComment": "Link in bio for more details! #newfeature #launch"
},
"status": "scheduled",
"scheduledAt": "2026-04-10T12:00:00Z"
}This is commonly used on Instagram to keep the caption clean while adding hashtags or CTAs in the first comment.
Not every platform can post one. Supported on X, Instagram, Facebook, LinkedIn, YouTube, Threads, Bluesky, Mastodon, Reddit and Telegram. Not supported on Discord, Pinterest, TikTok, Google Business, Tumblr or Snapchat.
Sending _firstComment to an unsupported platform does not fail the post.
The main post publishes normally and the comment is recorded against it as
failed with the reason <Platform> does not support first comments. So a
missing first comment on those platforms is expected behaviour, not an incident
— check the post's first-comment result rather than assuming an error dropped
it.
Note that _firstComment sits at the top level of platformSpecific, not
inside a platform key, so it applies to every channel on the post.
| Value | Description |
|---|---|
feed_photo |
Single photo post |
feed_video |
Single video post |
reel |
Short-form vertical video |
story |
Photo or video story (disappears after 24h) |
carousel |
Up to 10 photos/videos in a swipeable post |
{
"instagram": {
"collaborators": "@username1, @username2",
"shareToStory": true,
"trialReel": true,
"graduationStrategy": "auto",
"thumbnailTimestamp": 3.5
}
}| Field | Type | Description |
|---|---|---|
collaborators |
string | Comma-separated usernames to invite as collaborators |
shareToStory |
boolean | Also share the post to your story |
trialReel |
boolean | Post as a trial reel (limited audience first) |
graduationStrategy |
string | "manual" or "auto" -- how trial reels graduate to full audience |
thumbnailTimestamp |
number | Seconds into the video to use as the cover thumbnail |
- Instagram requires JPEG images. BulkPublish auto-converts PNG/WebP to JPEG before publishing.
- Carousels support up to 10 items (images or videos).
- Reels must be vertical video.
| Value | Description |
|---|---|
video |
Standard video upload |
photo_slideshow |
Photo slideshow (up to 35 images) |
{
"tiktok": {
"privacyLevel": "PUBLIC_TO_EVERYONE",
"disableDuet": false,
"disableStitch": false,
"disableComment": false,
"isAigc": false,
"brandContentToggle": false,
"brandOrganicToggle": false,
"thumbnailTimestamp": 2.0
}
}| Field | Type | Description |
|---|---|---|
privacyLevel |
string | Who can see the post (see table below) |
disableDuet |
boolean | Prevent others from creating duets |
disableStitch |
boolean | Prevent others from stitching |
disableComment |
boolean | Turn off comments |
isAigc |
boolean | Mark as AI-generated content |
brandContentToggle |
boolean | Mark as paid partnership |
brandOrganicToggle |
boolean | Mark as organic brand content |
thumbnailTimestamp |
number | Seconds into the video for the cover image |
| Value | Description |
|---|---|
PUBLIC_TO_EVERYONE |
Visible to everyone |
MUTUAL_FOLLOW_FRIENDS |
Visible to mutual followers |
FOLLOWER_OF_CREATOR |
Visible to your followers |
SELF_ONLY |
Visible only to you |
SEND_TO_USER_INBOX |
Upload to inbox for review before publishing |
- Photo slideshows require images at most 1080px on the longest side.
- Videos must be MP4 or MOV format.
| Value | Description |
|---|---|
video |
Standard YouTube video |
short |
YouTube Short (vertical, 3 minutes or less) |
{
"youtube": {
"title": "My Video Title",
"privacyStatus": "public",
"categoryId": "28",
"madeForKids": false,
"playlistId": "PLxxxxxxxx",
"thumbnailUrl": "https://example.com/thumb.jpg"
}
}| Field | Type | Description |
|---|---|---|
title |
string | Required. Video title |
privacyStatus |
string | "public", "unlisted", or "private" (default: "public") |
categoryId |
string | YouTube category ID (default: "22" People & Blogs) |
madeForKids |
boolean | Whether the video is made for kids (COPPA compliance) |
playlistId |
string | Add the video to this playlist after upload |
thumbnailUrl |
string | URL of a custom thumbnail image |
| ID | Category |
|---|---|
1 |
Film & Animation |
2 |
Autos & Vehicles |
10 |
Music |
15 |
Pets & Animals |
17 |
Sports |
19 |
Travel & Events |
20 |
Gaming |
22 |
People & Blogs |
23 |
Comedy |
24 |
Entertainment |
25 |
News & Politics |
26 |
Howto & Style |
27 |
Education |
28 |
Science & Technology |
29 |
Nonprofits & Activism |
- YouTube only accepts video uploads. Text-only posts are not supported.
- The
titlefield is required for all YouTube posts. - The post's
contentfield becomes the video description on YouTube.
{
"pinterest": {
"1": {"boardId": "board_123"},
"title": "Pin Title",
"description": "Pin description text",
"link": "https://example.com/page"
}
}| Field | Type | Description |
|---|---|---|
{channelId} |
object | Board selection per channel: {"boardId": "board_id"} |
title |
string | Required. Pin title |
description |
string | Pin description (overrides the post's content if set) |
link |
string | Destination URL when the pin is clicked |
dominantColor |
string | Hex color for the pin, e.g. #FF5733 |
coverImageUrl |
string | Cover image for video_pin posts. Optional — when omitted, an image attached alongside the video is used, otherwise the video's auto-extracted poster frame |
Pinterest requires a board for each pin. Set the board per channel ID:
{
"pinterest": {
"5": {"boardId": "board_abc123"}
}
}You can fetch available boards for a channel:
curl "https://app.bulkpublish.com/api/channels/5/options" \
-H "Authorization: Bearer bp_your_key_here"- A
titleis required for Pinterest pins. - Each pin can link to one external URL.
- Video pins need a cover image. You rarely have to supply one: the server resolves
coverImageUrl→ an attached image → the video's auto-extracted poster frame, and only fails when all three are missing (e.g. a video uploaded before poster generation existed).
| Value | Description |
|---|---|
post |
Standard update |
event |
Event with dates |
offer |
Offer with coupon code |
{
"gmb": {
"ctaType": "LEARN_MORE",
"ctaUrl": "https://example.com",
"eventTitle": "Grand Opening",
"startDate": "2026-04-10",
"startTime": "09:00",
"endDate": "2026-04-10",
"endTime": "17:00",
"couponCode": "SAVE20",
"redeemOnlineUrl": "https://example.com/redeem",
"termsConditions": "Valid until April 30, 2026"
}
}| Field | Type | Description |
|---|---|---|
ctaType |
string | Call-to-action button type (see table below) |
ctaUrl |
string | URL for the CTA button (required unless ctaType is CALL) |
eventTitle |
string | Required for event/offer posts. Title of the event or offer |
startDate |
string | Start date (YYYY-MM-DD). Required for events and offers |
startTime |
string | Start time (HH:MM). Optional |
endDate |
string | End date (YYYY-MM-DD). Required for events and offers |
endTime |
string | End time (HH:MM). Optional |
couponCode |
string | Coupon code (offers only) |
redeemOnlineUrl |
string | URL to redeem the coupon online |
termsConditions |
string | Terms and conditions text |
| Value | Label |
|---|---|
LEARN_MORE |
Learn More |
BOOK |
Book |
ORDER |
Order |
SHOP |
Shop |
SIGN_UP |
Sign Up |
CALL |
Call (uses listing phone number) |
{
"x": {
"replySettings": "everyone"
}
}| Field | Type | Description |
|---|---|---|
replySettings |
string | Who can reply to the tweet |
| Value | Description |
|---|---|
everyone |
Anyone can reply (default) |
following |
Only people you follow can reply |
mentionedUsers |
Only mentioned users can reply |
- X has a 280-character limit for tweets.
- Thread posts (multi-part) are supported via
postFormat: "thread"withthreadParts. - X is excluded from the Free plan. Pro or Business plan required.
{
"threads": {
"quotePostId": "12345678901234567"
}
}| Field | Type | Description |
|---|---|---|
quotePostId |
string | ID of a Threads post to quote |
- Thread posts (multi-part) are supported.
- Supports images and videos.
{
"facebook": {
"shareToStory": true
}
}| Field | Type | Description |
|---|---|---|
shareToStory |
boolean | Also share the post to your Facebook story |
Override via postTypeOverrides.linkedin:
| Type | Description | Media |
|---|---|---|
post (default) |
Text post, optionally with a single image or video | 0–1 image OR 0–1 video |
multi_image |
Image gallery | 2–20 images |
pdf_carousel |
Images converted to a swipeable PDF carousel | 2–20 images |
article |
Link share with preview (requires platformSpecific.linkedin.url) |
0–1 image (thumbnail) |
| Field | Type | Notes |
|---|---|---|
url |
string | Required for article; the link to share |
title |
string | Article or video title |
description |
string | Article description |
carouselTitle |
string | Title shown on a pdf_carousel |
- Images: JPEG, PNG, GIF — up to 10 MB each, max 20 per post.
- Video: MP4 — up to 500 MB, 3–1800 s, max 1 per post.
- Character limit: 3,000.
A connected LinkedIn channel is one of two accountType values, returned by GET /api/channels:
personal— a member's personal LinkedIn profile.organization— a LinkedIn company page.
- Connect personal profiles and company pages in the dashboard (each is a one-time
LinkedIn authorization). Once connected they behave identically — list them with
GET /api/channelsand post to them bychannelIdlike any other channel. - The two run on separate LinkedIn apps (LinkedIn requires the Community
Management API used by company pages to be the only product on its
application), so they are gated separately:
GET /api/platformsreports company pages undervariants.organizationwhile the platform-level state covers personal profiles. See Variants.
No platform-specific options. Standard post creation with text and images.
- Thread posts (multi-part) are supported.
- Up to 4 images per post.
No platform-specific options. Standard post creation with text, images, and videos.
- Thread posts (multi-part) are supported.
- Up to 4 media attachments per post.
- Character limit depends on the instance (typically 500).
{
"platformSpecific": {
"tumblr": {
"12": {
"blogName": "myblog",
"title": "An optional heading",
"tags": ["art", "design"],
"link": "https://example.com",
"sourceUrl": "https://example.com/original"
}
}
}
}| Field | Type | Description |
|---|---|---|
blogName |
string | Which blog to publish to. Defaults to the blog the channel was connected as. |
title |
string | Rendered as a heading above the post body. |
tags |
string[] | Tumblr tags, without the leading #. |
link |
string | Appended to the post as a link block. |
sourceUrl |
string | Attribution URL stored as the post's source. |
Note that platformSpecific.tumblr is keyed by channel ID, because a single
Tumblr account can own several blogs and each connected channel may target a
different one.
- A Tumblr account usually owns multiple blogs. The channel connects as the
account's primary blog; use
blogNameto publish to a different one. CallGET /api/channels/{id}/optionsto list the available blogs. - Up to 30 images per post, or exactly one video — a video cannot be mixed with images in the same post.
- Tags matter more on Tumblr than on most networks: they drive discovery.
- Posts are built with Tumblr's Neue Post Format. Content longer than 4,096 characters is automatically split across multiple text blocks.
{
"platformSpecific": {
"snapchat": {
"12": {
"title": "Behind the scenes",
"locale": "en_US",
"saveToProfile": true
}
}
}
}Set via postTypeOverrides.snapchat — one of story, saved_story, spotlight:
| Type | Media | Text behaviour |
|---|---|---|
story (default) |
exactly 1 image or video | The caption is not sent to Snapchat — stories carry no text. |
saved_story |
exactly 1 image or video | Published as a Saved Story with a title (max 45 chars; defaults to the first line of the caption, truncated). |
spotlight |
video only, 6–60s | The caption becomes the Spotlight description (max 160 chars, truncated); hashtags in it are clickable. |
| Field | Type | Description |
|---|---|---|
title |
string | Saved Story title, max 45 chars. Defaults to the first line of the caption, truncated. |
locale |
string | Spotlight locale, e.g. en_US (the default). |
saveToProfile |
boolean | Spotlight only. Default true; false sends skip_save_to_profile to Snap. |
Like Reddit, Discord and Tumblr, platformSpecific.snapchat is keyed by
channel ID (a flat object is also accepted and applies to every Snapchat
channel on the post).
- Every Snapchat post requires exactly one media file — images as jpg/png, videos as mp4/mov, vertical, 5–60 seconds (6–60s for Spotlight), max 1GB.
- First comments (
_firstComment) are not supported on Snapchat. - Engagement (individual comments/reactions) is not readable; per-post metrics are (see Metrics by Platform).
{
"platformSpecific": {
"reddit": {
"12": {
"subreddit": "webdev",
"title": "We rebuilt our scheduler — here's what broke",
"flairId": "a1b2c3d4-0000-11ee-8c99-0242ac120002"
}
}
}
}platformSpecific.reddit is keyed by channel ID, because each connected
Reddit account commonly posts to a different subreddit. A flat object (no
channel-ID level) is also accepted and applies to every Reddit channel on the
post.
| Field | Type | Description |
|---|---|---|
subreddit |
string | Required. Target subreddit. Accepts webdev, r/webdev, or /r/webdev — all are normalized to a bare lowercase name. Falls back to the subreddit stored on the channel if omitted. |
title |
string | Post title. When omitted, the first line of content is used, truncated to 300 characters. |
type |
string | Set to link to force a link submission. Usually unnecessary — see the kind table below. |
url |
string | Destination URL for a link post. Supplying it implies type: "link". |
flairId |
string | Link-flair ID. Call GET /api/channels/{id}/options to list a subreddit's flairs. |
thumbnailUrl |
string | Poster image for video posts. Optional — falls back to the video's auto-extracted poster frame. |
You do not set the kind directly. The server resolves it from the attached media and options, in this order:
| Condition | Reddit submission kind |
|---|---|
| An image file is attached | image |
| A video file is attached | video |
type is link, or url is set |
link |
| Otherwise | self (text post) |
- A media post accepts exactly one file. Attaching two images, or an image and a video, fails validation — Reddit has no multi-asset submission.
- Video posts need a poster image, but you rarely have to supply one.
thumbnailUrlfalls back to the video's auto-extracted poster frame, and the publish fails only when neither exists. Unlike Pinterest there is no attached-image fallback — a media post accepts exactly one file, so a video post cannot also carry a cover image. - Reddit returns HTTP 200 even when a subreddit rule rejects the submission. The
real error arrives in the response body and is surfaced as a failed post, so
check
errorMessagerather than assuming success. - Media submissions resolve asynchronously: Reddit confirms the permalink over a websocket. If it does not confirm within 20 seconds the post is marked failed with a message saying it may still have appeared — verify on the subreddit before retrying, or you risk a duplicate.
- A text post's
contentis submitted as the body, and the title is separate, so the first line is effectively used twice unless you settitleyourself. - Character limit: 40,000. Images up to 20 MB (jpg, png, gif); video up to 1 GB (mp4, mov).
{
"platformSpecific": {
"discord": {
"12": { "channelId": "1090123456789012345" }
}
}
}The outer key is the BulkPublish channel ID; the inner channelId is the
Discord channel (the snowflake ID of the text channel inside the server). They
are different IDs that happen to share a name — a connected Discord "channel" in
BulkPublish is an entire server (guild), so the message still needs a
destination channel within it.
A flat {"channelId": "..."} is also accepted and applies to every Discord
channel on the post, as is a default stored on the connection.
| Field | Type | Description |
|---|---|---|
channelId |
string | Required. The Discord text channel to post in. Call GET /api/channels/{id}/options to list the server's postable channels (text, announcement, and forum channels). |
- Publishing fails with "No Discord channel selected for this message" if no channel ID can be resolved from the post, a flat value, or the connection default.
- Posting uses a global bot token, not the per-user OAuth token. A publish
failure is therefore never a re-authentication problem — the channel is never
flagged
needs_reconnect, and reconnecting will not fix a failed Discord post. The usual causes are permissions: the bot lacks access to the channel, lacks permission to send messages there, or the channel was deleted. Those are returned as plain-language errors. - Up to 10 attachments per message, 25 MB each — Discord's limit for a standard (non-boosted) server.
_firstCommentis not supported. Discord exposes no comment API to the bot, so a first comment is recorded as failed while the main message publishes normally. See Auto First Comment.- Character limit: 2,000 — tighter than every platform except X (280),
Bluesky (300), Threads / Pinterest / Mastodon (500) and Google Business
(1,500). Use
platformContentto supply a shorter variant rather than letting the post fail validation.
No platform-specific options. Telegram takes nothing in platformSpecific;
the destination chat is fixed when the channel is connected, so a post needs
only content and any media.
Each Telegram connection stores its own bot token (from @BotFather) together with one target chat, which is why there is nothing to select per post. To publish to a second channel or group, connect it as a separate BulkPublish channel.
- Content is sent as plain text, without Markdown or HTML parse mode, so
characters like
&,<, and>are safe and never cause a formatting error. Telegram still auto-links bare URLs. Markdown you write is not rendered. - Telegram caps a media caption at 1,024 characters while a standalone message allows 4,096. When a post has media and text longer than 1,024 characters, the media is sent without a caption and the full text follows as a second message — nothing is truncated, but the post arrives as two messages. Keep text at or under 1,024 to get a single captioned message.
- Media is handed to Telegram as a URL rather than uploaded, and the Bot API caps download-by-URL well below its upload limits: 5 MB for images (jpg, png, webp) and 20 MB for video (mp4). Oversized files are rejected up front rather than failing at publish time.
- Up to 10 media items per message; more than 10 are sent as additional media groups.
- The returned post URL is a
t.melink for public chats and at.me/c/link for private supergroups and channels. Basic groups have no public permalink, so the URL is empty for them. - Character limit: 4,096.
When publishing to multiple platforms, you may want different post types per platform. Use postTypeOverrides:
{
"content": "New content!",
"channels": [
{"channelId": 1, "platform": "instagram"},
{"channelId": 2, "platform": "tiktok"}
],
"mediaFiles": [42],
"postTypeOverrides": {
"instagram": "reel",
"tiktok": "video"
},
"status": "scheduled",
"scheduledAt": "2026-04-10T12:00:00Z"
}If no override is set for a platform, BulkPublish selects the default post type based on the attached media.
Every platform reports a different subset of the metric columns, and the
columns that a platform has no API for are stored as 0. That makes "not
reported" and "measured zero" identical on the wire unless you read the support
fields — so never present a 0 as a measurement without checking them first.
GET /api/posts/{id}/metrics returns, per platform entry:
metricsSupported—falsewhen the platform has no readable per-post statistics API (Tumblr reports only a combined note count that cannot be split).supportedMetrics— the metric keys that platform can populate. Anything absent is a stored0.
GET /api/analytics/engagement returns the same information for a date range:
| Field | Meaning |
|---|---|
metricSupport |
platform → the metric keys it can report |
supportedTotals |
union across the window; a total* field whose key is missing here should render as "not available", never 0 |
partialTotals |
supported key → platforms in the window that do not report it, so the total covers fewer posts than it appears to |
conditionalMetrics |
supported but permission-gated (see the Facebook note below) |
unmeasuredPlatforms |
platforms present that report nothing at all |
metricsDisabledChannels |
channels whose metrics sync is switched off (see X below) |
| Platform | Reports | Never reports |
|---|---|---|
| X | impressions, likes, comments, shares, saves (bookmarks) | reach, clicks, video views |
| YouTube | impressions, video views, likes, comments | reach, shares, saves, clicks |
| impressions, reach, likes, comments, shares, saves | clicks, video views | |
| likes, comments, shares + impressions, reach, clicks¹ | saves, video views | |
| LinkedIn (company pages) | impressions, reach, likes, comments, shares, clicks | saves, video views |
| TikTok | impressions, video views, likes, comments, shares | reach, saves, clicks |
| Threads | impressions, likes, comments, shares | reach, saves, clicks, video views |
| impressions, clicks, saves, likes, comments, video views | reach | |
| Bluesky | likes, comments, shares, saves (bookmarks) | impressions, reach, clicks, video views |
| Mastodon | likes, comments, shares | everything else |
| likes (score), comments, shares (crossposts) | impressions, reach, saves, clicks, video views | |
| Discord | likes (reaction counts), comments (thread replies) | everything else |
| Snapchat | impressions, reach, likes, comments, shares, clicks, video views | saves |
| Google Business, Telegram, Tumblr | nothing | — |
| LinkedIn personal profiles | nothing | — |
¹ Facebook's impressions, reach and clicks come from the Page Insights
edge and require the read_insights permission. Without it they stay 0 even
though the metric is listed as supported — that is "may not be readable", a
third state distinct from both a dash and a trustworthy figure. These are the
keys reported in conditionalMetrics.
engagementRate is derived as engagements ÷ impressions, so it exists exactly
where impressions does — it is permanently 0 for Bluesky and Mastodon.
LinkedIn is account-type-gated. Share statistics are exposed only for
organization pages. A personal/profile channel reports nothing, which is why
LinkedIn can appear in both the matrix above and in unmeasuredPlatforms
depending on the channel.
- The snapshot has not run. All figures come from a stored snapshot refreshed
every 6 hours (or on demand via
POST /api/analytics/refresh) — never a live read. A just-published post appears immediately with zeros. - Metrics sync is switched off for the channel. X bills every read, so its
per-post sync is opt-in per channel and runs at most once every 7 days.
Until it is enabled, every X figure stays
0andPOST /api/analytics/refreshcannot change that. Affected channels are listed inmetricsDisabledChannels.
Each platform enforces its own character limit on the content field:
| Platform | Character Limit |
|---|---|
| X / Twitter | 280 |
| 2,200 | |
| TikTok | 2,200 |
| YouTube | 5,000 (description) |
| Threads | 500 |
| Bluesky | 300 |
| 63,206 | |
| 3,000 | |
| 500 | |
| Google Business | 1,500 |
| Mastodon | 500 (varies by instance) |
| Tumblr | 32,768 |
| 40,000 | |
| Discord | 2,000 |
| Telegram | 4,096 (1,024 when the text rides as a media caption) |
| Snapchat | 160 (used only as the Spotlight description / Saved Story title fallback — plain stories send no text) |
BulkPublish validates content length per platform before creating the post and returns an error if any platform's limit is exceeded.
When link tracking is on — the organization setting, or linkTrackingOverride on the post — links are rewritten to bulkpubli.sh short URLs at publish time, per channel. Two accounts on the same platform get distinct codes, so their clicks are counted separately.
A short URL is 28 characters, which can be longer than the link it replaces. Because validation runs on the rewritten text, shortening is skipped for any channel where the rewrite would push the post past that platform's limit: a post composed at 295/300 for Bluesky publishes with its original links rather than failing at 308. The post still goes out — only the tracking is dropped for that channel, and no short link is minted for it, so linkClicks stays 0 there.
The tight limits above are where this bites: X (280), Bluesky (300), Threads / Pinterest / Mastodon (500), and to a lesser degree Google Business (1,500) and Discord (2,000). It never applies on Facebook, LinkedIn, Tumblr, Reddit or Telegram.