upload-post
Version:
Official client library for Upload-Post API - Cross-platform social media upload for TikTok, Instagram, YouTube, LinkedIn, Facebook, Pinterest, Threads, Reddit, Bluesky, and X (Twitter)
1,015 lines (937 loc) • 58.1 kB
JavaScript
import axios from 'axios';
import FormData from 'form-data';
import fs from 'fs';
import { createReadStream } from 'fs';
const API_BASE_URL = 'https://api.upload-post.com/api';
/**
* Upload-Post API client
*
* Supports uploading to: TikTok, Instagram, YouTube, LinkedIn, Facebook,
* Pinterest, Threads, Reddit, Bluesky, X (Twitter), Discord, Telegram
*/
export class UploadPost {
/**
* @param {string} apiKey - Your API key from Upload-Post
*/
constructor(apiKey) {
this.apiKey = apiKey;
this.baseUrl = API_BASE_URL;
}
/**
* Make an API request
* @private
*/
async _request(endpoint, method = 'GET', data = null, isFormData = false, extraHeaders = {}) {
const config = {
method,
url: `${this.baseUrl}${endpoint}`,
headers: {
'Authorization': `Apikey ${this.apiKey}`,
'X-Upload-Post-Source': 'npm',
...extraHeaders,
},
maxContentLength: Infinity,
maxBodyLength: Infinity
};
if (data) {
if (isFormData) {
config.headers = { ...config.headers, ...data.getHeaders() };
config.data = data;
} else if (method === 'GET') {
config.params = data;
} else {
config.headers['Content-Type'] = 'application/json';
config.data = data;
}
}
try {
const response = await axios(config);
return response.data;
} catch (error) {
const message = error.response?.data?.message || error.response?.data?.detail || error.message;
throw new Error(`Upload-Post API error: ${message}`);
}
}
/**
* The API collapses two uploads carrying the same key within 24 hours into a
* single post. Retrying an upload without one publishes it twice.
* @private
*/
_idempotencyHeaders(options) {
const key = options.idempotencyKey || options.requestId;
return key ? { 'Idempotency-Key': String(key) } : {};
}
/**
* Add common upload parameters to form
* @private
*/
_addCommonParams(form, options) {
form.append('user', options.user);
if (options.title) form.append('title', options.title);
// Platforms
const platforms = Array.isArray(options.platforms) ? options.platforms : [options.platforms];
platforms.forEach(p => form.append('platform[]', p));
// Optional common parameters
if (options.firstComment) form.append('first_comment', options.firstComment);
if (options.altText) form.append('alt_text', options.altText);
if (options.scheduledDate) form.append('scheduled_date', options.scheduledDate);
if (options.timezone) form.append('timezone', options.timezone);
if (options.addToQueue !== undefined) form.append('add_to_queue', String(options.addToQueue));
if (options.maxPostsPerSlot !== undefined) form.append('max_posts_per_slot', String(options.maxPostsPerSlot));
if (options.asyncUpload !== undefined) form.append('async_upload', String(options.asyncUpload));
// AI auto-generation of native per-platform copy from the media (fills blank fields)
// Platform-specific title overrides
if (options.blueskyTitle) form.append('bluesky_title', options.blueskyTitle);
if (options.instagramTitle) form.append('instagram_title', options.instagramTitle);
if (options.facebookTitle) form.append('facebook_title', options.facebookTitle);
if (options.tiktokTitle) form.append('tiktok_title', options.tiktokTitle);
if (options.linkedinTitle) form.append('linkedin_title', options.linkedinTitle);
if (options.xTitle) form.append('x_title', options.xTitle);
if (options.youtubeTitle) form.append('youtube_title', options.youtubeTitle);
if (options.pinterestTitle) form.append('pinterest_title', options.pinterestTitle);
if (options.threadsTitle) form.append('threads_title', options.threadsTitle);
// New credential-based platforms
if (options.slackTitle) form.append('slack_title', options.slackTitle);
if (options.mastodonTitle) form.append('mastodon_title', options.mastodonTitle);
if (options.nostrTitle) form.append('nostr_title', options.nostrTitle);
if (options.lemmyTitle) form.append('lemmy_title', options.lemmyTitle);
if (options.devtoTitle) form.append('devto_title', options.devtoTitle);
if (options.hashnodeTitle) form.append('hashnode_title', options.hashnodeTitle);
if (options.wordpressTitle) form.append('wordpress_title', options.wordpressTitle);
if (options.whopTitle) form.append('whop_title', options.whopTitle);
if (options.listmonkTitle) form.append('listmonk_title', options.listmonkTitle);
// Platform-specific description overrides
if (options.description) form.append('description', options.description);
if (options.linkedinDescription) form.append('linkedin_description', options.linkedinDescription);
if (options.youtubeDescription) form.append('youtube_description', options.youtubeDescription);
if (options.facebookDescription) form.append('facebook_description', options.facebookDescription);
if (options.tiktokDescription) form.append('tiktok_description', options.tiktokDescription);
if (options.pinterestDescription) form.append('pinterest_description', options.pinterestDescription);
// Platform-specific first comment overrides
if (options.instagramFirstComment) form.append('instagram_first_comment', options.instagramFirstComment);
if (options.facebookFirstComment) form.append('facebook_first_comment', options.facebookFirstComment);
if (options.xFirstComment) form.append('x_first_comment', options.xFirstComment);
if (options.threadsFirstComment) form.append('threads_first_comment', options.threadsFirstComment);
if (options.youtubeFirstComment) form.append('youtube_first_comment', options.youtubeFirstComment);
if (options.redditFirstComment) form.append('reddit_first_comment', options.redditFirstComment);
if (options.blueskyFirstComment) form.append('bluesky_first_comment', options.blueskyFirstComment);
if (options.linkedinFirstComment) form.append('linkedin_first_comment', options.linkedinFirstComment);
if (options.firstCommentMedia) {
const mediaItems = Array.isArray(options.firstCommentMedia) ? options.firstCommentMedia : [options.firstCommentMedia];
for (const mediaItem of mediaItems) {
if (typeof mediaItem === 'string') {
if (mediaItem.toLowerCase().startsWith('http://') || mediaItem.toLowerCase().startsWith('https://')) {
form.append('first_comment_media[]', mediaItem);
} else {
if (fs.existsSync(mediaItem)) {
form.append('first_comment_media[]', createReadStream(mediaItem));
}
}
} else {
form.append('first_comment_media[]', mediaItem);
}
}
}
}
/**
* Add TikTok-specific parameters
* @private
*/
_addTiktokParams(form, options, isVideo = true) {
if (options.tiktokDisableComment !== undefined) form.append('disable_comment', String(options.tiktokDisableComment));
if (options.brandContentToggle !== undefined) form.append('brand_content_toggle', String(options.brandContentToggle));
if (options.brandOrganicToggle !== undefined) form.append('brand_organic_toggle', String(options.brandOrganicToggle));
// Shared by TikTok video AND photo uploads: the backend reads privacy_level and
// post_mode for both /upload and /upload_photos. Gating them behind isVideo made
// photo carousels always publish as PUBLIC_TO_EVERYONE / DIRECT_POST.
if (options.tiktokPrivacyLevel) form.append('privacy_level', options.tiktokPrivacyLevel);
if (options.tiktokPostMode) form.append('post_mode', options.tiktokPostMode);
if (isVideo) {
if (options.tiktokDisableDuet !== undefined) form.append('disable_duet', String(options.tiktokDisableDuet));
if (options.tiktokDisableStitch !== undefined) form.append('disable_stitch', String(options.tiktokDisableStitch));
if (options.tiktokCoverTimestamp !== undefined) form.append('cover_timestamp', options.tiktokCoverTimestamp);
if (options.tiktokIsAigc !== undefined) form.append('is_aigc', String(options.tiktokIsAigc));
} else {
// Photo-specific
if (options.tiktokAutoAddMusic !== undefined) form.append('auto_add_music', String(options.tiktokAutoAddMusic));
if (options.tiktokPhotoCoverIndex !== undefined) form.append('photo_cover_index', options.tiktokPhotoCoverIndex);
}
}
/**
* Add Instagram-specific parameters
* @private
*/
_addInstagramParams(form, options, isVideo = true) {
if (options.instagramMediaType) form.append('media_type', options.instagramMediaType);
if (options.instagramCollaborators) form.append('collaborators', options.instagramCollaborators);
if (options.instagramUserTags) form.append('user_tags', options.instagramUserTags);
if (options.instagramLocationId) form.append('location_id', options.instagramLocationId);
if (isVideo) {
if (options.instagramShareToFeed !== undefined) form.append('share_to_feed', String(options.instagramShareToFeed));
if (options.instagramCoverUrl) {
const coverVal = options.instagramCoverUrl;
if (typeof coverVal === 'string' && (coverVal.toLowerCase().startsWith('http://') || coverVal.toLowerCase().startsWith('https://'))) {
form.append('cover_url', coverVal);
} else if (typeof coverVal === 'string') {
form.append('cover_image', createReadStream(coverVal));
} else {
form.append('cover_image', coverVal);
}
}
if (options.instagramAudioName) form.append('audio_name', options.instagramAudioName);
if (options.instagramThumbOffset) form.append('thumb_offset', options.instagramThumbOffset);
}
}
/**
* Add YouTube-specific parameters
* @private
*/
_addYoutubeParams(form, options) {
if (options.youtubeTags) {
const tags = Array.isArray(options.youtubeTags) ? options.youtubeTags : options.youtubeTags.split(',').map(t => t.trim());
tags.forEach(tag => form.append('tags[]', tag));
}
if (options.youtubeCategoryId) form.append('categoryId', options.youtubeCategoryId);
if (options.youtubePrivacyStatus) form.append('privacyStatus', options.youtubePrivacyStatus);
if (options.youtubeEmbeddable !== undefined) form.append('embeddable', String(options.youtubeEmbeddable));
if (options.youtubeLicense) form.append('license', options.youtubeLicense);
if (options.youtubePublicStatsViewable !== undefined) form.append('publicStatsViewable', String(options.youtubePublicStatsViewable));
if (options.youtubeThumbnailUrl) form.append('thumbnail_url', options.youtubeThumbnailUrl);
if (options.youtubeSelfDeclaredMadeForKids !== undefined) form.append('selfDeclaredMadeForKids', String(options.youtubeSelfDeclaredMadeForKids));
if (options.youtubeContainsSyntheticMedia !== undefined) form.append('containsSyntheticMedia', String(options.youtubeContainsSyntheticMedia));
if (options.youtubeDefaultLanguage) form.append('defaultLanguage', options.youtubeDefaultLanguage);
if (options.youtubeDefaultAudioLanguage) form.append('defaultAudioLanguage', options.youtubeDefaultAudioLanguage);
if (options.youtubeAllowedCountries) form.append('allowedCountries', options.youtubeAllowedCountries);
if (options.youtubeBlockedCountries) form.append('blockedCountries', options.youtubeBlockedCountries);
if (options.youtubeHasPaidProductPlacement !== undefined) form.append('hasPaidProductPlacement', String(options.youtubeHasPaidProductPlacement));
if (options.youtubeRecordingDate) form.append('recordingDate', options.youtubeRecordingDate);
if (options.youtubePlaylistId) {
const playlistIds = Array.isArray(options.youtubePlaylistId)
? options.youtubePlaylistId.join(',')
: options.youtubePlaylistId;
form.append('youtube_playlist_id', playlistIds);
}
if (options.youtubeSubtitles && Array.isArray(options.youtubeSubtitles)) {
options.youtubeSubtitles.forEach((sub, idx) => {
if (sub.language) {
form.append(`youtube_subtitle_language_${idx}`, sub.language);
if (sub.name) form.append(`youtube_subtitle_name_${idx}`, sub.name);
if (sub.file) {
// file can be a string path or a ReadableStream
if (typeof sub.file === 'string') {
form.append(`youtube_subtitle_file_${idx}`, createReadStream(sub.file));
} else {
form.append(`youtube_subtitle_file_${idx}`, sub.file);
}
} else if (sub.url) {
form.append(`youtube_subtitle_file_${idx}`, sub.url);
}
}
});
}
}
/**
* Add LinkedIn-specific parameters
* @private
*/
_addLinkedinParams(form, options, isText = false) {
if (options.linkedinVisibility) form.append('visibility', options.linkedinVisibility);
if (options.targetLinkedinPageId) form.append('target_linkedin_page_id', options.targetLinkedinPageId);
if (isText && (options.linkedinLinkUrl || options.linkUrl)) {
form.append('linkedin_link_url', options.linkedinLinkUrl || options.linkUrl);
}
}
/**
* Add Facebook-specific parameters
* @private
*/
_addFacebookParams(form, options, isVideo = false, isText = false) {
if (options.facebookPageId) form.append('facebook_page_id', options.facebookPageId);
if (isVideo) {
if (options.facebookVideoState) form.append('video_state', options.facebookVideoState);
if (options.facebookMediaType) form.append('facebook_media_type', options.facebookMediaType);
if (options.thumbnailUrl) form.append('thumbnail_url', options.thumbnailUrl);
}
if (isText && options.facebookLinkUrl) {
form.append('facebook_link_url', options.facebookLinkUrl);
}
}
/**
* Add Pinterest-specific parameters
* @private
*/
_addPinterestParams(form, options, isVideo = false) {
if (options.pinterestBoardId) form.append('pinterest_board_id', options.pinterestBoardId);
if (options.pinterestAltText) form.append('pinterest_alt_text', options.pinterestAltText);
if (options.pinterestLink) form.append('pinterest_link', options.pinterestLink);
if (isVideo) {
if (options.pinterestCoverImageUrl) form.append('pinterest_cover_image_url', options.pinterestCoverImageUrl);
if (options.pinterestCoverImageContentType) form.append('pinterest_cover_image_content_type', options.pinterestCoverImageContentType);
if (options.pinterestCoverImageData) form.append('pinterest_cover_image_data', options.pinterestCoverImageData);
if (options.pinterestCoverImageKeyFrameTime !== undefined) form.append('pinterest_cover_image_key_frame_time', options.pinterestCoverImageKeyFrameTime);
}
}
/**
* Add X (Twitter) specific parameters
* @private
*/
_addXParams(form, options, isText = false) {
if (options.xReplySettings && options.xReplySettings !== 'everyone') form.append('reply_settings', options.xReplySettings);
if (options.xNullcast !== undefined) form.append('nullcast', String(options.xNullcast));
if (options.xQuoteTweetId) form.append('quote_tweet_id', options.xQuoteTweetId);
if (options.xGeoPlaceId) form.append('geo_place_id', options.xGeoPlaceId);
if (options.xForSuperFollowersOnly !== undefined) form.append('for_super_followers_only', String(options.xForSuperFollowersOnly));
if (options.xCommunityId) form.append('community_id', options.xCommunityId);
if (options.xShareWithFollowers !== undefined) form.append('share_with_followers', String(options.xShareWithFollowers));
if (options.xDirectMessageDeepLink) form.append('direct_message_deep_link', options.xDirectMessageDeepLink);
if (options.xLongTextAsPost !== undefined) form.append('x_long_text_as_post', String(options.xLongTextAsPost));
if (!isText) {
if (options.xTaggedUserIds) {
const ids = Array.isArray(options.xTaggedUserIds) ? options.xTaggedUserIds : options.xTaggedUserIds.split(',').map(t => t.trim());
ids.forEach(id => form.append('tagged_user_ids[]', id));
}
if (options.xPlaceId) form.append('place_id', options.xPlaceId);
if (options.xThreadImageLayout) form.append('x_thread_image_layout', options.xThreadImageLayout);
} else {
if (options.xPostUrl) form.append('post_url', options.xPostUrl);
if (options.xCardUri) form.append('card_uri', options.xCardUri);
// Poll options
if (options.xPollOptions) {
const pollOpts = Array.isArray(options.xPollOptions) ? options.xPollOptions : options.xPollOptions.split(',').map(t => t.trim());
pollOpts.forEach(opt => form.append('poll_options[]', opt));
if (options.xPollDuration) form.append('poll_duration', options.xPollDuration);
if (options.xPollReplySettings) form.append('poll_reply_settings', options.xPollReplySettings);
}
}
}
/**
* Add Threads-specific parameters
* @private
*/
_addThreadsParams(form, options) {
if (options.threadsLongTextAsPost !== undefined) form.append('threads_long_text_as_post', String(options.threadsLongTextAsPost));
if (options.threadsThreadMediaLayout) form.append('threads_thread_media_layout', options.threadsThreadMediaLayout);
if (options.threadsTopicTag) form.append('threads_topic_tag', options.threadsTopicTag);
}
/**
* Add Google Business Profile parameters
*
* gbpLocationId is required for accounts with more than one location; the API
* only auto-selects when exactly one location exists.
* @private
*/
_addGoogleBusinessParams(form, options) {
if (options.gbpLocationId) form.append('gbp_location_id', options.gbpLocationId);
if (options.gbpTopicType) form.append('gbp_topic_type', String(options.gbpTopicType).toUpperCase());
if (options.gbpMediaUrl) form.append('gbp_media_url', options.gbpMediaUrl);
if (options.gbpMediaFormat) form.append('gbp_media_format', String(options.gbpMediaFormat).toUpperCase());
if (options.gbpCtaType) form.append('gbp_cta_type', String(options.gbpCtaType).toUpperCase());
if (options.gbpCtaUrl) form.append('gbp_cta_url', options.gbpCtaUrl);
if (options.gbpEventTitle) form.append('gbp_event_title', options.gbpEventTitle);
if (options.gbpEventStartDate) form.append('gbp_event_start_date', options.gbpEventStartDate);
if (options.gbpEventStartTime) form.append('gbp_event_start_time', options.gbpEventStartTime);
if (options.gbpEventEndDate) form.append('gbp_event_end_date', options.gbpEventEndDate);
if (options.gbpEventEndTime) form.append('gbp_event_end_time', options.gbpEventEndTime);
if (options.gbpOfferCoupon) form.append('gbp_offer_coupon', options.gbpOfferCoupon);
if (options.gbpOfferRedeemUrl) form.append('gbp_offer_redeem_url', options.gbpOfferRedeemUrl);
if (options.gbpOfferTerms) form.append('gbp_offer_terms', options.gbpOfferTerms);
}
/**
* Add Reddit-specific parameters
* @private
*/
_addRedditParams(form, options, isText = false) {
if (options.redditSubreddit) form.append('subreddit', options.redditSubreddit);
if (options.redditFlairId) form.append('flair_id', options.redditFlairId);
if (isText && (options.redditLinkUrl || options.linkUrl)) {
form.append('reddit_link_url', options.redditLinkUrl || options.linkUrl);
}
}
/**
* Upload a video to social media platforms
*
* @param {string} videoPathOrUrl - Path to video file or video URL
* @param {Object} options - Upload options
* @param {string} options.title - Video title/caption
* @param {string} options.user - User identifier (profile name)
* @param {string[]} options.platforms - Target platforms (tiktok, instagram, youtube, linkedin, facebook, pinterest, threads, bluesky, x, discord, telegram)
* @param {string} [options.description] - Video description
* @param {string} [options.firstComment] - First comment to post
* @param {string} [options.scheduledDate] - ISO date for scheduling (e.g., "2024-12-25T10:00:00Z")
* @param {string} [options.timezone] - Timezone for scheduled date (e.g., "Europe/Madrid")
* @param {boolean} [options.addToQueue] - Add to posting queue instead of immediate post
* @param {boolean} [options.asyncUpload=true] - Process upload asynchronously
*
* TikTok options:
* @param {string} [options.tiktokPrivacyLevel] - PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY
* @param {boolean} [options.tiktokDisableDuet] - Disable duet
* @param {boolean} [options.tiktokDisableComment] - Disable comments
* @param {boolean} [options.tiktokDisableStitch] - Disable stitch
* @param {number} [options.tiktokCoverTimestamp] - Timestamp in ms for video cover
* @param {boolean} [options.tiktokIsAigc] - AI-generated content flag
* @param {string} [options.tiktokPostMode] - DIRECT_POST or MEDIA_UPLOAD
* @param {boolean} [options.brandContentToggle] - Branded content toggle
* @param {boolean} [options.brandOrganicToggle] - Brand organic toggle
*
* Instagram options:
* @param {string} [options.instagramMediaType] - REELS or STORIES
* @param {boolean} [options.instagramShareToFeed] - Share to feed
* @param {string} [options.instagramCollaborators] - Comma-separated collaborator usernames
* @param {string|Buffer|ReadableStream} [options.instagramCoverUrl] - Custom cover: URL string, file path, Buffer, or ReadableStream
* @param {string} [options.instagramAudioName] - Audio track name
* @param {string} [options.instagramUserTags] - Comma-separated user tags
* @param {string} [options.instagramLocationId] - Location ID
* @param {string} [options.instagramThumbOffset] - Thumbnail offset
*
* YouTube options:
* @param {string|string[]} [options.youtubeTags] - Video tags
* @param {string} [options.youtubeCategoryId] - Category ID (e.g., "22" for People & Blogs)
* @param {string} [options.youtubePrivacyStatus] - public, unlisted, or private
* @param {boolean} [options.youtubeEmbeddable] - Allow embedding
* @param {string} [options.youtubeLicense] - youtube or creativeCommon
* @param {boolean} [options.youtubePublicStatsViewable] - Show public stats
* @param {string} [options.youtubeThumbnailUrl] - Custom thumbnail URL
* @param {boolean} [options.youtubeSelfDeclaredMadeForKids] - Made for kids flag
* @param {boolean} [options.youtubeContainsSyntheticMedia] - AI/synthetic content flag
* @param {string} [options.youtubeDefaultLanguage] - Title/description language (BCP-47)
* @param {string} [options.youtubeDefaultAudioLanguage] - Audio language (BCP-47)
* @param {string} [options.youtubeAllowedCountries] - Comma-separated country codes
* @param {string} [options.youtubeBlockedCountries] - Comma-separated country codes
* @param {boolean} [options.youtubeHasPaidProductPlacement] - Paid placement flag
* @param {string} [options.youtubeRecordingDate] - Recording date (ISO 8601)
*
* LinkedIn options:
* @param {string} [options.linkedinVisibility] - PUBLIC, CONNECTIONS, LOGGED_IN, CONTAINER
* @param {string} [options.targetLinkedinPageId] - Page ID for organization posts
*
* Facebook options:
* @param {string} [options.facebookPageId] - Facebook Page ID
* @param {string} [options.facebookVideoState] - PUBLISHED or DRAFT
* @param {string} [options.facebookMediaType] - REELS, STORIES, or VIDEO
* @param {string} [options.thumbnailUrl] - Thumbnail URL for normal page videos (VIDEO type only)
*
* Pinterest options:
* @param {string} [options.pinterestBoardId] - Board ID
* @param {string} [options.pinterestLink] - Destination link
* @param {string} [options.pinterestCoverImageUrl] - Cover image URL
* @param {number} [options.pinterestCoverImageKeyFrameTime] - Key frame time in ms
*
* X (Twitter) options:
* @param {string} [options.xReplySettings] - everyone, following, mentionedUsers, subscribers, verified
* @param {boolean} [options.xNullcast] - Promoted-only post
* @param {string|string[]} [options.xTaggedUserIds] - User IDs to tag
* @param {string} [options.xPlaceId] - Location place ID
* @param {string} [options.xGeoPlaceId] - Geographic place ID
* @param {boolean} [options.xForSuperFollowersOnly] - Exclusive for super followers
* @param {string} [options.xCommunityId] - Community ID
* @param {boolean} [options.xShareWithFollowers] - Share community post with followers
* @param {boolean} [options.xLongTextAsPost] - Post long text as single post
*
* Threads options:
* @param {boolean} [options.threadsLongTextAsPost] - Post long text as single post (vs thread)
* @param {string} [options.threadsThreadMediaLayout] - Comma-separated list of how many media items per Threads post (e.g. "5,5")
* @param {string} [options.threadsTopicTag] - Topic tag for the Threads post (1-50 chars, no periods or ampersands)
*
* @returns {Promise<Object>} API response with request_id for async uploads
*/
async upload(videoPathOrUrl, options) {
const form = new FormData();
// Handle video (file path or URL)
if (typeof videoPathOrUrl === 'string' && (videoPathOrUrl.toLowerCase().startsWith('http://') || videoPathOrUrl.toLowerCase().startsWith('https://'))) {
form.append('video', videoPathOrUrl);
} else {
if (!fs.existsSync(videoPathOrUrl)) {
throw new Error(`Video file not found: ${videoPathOrUrl}`);
}
form.append('video', createReadStream(videoPathOrUrl));
}
this._addCommonParams(form, options);
const platforms = Array.isArray(options.platforms) ? options.platforms : [options.platforms];
if (platforms.includes('tiktok')) this._addTiktokParams(form, options, true);
if (platforms.includes('instagram')) this._addInstagramParams(form, options, true);
if (platforms.includes('youtube')) this._addYoutubeParams(form, options);
if (platforms.includes('linkedin')) this._addLinkedinParams(form, options);
if (platforms.includes('facebook')) this._addFacebookParams(form, options, true);
if (platforms.includes('pinterest')) this._addPinterestParams(form, options, true);
if (platforms.includes('x')) this._addXParams(form, options, false);
if (platforms.includes('threads')) this._addThreadsParams(form, options);
if (platforms.includes('reddit')) this._addRedditParams(form, options);
if (platforms.includes('google_business')) this._addGoogleBusinessParams(form, options);
return this._request('/upload', 'POST', form, true, this._idempotencyHeaders(options));
}
/**
* Upload photos to social media platforms
*
* @param {string[]} photosPathsOrUrls - Array of photo file paths or URLs
* @param {Object} options - Upload options
* @param {string} options.title - Post title/caption
* @param {string} options.user - User identifier (profile name)
* @param {string[]} options.platforms - Target platforms (tiktok, instagram, linkedin, facebook, x, threads, pinterest, reddit, bluesky, discord, telegram)
* @param {string} [options.description] - Photo description
* @param {string} [options.firstComment] - First comment to post
* @param {string} [options.altText] - Alt text for accessibility
* @param {string} [options.scheduledDate] - ISO date for scheduling
* @param {string} [options.timezone] - Timezone for scheduled date
* @param {boolean} [options.addToQueue] - Add to posting queue
* @param {boolean} [options.asyncUpload=true] - Process upload asynchronously
*
* TikTok options:
* @param {boolean} [options.tiktokAutoAddMusic] - Auto add music
* @param {boolean} [options.tiktokDisableComment] - Disable comments
* @param {number} [options.tiktokPhotoCoverIndex] - Index of photo for cover (0-based)
* @param {boolean} [options.brandContentToggle] - Branded content toggle
* @param {boolean} [options.brandOrganicToggle] - Brand organic toggle
*
* Instagram options:
* @param {string} [options.instagramMediaType] - IMAGE or STORIES
* @param {string} [options.instagramCollaborators] - Comma-separated collaborator usernames
* @param {string} [options.instagramUserTags] - Comma-separated user tags
* @param {string} [options.instagramLocationId] - Location ID
*
* LinkedIn options:
* @param {string} [options.linkedinVisibility] - PUBLIC (only PUBLIC supported for photos)
* @param {string} [options.targetLinkedinPageId] - Page ID for organization posts
*
* Facebook options:
* @param {string} [options.facebookPageId] - Facebook Page ID
*
* Pinterest options:
* @param {string} [options.pinterestBoardId] - Board ID
* @param {string} [options.pinterestAltText] - Alt text
* @param {string} [options.pinterestLink] - Destination link
*
* X (Twitter) options:
* @param {string} [options.xReplySettings] - Who can reply
* @param {boolean} [options.xNullcast] - Promoted-only post
* @param {string|string[]} [options.xTaggedUserIds] - User IDs to tag
* @param {boolean} [options.xLongTextAsPost] - Post long text as single post
*
* Threads options:
* @param {boolean} [options.threadsLongTextAsPost] - Post long text as single post
* @param {string} [options.threadsThreadMediaLayout] - Comma-separated list of how many media items per Threads post (e.g. "5,5")
* @param {string} [options.threadsTopicTag] - Topic tag for the Threads post (1-50 chars, no periods or ampersands)
*
* Reddit options:
* @param {string} [options.redditSubreddit] - Subreddit name (without r/)
* @param {string} [options.redditFlairId] - Flair template ID
*
* @returns {Promise<Object>} API response
*/
async uploadPhotos(photosPathsOrUrls, options) {
const form = new FormData();
// Handle photos (file paths or URLs)
for (const photoItem of photosPathsOrUrls) {
if (typeof photoItem === 'string' && (photoItem.toLowerCase().startsWith('http://') || photoItem.toLowerCase().startsWith('https://'))) {
form.append('photos[]', photoItem);
} else {
if (!fs.existsSync(photoItem)) {
throw new Error(`Photo file not found: ${photoItem}`);
}
form.append('photos[]', createReadStream(photoItem));
}
}
this._addCommonParams(form, options);
const platforms = Array.isArray(options.platforms) ? options.platforms : [options.platforms];
if (platforms.includes('tiktok')) this._addTiktokParams(form, options, false);
if (platforms.includes('instagram')) this._addInstagramParams(form, options, false);
if (platforms.includes('linkedin')) this._addLinkedinParams(form, options);
if (platforms.includes('facebook')) this._addFacebookParams(form, options, false);
if (platforms.includes('pinterest')) this._addPinterestParams(form, options, false);
if (platforms.includes('x')) this._addXParams(form, options, false);
if (platforms.includes('threads')) this._addThreadsParams(form, options);
if (platforms.includes('reddit')) this._addRedditParams(form, options);
if (platforms.includes('google_business')) this._addGoogleBusinessParams(form, options);
return this._request('/upload_photos', 'POST', form, true, this._idempotencyHeaders(options));
}
/**
* Upload text posts to social media platforms
*
* @param {Object} options - Upload options
* @param {string} options.title - Text content for the post
* @param {string} options.user - User identifier (profile name)
* @param {string[]} options.platforms - Target platforms (x, linkedin, facebook, threads, reddit, bluesky, discord, telegram)
* @param {string} [options.firstComment] - First comment to post
* @param {string} [options.scheduledDate] - ISO date for scheduling
* @param {string} [options.timezone] - Timezone for scheduled date
* @param {boolean} [options.addToQueue] - Add to posting queue
* @param {boolean} [options.asyncUpload=true] - Process upload asynchronously
* @param {string} [options.linkUrl] - Generic URL for link preview card (works for LinkedIn, Bluesky, Facebook). Platform-specific params take priority.
*
* LinkedIn options:
* @param {string} [options.targetLinkedinPageId] - Page ID for organization posts
* @param {string} [options.linkedinLinkUrl] - URL to attach as link preview on LinkedIn
*
* Bluesky options:
* @param {string} [options.blueskyLinkUrl] - URL to attach as external embed link preview on Bluesky
*
* Facebook options:
* @param {string} [options.facebookPageId] - Facebook Page ID
* @param {string} [options.facebookLinkUrl] - URL to attach as link preview on Facebook
*
* X (Twitter) options:
* @param {string} [options.xReplySettings] - Who can reply
* @param {string} [options.xPostUrl] - URL to attach
* @param {string} [options.xQuoteTweetId] - Tweet ID to quote
* @param {string|string[]} [options.xPollOptions] - Poll options (2-4 options)
* @param {number} [options.xPollDuration] - Poll duration in minutes (5-10080)
* @param {string} [options.xPollReplySettings] - Who can reply to poll
* @param {string} [options.xCardUri] - Card URI for Twitter Cards
* @param {boolean} [options.xLongTextAsPost] - Post long text as single post
*
* Threads options:
* @param {boolean} [options.threadsLongTextAsPost] - Post long text as single post
* @param {string} [options.threadsThreadMediaLayout] - Comma-separated list of how many media items per Threads post (e.g. "5,5")
* @param {string} [options.threadsTopicTag] - Topic tag for the Threads post (1-50 chars, no periods or ampersands)
*
* Reddit options:
* @param {string} [options.redditSubreddit] - Subreddit name (without r/)
* @param {string} [options.redditFlairId] - Flair template ID
*
* @returns {Promise<Object>} API response
*/
async uploadText(options) {
const form = new FormData();
this._addCommonParams(form, options);
// Generic link_url support
if (options.linkUrl) form.append('link_url', options.linkUrl);
const platforms = Array.isArray(options.platforms) ? options.platforms : [options.platforms];
if (platforms.includes('linkedin')) this._addLinkedinParams(form, options, true);
if (platforms.includes('facebook')) this._addFacebookParams(form, options, false, true);
if (platforms.includes('x')) this._addXParams(form, options, true);
if (platforms.includes('threads')) this._addThreadsParams(form, options);
if (platforms.includes('reddit')) this._addRedditParams(form, options, true);
if (platforms.includes('bluesky') && (options.blueskyLinkUrl || options.linkUrl)) {
form.append('bluesky_link_url', options.blueskyLinkUrl || options.linkUrl);
}
if (platforms.includes('google_business')) this._addGoogleBusinessParams(form, options);
return this._request('/upload_text', 'POST', form, true, this._idempotencyHeaders(options));
}
/**
* Upload a document to LinkedIn (PDF, PPT, PPTX, DOC, DOCX)
*
* @param {string} documentPathOrUrl - Path to document file or document URL
* @param {Object} options - Upload options
* @param {string} options.title - Post title/caption
* @param {string} options.user - User identifier (profile name)
* @param {string} [options.description] - Document description/commentary
* @param {string} [options.linkedinVisibility] - PUBLIC, CONNECTIONS, LOGGED_IN, CONTAINER
* @param {string} [options.targetLinkedinPageId] - Page ID for organization posts
* @param {string} [options.scheduledDate] - ISO date for scheduling
* @param {string} [options.timezone] - Timezone for scheduled date
* @param {boolean} [options.addToQueue] - Add to posting queue
* @param {boolean} [options.asyncUpload=true] - Process upload asynchronously
*
* @returns {Promise<Object>} API response
*/
async uploadDocument(documentPathOrUrl, options) {
const form = new FormData();
// Handle document (file path or URL)
if (typeof documentPathOrUrl === 'string' && (documentPathOrUrl.toLowerCase().startsWith('http://') || documentPathOrUrl.toLowerCase().startsWith('https://'))) {
form.append('document', documentPathOrUrl);
} else {
if (!fs.existsSync(documentPathOrUrl)) {
throw new Error(`Document file not found: ${documentPathOrUrl}`);
}
form.append('document', createReadStream(documentPathOrUrl));
}
// Force linkedin platform
form.append('user', options.user);
form.append('title', options.title);
form.append('platform[]', 'linkedin');
if (options.description) form.append('description', options.description);
if (options.scheduledDate) form.append('scheduled_date', options.scheduledDate);
if (options.timezone) form.append('timezone', options.timezone);
if (options.addToQueue !== undefined) form.append('add_to_queue', String(options.addToQueue));
if (options.maxPostsPerSlot !== undefined) form.append('max_posts_per_slot', String(options.maxPostsPerSlot));
if (options.asyncUpload !== undefined) form.append('async_upload', String(options.asyncUpload));
// AI auto-generation of native per-platform copy from the media (fills blank fields)
this._addLinkedinParams(form, options);
return this._request('/upload_document', 'POST', form, true, this._idempotencyHeaders(options));
}
// ==================== Status & History ====================
/**
* Get the status of an async upload
*
* @param {string} requestId - The request_id from an async upload
* @returns {Promise<Object>} Upload status
*/
async getStatus(requestId) {
return this._request('/uploadposts/status', 'GET', { request_id: requestId });
}
/**
* Get the status of a scheduled or queued upload by job ID
*
* @param {string} jobId - The job_id from a scheduled or queued upload
* @returns {Promise<Object>} Upload status
*/
async getJobStatus(jobId) {
return this._request('/uploadposts/status', 'GET', { job_id: jobId });
}
/**
* Get upload history
*
* @param {Object} [options] - Query options
* @param {number} [options.page=1] - Page number
* @param {number} [options.limit=20] - Items per page (20, 50, or 100)
* @returns {Promise<Object>} Upload history
*/
async getHistory(options = {}) {
return this._request('/uploadposts/history', 'GET', {
page: options.page || 1,
limit: options.limit || 20
});
}
/**
* Get analytics for a profile
*
* @param {string} profileUsername - Profile username
* @param {Object} [options] - Query options
* @param {string[]} [options.platforms] - Filter by platforms (instagram, linkedin, facebook, x, youtube, tiktok, threads, pinterest, reddit)
* @param {string} [options.pageId] - Facebook Page ID (required for Facebook analytics)
* @param {string} [options.pageUrn] - LinkedIn organization/company page URN or numeric ID. LinkedIn analytics are only available for pages you administer; personal profiles are not supported (LinkedIn exposes no member-level analytics). Defaults to the first administered organization page.
* @returns {Promise<Object>} Analytics data per platform
*/
async getAnalytics(profileUsername, options = {}) {
const params = {};
if (options.platforms && options.platforms.length > 0) {
params.platforms = options.platforms.join(',');
}
if (options.pageId) params.page_id = options.pageId;
if (options.pageUrn) params.page_urn = options.pageUrn;
return this._request(`/analytics/${encodeURIComponent(profileUsername)}`, 'GET', params);
}
/**
* Get total impressions for a profile from daily snapshots
*
* @param {string} profileUsername - Profile username
* @param {Object} [options] - Query options
* @param {string} [options.period] - Period shortcut: last_day, last_week, last_month, last_3months, last_year
* @param {string} [options.startDate] - Start date in YYYY-MM-DD format
* @param {string} [options.endDate] - End date in YYYY-MM-DD format
* @param {string} [options.date] - Single date in YYYY-MM-DD format
* @param {string[]} [options.platforms] - Filter by platforms
* @param {boolean} [options.breakdown] - Include per-platform and per-day breakdown
* @param {string[]} [options.metrics] - Specific metrics to aggregate (e.g., ['likes', 'comments', 'shares'])
* @returns {Promise<Object>} Total impressions data
*/
async getTotalImpressions(profileUsername, options = {}) {
const params = {};
if (options.period) params.period = options.period;
if (options.startDate) params.start_date = options.startDate;
if (options.endDate) params.end_date = options.endDate;
if (options.date) params.date = options.date;
if (options.platforms && options.platforms.length > 0) {
params.platform = options.platforms.join(',');
}
if (options.breakdown) params.breakdown = 'true';
if (options.metrics && options.metrics.length > 0) {
params.metrics = options.metrics.join(',');
}
return this._request(`/uploadposts/total-impressions/${encodeURIComponent(profileUsername)}`, 'GET', params);
}
/**
* Get analytics for a specific post across all platforms it was published to
*
* @param {string} requestId - The request_id from the upload
* @returns {Promise<Object>} Post analytics with per-platform metrics
*/
async getPostAnalytics(requestId) {
return this._request(`/uploadposts/post-analytics/${encodeURIComponent(requestId)}`, 'GET');
}
/**
* Get analytics for any post (including organic posts) using its native platform ID
*
* @param {string} platformPostId - The native post ID on the platform (e.g., Instagram media ID)
* @param {string} platform - The platform to query (instagram, youtube, tiktok, facebook, linkedin, x, threads, pinterest, reddit)
* @param {string} user - The profile_username that owns the social account
* @returns {Promise<Object>} Post analytics with live per-post metrics from the platform API
*/
async getPostAnalyticsByPlatformId(platformPostId, platform, user) {
return this._request('/uploadposts/post-analytics', 'GET', {
platform_post_id: platformPostId,
platform,
user,
});
}
/**
* Get available metrics configuration for all supported platforms
*
* @returns {Promise<Object>} Platform metrics config (primary fields, available metrics, labels)
*/
async getPlatformMetrics() {
return this._request('/uploadposts/platform-metrics', 'GET');
}
/**
* Get detailed Reddit posts with full media information
*
* @param {string} profileUsername - Profile username
* @returns {Promise<Object>} Detailed Reddit posts
*/
async getRedditDetailedPosts(profileUsername) {
return this._request('/uploadposts/reddit/detailed-posts/', 'GET', { profile_username: profileUsername });
}
/**
* Get recent media from a connected social account.
*
* @param {string} platform - instagram, tiktok, youtube, linkedin, facebook, x, threads, pinterest, bluesky, reddit
* @param {string} user - Profile username
* @param {Object} [options]
* @param {string} [options.pageUrn] - LinkedIn only. Numeric org ID, full URN, or "me" to force the personal profile.
* @returns {Promise<Object>}
*/
async getMedia(platform, user, options = {}) {
const params = { platform, user };
if (options.pageUrn) params.page_urn = options.pageUrn;
return this._request('/uploadposts/media', 'GET', params);
}
// ==================== Scheduled Posts ====================
/**
* List scheduled posts
*
* @returns {Promise<Object>} List of scheduled posts
*/
async listScheduled() {
return this._request('/uploadposts/schedule', 'GET');
}
/**
* Cancel a scheduled post
*
* @param {string} jobId - Scheduled job ID
* @returns {Promise<Object>} Cancellation result
*/
async cancelScheduled(jobId) {
return this._request(`/uploadposts/schedule/${jobId}`, 'DELETE');
}
/**
* Edit a scheduled post
*
* @param {string} jobId - Scheduled job ID
* @param {Object} options - Edit options
* @param {string} [options.scheduledDate] - New scheduled date (ISO 8601)
* @param {string} [options.timezone] - New timezone
* @returns {Promise<Object>} Edit result
*/
async editScheduled(jobId, options) {
const body = {};
if (options.scheduledDate) body.scheduled_date = options.scheduledDate;
if (options.timezone) body.timezone = options.timezone;
return this._request(`/uploadposts/schedule/${jobId}`, 'PATCH', body);
}
// ==================== User Management ====================
/**
* List all users/profiles
*
* @returns {Promise<Object>} List of users
*/
async listUsers() {
return this._request('/uploadposts/users', 'GET');
}
/**
* Create a new user/profile
*
* @param {string} username - Profile name to create
* @returns {Promise<Object>} Created user
*/
async createUser(username) {
return this._request('/uploadposts/users', 'POST', { username });
}
/**
* Delete a user/profile
*
* @param {string} username - Profile name to delete
* @returns {Promise<Object>} Deletion result
*/
async deleteUser(username) {
return this._request('/uploadposts/users', 'DELETE', { username });
}
/**
* Generate a JWT for platform integration
* Used when integrating Upload-Post into your own platform
*
* @param {string} username - Profile username
* @param {Object} [options] - JWT options
* @param {string} [options.redirectUrl] - URL to redirect after linking
* @param {string} [options.logoImage] - Logo image URL for the linking page
* @param {string} [options.redirectButtonText] - Text for redirect button
* @param {string[]} [options.platforms] - Platforms to show for connection
* @param {boolean} [options.showCalendar] - Whether to show the calendar view
* @param {boolean} [options.readonlyCalendar] - Show only a read-only calendar (no editing, no account connection)
* @param {string} [options.connectTitle] - Custom title for the connection page
* @param {string} [options.connectDescription] - Custom description for the connection page
* @param {('en'|'es'|'de'|'fr'|'pt')} [options.language] - Force the connection page language for this profile. When omitted, the page auto-detects the visitor's browser language and falls back to English.
* @returns {Promise<Object>} JWT and connection URL
*/
async generateJwt(username, options = {}) {
const body = { username };
if (options.redirectUrl) body.redirect_url = options.redirectUrl;
if (options.logoImage) body.logo_image = options.logoImage;
if (options.redirectButtonText) body.redirect_button_text = options.redirectButtonText;
if (options.platforms && options.platforms.length > 0) body.platforms = options.platforms;
if (options.showCalendar !== undefined) body.show_calendar = options.showCalendar;
if (options.readonlyCalendar !== undefined) body.readonly_calendar = options.readonlyCalendar;
if (options.connectTitle) body.connect_title = options.connectTitle;
if (options.connectDescription) body.connect_description = options.connectDescription;
if (options.language) body.language = options.language;
return this._request('/uploadposts/users/generate-jwt', 'POST', body);
}
/**
* Validate a JWT token
*
* @param {string} jwt - JWT token to validate
* @returns {Promise<Object>} Validation result
*/
async validateJwt(jwt) {
return this._request('/uploadposts/users/validate-jwt', 'GET', null, false, { Authorization: `Bearer ${jwt}` });
}
/**
* Get user preferences (including calendar settings)
*
* @returns {Promise<Object>} User preferences
*/
async getUserPreferences() {
return this._request('/uploadposts/users/preferences', 'GET');
}
/**
* Update user preferences (including calendar settings)
*
* @param {Object} options - Preferences options
* @param {number} [options.weekStartDay] - Week start day (0=Sunday, 1=Monday)
* @returns {Promise<Object>} Updated preferences
*/
async updateUserPreferences(options = {}) {
const body = {};
if (options.weekStartDay !== undefined) body.week_start_day = options.weekStartDay;
return this._request('/uploadposts/users/preferences', 'POST', body);
}
/**
* Get notification configuration (including webhook settings)
*
* @returns {Promise<Object>} Notification config
*/
async getNotificationConfig() {
return this._request('/uploadposts/users/notifications', 'GET');
}
/**
* Update notification configuration (including webhook settings)
*
* @param {Object} options - Notification config options
* @param {string[]} [options.webhookEvents] - Webhook event types to subscribe to (upload_completed, social_account_connected, social_account_disconnected, social_account_reauth_required)
* @param {string} [options.webhookUrl] - Webhook URL for notifications
* @returns {Promise<Object>} Updated notification config
*/
async updateNotificationConfig(options = {}) {
const body = {};
if (options.webhookEvents) body.webhook_events = options.webhookEvents;
if (options.webhookUrl) body.webhook_url = options.webhookUrl;
if (options.channels) body.channels = options.channels;
if (options.telegramChatId) body.telegram_chat_id = options.telegramChatId;
if (options.slackWebhookUrl) body.slack_webhook_url = options.slackWebhookUrl;
if (options.whatsappToPhone) body.whatsapp_to_phone = options.whatsappToPhone;
return this._request('/uploadposts/users/notifications', 'POST', body);
}
/**
* Send a test notification through the configured channels
*
* @returns {Promise<Object>} Test result
*/
async testNotifi