2026-09-02 — Spec prose: edit keeps existing channel rows; unconfirmed also covers processing timeouts (docs only, no package bump)
-
PUT /api/posts/{id}withchannelsnow documents the server behaviour: rows for channels that stay on the post keep their publish state (a channel that already published is not re-published on the next publish); removed channels are dropped, new ones added aspending. -
postPlatforms[].status = 'unconfirmed'description now also covers the case where the platform was still processing an upload when polling stopped (long video transcodes). As before: check the account, then retry withrepublish: trueonly if the post is not live. -
DELETE /api/posts/{id}returns409 PUBLISH_IN_PROGRESSwhile a post is publishing/processing.POST /api/posts/{id}/publishon a failed/partial post re-sends its failed channels;400 UNCONFIRMED_REQUIRES_REPUBLISHwhen only unconfirmed rows remain.POST /api/posts/{id}/storyis single-flight per post+platform (409 STORY_IN_PROGRESS) and reports a lost response as422 STORY_UNCONFIRMED.POST /api/posts/bulkretryhonoursmaxRetriesanddeleteskips in-flight posts (skippedInProgress). Editing a partial post no longer resets it to draft.
homepage/Homepageis now https://www.bulkpublish.com on the npm and PyPI packages instead of the GitHub repo. The registry listings led with the source repo, which is not where someone evaluating the SDK wants to land.repositoryandIssuesstill point at GitHub, so the source is one click away on both registry pages.
- Python
DocumentationURL 404'd. It pointed athttps://bulkpublish.com/docs/api, which does not exist on the marketing site; the API reference lives athttps://app.bulkpublish.com/docs.
2026-08-31 — Media list: real total + server-side type filter (node 1.14.0, python 0.15.0, mcp 1.18.0)
GET /api/medianow returnstotal— the count of files matching the filters across ALL pages. Clients were showingfiles.length(capped atlimit, max 100) as the library size, so libraries with more than 100 files read as exactly 100.GET /api/mediaacceptstype=image|video(matches the MIME type prefix), so image/video filtering is server-side instead of filtering only the fetched page. Surfaced in the node SDK (ListMediaParams.type,ListMediaResponse.total), python SDK (type=kwarg), the MCPlist_mediatool, Postman, and the media-uploads guide.
- Python docstring drift:
media.list()claimed the response carriedtotalPages— it never did (andtotalonly exists as of this release).
list_schedules/create_scheduletool descriptions and the/api/schedulesOpenAPI prose now say where schedules surface in the web app: managed on the Repeat Posts page, upcoming runs projected on the Calendar, and a post record existing only once an occurrence fires (carryingrecurringScheduleId). Agents kept concluding schedules were "not created" because nothing showed in post lists before the first run.- Drift fix:
list_schedulesclaimed schedules return a "cron expression" — the model isfrequency/timeOfDay/dayOfWeek/dayOfMonth, and always has been.
- Hosted MCP server:
initializenow requires credentials. A packet capture of Grok Bot's connector (Cursor MCP runtime) showed it never requests any/.well-known/*path and never receives a 401 during connect — such clients start the OAuth flow ONLY from a 401 +WWW-Authenticatechallenge. With the handshake fully anonymous they connected, listed all tools, then had nowhere to sign in ("didn't provide a sign-in link"). Unauthenticatedinitializenow returns the same 401 challengetools/callalways did. Keyed clients (?key=,Bearer bp_…,X-BulkPublish-Key, Smithery?config=) and OAuth token holders are unaffected — regression-checked locally (8-case matrix).tools/listand resources stay anonymous for bare scanners, andserver-card.jsonnow reportsauthentication: { required: true, type: "oauth2" }instead offalse. Hosted-server-only; no REST/SDK surface.
- Hosted MCP server: RFC 9728 root fallback for OAuth discovery.
GET /.well-known/oauth-protected-resource(no/mcpsuffix) 404'd; only the path-suffixed document existed. Clients that connect through the anonymousinitialize/tools/listpath never see our 401WWW-Authenticatechallenge, so they have noresource_metadatahint and fall back to the bare root path — Grok Bot showed "didn't provide a sign-in link" instead of starting OAuth. The root path now rewrites to the suffixed route, so the two documents are the same handler and cannot drift. No auth behavior changed: anonymous initialize/tools-list,?key=connectors, and the 401-on-tools/callchallenge are all as before (regression-checked locally). Hosted-server-only — no REST contract change, no SDK/spec surface touched.
- Extra channel slots are purchasable on every plan, Free included.
POST /api/quotas/channel-slots/checkoutno longer returns403 SLOT_PLAN_INELIGIBLEfor Free organizations (the code remains reserved), and channel-limit403 QUOTA_EXCEEDEDerrors now includeaddon: "channel_slot"on every plan. The per-subscription seat clamp rose from 20 to 100 (countclamped to 1..100) — it is an abuse guard, not a product limit. Updated in both spec copies, the Postman collection, the Node and Python type docs, and the MCP quota tool description.
2026-08-26 — Drop outbound-webhook wording, correct the PyPI platform count (node 1.13.1, python 0.14.1, mcp 1.17.2)
-
Every mention of outbound webhooks. The BulkPublish API has no webhook delivery: the
webhookstable is written by a CRUD endpoint and read by a quota counter, and nothing else in the codebase ever reads it — itslastTriggeredAtandfailureCountcolumns are never written by anything. Documenting a limit for it (limits.webhooks,usage.webhooks) meant the SDKs and the MCP quota widget advertised a capability that does not exist. Removed from the Node types, the Python type docs, the MCP quota table, and the OAuth scope prose in both spec copies.This does not touch the publishing API, which is real and unchanged.
- PyPI advertised 11 platforms.
pyproject.toml'sdescriptionis the summary line PyPI renders, and it had never been updated past 11 — the README in the same package said 15. The advertisable count is 15.
2026-08-26 — SDK parity sweep, corrected utility types, OpenAPI 3.1 nullability (node 1.13.0, python 0.14.0)
- Node SDK: twelve account-level methods that only existed in Python.
listApiKeys,createApiKey,deleteApiKey,apiKeyUsage,apiKeyUsageHistory,quotaUsage,listNotifications,getNotificationPreferences,updateNotificationPreferences,listOrganizations,linkPreviewandactivityLog. Their response types were already declared intypes.ts— only the methods were missing, so a Node consumer had no way to read quotas (including the channel-slot add-on state), API-key usage, notifications, organizations or the activity log. - Python SDK: the
platformsresource, sync and async, mirroring Node's. Both clients now expose the same nine resource namespaces. Without it a Python caller could not check platform availability before offering a connect button or creating a post against a platform in stateoff. ApiKeyUsage,ApiKeyPerKeyUsage,ApiKeyUsageHistoryEntry,NotificationPreferences,UpdateNotificationPreferencesParams,OrganizationandLinkPreviewtypes in the Node SDK.
- Python's utility types described a contract the server does not return.
Traced each against its route rather than the existing docstring:
ApiKeyUsageclaimed{used, limit, remaining, resetsAt}; the route returns{today, limit, plan, perKey[]}— three of four names wrong. Theapi_key_usage()docstring and its example were wrong to match.NotificationPreferencesclaimed{email, push, publishSuccess, publishFailure, weeklyDigest}; every one of those is invented. The real columns areemailOnFailure,emailOnTokenExpiry,inAppPublished,inAppFailed,inAppScheduleReminder,inAppTokenExpiry.Organizationtypedidas a string (it is an integer) and omittedplanandownerId.LinkPreviewomitteddomain.
ListNotificationsResponseomittedunreadTotalin both SDKs. The server returns it deliberately counted account-wide rather than from the page, so clients reconstructing it fromnotifications.filter(...)under- report it on any paginated fetch.nullable: truein anopenapi: 3.1.0document. 3.1 does not define the keyword, so 36 fields that are routinely null —publishedAton an unpublished post,recurringScheduleon a one-off, mediawidth/height— were advertised to every 3.1 consumer and generator as never-null. Now"type": ["x", "null"];redocly lintgoes from 36 errors to valid.- The two
openapi.jsoncopies had diverged in both directions. This one documented/api/api-keys(4 paths) that the in-app copy did not; the in-app copy documented the live OAuth 2.1/api/oauth/tokenand/api/oauth/revokethat partners reading this spec could not see. Both now carry all of them with matching tag declarations, and are identical apart from/api/push/tokens, which stays webapp-only as a mobile-app internal. - Channel slots were still documented as "a one-time $2.99 purchase valid 30
days" in the
quotas/usagedescription here, in the webapp copy, and in the Postman collection. They have been a seat-based subscription at $2.99 per slot per month since the model changed; the checkout endpoint's own description was already correct. The documented slot shape also gainsautoRenews, which the response has carried since. - The engagement endpoint claimed "eleven of the fifteen platforms" and
"exactly four" with no comment API. Snapchat's handler returns
unsupported: true, making five of sixteen. All sixteen classifications re-verified against the handlers.
2026-08-23 — Agent-readiness: operationIds, per-operation OAuth scopes, widget CSP, server card (mcp 1.17.1)
- Every operation in both spec copies now has a unique
operationId(76 in the webapp copy, 79 here including the api-keys endpoints). Names follow the resource-verb convention (listPosts,createPost,getQueueSlot, …) so LLM function-calling imports and SDK generators get stable, descriptive ids. - Per-operation
securitynow declares the minimal OAuth scope next to the API-key scheme (e.g.oauth2: [posts:write]onPOST /api/posts), traced fromwebapp/src/lib/oauth-server/scopes.tsSCOPE_MAP. Operations OAuth tokens cannot reach at all (organizations, notifications, checkouts, api-keys,POST /api/analytics/refresh,DELETE /api/channels/{id}) list onlyapiKey— the existing prose in the oauth2 scheme description already documents that boundary./api/oauth/tokenand/api/oauth/revokearesecurity: [](they are how you GET a token). This repo's spec copy also gains theoauth2security scheme it was missing. - mcp-server 1.17.1: widget HTML now embeds a
Content-Security-Policy<meta>(injected at build time byscripts/embed-ui.mjs, same origin lists as_meta.ui.csp), and/.well-known/mcp/server-card.jsongains the top-levelname,description,version,serverUrlfields agents expect (theserverInfoshape stays for MCP-initialize-style readers). - Postman collection, Node and Python SDKs are unchanged: none of them surface
operationIdor per-operation security, and no endpoint, field, or default moved.
info.descriptionin both spec copies (this repo and the webapp'spublic/openapi.json) went from "14 social media platforms" to 15 — Snapchat's Public Profile API access is approved and the platform is a normal connect option now. The count still deliberately excludes Reddit, which stays off pending Reddit API approval.- Same correction in the Postman collection, the Python package docstring and
README, the Node README and package description,
mcp-server/server.json, and both Cursor plugin manifests — those variously claimed 11, 14 or 16. - No schema, enum, default or endpoint changed: Snapchat's post types
(
story,saved_story,spotlight), options and media rules were already documented across the spec, SDKs and MCP tools as of 2026-08-20.
GET /api/posts/{id}/engagement:engagement.commentsNotice— why the COMMENT list specifically is empty, set whenever the comment read did not succeed (missing token, a declined gate such as X analytics being off, a platform API error, or no thread existing for the post).noticemixes comment- and reaction-scoped messages; clients that render only comments should showcommentsNotice, and treat emptycommentswith the field absent as a genuine measured zero. Source:EngagementDatain the webapp'slib/platforms/types.ts; every handler that can fail a comment read sets it.- Same endpoint: per-platform
errordocumented — set when the live read threw, or when the post is published (platformPostIdpresent) but the channel has no stored token and needs reconnecting.engagement: nullpluserrormeans the post IS live and the data could not be read;nullwithouterrormeans the post never published to that channel.
No SDK code changes: neither the node nor the Python SDK types this endpoint's per-platform payload beyond passthrough. Spec + webapp copy updated; Postman regeneration pending.
POST /api/quotas/channel-slots/checkoutis now a seat-based SUBSCRIPTION purchase ($2.99 per slot per month), replacing the one-time 30-day slot. Body gains optional{ count }(default 1, clamped 1..20). With no existing slot subscription the response is unchanged ({ url, checkoutId }→ Polar checkout); with an existing one the server increases its seats (prorated) and returns{ updated: true, seats }with no redirect — clients must handle both shapes. New400 SLOT_MAX_REACHEDat the 20-slot cap. Slots renew with the subscription, and slots no connected channel is using are auto-canceled or reduced shortly before renewal (email sent), so orgs are never billed for unused headroom. Spec + Postman updated.GET /api/quotas/usage:channelSlots.slots[]entries gainautoRenews(boolean) — whether the slot renews with the subscription or lapses atexpiresAt. Typed in the node SDK, documented in the Python types and the MCPget_quota_usagetool description.
GET /api/quotas/usageresponse types corrected in both SDKs. The nodeQuotasUsageResponseclaimed per-resource{used, limit, allowed}objects (daily,monthly,scheduled, …) and the PythonQuotaUsageclaimedposts/channels/mediadicts — shapes the server has never returned. The real response (source:getAllUsage()in the webapp'slib/quotas/check.ts, returned verbatim withorganizationIdadded by the route) is{organizationId, plan, limits, subscription, channelSlots, usage}whereusageholds flat counters (channels,postsToday,postsThisMonth,pendingScheduled,scheduledToday,apiKeys,webhooks,recurringSchedules,mediaStorageMB,labels,orgMembers,xApiSpendDcents,xCreditDcents,aiRunsUsed,aiRunsLimit,aiCreditDcents,subscriptionRefundCount) that pair withlimitskeys. Node adds aQuotasPlanLimitsinterface forlimits;channelSlots(added in 1.11.0 / 0.12.0, already correct) is now non-optional. The Pythonquota_usage()docstring example (quotas['posts']['used']) was updated to the real keys.- Python
__version__was stale (0.11.0while pyproject said0.12.0); both now read0.12.1.
POST /api/quotas/channel-slots/checkout— creates a Polar checkout for the Extra Channel Slot add-on (fixed price $2.99; no request body, unlike the PWYW credit checkouts). One purchase grants one slot valid 30 days: it raises the org's effective total channel limit by one and allows one channel above the per-platform cap (slots are generic and shared across platforms). Purchase is Pro/Business-only (403 SLOT_PLAN_INELIGIBLE); granted slots keep working on any tier until expiry. Added to openapi.json and the Postman collection.GET /api/quotas/usagenow returnschannelSlots—{ active, slots: [{id, expiresAt}], baseChannelLimit, effectiveChannelLimit }. Typed in the node SDK (QuotasUsageResponse.channelSlots) and the Python SDK (QuotaUsage.channelSlots); the MCPget_quota_usagetool description now mentions it.- Channel-limit
403 QUOTA_EXCEEDEDerrors on paid plans carry a newaddon: "channel_slot"field signalling that buying a slot (not only a plan upgrade) lifts the limit.
- When a slot expires (or its order is refunded) and the org is left over its limit, the webapp suspends the most recently connected excess channels — paused with tokens kept, not disconnected — after 7-day and 1-day warning emails.
- Snapchat is the 16th platform (
snapchat, display name "Snapchat"), mirroring webapp commit 396624a. Added to every platform enum — openapi Channel + PlatformAvailability schemas, nodePlatformunion, MCPPLATFORM_ENUM/platformSpecific/platformContent/postTypeOverrides, Postman example body, and the manage-channels skill. - Post types (
postTypeOverrides.snapchat):story(default; the caption is NOT sent to Snapchat),saved_story(title max 45 chars, defaults to the caption's first line truncated),spotlight(video only, 6–60s; the caption becomes the description, max 160 chars, truncated; hashtags clickable). - Media rule: every Snapchat post requires exactly ONE file — images jpg/png, videos mp4/mov, vertical, 5–60s (Spotlight 6–60s), max 1GB.
platformSpecific.snapchatis keyed by BulkPublish channel id (flat object also accepted):title(Saved Story title, 45 chars),locale(Spotlight, defaulten_US),saveToProfile(Spotlight only, default true; false sendsskip_save_to_profile). Documented across openapi, node JSDoc, python docstrings, MCP zod schemas, the platforms guide, and the plugin skills.- Metrics: impressions, reach, likes, comments, shares, clicks, videoViews
(never saves) — added to the support matrix. Engagement (individual
comments/reactions) and
_firstCommentare NOT supported on Snapchat; the unsupported-first-comment lists now include it. - Character limit 160 documented — used only as the Spotlight description / Saved Story title fallback.
- README platform count 14 → 15 and Snapchat added to the enumerations and the
Supported Platforms table. The count still deliberately excludes Reddit
(switched off pending Reddit API approval), matching
info.descriptionin the webapp's openapi.json, which remains "14 social media platforms". - Versions bumped: node 1.9.0 → 1.10.0, mcp-server 1.14.2 → 1.15.0, python 0.11.0 (from 0.10.0).
-
Follow-up (same day): the separate per-day read cap is gone — reads and writes draw from ONE monthly X budget (pro $3/mo, business $10/mo, rescaled from $5/$15), and metered reads are unavailable on the Free plan even with credits. Description updated in openapi.json + Postman; still docs-only.
-
GET /api/quotas/x-usagedescription (openapi.json + Postman): metered X reads (metrics sync, engagement, user search, health checks) are now billed to the org's monthly X budget — plan allowance first, purchased credits as overage — instead of being absorbed by BulkPublish; a per-day read cap remains as a circuit breaker.absorbedDcentsstays in the response for compatibility but only covers historical pre-2026-08 usage. Response shape unchanged, so the SDKs are untouched and unbumped.
openapi.json(info.description): the/docsintro said only "Publish to 15 social media platforms from a single API" and mentioned MCP nowhere, while/docsalso strips Scalar's own MCP buttons — so the one page developers land on gave no route to the MCP server at all. It now carries a short "Using this API from an AI agent" section: the hosted endpointhttps://mcp.bulkpublish.com/mcp(OAuth 2.1, or?key=), the localnpx -y @bulkpublish/mcp-serversetup, and a link to the AI toolkit. Mirrors the webapp copy atwebapp/public/openapi.json.- Platform count corrected to 14. 15 counted Reddit, which is switched off
pending Reddit API approval; verified against
GET /api/platforms/public. Docs-only — no endpoint, field, or enum changed, so the SDKs are untouched and unbumped.
- MCP server (1.14.2):
bulk_postswas annotatedopenWorldHintabsent (reported as false) even though itsretryaction re-publishes posts to the connected third-party platforms — exactly whatretry_postdoes, and that one was alreadyopenWorldHint: true. The hint describes a tool's widest reach, sobulk_postsnow sets it too.deleteandrescheduleremain internal-only;destructiveHintis unchanged.
-
MCP server (1.14.1): the ChatGPT compatibility key
_meta["openai/widgetCSP"]was being handed the same camelCase object as the MCP Apps standard_meta.ui.csp(connectDomains/resourceDomains). ChatGPT's key expects snake_case (connect_domains/resource_domains), so it saw no recognized domain list and the OpenAI plugin directory's tool scan failed with "openai/widgetCSP must contain at least one CSP or redirect domain list". The OpenAI key is now derived from the samecspobject under snake_case names, so the two cannot drift.No behavior change for Claude or any other MCP client:
ui.cspis untouched, and the domains allowed are identical on both keys.
Mirrors webapp commit bedafa4.
- New per-platform status
unconfirmed(joins pending, publishing, published, failed, processing). Terminal: the publish request may have reached the platform but its response was lost — the post may already be live. Never auto-retried. POST /api/posts/{id}/retryaccepts an optional JSON body{ "republish": boolean }(defaultfalse; server resolvesbody?.republish === true). Without it, onlyfailedplatforms are retried; if the post hasunconfirmedplatforms and no failed ones, the endpoint returns 400 with codeUNCONFIRMED_REQUIRES_REPUBLISH. Passingrepublish: truealso retries unconfirmed platforms — check the account first, since the post may already be live and retrying can duplicate it.- Synced:
openapi.json+ Postman collection, node SDK (PlatformStatusunion,posts.retry(id, { republish }), 1.9.0), python SDK (posts.retry(post_id, republish=...),PostPlatformdocstring, 0.10.0), MCP serverretry_posttool (1.14.0), curl quick-reference example.
Every platform's metrics/engagement/account-analytics wiring was verified against the platform's current official API documentation. Three under-claims were real and are fixed server-side; the doc surfaces here are re-synced. The response schemas are unchanged — only which platforms/keys carry data.
- Reddit now reports per-post metrics — likes (
score), comments (num_comments) and shares (num_crossposts) viaGET /api/info. It no longer appears inunmeasuredPlatforms, andmetricSupport.reddit/supportedMetricslistlikes, comments, shares(pluslinkClicks). Impressions are still not claimed:view_countis null through the data API. - Discord now reports per-post metrics — likes (sum of
reactions[].count) and comments (the started thread'smessage_count). No views field exists on the Message resource, so nothing else is claimed. - X now reports
saves—public_metrics.bookmark_countwas already being fetched and is now surfaced as thesavesmetric instead of a platform-specific extra. Quote counts remain inplatformSpecificMetrics. - The unmeasured list shrank and its wording is now precise: Google Business and Telegram have no readable metrics API; Tumblr reports only a combined note count that cannot be split into likes/reblogs/replies (so it stays unmeasured, but the platform is not metric-less); LinkedIn personal/profile channels stay unmeasured because the Member Post Analytics API is approval-gated, not because the platform lacks one.
- Fixed a false doc claim: the spec said "Pinterest reports no likes or
comments" — Pinterest's pin analytics return
TOTAL_REACTIONSandTOTAL_COMMENTS, and the server has always mapped them. The support matrix inguides/platforms.mdand both spec copies now match the server'sMETRIC_SUPPORTtable exactly. - Synced:
openapi.json, node SDK JSDoc (1.8.4), python SDK docstrings (0.9.5), MCP server tool description (1.13.1),guides/platforms.md.
GET /api/posts/{id}/engagement previously returned unsupported: true for
eight of the fifteen platforms. Four of those eight had a readable comment API
the whole time, so the endpoint was reporting "this network has no comments"
about networks that do. Implemented in the webapp handlers and re-documented
here; the response schema is unchanged.
GET /api/posts/{id}/engagementdescription — now names exactly which platforms return what, instead of "e.g. TikTok".- Newly returning data: Reddit (comment tree; votes are anonymous so it
sets
reactionsUnsupported), Tumblr (notes — replies and reblogs-with-commentary becomecomments, likes and bare reblogs becomereactions), Discord (reactors, plus replies in a thread started from the message) and X (replies via conversation search + liking users). - X is gated and says so: reads are billed per tweet and per user, so
comments/reactionscome back empty with an explanatorynoticeunless the channel hasmetadata.metricsSyncEnabledand the org is inside its daily read budget. X replies also come from recent search, which only covers the last 7 days. - Still
unsupported: true, now with anoticeexplaining why: TikTok (comments are Research-API only), Pinterest (v5 exposes no Pin comments), Google Business (reviews attach to the location, not the post) and Telegram (a bot only learns of comments through pushed updates).
- Newly returning data: Reddit (comment tree; votes are anonymous so it
sets
No SDK, MCP or integration change: none of them wrap this endpoint — it is
documented in openapi.json only.
2026-08-08 — Reddit, Discord and Telegram options documented (node SDK 1.8.3, python SDK 0.9.4, MCP 1.13.0)
Reddit, Discord and Telegram have been publishable for a while, but no surface
described what they accept in platformSpecific. Traced from the handlers
(webapp/src/lib/platforms/reddit.ts, discord.ts, telegram.ts) and the
worker that feeds them (publish.worker.ts, which passes
post.platformSpecific[platform] to the handler).
platformSpecific.reddit—{ subreddit, title, type, url, flairId, thumbnailUrl }.subredditis required, falling back to the one stored on the channel;webdev,r/webdevand/r/webdevare all accepted.titledefaults to the first line ofcontenttruncated to 300 characters. The submission kind is derived, not set: image attached →image, video →video,type: "link"orurl→link, elseself. A media post accepts exactly one file. Also documented: Reddit returns HTTP 200 on a rule rejection (surfaced as a failed post), and media submissions confirm asynchronously with a 20-second timeout after which the post fails with a "may still have appeared" warning — verify before retrying to avoid a duplicate.platformSpecific.discord—{ channelId }, required. The innerchannelIdis the target Discord text channel snowflake, which is not the BulkPublish channel id used as the outer key; a connected Discord "channel" in BulkPublish is an entire server. Publishing uses a global bot token rather than the per-user OAuth token, so a failure is never a reconnect situation and the channel is never flaggedneeds_reconnect— the causes are bot permissions.platformSpecific.telegram— documented as accepting no options; the destination chat is fixed when the channel is connected. Content is sent without a parse mode (so&,<,>are safe and Markdown is not rendered), media is fetched by URL (5 MB images / 20 MB video, well under Telegram's upload limits), and text over the 1,024-character caption limit is posted as a second message alongside captionless media rather than being truncated.- The channel-id-keyed shape is now documented generally.
reddit,discordandtumblrnest options under the BulkPublish channel id ({"reddit": {"12": {…}}}) because each connected account commonly targets a different subreddit / Discord channel / blog. A flat object is also accepted and applies to every channel of that platform. Previously only the Tumblr guide mentioned this. - Object schemas for
reddit,discord,telegramandtumblrinPlatformSpecificin both OpenAPI copies — they were described in prose only (Telegram not at all) and had nopropertiesentries. Character limits and post types for the four platforms were also missing from the platforms guide's tables (Reddit 40,000, Discord 2,000, Telegram 4,096, all post typepost). - MCP
create_post/update_postnow expose real field shapes for these four platforms instead of emptypassthrough()objects, so a model gets field hints rather than guessing (Reddit's requiredsubredditin particular). Both the channel-keyed and flat shapes validate.postTypeOverridesgained the missingmastodon,reddit,discord,telegramandtumblrkeys, allpost— they were valid server-side but rejected by the tool schema.
reddit.thumbnailUrlis documented as optional, matching the handler. An in-flight draft of this entry claimed the poster-frame fallback was never implemented; it was traced against a worktree branched before the fallback landed.reddit.tsdoes fall back tovideoFile.posterUrl. What genuinely differs from Pinterest is that Reddit has no attached-image fallback — a media post accepts exactly one file, so a video post cannot also carry a cover image. Every surface says that now.- The node SDK JSDoc and python SDK docstring for
platformSpecificlisted only eight platforms; they now cover Reddit, Discord, Telegram and Tumblr too. The node package description still advertised "11 platforms" — corrected to 15. - "All platforms support
_firstComment" was wrong. Five do not: Discord, Pinterest, TikTok, Google Business and Tumblr have nopublishCommentimplementation, so the base handler returns "does not support first comments". The post still publishes and the comment is recorded asfailed— documented in the guide and the OpenAPI description, along with the fact that_firstCommentsits at the top level ofplatformSpecificrather than inside a platform key (resolution site: the first-comment block ofpublish.worker.ts, which reads it off the whole object). - The Discord section's character-limit note and the link-tracking paragraph both omitted Google Business (1,500) from the list of limits tighter than Discord's 2,000.
-
Reddit video posts no longer hard-require
platformSpecific.reddit.thumbnailUrl. Same fallback as the Pinterest cover below: an omitted thumbnail falls back to the video's auto-extracted poster frame (resolution site:publishPostvideo branch inwebapp/src/lib/platforms/reddit.ts); it only fails when neither exists. The webapp composer also gained media-library pickers for all three cover/thumbnail fields (Pinterest, YouTube, Reddit) — the API contract is unchanged (still URL strings). -
platformSpecific.pinterest.coverImageUrlonPOST /api/posts/PUT /api/posts/:id. Pinterest requires a cover image on video pins; until now nothing could set one, so everyvideo_pinfailed with "A video pin needs a cover image". The field is optional: when omitted the server falls back to an image attached alongside the video, then to the video's auto-extracted poster frame (resolution site:publishVideoPininwebapp/src/lib/platforms/pinterest.ts), and fails only when all three are missing. Documented in both OpenAPI copies, the Postman collection, the node SDK JSDoc, the python SDK docstring, thecreate_post/update_postMCP tool schemas, and the platforms guide (which also picks up the previously undocumenteddominantColor).
- The server now rejects non-string
platformContentvalues with 400VALIDATION_ERROR.platformContentmaps platform name → caption string (e.g.{"youtube": "clip #fyp"}). A client that sent nested objects ({"youtube": {"content": "..."}}) previously had them stored verbatim and the post crashed at publish time withcontent.split is not a function; the malformed shape is now caught at create/update. Documented in both OpenAPI copies. The node SDK and MCP server already typed this as string-valued (no change); the python SDK'splatform_contenttightens fromDict[str, Any]toDict[str, str](0.9.2 — also fixes__version__lagging at 0.9.0). Postman examples were already string-valued.
GET /api/postsis ordered by the timestamp that applies to each post, newest first:publishedAtif the post is live, elsescheduledAtif it is due, elsecreatedAt, withiddescending as a tiebreaker. It previously ordered bycreatedAtalone, so a post drafted weeks before it published sorted among the day it was written rather than the day it went live — on a real account a post published today sat 92 rows down its own Published listing. Drafts and scheduled posts are unaffected in practice, since their applicable timestamp is the one they were already sorted by. Documented in the spec, the Postman collection, both SDK list methods, and thelist_postsMCP tool. No request or response field changed.
Raised by the Anthropic MCP Directory review.
get_analyticsandsearch_mentionsno longer tell the model to avoid its own tools. Their appended "use this when…" hints ended with "never use web browsing for this" and "never use built-in web search". A tool description states what the tool does and when it is useful; which tool to reach for is the host model's decision. Both hints now describe the tool only, and the comment aboveTOOL_USE_HINTSsays so for future entries.search_mentions's hint described the wrong tool. It read as brand/keyword monitoring across social media; the tool returns @mention suggestions for a single connected channel, which is what the hint now says.
2026-08-01 — link tracking: linkClicks and linkTrackingOverride (node SDK 1.8.0, python SDK 0.9.0, MCP 1.12.0)
Catches the public spec and every SDK up with the shortlink feature, which shipped server-side without its contract-sync fan-out.
linkTrackingOverrideonPOST /api/postsandPUT /api/posts/:id. Per-post override for link tracking (bulkpubli.sh):trueforces links in the post to be shortened and their clicks counted,falseforces them to publish as written,null(the default) inherits the organization's Link Tracking setting. Exposed aslink_tracking_overridein the python SDK,linkTrackingOverridein the node SDK and on thecreate_post/update_postMCP tools.linkClickson metrics and engagement responses, plustotals.linkClicksandtotalLinkClicks. Clicks onbulkpubli.shshort links, measured by BulkPublish rather than reported by the platform. It sits outsidelatestonGET /api/posts/:id/metricsbecause it is not a platform snapshot, and it is deliberately not folded intoclicks/totalClicks— one visit can register in both, so adding them double-counts. Because we measure it ourselves it is available on every platform, including those that report no per-post metrics at all, sosupportedMetricsalways contains it. Bot and link-preview traffic is excluded; it is 0 for organizations that have not enabled Link Tracking.sortandorderonGET /api/analytics/engagement.sortacceptsdate(default),impressions,likes,comments,sharesandlinkClicks;orderisasc/desc(defaultdesc). Both apply toallPosts. Added toanalytics.engagement()in both SDKs. Not surfaced on the MCP server, which has no engagement tool.largeUrlonMediaFile— the 1200px-wide webp derivative for lightboxes, alongside the existingthumbnailUrl(160x160 crop) andpreviewUrl(400px). For videos all three come from an extracted poster frame.nullon media uploaded before the derivative existed, until the backfill runs.POST /api/organizations/leavewas missing from the public spec copy.
- Shortening can be skipped to protect a publish. A short URL is 28 characters and can be longer than the link it replaces, and validation runs on the rewritten text — so shortening is skipped for any channel where the rewrite would push the post past that platform's character limit, rather than failing a post the composer accepted. The post publishes with its original links and no short link is minted for that channel. See Character Limits.
- Python
update()cannot clear a nullable field._snake_to_camel_dictdropsNonerather than sending JSONnull, soupdate(post_id, link_tracking_override=None)leaves the existing override in place. Pass an explicitTrue/False, or call the REST endpoint directly. Pre-existing behaviour, now stated in the docstring.
info.descriptionsaid 11 platforms. The platform enum has carried 15 for some time (Reddit, Discord, Telegram and Tumblr were added without updating the prose). Corrected in both spec copies.- The public spec copy had drifted from the webapp's. It is now rebased on
webapp/public/openapi.jsonverbatim, which reorders keys throughout — the only intentional divergence left is the/api/api-keys*surface and its two tags, which are public-SDK-only. Diffing the two now yields nothing but those.
avgEngagementRateonGET /api/analytics/engagementwas averaged over only the posts with non-zero engagement. The server storesengagement_rateas engagements ÷ impressions and writes 0 when impressions is 0, so filtering onrate > 0dropped every post that genuinely got no engagement — 66% of measurable rows in production — and reported a mean over only the posts that performed. It is now averaged over every row withimpressions > 0, which is exactly when the stored rate is meaningful. Expect this number to go DOWN, and to be correct.engagementRateonGET /api/analytics/accountis nownull, never0. No platform handler computes an account-level engagement rate — the internalgetAccountAnalyticscontract has no such field — so the column was its default 0 on every row and was indistinguishable from a measured 0%. Both SDKs already typed it as nullable, so this is not a breaking change. For a real rate useplatformMetrics[].engagementRatefrom the engagement endpoint.
platformMetrics[].engagementRatevs the post-levelengagementRateon the engagement response are different numbers: the post-level value is the mean over the post's channels that were measurable, the per-channel value is that one channel's own rate. Rendering the post-level average beside a single channel's counters shows one network's percentage next to another network's zeros.profileViewsis Facebook-only (page_views_total) andwebsiteClicksis Google-Business-only. A 0 on any other platform means "not reported", not "measured zero".
isson every authorization response. RFC 9207, required by the MCP 2026-07-28 spec (SEP-2468): the authorization server names itself in the redirect back to the client, so a client configured with several authorization servers cannot be tricked into redeeming our code at a different one (the "authorization server mix-up" attack). The value is byte-identical to theissuerin/.well-known/oauth-authorization-server— including its trailing slash, whichURL.hrefadds to an origin-only URL — because clients compare the two as exact strings.authorization_response_iss_parameter_supported: trueis now advertised in the authorization-server metadata so clients know to validate it.application_typesurvives Dynamic Client Registration. SEP-837: desktop and CLI clients register asnativeso an authorization server knows not to reject their localhost redirects. It was echoed in the registration response but dropped from the sealedclient_id, so reading the client back lost it. We accept any registeredredirect_uri, so no request that previously worked behaves differently.
- Consent screen restyled to match
app.bulkpublish.com/login. It was styled from scratch — system font stack, bordered input, dark button, drop shadows, and an amber (#d97706) that is not our accent — so next to the app's own sign-in it read as a different product. Now uses the app's Inter/DM Sans, logo, card geometry, filled input, and accent pill button. The light-only palette is deliberate: the dark-mode block was where the off-brand amber lived, and the webapp is light-only. @modelcontextprotocol/ext-apps1.7.2 → 1.7.5.
The v2 SDKs (@modelcontextprotocol/core/server/client, all 2.0.0) implement the new spec; this server still runs @modelcontextprotocol/sdk 1.29.0, whose newest protocol version is 2025-11-25. Migrating is tracked separately — it retires the initialize handshake and Mcp-Session-Id, requires Mcp-Method/Mcp-Name headers, and replaces server-initiated elicitation/sampling with Multi Round-Trip Requests. Our transport is already stateless (sessionIdGenerator: undefined, fresh server per request), so the architectural shift is not a rewrite. Dynamic Client Registration is now formally deprecated in favour of Client ID Metadata Documents, with a twelve-month minimum window.
2026-07-29 — platform variants: LinkedIn company pages gated separately (node SDK 1.7.1, python SDK 0.8.1, MCP 1.10.1)
variantsonGET /api/platformsentries — sub-platforms gated independently of their parent, keyed by the channelaccountTypethey cover. Today the only one islinkedin.organization(company pages): LinkedIn requires the Community Management API that company pages use to be the only product on its application, so pages live in a separate LinkedIn app with its own review and can be paused while personal-profile posting is fully live. The platform-level state describes personal profiles — checkvariants.organizationbefore offering a company-page connect. Each variant carrieslabel,enabled,state,reason,canConnect,canPublish,message, and (owners/admins only)envVar. A variant is never more permissive than its parent: a platform in stateoffmeans every variant is off too.accountTypeonPLATFORM_DISABLEDerrors from post create, bulk create and publish-now, naming the variant that is unavailable when a variant — rather than the whole platform — is what blocks the write. Absent when the platform itself is off.
- Python package version was reported two ways.
pyproject.tomlsaid 0.8.0 whilebulkpublish.__version__still said 0.7.1. Both now read 0.8.1.
2026-07-28 — per-metric support on analytics responses (node SDK 1.7.0, python SDK 0.8.0, MCP 1.10.0)
metricSupport,supportedTotals,partialTotalsandconditionalMetricsonGET /api/analytics/engagement, plussupportedMetricson everyplatformMetricsentry and on each platform ofGET /api/posts/{id}/metrics. Platform support is not all-or-nothing: each platform reports a different subset of the eight metric columns, and the server stores all eight as NOT NULL integers, so a field the platform API never returns was persisted as0and was indistinguishable from a measured zero. X reports impressions/likes/comments/shares only — never reach, saves, clicks or video views — so an X-only account saw four confident zeroes that were not measurements. A key absent fromsupportedMetrics/supportedTotalsmust be rendered as unavailable (a dash), never as0.partialTotalsnames the platforms excluded from an otherwise-real total;conditionalMetricsflags supported-but-permission-gated metrics (Facebook insights needread_insights).metricsDisabledChannelson the engagement response — channels whose metrics sync is switched off, whose posts therefore contribute zeroes. X is the only platform this applies to: its reads are billed, so per-post sync is opt-in per channel and runs at most once every 7 days.POST /api/analytics/refreshwill not produce X figures for a channel that has not opted in.
- Bluesky reported no saves.
app.bsky.feed.defs#postViewcarriesbookmarkCount— the atproto equivalent of a save — and it was never read. Bluesky now reportssaves. - Negative counts are clamped to 0. LinkedIn documents that
likeCountcan go negative (an unlike on a sponsored share counts as organic while the original like never did). Stored raw it subtracted from org-wide totals;engagementRateis now computed from the clamped values too. - Pinterest pins always reported 0 likes and 0 comments. The server sent
metric_types=LIFETIME, which is in neither of Pinterest's twometric_typesenum sets. Pinterest tolerated it (the parameter's documented default is "all") so impressions, clicks and saves still came through — but an invalid value cannot requestTOTAL_REACTIONS/TOTAL_COMMENTS, so engagement was permanently zero. Those are now requested explicitly, with a fallback to the standard-only metric set because video metric names are valid for video pins only. - Threads post metrics never returned anything. The insights call included
reach, which is a Threads user-level metric; Meta rejects the whole request when any metric is invalid.reachis gone (and correctly reported as unsupported for Threads), and off-platformsharesare now summed withreposts. - Instagram account-level insights never returned anything. The call requested
profile_viewsandwebsite_clicks, both removed from the IG User metric set; their replacements (views,profile_links_taps) aretotal_valuemetrics that cannot be combined withperiod=day. Split into two calls, so reach and views/link-taps are recorded again.
2026-07-28 — engagement metrics: unmeasured platforms + top-only mode (node SDK 1.6.1, python SDK 0.7.1)
GET /api/quotas/usageacceptstz(IANA name, defaults toUTC) and the response gainsusage.scheduledToday— posts scheduled FOR the current day, excludingdraft/failed. It pairs withlimits.scheduledPerDay(3/day on Free), which previously had no usage figure anywhere, so callers could not see the tightest limit in the product until a write 403'd.tzonly picks the day boundary, so the figure matches the day the limit is enforced against; a malformed value falls back toUTC.- Note the three post limits are distinct and must not be read interchangeably:
usage.postsToday↔limits.postsPerDay(posts created today),usage.scheduledToday↔limits.scheduledPerDay(scheduled for today),usage.pendingScheduled↔limits.maxPendingScheduled(all pending, any date).
groupByonanalytics.engagement(node + python) is deprecated and was always ignored. The server has never read it;byDayis daily buckets, so passing'week'/'month'silently returned daily data. Aggregate client-side. The field is retained this release so existing builds keep compiling, and will be removed in the next major.
GET /api/analytics/engagement?top=1returns only the rankedtopPostsleaderboard;allPostscomes back as an empty array. For dashboards that render a short list and should not download every post in the window.bp.analytics.engagement({ top: '1' })(node),bp.analytics.engagement(top=True)(python).
unmeasuredPlatformson the engagement response, plusmetricsSupportedon eachplatformMetricsentry. These name the platforms in the window that cannot report per-post metrics at all: Google Business, Telegram, Discord, Reddit and Tumblr have no metrics API, and LinkedIn exposes share statistics only for organization pages — personal/profile channels never report. Posts on those platforms are still counted in every total, with zeroes, so a zero for one of them means "not reported", not "measured zero". Previously nothing in the response distinguished the two, and clients rendered an unmeasurable post as a confident0 impressions.- The engagement figures are a synced snapshot (every 6 hours, or on demand via
POST /api/analytics/refresh) — not a live read of the platform. This was never stated.
2026-07-25 — Tumblr platform + platform availability (mcp-server 1.9.0, node SDK 1.6.0, python SDK 0.7.0)
- Tumblr is now supported — 15 platforms.
tumblris a valid value everywhereplatformappears (post channels,platformContent,platformSpecific,postTypeOverrides, theChannelschema). Post type ispost; content limit 32,768 characters. platformSpecific.tumblris keyed by channel ID, because one Tumblr account can own several blogs and each connected channel may target a different one. Fields:blogName(defaults to the blog the channel was connected as),title(rendered as a heading),tags(array, no leading#),link,sourceUrl. List a channel's blogs withGET /api/channels/{id}/options.- Tumblr media rules: up to 30 images, or exactly one video — a video cannot be mixed with images in the same post. Content over 4,096 characters is split across multiple Neue Post Format text blocks automatically.
GET /api/platforms— returns every supported platform with its current availability (state,reason,canConnect,canPublish,message). Disabled platforms are included withenabled: falserather than omitted, so callers can tell "switched off right now" from "not supported".envVaris returned only to organization owners/admins. Exposed asbp.platforms.list()(node),bp.channels.list_platforms()(python, sync + async), and thelist_platformsMCP tool.platformAvailable,platformState,platformMessageon theChannelobject — a channel can be perfectly healthy while its platform is switched off server-side.PLATFORM_DISABLED(403) onPOST /api/posts, bulk create, andPOST /api/posts/{id}/publishwhen a target platform is disabled server-side. Distinct fromFEATURE_DISABLED(not on the org's plan):PLATFORM_DISABLEDis temporary and resolves on its own, and posts already scheduled when a platform is disabled are held, not failed — they publish automatically once it returns. Do not delete and recreate them.- New "Platform Availability" section in the platforms guide covering the three states (
on/connect_off/off) and both error codes.
PATCH /api/posts/{id}is now in the spec — it was previously an undocumented route. It only acceptsrecurringScheduleId(passnullto detach a repeat schedule); every other field is now rejected with 400 andunsupportedFields. To edit a post — including moving a draft toscheduled— usePUT /api/posts/{id}with{"status": "scheduled", "scheduledAt": "<future ISO 8601>"}. Previously PATCH silently ignored those fields and still returned 200, which read as success.platformSpecificprose now documents the Reddit (subreddit,title,flairId, …) and Discord (channelId) option shapes, which were never described when those platforms landed.
2026-07-24 — approval gating for automated sources (mcp-server 1.8.0, node SDK 1.5.0, python SDK 0.6.0)
requireApproval(boolean, default false) on recurring schedules —POST /api/schedulesandPUT /api/schedules/{id}; also returned on the schedule object. Every occurrence the schedule generates lands withapprovalStatuspendingand the scheduler skips it until an approver releases it viaPOST /api/posts/{id}/approve.requireApproval(boolean, default false) on RSS autopost feeds —POST /api/rss-feedsandPUT /api/rss-feeds/{id}. Items auto-published from the feed land asapprovalStatuspendingand wait for approval. Only meaningful whenmodeispublish(draft items never publish on their own, and a feed force-demoted to draft by the plan gate stays ungated).- Documented that creating a post with
repeatSchedulewhile the post itself is approval-gated (requestApproval, or a contributor role) propagates the gate onto the created recurring schedule; editing such a post keeps the schedule's gate in step. - Surfaces updated: openapi.json (4 request bodies + the
Scheduleschema), Postman collection (schedules + rss-feeds create/update bodies), node SDK (CreateScheduleParams/UpdateScheduleParams/RecurringSchedule,CreateRssFeedParams/UpdateRssFeedParams+ resource JSDoc), python SDK (Schedule/RssFeedtypes,schedules.create/updatedocs,rss_feeds.create/updaterequire_approvalkwarg sync + async), MCP server (create_schedule,update_schedule,create_rss_feed,update_rss_feed), scheduling guide ("Gating automated sources").
- Node SDK
schedulesresource used a stale cron-based model.SchedulesResourcedeclared its own localRecurringSchedule/CreateScheduleParams/UpdateScheduleParamswithcronExpression/content/mediaFiles, which the API does not accept. It now uses the correctfrequency/timeOfDay/dayOfWeek/dayOfMonth/contentTemplate/mediaFileIdstypes fromtypes.ts, and the class example was corrected.
- Post approval flow (team roles Phase 2). Posts now carry
approvalStatus(none(default) |pending|approved|rejected), plusapprovedBy,approvedAt, andrejectionReason. Approval is orthogonal tostatus; the scheduler skipspending/rejectedposts even when scheduled and overdue. requestApproval(boolean, default false) onPOST /api/postsandPUT /api/posts/{id}— holds a scheduled post for team approval (approvalStatusbecomespending). Forced server-side for API keys of members whose role lackspost:publish(contributors), regardless of the flag.approvalStatusquery param onGET /api/posts— e.g.pendingfor the approval queue.POST /api/posts/{id}/approve— requires a role withpost:approve(owner, admin, approver); releases a pending post (publishes immediately if itsscheduledAthas passed).POST /api/posts/{id}/reject— optional{reason}(max 2000 chars); returns the post to draft withapprovalStatusrejected; the author is notified. Both: 400 if not pending, 403 if the role lackspost:approve, 404 if not found.POST /api/posts/{id}/publishnow returns403 APPROVAL_REQUIREDfor roles withoutpost:publish; publishing a pending/rejected post as an approver implicitly approves it.- Surfaces updated: openapi.json + Postman collection (2 new requests,
requestApprovalin create/update bodies,approvalStatuslist filter), node SDK (posts.approve()/posts.reject(), typed Post fields, params), python SDK (posts.approve()/posts.reject()sync + async,request_approval/approval_statuskwargs), MCP server (approve_post/reject_posttools,requestApprovalon create/update,approvalStatuson list_posts), guides (scheduling + authentication) and READMEs.
PUT /api/posts/{id}now accepts an optionalstatus('draft'|'scheduled') to move a post between draft and scheduled. Previously astatusfield in the update body was ignored. Setting'scheduled'requires a futurescheduledAt(in the request or already stored) and at least one channel; setting'draft'unschedules the post. Any other value is rejected (400). Omitstatusto leave it unchanged (failed/partial posts still auto-reset to draft on edit). To publish immediately, usePOST /api/posts/{id}/publish.- openapi.json (+ Postman collection) requestBody and endpoint prose updated (removed the "status is ignored / cannot be changed" note). Node SDK:
statusadded toUpdatePostParams+ anupdate()example. Python SDK:statusdocumented onposts.update(). MCP:statusenum input added to theupdate_posttool.
2026-07-18 — RSS custom-field caption tokens (docs) (mcp-server 1.6.1, node SDK 1.3.1, python SDK 0.4.1)
- openapi.json / mcp-server / node + python SDK types: Documented that a feed item's own extra leaf fields (namespaced or not) can be used as
{fieldName}caption tokens in addition to the standard set — the webapp editor surfaces a feed's real fields as pills after a preview. Docs/prose only; thetemplatestring is forwarded unchanged.
- openapi.json prose (mirrors the webapp copy):
POST /api/posts— documented that X and Mastodon count every URL as a flat 23 characters (server-side validation now measures content this way), and documented publish-time link-card behavior for text-only posts with a URL: Facebooklinkparam, Bluesky external embed, LinkedIncontent.article(no card if the page yields no title; LinkedIn's API never scrapes URLs), other platforms self-unfurl or render plain text (Instagram/TikTok caption links are not clickable).GET /api/rss-feeds— documented polling behavior: conditional GET (ETag/Last-Modified), exponential error backoff (15 min → 24 h), auto-disable after 20 consecutive failed polls (enabledflips to false,lastErrorexplains), and first-successful-poll backlog baselining.
- No request/response shapes changed; SDKs and MCP server are unaffected.
- RSS field mapping —
fieldMappingonPOST /api/rss-feedsandPUT /api/rss-feeds/{id}(and in every feed response;null= the built-in default, which matches the previous behavior: template"{title}\n\n{link}", no media,stripHtmltrue,smarttruncation). Controls how each feed item becomes a post:template(max 2000 chars) — tokens{title} {link} {description} {content} {author} {categories} {feedName}; a line whose tokens all render empty is dropped (defaults traced toDEFAULT_FIELD_MAPPINGinwebapp/src/lib/rss/mapping.ts).mediaField(nonedefault /image/video/auto= video else image) — the selected item enclosure is re-hosted to the org media library and attached to the post. Media selection is post-level; channels whose platform requires media (Instagram, TikTok, YouTube, Pinterest) are skipped for items lacking a usable enclosure, with the reason recorded in the activity log.stripHtml(defaulttrue),truncate(smartdefault — word-boundary trim keeping a trailing link line /hard/skip= drop that channel),hashtags(max 500 chars, appended).channelOverrides— per-channel text overrides keyed by channel id string (template,hashtags,stripHtml,truncate;mediaFieldcannot be overridden). Rendered per-channel text is written to the post'splatformContent, so channels on the same platform share one text — the same model as composer overrides.- Node SDK:
RssFieldMapping/RssMappingChannelOverridetypes +fieldMappingon create/update params. Python SDK:RssFieldMapping/RssMappingChannelOverrideTypedDicts +field_mappingkwarg (clear_field_mapping=TruesendsfieldMapping: null). MCP:fieldMappinginput oncreate_rss_feed/update_rss_feed(nullable on update to clear).
- The webapp also gained an internal
POST /api/rss-feeds/preview(renders the feed's newest item per channel) — intentionally not in openapi.json, same as bulk-create.
2026-07-17 — channel sets, RSS autopost, multipart media uploads (mcp-server 1.5.0, node SDK 1.2.0, python SDK 0.3.0)
- Channel Sets —
GET/POST /api/channel-sets+PUT/DELETE /api/channel-sets/{id}: saved channel groupings for one-click multi-channel targeting. Max 50 sets per organization; names are unique per org (duplicates fail with a 409, error codeDUPLICATE_NAME). Node SDKbp.channelSets, Python SDKbp.channel_sets, and MCP toolslist/create/update/delete_channel_set. - RSS Autopost —
GET/POST /api/rss-feeds+PUT/DELETE /api/rss-feeds/{id}: RSS/Atom feeds polled every 15 minutes; new items become posts on the chosen channels. Max 20 feeds per organization.modedefaults todraft(new items become draft posts for review);publishauto-publishes. ChangingfeedUrlre-baselines the feed — only items published after the change are posted, so the new feed's backlog is not flooded. Node SDKbp.rssFeeds, Python SDKbp.rss_feeds, and MCP toolslist/create/update/delete_rss_feed. - Multipart media uploads —
POST /api/media/multipart/create|complete|abort: chunked direct-to-storage uploads for large files (videos up to 1GB; images stay capped at 100MB). Fixed 10MB part size, one presigned PUT URL per part (valid 3600s), ETag collected per part so a failed part can be retried alone;completeruns the same verification as/api/media/finalizeand auto-aborts on failed assembly. Node SDKmedia.createMultipart/completeMultipart/abortMultipart, Python SDKmedia.create_multipart/complete_multipart/abort_multipart, MCP toolscreate/complete/abort_multipart_upload, and a new "Multipart Uploads" section in the media guide. - openapi.json (+ Postman collection, regenerated) updated with the 7 new endpoints and the
Channel Sets/RSS Autoposttags. The MCP server now exposes 48 tools.
2026-07-16 — contract-drift audit (mcp-server 1.4.0, node SDK 1.1.0, python SDK 0.2.0, MCP registry 1.2.0)
- MCP
create_schedule/update_schedulerewritten to the server's real model —frequency/timeOfDay/dayOfWeek/dayOfMonth/contentTemplateinstead of the never-implementedcronExpression/contentfields. The oldcreate_schedulealways returned a 400; the oldupdate_schedulesilently ignored content/cron changes. Python SDKScheduletype and docstrings updated to match. - MCP
get_queue_slotnow takestimezone(the only parameter the server reads) instead of the silently-ignoredchannelId/date. Same fix in the Python SDK'sposts.queue_slot(). - Python SDK labels were silently dropped on post create/update — the SDK sent
labelIdswhere the server readslabels. - MCP platform naming —
google_businessrenamed togmbeverywhere (the server's key);postTypeOverrides.google_businesswas silently ignored before. Addedreddit,discord,telegramto platform enums (MCP + Node SDK). - MCP
update_postno longer offers astatusfield — the server's PUT never read it (usepublish_postorscheduledAt). - MCP
bulk_postsnow supportsreschedule+scheduledAt, and its annotation no longer mislabels it as "Bulk-create posts". deleteMediaAfterPublishdefault corrected tofalsein openapi.json, the Node SDK JSDoc, and the media guide — media is kept and reclaimed by a 3-month retention sweep; the server also forcesfalsefor recurring and bulk-created posts.processingadded to post-status enums (openapi, Node SDK, MCP list filter) — posts whose platforms are all in async processing report this status.- Python response types rewritten to match actual API responses — integer IDs,
postPlatforms/platformUrl,fileName/sizeBytes/originalUrl,accountName/isActive/tokenStatus, structuredSchedule,QueueSlot.timezone. - openapi.json resynced from the app spec — adds the engagement, presign/finalize, x-usage, credits-checkout, and AI-caption endpoints, the 3 new platforms, documents the 30-day analytics window clamp and status-reset-on-edit behavior, corrects the posts
limitmaximum (500), and makestimezone(schedules) and channel-itemplatformoptional as the server treats them. Postman collection regenerated.
- MCP server is now hostable over Streamable HTTP — a multi-tenant remote endpoint (
https://mcp.bulkpublish.com/mcp) alongside the stdio bin, so web hosts (claude.ai custom connectors, Smithery's gateway, ChatGPT Apps) can connect. Serves/.well-known/mcp/server-card.json(skip-scan metadata) and/health. Per-request API key via?key=/Authorization/ config; unauthenticatedinitialize/tools/listso scans succeed. - MCP Apps composer (
compose_post) and five read-onlyview_*widgets (view_analytics,view_posts,view_channels,view_media,view_quota) that render inline in MCP Apps hosts. - In-composer media upload for images and video (presigned direct-to-R2) via new tools
create_media_upload+finalize_media_upload. The MCP server now exposes 37 tools.
- LinkedIn company pages: connect personal profiles and organization (company) pages from the dashboard. Both appear in
GET /api/channelswithaccountTypeofpersonalororganizationand are posted to bychannelIdlike any other channel. - Documented all LinkedIn post types —
post,multi_image,pdf_carousel,article— plus media limits (images JPEG/PNG/GIF ≤10 MB, max 20; MP4 ≤500 MB, 3–1800 s) and the 3,000-character limit. accountTypefield on the Channel type (Python SDK; already present in the Node SDK).
postTypeOverridesLinkedIn options now listpdf_carouselandarticle.
POST /api/channels/connect-linkedin-pagefrom the API spec — connecting a LinkedIn page is a one-time dashboard OAuth action, not an API operation. The API lists and posts to already-connected channels.GET /api/channels/:id/optionsno longer returns LinkedIn organizations (connection moved to the dashboard OAuth flow).
- MCP server with 12 tools for Claude Desktop and other AI assistants
- Python examples: CSV bulk publish, weekly scheduling, cross-platform posting, analytics export
- Node/TypeScript examples: scheduling, upload-and-publish
- curl quick-reference with every API endpoint
- AI agent examples: LangChain, Anthropic tool_use, OpenAI function calling