# dropspace dropspace is a SaaS platform for multi-platform content distribution. create launches with AI-generated per-platform copy, attach your own images and videos, manage writing personas, and publish to Twitter, Reddit, LinkedIn, Facebook, Instagram, TikTok, YouTube, and more. website: https://www.dropspace.dev docs: https://www.dropspace.dev/docs API: https://api.dropspace.dev ## key features - multi-platform publishing to 10 social networks in one click - AI content generation tailored per platform (tone, length, format) - post your own images and videos (by URL or base64) - attached per platform to fit each one's media rules - brand personas to maintain distinct voices across platforms, with a brand kit (colors, logo) for autopilot image slides - link click tracking: the product URL becomes a tracked link on X, LinkedIn, Facebook, Reddit, and YouTube; clicks by people show in analytics - AI images built from your product page's own image (use_product_image) - developer REST API with granular scopes and webhooks (a Slack webhook URL gets readable messages) - boosts: paid Meta promotion on your own ad account (engagement or traffic) that pauses itself when click-through decays or cost per click passes your cap - MCP server for AI agent integration (107 tools; npm package or hosted endpoint) - autopilot: autonomous nightly content engine for pro/premium plans (drafts, schedules, posts) - from AI-generated media or the user's own photos and videos in their media library - agent payments (x402 crypto + MPP Stripe) for autonomous agent workflows - discoverable on agentic.market and CDP x402 bazaar - launch scheduling with timezone support - per-platform content customization and preview ## discoverable on - agentic.market: https://agentic.market/?service=www-dropspace-dev - CDP x402 bazaar: https://api.cdp.coinbase.com/platform/v2/x402/discovery/resources (resource: https://api.dropspace.dev/launches) - /.well-known/x402: https://api.dropspace.dev/.well-known/x402 ## supported platforms | platform | posting | notes | |----------|---------|-------| | Facebook | automated | page posts with images and links | | LinkedIn | automated | personal profiles and company pages | | Twitter / X | automated | tweets and threads | | Reddit | automated | multi-subreddit fan-out via `platform_contents.reddit.metadata.subreddits` (max 10, one post each; per-community status via `GET /launches/{id}/destinations`, retry one via `POST /launches/{id}/retry` `{"subreddit":""}`) + optional `metadata.first_comment`; ICP-matched subreddit discovery via `POST /reddit/subreddits/suggest` | | Instagram | automated | photo posts with optimized captions | | TikTok | automated | short-form video; post directly or save to drafts for in-app editing | | YouTube | automated | video uploads with title, description, and metadata | | Product Hunt | manual (guided) | taglines, descriptions, maker comments | | Hacker News | manual (guided) | Show HN posts with technical framing | | Substack | manual (guided) | newsletter drafts with AI content adaptation | ## platform guides ### Facebook publish to Facebook with AI-generated posts dropspace helps you publish on Facebook with AI-generated posts optimized for engagement. craft compelling page posts, reach wider audiences, and manage your Facebook presence alongside your other connected platforms - all from one dashboard. **features:** - AI-optimized page posts: generate Facebook posts tailored for maximum engagement with smart formatting, compelling hooks, and audience-aware language. - multi-format content: create text posts, image posts, and link shares - all optimized for Facebook's algorithm and engagement patterns. - audience reach insights: understand how your content performs on Facebook with integrated analytics and engagement tracking. - cross-platform scheduling: schedule your Facebook posts to go live alongside Twitter, LinkedIn, Reddit, Instagram, and TikTok - all at the perfect time. - AI persona matching: your AI persona ensures every Facebook post matches your brand voice, keeping your messaging consistent across platforms. **tips:** - use images or videos - Facebook posts with visual content tend to get more engagement than text-only posts - keep posts concise and direct - shorter posts (under 150 characters) tend to get more engagement - post during peak hours - Facebook engagement peaks on weekdays between 9 AM and 2 PM - ask questions to spark comments - posts that generate conversations tend to rank higher ### LinkedIn grow your professional audience on LinkedIn with AI-powered posts dropspace generates professional LinkedIn posts that get your work in front of the right people. publish content that speaks directly to decision-makers, build credibility with consistent posting, and turn LinkedIn into a real acquisition channel. **features:** - professional tone optimization: AI crafts LinkedIn posts with the right professional tone - authoritative but approachable, informative but engaging. - industry-aware framing: turn product announcements into posts worth reading. dropspace adds context about the problem you solve and why it matters to your audience. - engagement-optimized formatting: smart line breaks, strategic emoji usage, and hook-first structures that drive engagement in the LinkedIn feed. - company page support: post from both personal profiles and company pages to maximize your reach across your professional network. - hashtag strategy: AI selects relevant professional hashtags that increase discoverability without looking spammy. - cross-platform publishing: coordinate your LinkedIn post with Twitter, Reddit, Instagram, and other auto-post platforms for unified multi-channel distribution. **tips:** - lead with a strong hook - the first two lines determine whether people click 'see more' - use line breaks generously - walls of text get scrolled past on LinkedIn - share personal context or a story to humanize your announcement - post on Tuesday through Thursday mornings for peak B2B engagement - tag relevant people and companies to expand your reach beyond your network ### Twitter / X publish to Twitter / X with AI-generated threads and posts dropspace creates Twitter / X posts formatted for engagement. craft tweets and threads with AI-optimized hooks and smart character limits - then post alongside your other platforms in one click. **features:** - smart character optimization: AI packs maximum impact into Twitter's character limits. every word earns its place with compelling, concise copy. - thread generation: automatically break longer announcements into engaging tweet threads with hooks that keep readers scrolling. - strong opening hooks: AI writes opening lines that encourage interaction. hooks designed to drive retweets and replies. - hashtag optimization: smart hashtag selection that increases discoverability without cluttering your tweet. quality over quantity. - multi-platform sync: publish on Twitter alongside LinkedIn, Reddit, Instagram, Facebook, and TikTok - all from your dropspace dashboard. **tips:** - front-load your best content - the first line is everything on Twitter - use threads for posts with multiple features or benefits to share - engage with replies quickly after posting - early engagement signals boost reach - include one clear call-to-action rather than multiple competing asks - post between 8-10 AM and 6-9 PM for maximum visibility ### Reddit reach engaged communities on Reddit with AI-crafted posts dropspace generates Reddit posts that respect community norms while sharing your content. AI crafts value-first posts tailored for specific subreddits - no spam, no self-promotion flags. **features:** - subreddit-aware content: AI adapts your content to match the tone, rules, and expectations of each target subreddit. - community voice generation: Reddit hates marketing speak. AI generates posts that sound like a community member sharing something valuable. - community rules compliance: smart content generation that avoids common self-promotion pitfalls and respects subreddit-specific posting rules. - value-first framing: your content is framed around the problem it solves, not the product itself - the approach that performs best on Reddit. - multi-subreddit targeting: create tailored versions of your announcement for different subreddits, each with the right tone and angle. **tips:** - lead with value - explain the problem you solve before mentioning your product - read the subreddit rules carefully - each community has unique self-promotion policies - engage in comments after posting to build trust with the community - avoid marketing language - Reddit users can spot promotional content instantly - build karma in your target subreddits before launch day for better posting privileges ### Instagram create Instagram captions and visuals with AI dropspace creates Instagram captions and visuals. generate captions with optimized hashtags, pair them with AI-generated images, and publish alongside your other platforms. **features:** - caption optimization: AI writes Instagram captions with engaging hooks, strategic line breaks, and calls-to-action that drive profile visits and link clicks. - hashtag strategy: smart mix of popular and niche hashtags to maximize discoverability without getting lost in oversaturated tags. - AI visual generation: generate launch visuals with AI. create product shots, announcement graphics, and carousel-ready images. - caption formatting: proper formatting with line breaks, emoji placement, and structure that looks great in the Instagram feed. - cross-platform coordination: sync your Instagram post with Twitter, LinkedIn, Facebook, Reddit, and TikTok for cohesive multi-platform distribution. **tips:** - use AI-generated images to create polished visuals - high-quality visual content performs better on Instagram - put the most important info in the first line - most users see only the preview before tapping 'more' - use 3-5 targeted hashtags per post - Instagram's algorithm now penalizes hashtag stuffing - post between 11 AM and 1 PM or 7-9 PM for peak Instagram engagement ### TikTok create TikTok content with AI-generated video and captions dropspace helps you create TikTok-ready content with AI-generated videos, trending hooks, and optimized captions. reach TikTok's audience with short-form content designed for the for you page. **features:** - AI video generation: generate short-form videos with AI. create product demos, announcement clips, and attention-grabbing visuals. - trending hook optimization: AI writes opening hooks based on what's working on TikTok right now - grab attention in the first second. - caption and hashtag strategy: optimized captions with trending hashtags and sounds references to maximize for you page placement. - short-form content design: content structured for TikTok's fast-paced format. concise, punchy, and designed to keep viewers watching. - post directly or save to drafts: publish straight to your profile, or send the post to your TikTok drafts to finish editing and publish from the TikTok app. - multi-platform publishing: coordinate your TikTok posts with Instagram, Twitter, LinkedIn, Facebook, and Reddit from one dashboard. **tips:** - hook viewers in the first second - TikTok prioritizes watch time in its ranking - keep videos under 60 seconds for the best chance at for you page placement - use trending sounds and formats to build on existing momentum - post on a regular schedule - TikTok favors creators who publish regularly - show the product in action rather than talking about features - save to drafts if you want to add effects, sounds, or final edits in the TikTok app before publishing ### YouTube publish videos to YouTube with AI-generated titles and descriptions dropspace helps you publish on YouTube with AI-generated video titles, descriptions, and tags. upload product demos, launch announcements, and tutorials with metadata optimized for YouTube's search algorithm - then schedule alongside your other platforms. **features:** - AI title optimization: generate compelling, search-optimized video titles that balance click-through rate with authenticity - no clickbait needed. - description SEO: AI-crafted video descriptions with keyword optimization, timestamps, and resource links that drive viewer engagement and search rankings. - tag strategy: smart tag recommendations that increase discoverability without cluttering your video metadata. targets relevant search terms for your niche. - Shorts support: auto-detect vertical videos under 60 seconds as Shorts, or manually choose between Shorts and regular uploads. optimized metadata for each format. - multi-platform coordination: sync your YouTube upload with Twitter, LinkedIn, Reddit, Instagram, and TikTok - schedule clips and teasers alongside the full video. **tips:** - hook viewers in the first 3 seconds - YouTube's algorithm heavily weights early viewer retention - front-load keywords in your title - the first 60 characters matter most for search - include the product URL in the first 2 lines of description (shown before 'Show More') - publish consistently - YouTube's algorithm favors creators with regular upload schedules - use the category and tags fields to help YouTube understand and recommend your content ### Substack repurpose your posts into Substack newsletters dropspace helps you turn your posts into Substack newsletters. AI adapts your announcement into a reader-friendly format with storytelling hooks, scannable structure, and a clear call-to-action. Substack is a guided platform - dropspace generates your newsletter draft, and you copy it into Substack to publish. **features:** - newsletter formatting: AI restructures your post content into a newsletter-friendly format with headers, pull quotes, and scannable sections that keep readers engaged. - audience cross-pollination: drive your social media audience to your Substack and vice versa. coordinate your newsletter timing with auto-posts on Twitter, LinkedIn, and Reddit. - AI content adaptation: your post description is automatically rewritten for a newsletter audience - longer form, more context, and a narrative arc that works in the inbox. - cross-platform coordination: time your Substack newsletter to go out alongside your auto-posted social content for maximum reach on launch day. - persona voice matching: your AI persona ensures the newsletter matches your writing voice, keeping your Substack consistent with your other content. **tips:** - lead with a story or personal angle - Substack readers expect more depth than a social post - keep it scannable with headers and short paragraphs - even long-form readers skim first - link back to your full launch or product page with a clear call-to-action - send at a consistent day and time to build reader habits - cross-promote your Substack in your social posts to grow your subscriber base ### Product Hunt nail your Product Hunt launch with AI-optimized content dropspace prepares you for a successful Product Hunt launch with AI-generated taglines, descriptions, and maker comments. plan your launch day strategy, optimize your listing, and coordinate with auto-posts on your connected social platforms. Product Hunt is a guided platform - dropspace generates your content, and you post it manually. **features:** - tagline optimization: AI generates and refines taglines that capture your product's value in the few words that matter most on Product Hunt. - description crafting: compelling product descriptions that highlight your unique value proposition and drive upvotes from the PH community. - maker comment strategy: AI helps plan your maker comments - the behind-the-scenes story that PH users love and that drives engagement. - launch day coordination: coordinate your Product Hunt listing with auto-posts on Twitter, LinkedIn, and Reddit for maximum launch day visibility. **tips:** - launch at 12:01 AM PST to get a full day of voting - timing is critical on Product Hunt - prepare your maker comment ahead of time - tell the story behind why you built the product - keep your tagline under 60 characters and focus on the core value proposition - coordinate supporters to visit and engage within the first hour for early ranking momentum - respond to every comment on your launch - PH rewards active makers ### Hacker News reach the tech community on Hacker News with compelling posts dropspace helps you craft Hacker News submissions that hold up to a technical audience. AI generates titles optimized for HN - factual, specific, and focused on technical merit rather than marketing fluff. Hacker News is a guided platform - dropspace generates your content, and you post it manually. **features:** - title optimization: AI crafts HN titles that are descriptive, honest, and technically focused - the style that performs best with the HN community. - technical audience framing: content framed for developers and tech enthusiasts. focus on technical innovation, architecture decisions, and real results. - discussion generation: titles and descriptions designed to spark thoughtful technical discussion - the key to staying on the front page. - show HN formatting: properly formatted Show HN posts that follow community conventions and guidelines for project showcases. **tips:** - use 'Show HN:' prefix for project showcases - it's the accepted format for sharing your own work - keep titles factual and descriptive - avoid superlatives and marketing language - be ready to answer technical questions in the comments - HN values maker engagement - share what makes your approach technically interesting, not just what the product does - post between 8-11 AM EST for maximum US audience visibility ## how-to guides ### install a community automation skill set up a community-built automation template from the dropspace marketplace in 10 minutes - from API key to running pipeline. difficulty: beginner | estimated time: 10 minutes **steps:** 1. **get a dropspace API key**: go to your dropspace settings and create a new API key. select the scopes your chosen template needs - most templates require read, write, generate, and send. tip: if you're unsure which scopes to pick, start with the defaults suggested in the template's README. 2. **install the dropspace MCP server (optional)**: for the best experience, install the dropspace MCP server so your AI assistant can help you configure and debug the automation: claude mcp add dropspace -- npx -y -p @jclvsh/dropspace dropspace-mcp tip: this step is optional but recommended. the MCP server lets Claude Code or Cursor interact with your dropspace account directly. 3. **clone the openclaw repo**: clone the markus repository and navigate to the template directory for your chosen use case: git clone https://github.com/joshchoi4881/markus && cd markus tip: you can also browse and install templates directly from clawhub: https://clawhub.ai/jclvsh/dropspace 4. **run the setup wizard**: each template includes a setup wizard that walks you through configuration. run npm install followed by npm run setup in the template directory. the wizard prompts for your API key, platform preferences, and schedule. 5. **configure your API key and platform connections**: the setup wizard creates a local config file. verify your API key is set correctly and that the platforms you want to publish to are connected in your dropspace account. tip: make sure your platform accounts are connected in dropspace settings before running the automation - the template posts on your behalf via the API. 6. **run the automation**: start the automation with npm start. the template connects to the dropspace API, generates content according to its pipeline, and publishes to your configured platforms. check the terminal output for status updates. ### how to launch on Reddit with dropspace a step-by-step guide to launching on Reddit using dropspace's AI-powered content generation and multi-subreddit posting. difficulty: beginner | estimated time: 10 minutes **steps:** 1. **create your account & connect Reddit**: sign up for dropspace and navigate to settings to connect your Reddit account via OAuth. dropspace will request the minimum permissions needed to post on your behalf. tip: make sure your Reddit account has enough karma and age to post in your target subreddits. most subreddits require at least a few weeks of account history. 2. **create a new post**: head to your dashboard and create a new post. give it a name, add a description of your product, and optionally upload images or generate AI media. 3. **configure Reddit settings**: select which subreddits you want to post to. dropspace lets you choose from popular launch subreddits or enter custom ones. each subreddit gets its own tailored post. tip: research each subreddit's rules before launching. some have specific formatting requirements or restrict self-promotion to certain days. 4. **generate AI content**: use dropspace's AI to generate persona-aware content for each subreddit. the AI adapts tone and style - casual for r/SideProject, technical for r/webdev, and narrative-driven for r/startups. 5. **review and customize content**: review the generated content for each subreddit. edit titles, body text, and flair as needed. dropspace shows you a preview of how each post will appear. 6. **schedule or publish immediately**: choose to publish right away or schedule your post for the optimal time. dropspace handles posting to all selected subreddits simultaneously. ### automate LinkedIn posts with AI learn how to connect LinkedIn and use AI personas to generate professional, platform-optimized content for your posts. difficulty: beginner | estimated time: 8 minutes **steps:** 1. **connect your LinkedIn account**: go to settings and connect your LinkedIn account. dropspace supports both personal profiles and company pages through OAuth. 2. **create a persona for professional tone**: set up an AI persona that matches your professional brand voice. configure it for thought leadership, industry expertise, or a casual-professional hybrid. tip: analyze your best-performing LinkedIn posts and describe that style to your persona. the AI will learn to match it. 3. **create a post**: create a new post with your product details. include a clear value proposition and any supporting data or metrics - LinkedIn audiences respond well to specifics. 4. **generate AI content optimized for LinkedIn**: let dropspace generate LinkedIn-specific content. the AI structures posts with hooks, bullet points, and calls-to-action that perform well in LinkedIn's algorithm. 5. **review the professional formatting**: check the generated post for proper LinkedIn formatting: short paragraphs, line breaks for readability, relevant hashtags, and a compelling opening line. 6. **publish**: publish your post. dropspace posts to LinkedIn along with any other platforms you've configured, all from a single click. ### set up webhook notifications for your posts configure webhook endpoints to receive real-time notifications when posts are published, media is generated, and more. difficulty: intermediate | estimated time: 15 minutes **steps:** 1. **navigate to developer settings**: go to your dropspace settings and find the developer section. you'll need an API key - create one if you haven't already. 2. **create a webhook endpoint**: add a new webhook by providing your endpoint URL. this should be a publicly accessible HTTPS URL that can receive POST requests. tip: for local development, use a tunneling service like ngrok to expose your local server to the internet. 3. **configure event types**: select which events you want to receive: launch.published, launch.completed, launch.failed, media.completed, persona.completed, and more. start with the events you need. 4. **test with a sample event**: use the test button to send a sample payload to your endpoint. verify that your server receives and acknowledges the request with a 200 status code. 5. **handle webhook payloads in your app**: parse the incoming JSON payload in your application. each event type has a specific payload structure documented in the API reference. 6. **verify webhook signatures**: validate the X-Dropspace-Signature header using HMAC-SHA256 with your webhook secret over `{X-Dropspace-Timestamp}.{body}`. this ensures the request genuinely came from dropspace and prevents replay attacks (reject if X-Dropspace-Timestamp is older than 5 minutes). tip: always use timing-safe comparison when verifying signatures to prevent timing attacks. ### create your first dropspace API integration build a complete integration with the dropspace API - from authentication to creating posts, generating content, and monitoring via webhooks. difficulty: intermediate | estimated time: 20 minutes **steps:** 1. **create an API key with appropriate scopes**: navigate to developer settings and create a new API key. select the scopes you need: read, write, and admin (admin is required to register webhooks). tip: follow the principle of least privilege - only request the scopes your integration needs. 2. **install the SDK or set up HTTP client**: install the @jclvsh/dropspace package for a type-safe client, or use any HTTP client to call the REST API directly at api.dropspace.dev. 3. **authenticate requests**: include your API key in the Authorization header as a Bearer token: Authorization: Bearer ds_live_... - all API requests require this header. 4. **create a post via API**: make a POST request to /launches with your post name, description, and target platforms. the API returns the created post with its ID for subsequent operations. 5. **generate content for the post**: call POST /launches/{id}/generate-content to trigger AI content generation. optionally specify a persona ID to use your brand voice. content is generated asynchronously. 6. **trigger publishing**: when your content is ready, call POST /launches/{id}/publish to start publishing across all configured platforms. the API returns immediately while publishing happens in the background. 7. **monitor via webhooks**: set up webhooks (see the webhook guide) to receive launch.published and launch.completed events. this lets your integration react in real-time without polling. ### schedule your first multi-platform post learn how to create a post, generate AI content for multiple platforms, and schedule it to publish at the perfect time. difficulty: beginner | estimated time: 5 minutes **steps:** 1. **create a new post**: from your dashboard, click 'new post' and give it a name. describe what you want to share - a product update, thought piece, announcement, or campaign. 2. **select your platforms**: choose which platforms to target. dropspace auto-posts to Facebook, LinkedIn, Twitter, Reddit, Instagram, and TikTok. it also generates AI content for Product Hunt, Hacker News, and Substack (guided - you post manually). tip: connect your platform accounts in settings first if you haven't already. 3. **generate AI content**: click generate to let AI create platform-optimized content. each platform gets tailored copy - professional for LinkedIn, concise for Twitter, visual-first for Instagram. 4. **review and edit**: review the generated content for each platform. edit anything that doesn't sound right - you're always in control of the final message. 5. **schedule or publish**: choose to publish immediately or schedule for a specific date and time. dropspace auto-posts to your connected social platforms. for guided platforms (Product Hunt, Hacker News, Substack), copy the AI-generated content and post manually. tip: weekday mornings tend to see higher engagement across most platforms. ### create a brand persona for your agency set up AI personas to manage multiple brand voices - perfect for agencies, ghostwriters, and anyone managing content for multiple clients. difficulty: beginner | estimated time: 8 minutes **steps:** 1. **navigate to personas**: go to the personas section in your dashboard. this is where you create and manage all your brand voices. 2. **create a new persona**: click 'new persona' and give it a name that identifies the brand or client. add a description of the voice, tone, and style you want the AI to emulate. tip: include examples of the brand's existing content to help the AI match the voice more accurately. 3. **configure voice attributes**: define the persona's tone (professional, casual, witty), target audience, industry, and any specific terminology or phrases the brand uses. 4. **run AI analysis**: let AI analyze the persona configuration and build a voice profile. this takes a few seconds and creates a reusable template for all future content. 5. **test with a post**: create a test post and select your new persona. generate content and verify the AI output matches the brand voice you configured. 6. **repeat for each client**: create a separate persona for each brand or client you manage. when creating posts, select the appropriate persona to switch voices instantly. ### automate posting with Claude Code + MCP connect dropspace to Claude Code using the MCP server and automate content creation, scheduling, and publishing from your terminal. difficulty: intermediate | estimated time: 15 minutes **steps:** 1. **get your API key**: go to settings in the dropspace dashboard and create a new API key. select the scopes you need: read, write, generate, and send. tip: follow the principle of least privilege - only request the scopes your automation needs. 2. **configure the MCP server**: add the dropspace MCP server to your Claude Code configuration at ~/.claude/settings.json. set the DROPSPACE_API_KEY environment variable to your API key. 3. **verify the connection**: open Claude Code and ask it to 'list my dropspace posts'. if the MCP server is configured correctly, it will use the dropspace tools to fetch your posts. 4. **create a post via Claude Code**: ask Claude Code to 'create a new dropspace post about [your topic]'. the assistant will use the MCP tools to create the post and generate content. 5. **generate and review content**: ask Claude Code to generate content for your post. review the AI-generated posts for each platform and request edits if needed. 6. **publish from your terminal**: when the content is ready, ask Claude Code to publish the post. it will trigger publishing across all configured platforms. ### set up agent payments with x402 or MPP let your AI agent pay per post with x402 crypto or MPP Stripe - no account, no API key, no human in the loop. difficulty: intermediate | estimated time: 15 minutes **steps:** 1. **understand the two protocols**: x402 uses USDC on Base (crypto) at $0.55/post. MPP uses Stripe (card) at $0.60/post. x402 includes 5 free posts/month. no account or signup needed for either. 2. **choose your protocol**: x402 if your agent already has a crypto wallet. MPP if you prefer standard card payments. both produce the same result. tip: most agents default to MPP since it doesn't require managing crypto keys. 3. **configure x402 payments**: agent needs a wallet with USDC on Base. include the payment proof in the X-PAYMENT header. the x402 facilitator handles verification. see https://www.x402.org for the spec. tip: testnet available for development. 4. **configure MPP payments**: agent pays with a standard card via Stripe's machine payment protocol. include the MPP payment token in the request. see https://www.mppx.dev for the spec. 5. **make your first paid post**: POST to api.dropspace.dev/launches with the payment header. the response includes post URLs for each platform. x402 wallets get 5 free posts/month. tip: use dropspace_platforms to post to official accounts - no OAuth setup needed. 6. **buy credit packs (MPP only)**: MPP agents can prepay with credit packs: 20 credits for $12.00 via POST /credits/purchase. credits are deducted automatically on post creation - no payment credential needed for each request. check balance with GET /credits. tip: credit packs reduce Stripe fees from 53% to 8% by batching charges. x402 wallets don't need credits - crypto fees are already low. 7. **handle errors and monitor usage**: 402 means payment required (free tier exhausted and no credits). 400 means invalid payment proof. track usage via the response headers. tip: the /usage endpoint shows remaining free posts and credit balance. ### build a content pipeline with community templates set up an automated content pipeline using open-source community templates - from installing a skill to scheduling daily posts. difficulty: beginner | estimated time: 10 minutes **steps:** 1. **browse available templates**: go to /community to see available automation templates. each template lists supported frameworks, content formats, and required API scopes. tip: the AI content engine template is the most popular starting point. 2. **install the skill for your framework**: pick your framework (OpenClaw, Manus, Claude Code, or Cursor) and follow the one-click install. each framework has its own setup path on the template page. 3. **configure your app**: edit app.json with your product details: name, description, target audience, and voice. this is what the template uses to generate content. tip: be specific about your audience - "developers building with Next.js" works better than "developers". 4. **create a persona for brand voice**: go to your dropspace dashboard and create a persona. the persona controls tone, vocabulary, and platform-specific formatting. assign it to your template's configuration. tip: analyze an existing brand account to bootstrap your persona's voice. 5. **run your first pipeline**: trigger the template manually to verify everything works. check that content generates correctly and posts land on the right platforms. tip: start with dropspace official accounts (free) before connecting your own OAuth accounts. 6. **automate with scheduling**: set up cron jobs or your framework's scheduling feature for daily runs. most templates run at 1-2 AM to generate content for the next day. tip: stagger post times across platforms for better reach. ## comparisons ### dropspace vs Buffer API infrastructure for agents vs. scheduling dashboard for humans **differentiators:** - REST API and MCP server: dropspace is API-first: every capability is available programmatically via REST API or MCP server. Buffer's API is read-heavy with limited write access - designed for building integrations on top of a human product, not as primary infrastructure for agents. - x402/MPP pay-per-use: agents pay $0.55/post via x402 (crypto) or $0.60/post via MPP (card) - no account required, no monthly subscription. Buffer requires a subscription. monthly seats don't match agent usage patterns. - completion webhooks: dropspace sends webhooks when posts go live, not just when they're queued. agents need to know what actually happened. Buffer doesn't provide completion webhooks. - MCP-native: dropspace has a first-class MCP server with 107 tools for social distribution. agents using Claude, Cursor, or any MCP client can discover and call dropspace tools natively. Buffer has no MCP integration. - AI content generation: dropspace generates platform-optimized content from a single input - tweet thread, LinkedIn post, Reddit submission, all from one brief. Buffer is a scheduler - you write all content yourself. - agent-native auth: authenticate via API key, x402 wallet proof, or MPP card payment. no OAuth browser flows, no email verification, no 2FA requiring a human. Buffer uses OAuth flows that agents can't complete without human intervention. **verdict:** dropspace is API infrastructure for AI agents. Buffer is a scheduling dashboard for humans. different tools for different users. ### dropspace vs Hootsuite API infrastructure for agents vs. enterprise dashboard for humans **differentiators:** - REST API and MCP server: dropspace is API-first: every capability is programmatically accessible. Hootsuite's API requires enterprise approval and is designed for building integrations on top of a human-operated platform, not for autonomous agent workflows. - x402/MPP pay-per-use: dropspace agents pay $0.55/post via x402 (crypto) or $0.60/post via MPP (card) with no account required. Hootsuite starts at $99/mo with no free tier. the economics are wrong for agent workloads that don't need a monthly commitment. - MCP-native: dropspace ships a first-class MCP server with 107 tools purpose-built for social distribution. Hootsuite has no MCP integration - agents can't discover or call Hootsuite tools natively. - AI content generation included: AI content generation is core to dropspace - every post gets platform-optimized content. Hootsuite charges for AI features as an add-on and still expects humans to drive the workflow. - no enterprise complexity: dropspace is simple, API-first, and focused on the publishing use case. Hootsuite is an enterprise suite with an approval workflow, team permissions, and dashboard features that agents don't need. - transparent pricing: dropspace starts free with clear upgrade paths. Hootsuite starts at $99/mo with no free tier. for agents that publish intermittently, the cost difference is significant. **verdict:** dropspace is API infrastructure for AI agents. Hootsuite is an enterprise scheduling dashboard for humans. different tools for different users. ### dropspace vs Later API infrastructure for agents vs. visual scheduler for humans **differentiators:** - REST API and MCP server: dropspace is API-first: full REST API, webhook notifications, and 37 MCP tools for agent workflows. Later has no public developer API - it's a closed platform designed for human operators. - x402/MPP pay-per-use: dropspace agents pay $0.55/post via x402 (crypto) or $0.60/post via MPP (card) with no account required. Later requires a subscription. agents that post intermittently shouldn't pay monthly. - AI content generation: dropspace generates platform-optimized content per platform from a single input. Later's AI is limited to caption suggestions - you still write and format content for each platform yourself. - Reddit and developer platforms: dropspace auto-posts to Reddit and generates guided content for Product Hunt and Hacker News. Later doesn't support either - it's focused on visual platforms like Instagram and TikTok. - MCP-native: agents using Claude, Cursor, or any MCP client can discover and call dropspace tools natively. Later has no MCP integration. - completion webhooks: dropspace sends webhooks when posts go live. agents need to know what actually happened. Later provides no completion webhooks. **verdict:** dropspace is API infrastructure for AI agents. Later is a visual scheduling dashboard for humans. different tools for different users. ### dropspace vs Postbridge API infrastructure for agents vs. cross-posting scheduler for humans **differentiators:** - REST API and MCP server: dropspace is API-first: full REST API, webhook notifications, and 37 MCP tools. Postbridge offers an API add-on but no MCP integration or webhook infrastructure - it's designed around a human-operated dashboard. - x402 and MPP agent payments: autonomous agents pay per post via x402 crypto or MPP Stripe - no account or API key needed. Postbridge has no machine-to-machine payment protocol. - AI content generation: dropspace generates unique, platform-optimized content for every post. Postbridge is a scheduling tool - you write all content yourself. - MCP-native: dropspace ships a first-class MCP server with 107 tools. agents using Claude, Cursor, or any MCP client can discover and call dropspace tools natively. Postbridge has no MCP integration. - completion webhooks: dropspace sends webhooks when posts go live. Postbridge has no equivalent webhook infrastructure for agents to receive completion events. - community marketplace: browse and install open-source automation templates built by the community on openclaw. Postbridge has no developer ecosystem. **verdict:** dropspace is API infrastructure for AI agents. Postbridge is a cross-posting scheduler for humans. different tools for different users. ### dropspace vs Postiz API infrastructure for agents vs. open-source scheduler for humans **differentiators:** - REST API and MCP server: dropspace is API-first: full REST API, webhook notifications, and 37 MCP tools. Postiz has an API but it's designed for integrating with a human-operated scheduler - not as primary infrastructure for autonomous agent workflows. - x402/MPP pay-per-use: dropspace agents pay $0.55/post via x402 (crypto) or $0.60/post via MPP (card) with no account required. Postiz requires a subscription or self-hosted setup. the economics are wrong for intermittent agent usage. - AI content generation: AI content generation is core to dropspace - every post gets platform-optimized content from your brand persona. Postiz has an AI copilot, but content creation is supplementary to the scheduling workflow. - MCP-native: dropspace ships a first-class MCP server with 107 tools purpose-built for social distribution. Postiz has no MCP integration. - agent payments: autonomous agents pay per post with x402 crypto or MPP Stripe payments. Postiz has no machine-to-machine payment protocol. - post workflow: dropspace is built around coordinated multi-platform posts. Postiz is a general-purpose scheduler with no concept of a structured multi-platform publishing event. **verdict:** dropspace is API infrastructure for AI agents. Postiz is an open-source scheduling dashboard for humans. different tools for different users. ### dropspace vs DIY platform integrations one API vs nine integration projects **differentiators:** - one integration vs nine: integrating directly with Facebook, LinkedIn, Twitter, Reddit, Instagram, TikTok, YouTube, Substack, Product Hunt, and Hacker News means navigating 10 different developer portals, approval processes, and API designs. dropspace is one integration that covers all ten. - OAuth handled for you: managing OAuth tokens across 10 platforms - including refresh logic, scope management, and token storage - is a significant ongoing maintenance burden. dropspace handles all platform authentication so you don't have to. - per-platform content formatting: Twitter has character limits and thread logic. LinkedIn has its own mention syntax. Reddit requires subreddit-specific rules. each platform has unique content requirements. dropspace formats content per platform automatically from a single input. - rate limit management: each platform has its own rate limit system with different windows, backoff strategies, and error codes. dropspace manages rate limits across all platforms and retries on transient failures. - platform API stability: platform APIs change. breaking changes, deprecations, and new auth requirements mean ongoing maintenance. dropspace maintains all platform integrations so you only deal with one stable API. - AI content generation included: DIY integrations give you a publishing channel - you still have to write all the content yourself. dropspace includes AI content generation that adapts your message for each platform's tone and format. **verdict:** dropspace is API infrastructure for AI agents. building your own platform integrations is undifferentiated engineering work. use dropspace when social media distribution isn't your core product. ### dropspace vs Composio purpose-built for social vs. general-purpose agent tooling **differentiators:** - purpose-built for social media: dropspace is designed specifically for social media distribution. every feature - AI content generation, personas, per-platform formatting, post workflows - is built around the social publishing use case. Composio is a general-purpose tool connector. - AI content generation per platform: dropspace generates platform-optimized content from a single input - a tweet thread for Twitter, a professional post for LinkedIn, a community-focused post for Reddit. Composio passes your content through as-is with no platform adaptation. - x402/MPP pay-per-post: agents can use dropspace without an account via x402 (crypto) at $0.55/post or MPP (card) at $0.60/post. Composio requires account setup and subscription management. dropspace is the better choice for agents that need to pay per use. - brand personas: create reusable brand voice profiles that agents can assign to posts. Composio has no concept of brand voice or AI-driven content adaptation. - media generation: generate AI images and videos directly via the dropspace API and include them in posts. Composio doesn't include media generation - you'd need a separate integration. - MCP server with 37 social tools: dropspace's MCP server has 107 tools purpose-built for social distribution workflows. Composio has broader coverage but shallower depth for any individual use case. **verdict:** dropspace is API infrastructure for AI agents focused on social media. Composio is general-purpose agent tooling. if social media distribution is a core workflow, dropspace goes deeper. if you need access to hundreds of different SaaS APIs, they're complementary. ## free tools - post fit checker (https://www.dropspace.dev/tools/post-fit-checker): paste one post and see whether it fits X, LinkedIn, Facebook, Instagram, TikTok, YouTube, and Reddit - character limits, where it gets cut, and what shows before "more". no sign-up; the text stays in the browser. ## frequently asked questions ### posts **Q: can I see how many people clicked my link?** yes. turn on 'count link clicks' when you add a product URL to a post. on X, LinkedIn, Facebook, Reddit, and YouTube, the link is posted as a short dropspace.dev link, and clicks from people show up in the post's analytics. link previews and bots aren't counted. Instagram, TikTok, and Snapchat keep your plain link because links aren't clickable there. **Q: can I boost a post with paid ads?** yes, on Facebook and Instagram through your own Meta ad account - Meta bills you, dropspace never does. a boost starts paused until you activate it. choose engagement (likes, comments, shares) or traffic (clicks on the link in a Facebook link post). a running boost pauses itself if its click-through drops below half of its first day, and you can set a cost-per-click cap that pauses it too. **Q: how do I create a post?** go to the posts page and click the create button. you can add a title, description, and media content. once ready, select which platforms to post to and either publish immediately or schedule it for later. **Q: can I schedule a post for a specific time?** yes, when creating or editing a post, you can set a scheduled date and time. dropspace will automatically publish your post to all selected platforms at the scheduled time. **Q: what happens if a platform fails during publishing?** dropspace publishes to each platform independently. if one platform fails, the others will still be published successfully. you can retry failed platforms from the post detail page. **Q: can I edit a post after publishing?** you can edit the post content in dropspace, but changes won't be reflected on platforms where it was already posted. social media platforms generally don't support editing published posts via API. ### platform connections **Q: does posting through dropspace affect my reach or engagement?** no. dropspace uses the official platform APIs - the same APIs that Meta, LinkedIn, TikTok, and others provide for authorized third-party apps. posts made through official APIs are treated identically to posts made natively. dropspace has been reviewed and approved by Meta, TikTok, and LinkedIn through their app review processes, which means your posts go through the exact same publishing pipeline as the native apps. there is no shadowban, reach penalty, or engagement reduction from using official API posting. **Q: which platforms does dropspace support?** dropspace supports Facebook, LinkedIn (personal and company pages), Twitter/X, Reddit, Instagram, and TikTok for automated posting. Substack, Product Hunt, and Hacker News are supported for manual posting with guided instructions. **Q: how do I connect a social media account?** go to settings and find the platform connections section. click connect next to the platform you want to add. you'll be redirected to the platform's OAuth page to authorize dropspace. **Q: why is my platform connection showing as unhealthy?** platform connections can become unhealthy if your access token expires or if you revoke access on the platform's side. go to settings, disconnect the platform, and reconnect it to refresh the token. **Q: can I connect multiple accounts for the same platform?** currently, you can connect one account per platform. for LinkedIn, you can connect both a personal profile and a company page as separate connections. ### AI content generation **Q: can AI images show my actual product?** yes. if your post has a product URL, the media generator offers the images from that page as a reference, so the generated image is built around your real product. autopilot can do the same for its image slides - set a product image in autopilot settings. **Q: how does AI content generation work?** dropspace uses AI to generate platform-optimized text content for your posts. it adapts the tone, length, and format for each platform (e.g., shorter for Twitter, more detailed for LinkedIn). **Q: can I generate images and videos with AI?** yes, dropspace supports AI image generation and AI video generation. you can generate images from text prompts and create short video content for your posts. **Q: how do I control the tone of generated content?** use personas to define your brand voice. create a persona in the personas section with your preferred tone, style, and key messaging points. when generating content, select your persona to guide the AI output. **Q: are there limits on AI generation?** yes, AI generation usage is based on your plan. check your current usage and limits in the settings page under billing. you can upgrade your plan for higher limits. ### personas & brand voice **Q: can autopilot's image slides use my brand colors and logo?** yes. open a persona and set its brand kit: text color, outline color, and a logo for one corner. image slides autopilot posts as that persona use it. leave it empty for white text on a black outline. **Q: what is a persona?** a persona represents your brand voice and style preferences. it includes your tone, target audience, key themes, and messaging guidelines. the AI uses this context to generate content that sounds like you. **Q: how do I create a persona?** go to the personas page and click create. you can describe your brand, provide example content, and define your target audience. the AI will analyze these inputs to build a comprehensive brand voice profile. **Q: can I have multiple personas?** yes, you can create multiple personas for different brands, products, or communication styles. select the appropriate persona when generating content for a post. ### autopilot & your media **Q: can autopilot post my own photos and videos?** yes. upload them on the media page, or check 'save my uploads to my media library' when you create a post. in autopilot settings, set where photos and videos come from: AI-generated, your library only, or your library first with AI when nothing fits. autopilot picks media by its description, and describes photos for you on its next run. videos need a description from you. autopilot is on pro and premium. **Q: what if my media library is empty?** autopilot falls back to AI-generated slides and video until you add media. ### billing & plans **Q: what plans are available?** dropspace offers multiple plans with different limits for posts, AI generations, and platform connections. visit the settings page to see current plan options and pricing. **Q: how do I upgrade or change my plan?** go to settings and click on the billing section. you can upgrade, downgrade, or manage your subscription through the Stripe customer portal. **Q: what happens when I reach my plan limits?** when you reach a limit (e.g., monthly posts or AI generations), you'll see a notification. you can either wait for your billing period to reset or upgrade to a higher plan for more capacity. **Q: can I cancel my subscription?** yes, you can cancel anytime from the billing section in settings. your access continues until the end of your current billing period. **Q: can AI agents use dropspace without a subscription?** yes, AI agents can pay $0.55/post via x402 (crypto) or $0.60/post via MPP (card) - no account or subscription required. x402 wallets get 5 free posts per month to get started. **Q: what are credit packs?** credit packs let MPP (Stripe) agents prepay for posts in bulk. buy 20 credits for $12.00 instead of paying $0.60 per post individually. credits are deducted automatically when creating posts and never expire. **Q: how do I buy credit packs?** send an MPP payment credential for $12.00 to POST /credits/purchase. check your balance anytime with GET /credits. credit packs are for MPP (Stripe) agents only - x402 wallets use per-post USDC settlement. ### sharing & affiliates **Q: can I share my results?** yes. open analytics and choose 'share a 30-day recap'. it shows what you published from your own accounts in the last 30 days: posts, likes, comments, shares, a count per platform, and your top post. sharing creates a public link with those numbers and a link to your top post. your name is not shown, and anyone with the link can see it until you delete it. **Q: does dropspace have an affiliate program?** yes. apply from settings > affiliate by picking the code for your link; we review each application before the link starts working. you earn 30% of each subscription payment a person you referred makes in their first 12 months. a referral counts when someone signs up within 30 days of first clicking your link. a commission becomes payable 30 days after the payment and is cancelled if that payment is refunded or disputed. card subscriptions on the web count; App Store purchases and pay-per-launch agent payments don't. your own accounts don't count, and commissions are in US dollars. ### developer API **Q: can I get dropspace events in Slack?** yes. add a webhook in settings with a Slack incoming webhook URL (hooks.slack.com/services/...). launch results, expired connections, and new recommendations arrive as readable messages in that channel. any other URL gets signed JSON. **Q: does dropspace have an API?** yes, dropspace offers a REST API and an MCP server for AI agents. you can create and manage posts, trigger publishing, and manage personas programmatically. the MCP server works with Claude Code, Cursor, and other AI tools, as an npm package or as a hosted endpoint at https://api.dropspace.dev/mcp. visit the API documentation page for details. **Q: how do I get an API key?** go to settings and find the API keys section. click create to generate a new API key. make sure to copy and store it securely - it's only shown once. **Q: is there a rate limit on the API?** yes, API requests are rate-limited based on your plan. the current limits are shown in the API documentation. rate limit headers are included in every response. **Q: does the API support webhooks?** yes, dropspace can send real-time webhook notifications when posts are published, failed, or completed. you can configure webhook endpoints in the settings page under API keys. ### community marketplace **Q: what is the community marketplace?** the community marketplace is where dropspace users share and discover post templates, personas, and platform strategies. browse what others have built, or publish your own to help the community. **Q: how do I submit a use case?** go to the community marketplace and click submit. describe how you use dropspace, include any templates or personas you want to share, and your submission will be reviewed and published. ### troubleshooting **Q: my post failed to publish - what should I do?** check the post detail page for specific error messages per platform. common issues include expired tokens (reconnect the platform), content policy violations (review your content), or rate limits (wait and retry). **Q: I'm getting a 'token expired' error** go to settings, disconnect the affected platform, and reconnect it. this will refresh your access token. if the issue persists, the platform may have revoked your app authorization. **Q: images aren't uploading to a platform** check that your image meets the platform's requirements (file size, dimensions, format). some platforms have stricter limits. try using a standard JPEG or PNG under 5MB. **Q: the scheduled post didn't publish** scheduled posts require an active platform connection at the time of publishing. check that your connected accounts are still healthy in settings. also verify the scheduled time was set in your correct timezone. ## blog ### community automation templates - what's live and what's next published: 2026-03-26 | 6 min read 3 open-source templates, 5 content formats, 4 supported frameworks. here's what shipped and how to build your own. read more: https://www.dropspace.dev/blog/community-automation-templates ### march 2026 - 145 posts across 8 accounts in 18 days published: 2026-03-26 | 5 min read real numbers from running 4 automation modes across 8 accounts. 28,092 reach, $0 ad spend, less than 5 hours of total time. read more: https://www.dropspace.dev/blog/march-2026-case-study ### how to give your AI agent social media access published: 2026-03-20 | 7 min read agents can reason, plan, and execute - but they can't post. here's how to wire up the dropspace API so your agent can publish to 10 platforms in one call. read more: https://www.dropspace.dev/blog/how-to-give-your-ai-agent-social-media-access ### mcp tools for content distribution published: 2026-03-20 | 6 min read what the model context protocol is, how agents discover tools, and how the dropspace MCP server turns social media publishing into a native agent capability. read more: https://www.dropspace.dev/blog/mcp-tools-for-content-distribution ### the case for agent-native social media infrastructure published: 2026-03-20 | 5 min read buffer and hootsuite were built for humans. agents have different requirements. here's why the existing stack doesn't work, and what agent-native infrastructure looks like. read more: https://www.dropspace.dev/blog/the-case-for-agent-native-social-media-infrastructure ### introducing the dropspace community marketplace published: 2026-03-19 | 5 min read browse and install automation templates built by the community. powered by openclaw, an open-source agent framework for content pipelines. read more: https://www.dropspace.dev/blog/community-marketplace-openclaw ### announcing MPP Stripe payments for agents published: 2026-03-19 | 4 min read dropspace now supports the Machine Payments Protocol (MPP) as a Stripe-based alternative to x402 for agent payments, letting AI agents pay per request with card charges instead of crypto wallets. read more: https://www.dropspace.dev/blog/mpp-stripe-payments-for-agents ### cross-posting done right published: 2026-03-04 | 6 min read reaching your audience means being where they are. learn why cross-platform publishing is essential and how to tailor content for each platform without burning out. read more: https://www.dropspace.dev/blog/why-cross-posting-matters ### managing multiple brands with personas published: 2026-03-04 | 5 min read how agencies, ghostwriters, and multi-brand teams use dropspace personas to maintain distinct brand voices across every platform. read more: https://www.dropspace.dev/blog/managing-multiple-brands-with-personas ### announcing x402 payments for autonomous agents published: 2026-03-04 | 5 min read dropspace now supports the x402 protocol for machine-to-machine payments, enabling AI agents to authenticate, pay per request, and run autonomous launch workflows. read more: https://www.dropspace.dev/blog/x402-payments-autonomous-agents ### introducing the dropspace MCP server published: 2026-02-20 | 4 min read the @jclvsh/dropspace package brings 107 tools across 17 categories to Claude Code, Cursor, and other MCP-compatible AI coding assistants. read more: https://www.dropspace.dev/blog/dropspace-mcp-server ### dropspace developer API v1 published: 2026-02-17 | 6 min read programmatic access to dropspace with API keys, granular scopes, AI content generation, webhook notifications, and comprehensive error codes. read more: https://www.dropspace.dev/blog/developer-api-v1 --- # API documentation ## base URL ``` https://api.dropspace.dev ``` ## authentication all requests require a bearer token: ``` Authorization: Bearer ds_live_... ``` create API keys at https://www.dropspace.dev/settings under the API section. ## response envelope all v1 responses use a discriminated envelope. check the `success` field to branch: ``` // success { "success": true, "data": {...} } { "success": true, "data": [...], "pagination": { "page": 1, "page_size": 50, "total": 120, "total_pages": 3 } } // error { "success": false, "error": { "code": "LAUNCH_001", "message": "...", "requestId": "req_abc123", "field": "name" } } ``` `requestId` is always present and matches the `X-Request-ID` response header. quote it in support tickets. ## response headers | header | when present | meaning | |--------|--------------|---------| | `X-Request-ID` | every response | correlation id; matches `error.requestId` in body | | `X-RateLimit-Limit` | 2xx responses | requests allowed per window for this API key | | `X-RateLimit-Remaining` | 2xx responses | requests remaining in current window | | `X-RateLimit-Reset` | 2xx responses | unix-ms epoch when the window resets | | `X-Dropspace-Notice` | most v1 responses (all errors + helper-built successes) | one-line notice of the latest API change - surface it when it changes; deprecated operations also carry `Deprecation` + `Sunset` | | `Retry-After` | 429 responses | seconds to wait before retrying | ## scopes API keys support scoped permissions. assign only the scopes your integration needs. | scope | description | |-------|-------------| | read | view launches, personas, media, and connections | | write | create, update, and soft-delete launches and personas | | generate | AI content and media generation | | send | publish launches and deliver webhooks | | admin | manage API keys and webhooks | default scopes (if none specified): read, write. pass an explicit scopes list if the key needs generate, send, or admin. legacy publish/delete values were fully retired 2026-07-20. ## endpoints ### launches create, manage, and publish launches across multiple platforms. #### GET /launches list launches with pagination (supports agent payment auth: x402 + MPP) **query parameters:** | name | type | required | description | |------|------|----------|-------------| | page | integer | no | page number (default: 1) | | page_size | integer (1-100) | no | items per page (default: 50) | | status | string | no | filter by launch status (draft, manual, trigger, scheduled, paused, running, completed, partial, failed, cancelled) | **response (200):** ```json { "data": [ { "id": "uuid", "name": "string", "status": "draft|manual|trigger|scheduled|paused|running|completed|partial|failed|cancelled", "platforms": ["twitter", "reddit"], "scheduled_date": "ISO 8601 | null", "product_url": "string | null", "product_description": "string | null", "persona_id": "uuid | null", "media_mode": "images | video | null", "media_assets": [ { "type": "image", "url": "https://...", "fal_request_id": "..." } ] | null, "dropspace_platforms": ["twitter"] | null, "user_platform_accounts": { "twitter": "token-uuid", "linkedin:personal": "token-uuid", "facebook:page:123456": "token-uuid" } | null, "created_at": "ISO 8601", "updated_at": "ISO 8601" } ], "pagination": { "page": 1, "page_size": 50, "total": 42, "total_pages": 1 } } ``` #### POST /launches create a launch with AI-generated or custom content (supports agent payment auth: x402 + MPP) **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | title | string (1-200 chars) | yes | launch title | | product_description | string (1-2000 chars) | no | product description: required for AI generation unless product_url, custom_content, or platform_contents is provided | | platforms | string[] (1-10) | yes | target platforms - can be omitted when dropspace_platforms or user_platform_accounts is provided (inferred from their union) | | product_url | URL | no | product URL: scraped to generate content; satisfies the description requirement on its own | | track_links | boolean | no | swap product_url in the post copy for a tracked dropspace.dev redirect on twitter, linkedin, facebook, reddit, and youtube (one link per posted item); clicks by people, not link-preview crawlers, appear as metrics.tracked_clicks in analytics. instagram, tiktok, and snapchat keep the plain URL. API-key launches only: x402 and MPP launches post to dropspace's official accounts and ignore it. default false | | save_to_library | boolean | no | add the launch's uploaded (non-AI) media_assets to the media library, where autopilot can post them. API-key launches only. default false | | scheduled_date | ISO 8601, any offset | no | schedule for future (≥ 15 min from now). any ISO 8601 offset works: `2026-12-01T12:00:00Z`, `…T12:00:00.000Z`, or `2026-12-01T08:00:00-04:00` | | persona_id | UUID | no | writing style persona to use | | dropspace_platforms | string[] | no | post via dropspace official accounts: every auto-post platform except reddit (to post to reddit, target your own account with user_platform_accounts). published content begins with a "posted from dropspace user:" attribution line, inside the first tweet of a twitter thread (see attribution_mode) | | attribution_mode | "handle" | "anonymous" | no | how official-account posts credit you: "handle" (default) signs with your connected @handle on that platform when you have one, "anonymous" always uses the plain note | | user_platform_accounts | object | no | map of platform key → token_id (UUID). most platforms use a simple key: "twitter", "reddit", "instagram", "tiktok", "youtube" (your primary/only account on that platform). to target a SPECIFIC account when you have more than one on the same platform, use a composite key: Instagram/TikTok/YouTube/Twitter/Reddit use "instagram:" / "tiktok:" / "youtube:" / "twitter:" / "reddit:" (the account's entity_id from GET /connections); LinkedIn uses "linkedin:personal" or "linkedin:organization:". a Facebook post goes to a PAGE: "facebook:page:" names one (the page's entity_id from GET /connections), and a bare "facebook" key works when you have exactly one page - send a connection id as the value (the page's row or your own) and dropspace resolves the page; with several pages the request returns a 400 listing them. multiple keys allowed - e.g. post to two Instagram accounts, or personal + org - by listing several keys at once. for Reddit, each targeted account runs its own subreddit fan-out. an entry with an unsupported key, or a token id that isn't one of your connected accounts, returns a 400 | | media | array | no | inline media upload - each item is either `{ source: "url", url: "https://..." }` to fetch from a URL, or `{ source: "base64", data: "...", filename: "photo.jpg", mime_type: "image/jpeg" }` for raw data. the server uploads to storage and populates media_assets automatically. mutually exclusive with media_assets. max 10 items (9 images + 1 video). images: jpeg, png, webp, gif (5MB). videos: mp4, mov (512MB via URL, 4MB via base64). when provided, media_attach_platforms and media_mode are auto-inferred if not set. **for videos >~50 MB, prefer pre-uploading to your own storage and passing `media_assets` instead: the inline `media` URL path runs inside the POST request lifecycle (bounded by the 5-min route maxDuration) and is sensitive to slow upstreams and Vercel function memory**. URL items share one ~270s total fetch budget and are fetched one at a time. a client that times out does not cancel the request - send an `Idempotency-Key` header (up to 255 characters of letters, digits, `.`, `_`, and `-`; anything else answers 400) so a retry cannot create a second launch | | media_assets | array | no | pre-uploaded media objects (id, url, type, filename, size, mime_type). mutually exclusive with media | | media_attach_platforms | string[] | no | platforms to attach media to (subset of platforms). auto-inferred from selected platforms when using media | | media_mode | "images" | "video" | no | media mode for the launch. auto-inferred from media types when using media | | platform_contents | object | no | pre-written content per platform - each value needs `content` (string). for Twitter, you can alternatively provide `thread` (string[], each ≤ 280 chars or ≤ 25,000 for X Premium accounts, max 6 tweets) instead of `content` - mutually exclusive with `content`. Reddit requires `title` (string, max 300 chars) and supports an optional `metadata` object: `metadata.subreddits` (string[], max 10, names without the `r/` prefix) fans the launch out to one post per subreddit: each gets its own posting log, readable per-community via `GET /launches/:id/destinations` and retryable via `POST /launches/:id/retry` + `{ "subreddit": "" }`; `metadata.first_comment` (string, max 1,500 chars) is posted as a comment under each Reddit post once it goes live. Product Hunt and Hacker News support an optional `title` field (max 60 and 80 chars respectively). TikTok supports a `tiktok_settings` object. `publish_mode` selects delivery: "direct" (default - publishes immediately to the profile) or "draft" (uploads to the TikTok inbox so the creator finishes editing and publishes in the TikTok app). for direct posts, `privacy_level` is required before publishing ("PUBLIC_TO_EVERYONE", "FOLLOWER_OF_CREATOR", "MUTUAL_FOLLOW_FRIENDS", or "SELF_ONLY"); in draft mode privacy is chosen in-app and `privacy_level` is not required. optional booleans (direct mode): `allow_comments`, `allow_duet`, `allow_stitch`, `is_commercial`, `is_your_brand`, `is_branded_content`, `auto_add_music`. `cover_timestamp_ms` (integer, 0-600000) picks the VIDEO cover frame in milliseconds from the start - video posts only, since photo posts take their cover by index; omit it for the first frame. when provided, AI content generation is skipped. per-platform character limits are enforced: Twitter 280/tweet (25,000 for X Premium) × 6 tweets, LinkedIn 3,000, Instagram 2,200, Reddit 3,000, Facebook 3,000, TikTok 4,000, YouTube 5,000 (description, title max 100), Product Hunt 500, Hacker News 2,000, Substack 3,000. YouTube requires video and supports an optional `title` field (max 100 chars) plus a `youtube_settings` object: `thumbnail_url` (https URL to a custom video thumbnail - applied after upload via the YouTube thumbnails.set API; requires a VERIFIED YouTube channel and is best-effort, so a thumbnail failure never fails the post), `privacy_status` ("public", "unlisted", or "private"), `category_id` (e.g. "28"), `tags` (string[]), `post_type` ("auto" | "short" | "regular" - auto-detects Shorts), `made_for_kids` (boolean), and `contains_synthetic_media` (boolean - the YouTube altered/synthetic content disclosure, required for realistic AI-generated or altered video). Instagram and Facebook each take a settings object for the 24-hour story surface: `instagram_settings` / `facebook_settings` with `post_type` ("feed" - the default - or "story"). Instagram alone takes a third `post_type`, "carousel": ONE carousel holding every attached image and the launch's video, in the order of your media, where "feed" would post the video as a reel and leave the images out. a carousel is 2 to 10 items (Instagram's limit) and a launch carries at most one video, so that is up to 9 images and a video; fewer than 2 or more than 10 fails validation. every other platform on the same launch still posts one media type. a fourth Instagram `post_type`, "trial_reel", posts the launch's video as a reel shown to non-followers first (Meta's trial reel): it is not on the profile or in followers' feeds until it graduates, by hand in the Instagram app (`graduation_strategy` "MANUAL", the default) or automatically when Instagram sees it performing well ("SS_PERFORMANCE"); dropspace sends Meta's own values and does not read back whether a reel has graduated. a trial reel needs a video and leaves photos out whatever `media_mode` says (a launch without a video fails validation before publishing), and an account Instagram lets post one: Instagram allows trial reels from professional (business or creator) accounts with 200 or more followers, so POST /launches and PATCH /launches/:id refuse the post type for a connected account below that bar, naming the account and its count (a stored count that says no is re-read from Instagram first); a trial reel Instagram itself refuses at publish fails on its own posting log with error_code "NOT_ELIGIBLE", never as an expired token, while the launch's other platforms post; the posting log's metadata carries `post_type: "trial_reel"` and `graduation_strategy`, and comments and analytics read a trial reel like any reel. a story publishes EVERY attached image as its own frame, in order (a video wins unless `media_mode` is "images"; a video pool publishes one frame per video), so five images give a five-frame story. each frame is a separate publish and a published story cannot be unpublished, so if a later frame fails the earlier ones stay live and the posting log records `story_frame_count` against `story_frames_requested`, with `story_frame_ids` listing them all - `post_id` and `post_url` point at the first frame. neither platform's API accepts a caption, text overlay, sticker, or link on a story, so `content` is ignored, and story posts read unsupported from GET /launches/:id/comments (viewers reply by direct message). instagram story analytics carry `views`, `reach` and `replies` while the story is live; Meta serves story insights only for 24 hours and withholds them entirely under 5 viewers, so an expired or low-view story reports no metrics rather than zeros. mutually exclusive with custom_content | | custom_content | string | string[] | no | single text distributed to all selected platforms, or array of tweet strings when twitter is selected. string form is validated against the most restrictive platform limit. array form creates a twitter thread (each ≤280 chars or ≤25,000 for X Premium, max 6) and consolidates for other platforms. mutually exclusive with platform_contents. when provided, AI content generation is skipped | | custom_content_reddit_title | string (max 300 chars) | no | reddit post title - required when using custom_content with reddit in platforms | | publish | boolean | no | immediately publish after creation. returns 202 instead of 201. mutually exclusive with scheduled_date. requires both `write` and `send` scopes (API key auth). | | wait | boolean | no | wait for publishing to complete and return post URLs inline (returns 200 with posting_status). requires publish: true. response includes posting_status with per-platform results. typical wait: 10-30s for text platforms (Twitter, LinkedIn, Reddit, Facebook), up to 60s+ with Instagram or TikTok | **response (201):** ```json // 201 (create only): the full launch object is echoed back { "data": { "id": "uuid", "name": "announcing our new feature", "status": "trigger", "platforms": ["linkedin", "twitter", "reddit"], "scheduled_date": null, "product_url": "https://example.com", "product_description": "a tool that does X", "persona_id": null, "media_mode": "images", "dropspace_platforms": ["twitter"], "user_platform_accounts": { "linkedin": "uuid", "reddit": "uuid" }, "platform_contents": { "twitter": { "content": "..." }, "linkedin": { "content": "..." }, "reddit": { "content": "...", "title": "..." } }, "media_assets": [ { "id": "uuid", "url": "https://...", "type": "image", "filename": "photo.jpg", "size": 12345, "mime_type": "image/jpeg" } ], "error_message": null, "started_at": null, "completed_at": null, "created_at": "2026-03-25T12:00:00Z", "updated_at": "2026-03-25T12:00:00Z" } } // 200 (publish: true, wait: true): adds posting_status with per-platform results { "data": { "id": "uuid", "name": "announcing our new feature", "status": "completed", "platforms": ["linkedin", "twitter"], "scheduled_date": null, "product_url": "https://example.com", "product_description": "a tool that does X", "persona_id": null, "media_mode": null, "dropspace_platforms": null, "user_platform_accounts": { "linkedin": "uuid", "twitter": "uuid" }, "platform_contents": { "twitter": { "content": "..." }, "linkedin": { "content": "..." } }, "media_assets": null, "error_message": null, "started_at": "2026-03-25T12:00:05Z", "completed_at": "2026-03-25T12:00:30Z", "created_at": "2026-03-25T12:00:00Z", "updated_at": "2026-03-25T12:00:30Z", "posting_status": { "twitter": { "status": "success", "post_url": "https://x.com/user/status/123" }, "linkedin": { "status": "success", "post_url": "https://linkedin.com/feed/update/..." } } } } ``` **errors:** - 400 SERVER_002: validation error - 400 UPLOAD_001: unsupported media type - 400 UPLOAD_002: media file too large - 400 UPLOAD_003: media storage upload failed - 400 LAUNCH_007: platform requirements not met (e.g. Instagram/TikTok need media) - 404 SERVER_003: persona not found - 429 LAUNCH_002: plan launch limit reached - 429 RATE_001: content generation rate limit - 400 CONTENT_004: content blocked by official-account safety moderation (genuine rejection: do not resubmit the same content) - 503 CONTENT_005: content safety check temporarily unavailable (transient: safe to retry with the same content) **notes:** - the media field lets you upload images/videos inline via URL or base64 without pre-uploading to storage - the server handles upload and returns media_assets in the response - media and media_assets are mutually exclusive - use media for inline upload, or media_assets if you've already uploaded files to storage - **operational tip**: the inline `media` URL fetch runs inside the POST request (bound by the 5-min route maxDuration) and streams the body through to storage. for videos >~50 MB or slow source hosts, pre-uploading to your own storage and passing `media_assets` is more reliable: it lets the publish worker pull from a fast intra-region URL when it actually posts to the platform, instead of bottlenecking the create request - content is generated automatically via Claude using your description, scraped website data, and persona style - custom_content and platform_contents are mutually exclusive - use one or the other (or neither for full AI generation) - product_url is optional but enhances AI generation by providing scraped website data for additional context - if platform_contents is provided, those platforms skip AI generation - only platforms without a truthy content field are generated - custom_content as a string distributes the same text to all platforms - validated against the lowest character limit. as an array (string[]), it creates a numbered twitter thread and joins tweets with double newlines for other platforms. array form requires twitter in platforms - partial coverage is supported: provide content for some platforms and let AI generate the rest - for Twitter threads, use `platform_contents.twitter.thread: ["tweet 1", "tweet 2"]` instead of `content` - each tweet ≤ 280 chars (≤ 25,000 for X Premium accounts), max 6 tweets. mutually exclusive with `content` - for Reddit, `platform_contents.reddit.metadata.subreddits` (string[], max 10) posts the launch once per subreddit: each destination gets its own posting log, and posts are paced automatically to respect Reddit's shared rate limit. omit it to post to the default community. read the per-destination outcome from `GET /launches/:id/destinations` (it names the `subreddit` on each row - `GET /launches/:id/status` reports them all as the same `platform`), and retry one community with `POST /launches/:id/retry` + `{ "subreddit": "" }`. **each subreddit has its own rules**: many restrict or ban promotional posts, some require flair, and posting where the account has no history gets filtered by AutoMod or the spam filter. prefer a few genuinely relevant subreddits over a broad blast; the web app surfaces per-community rules and account standing before posting - for Reddit multi-account, each connected reddit account can target its OWN subreddits: set `platform_contents["reddit:"].metadata.subreddits` for a specific account (falls back to the base `reddit` block). a subreddit is posted by at most ONE of your connected reddit accounts per launch - if two accounts list the same community, the first (primary first) posts it and the other is skipped with `SUBREDDIT_ALREADY_TARGETED` - for Reddit, `platform_contents.reddit.metadata.first_comment` (string, max 1,500 chars) posts a comment under each Reddit post once it is confirmed live - useful for the link or extra context when the post itself should stay native. a first-comment failure never affects the launch result - media is distributed to selected platforms with per-platform limits (Instagram/Facebook: 10, Reddit: 20, TikTok: 35) - Instagram requires at least one image, video, or AI-generated video. TikTok requires video or photos - TikTok direct posts (`tiktok_settings.publish_mode: "direct"`, the default) require `tiktok_settings.privacy_level` to be set before publishing (TikTok Content Sharing Guidelines). set it at creation time via `platform_contents.tiktok.tiktok_settings` or update it later via PATCH. branded content (`is_branded_content: true`) cannot use SELF_ONLY privacy. with `publish_mode: "draft"` the post is uploaded to the creator's TikTok inbox to finish and publish in the TikTok app, so `privacy_level` is not required - when `publish: true`, the launch is created and immediately queued for publishing - returns **202** instead of 201. if publish validation fails after creation, returns 201 with a `publish_error` field containing `{ code, message }` - retry via `POST /launches/:id/publish` - `publish` and `scheduled_date` are mutually exclusive - use one or the other - `publish: true` requires at least one posting account (`user_platform_accounts` or `dropspace_platforms`). with API key auth, both `write` and `send` scopes are required - `wait: true` runs the publisher synchronously and returns 200 with `posting_status` containing per-platform results and post URLs. the request stays open until all platforms finish (typically 10-30s, up to 60s+ with Instagram/TikTok). if any platform fails, `posting_status` still shows successes - check `status` field per platform #### GET /launches/:id get a single launch with posting status (supports agent payment auth: x402 + MPP) **response (200):** ```json { "data": { "id": "uuid", "name": "string", "status": "completed", "platforms": ["linkedin", "twitter"], "platform_contents": { "twitter": { "content": "..." } }, "media_assets": [], "dropspace_platforms": ["twitter"] | null, "user_platform_accounts": { "twitter": "token-uuid", "linkedin:personal": "token-uuid" } | null, "posting_status": { "twitter": { "status": "success", "post_url": "https://x.com/...", "posted_at": "ISO 8601" }, "linkedin": { "status": "failed", "error_message": "rate limited by platform" } }, "started_at": "ISO 8601", "completed_at": "ISO 8601" } } ``` **errors:** - 404 LAUNCH_001: launch not found #### PATCH /launches/:id update a draft/scheduled/cancelled launch **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | scheduled_date | ISO 8601, any offset | null | no | schedule for future (≥ 15 min from now), null to unschedule. any ISO 8601 offset works: `2026-12-01T12:00:00Z` or `2026-12-01T08:00:00-04:00` | | status | "draft" | "manual" | "trigger" | "scheduled" | "paused" | "cancelled" | no | update launch status. "paused" holds a scheduled launch: it keeps its scheduled_date, is skipped by publishing, and stays paused through other edits. send "scheduled" to resume it; if its time has passed, send a new scheduled_date in the same request. only a scheduled launch can be paused. "cancelled" clears scheduled_date | | platforms | string[] (1-10) | no | target platforms for this launch | | name | string | no | launch name/title (max 200 chars) | | product_description | string | no | product description (max 10,000 chars) | | product_url | string | no | product URL (empty string to clear) | | persona_id | string | null | no | persona ID for content generation, null to clear | | platform_contents | object | no | per-platform content update (deep-merged with existing) - fields you include replace the old value, omitted fields are preserved. a `*_settings` object (`instagram_settings`, `tiktok_settings`, `youtube_settings`, `snapchat_settings`, `facebook_settings`) merges one level deeper, so one setting can be sent without the rest; every other value (a string, `images`, reddit `metadata`) replaces whole. for Twitter, you can use `thread` (string[], each ≤ 280 chars or ≤ 25,000 for X Premium, max 6) instead of `content` - mutually exclusive with `content`. Reddit `title` is optional (existing title preserved if omitted; max 300 chars if provided). Product Hunt and Hacker News support an optional `title` field (max 60 and 80 chars respectively). TikTok `tiktok_settings` can be set or updated here (deep-merged) - see POST docs for field details. per-platform character limits are enforced: Twitter 280/tweet (25,000 for X Premium) × 6 tweets, LinkedIn 3,000, Instagram 2,200, Reddit 3,000, Facebook 3,000, TikTok 4,000, YouTube 5,000 (description, title max 100), Product Hunt 500, Hacker News 2,000, Substack 3,000. YouTube `title` is optional on update (existing title preserved if omitted) | | user_platform_accounts | object | no | map of platform key → token_id (UUID). most platforms use a simple key: "twitter", "reddit", "instagram", "tiktok", "youtube" (your primary/only account on that platform). to target a SPECIFIC account when you have more than one on the same platform, use a composite key: Instagram/TikTok/YouTube/Twitter/Reddit use "instagram:" / "tiktok:" / "youtube:" / "twitter:" / "reddit:" (the account's entity_id from GET /connections); LinkedIn uses "linkedin:personal" or "linkedin:organization:". a Facebook post goes to a PAGE: "facebook:page:" names one (the page's entity_id from GET /connections), and a bare "facebook" key works when you have exactly one page - send a connection id as the value (the page's row or your own) and dropspace resolves the page; with several pages the request returns a 400 listing them. multiple keys allowed - e.g. post to two Instagram accounts, or personal + org - by listing several keys at once. for Reddit, each targeted account runs its own subreddit fan-out. an entry with an unsupported key, or a token id that isn't one of your connected accounts, returns a 400 | | dropspace_platforms | string[] | no | post via dropspace official accounts: every auto-post platform except reddit (to post to reddit, target your own account with user_platform_accounts). published content begins with a "posted from dropspace user:" attribution line, inside the first tweet of a twitter thread (see attribution_mode) | | attribution_mode | "handle" | "anonymous" | no | how official-account posts credit you: "handle" (default) signs with your connected @handle on that platform when you have one, "anonymous" always uses the plain note | | media | array | no | inline media upload - same format as create. replaces existing media. mutually exclusive with media_assets | | media_assets | array | no | pre-uploaded media objects. replaces existing media. mutually exclusive with media | | media_attach_platforms | string[] | no | platforms to attach media to. auto-inferred when using media | | media_mode | "images" | "video" | no | media mode. auto-inferred when using media | **errors:** - 400 UPLOAD_001: unsupported media type - 400 UPLOAD_002: media file too large - 400 UPLOAD_003: media storage upload failed - 404 LAUNCH_001: launch not found - 409 LAUNCH_003: cannot update a running, completed, or partial launch **notes:** - all fields are optional but at least one must be provided - a running launch can only be updated to set status to cancelled - unrecognized fields return a 400 error - status is auto-derived for editable launches (draft, manual, trigger, scheduled). the server determines status based on your launch configuration: has posting accounts (user_platform_accounts or dropspace_platforms) + scheduled_date → scheduled. has posting accounts + no scheduled_date → trigger. has content + no posting accounts → manual. you cannot force "manual" on a launch with posting accounts. it will auto-transition to "trigger". to unschedule a launch, PATCH with { "scheduled_date": null }, status auto-becomes "trigger". to hold a scheduled launch without losing its date, PATCH with { "status": "paused" }: a paused launch is not auto-derived, is not published (POST /launches/{id}/publish refuses it), and stays paused until you PATCH { "status": "scheduled" } - platform_contents merges per platform - existing platforms not included in the update are preserved. within a platform, fields you include replace the old value - for Twitter threads, use `platform_contents.twitter.thread: ["tweet 1", "tweet 2"]` instead of `content` - each tweet ≤ 280 chars (≤ 25,000 for X Premium), max 6 tweets - media and media_assets replace existing media entirely (no merging) - TikTok `tiktok_settings` is deep-merged - you can update individual fields (e.g. only `privacy_level`) without overwriting the rest #### DELETE /launches/:id delete a launch (any status except running) **response:** 204 no content **errors:** - 409 LAUNCH_003: cannot delete a launch that is currently running - 404 LAUNCH_001: launch not found #### POST /launches/:id/publish queue launch for publishing (async) (supports agent payment auth: x402 + MPP) **response (202):** ```json { "data": { "message": "publish queued" } } ``` **errors:** - 404 LAUNCH_001: launch not found - 422 LAUNCH_007: launch has no platforms configured for posting - 400 LAUNCH_007: platform requirements not met - see errors array for details - 409 LAUNCH_004: launch is not in a publishable state or already publishing - 403 AUTH_002: plan restriction on own connected accounts (dropspace official accounts are always allowed) **notes:** - the launch transitions to running - poll /status or listen for launch.completed / launch.failed / post.deleted webhooks - when validation fails (LAUNCH_007), the response includes a `details` array listing all issues that must be fixed before publishing - platform validation checks: character limits (all platforms), Reddit title ≤ 300 chars, Reddit video mode needs video (thumbnail auto-generated if not provided), Reddit image mode needs images, Instagram reel needs video, Instagram carousel needs ≥ 2 images, Instagram post_type "carousel" needs 2 to 10 images and videos combined, Instagram post_type "trial_reel" needs a video (photos are left out), TikTok requires privacy_level in tiktok_settings (unless publish_mode is "draft"), TikTok video mode needs video, TikTok photo mode needs images, YouTube requires video, YouTube title ≤ 100 chars, YouTube description ≤ 5,000 chars - **video size**: per-platform limit is 512 MB. YouTube uploads stream chunked through the worker (memory-bounded). multi-platform launches that share the same video pre-download it once; for very large files (>~300 MB) prefer publishing to one platform at a time, since the pre-download buffer doubles peak memory until each poster releases its handle #### POST /launches/:id/retry retry failed platforms only. optional body `{ "subreddit": "" }` narrows the retry to that ONE Reddit destination (get names from `GET /launches/:id/destinations`), leaving sibling subreddits that already succeeded untouched; add `"account_id": ""` alongside it to target a specific Reddit account when the launch posted from more than one; omit the body to retry every failed platform. **response (202):** ```json { "data": { "message": "retry queued", "platforms": ["reddit", "tiktok"] } } ``` **errors:** - 404 LAUNCH_001: launch not found - 400 LAUNCH_003: no failed platforms to retry - 409 LAUNCH_004: launch is already running #### POST /launches/:id/retry-content retry content generation for failed platforms **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | platforms | string[] | no | filter to specific platforms | **response (200):** ```json { "data": { "retried": ["twitter", "reddit"], "succeeded": ["twitter"], "still_failing": ["reddit"], "rate_limited": [] } } ``` **errors:** - 400 SERVER_002: no failed platforms to retry - 404 LAUNCH_001: launch not found - 429 LAUNCH_002: per-launch regeneration limit reached - 429 RATE_001: content generation rate limit #### POST /launches/:id/generate-content regenerate AI content for all or specific platforms **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | platforms | string[] (1-10) | no | platforms to regenerate (defaults to all) | | generate_video_scripts | ["instagram", "tiktok"] subset | no | generate video scripts for these platforms | | linkedin_version | "personal" | "company" | no | which LinkedIn voice to regenerate - personal profile or company page (default: personal) | **response (200):** ```json { "data": { "id": "uuid", "name": "announcing our new feature", "platform_contents": { "twitter": { "content": "1/ Fresh new thread..." }, "linkedin": { "content": "New version..." } } }, "generation": { "platforms_generated": ["linkedin", "twitter"], "failures": null } } ``` **errors:** - 400 SERVER_002: no product_description or platforms - 404 LAUNCH_001: launch not found - 409 LAUNCH_003: launch is running/completed/partial - 429 RATE_001: content generation rate limit - 503 SERVER_001: content generation unavailable **notes:** - existing content for other platforms is preserved - media, video sources, and generated videos are never overwritten #### GET /launches/:id/status detailed posting logs per platform (supports agent payment auth: x402 + MPP) **response (200):** ```json { "data": { "launch_id": "uuid", "launch_status": "completed", "posting_logs": [ { "id": "uuid", "platform": "twitter", "status": "success", "post_url": "https://x.com/...", "post_id": "string | null", "error_message": "string | null", "error_code": "string | null", "attempt_count": 1, "posted_at": "ISO 8601", "created_at": "ISO 8601" } ] } } ``` **errors:** - 404 LAUNCH_001: launch not found **notes:** - launch_status is "completed" when every destination posted, "partial" when some posted and some failed, and "failed" when none posted. - a platform in the launch's platforms that has no account to post from (no user_platform_accounts key for it and no dropspace official posting) gets a failed log with error_code "NO_ACCOUNT" instead of being skipped, so a launch that posted elsewhere reads "partial". when none of the platforms has an account, nothing is posted, no logs are written, and the launch is marked "completed" as ready for manual posting. - error_code is a stable machine-readable code for a failed log. see posting error codes in the error handling docs for the full list and what to do about each one. #### GET /launches/:id/destinations one row per destination, naming the account and subreddit. use this instead of /status when a launch fans out to several destinations on one platform (Reddit subreddits, LinkedIn personal+organization, Facebook pages): /status reports every one of them as the same `platform` with no way to tell which is which, and a failed destination has no post_url to infer it from. `subreddit` is the community name (Reddit fan-out rows only, null elsewhere); `account_type` identifies every destination. pair with `POST /launches/:id/retry` + `{ "subreddit": "" }` to retry a single community. (supports agent payment auth: x402 + MPP) **response (200):** ```json { "data": [ { "posting_log_id": "uuid", "platform": "reddit", "account_type": "user:subreddit:webdev", "subreddit": "webdev", "status": "success", "post_url": "https://reddit.com/r/webdev/comments/...", "post_id": "string | null", "error_message": "string | null", "error_code": "string | null", "attempt_count": 1, "posted_at": "ISO 8601", "created_at": "ISO 8601" }, { "posting_log_id": "uuid", "platform": "reddit", "account_type": "user:subreddit:SideProject", "subreddit": "SideProject", "status": "failed", "post_url": null, "post_id": null, "error_message": "post removed by automod", "error_code": "POST_REMOVED", "attempt_count": 1, "posted_at": null, "created_at": "ISO 8601" } ] } ``` **errors:** - 404 LAUNCH_001: launch not found #### GET /launches/:id/analytics publishing analytics with per-post engagement metrics (live refresh) **response (200):** ```json { "data": { "launch_id": "uuid", "launch_name": "announcing our new feature", "launch_status": "completed", "summary": { "total": 3, "successful": 3, "failed": 0, "pending": 0 }, "fetched_at": "2026-02-22T10:00:00Z", "next_refresh_at": "2026-02-22T10:05:00Z", "platforms": [ { "platform": "twitter", "status": "success", "post_url": "https://x.com/...", "post_id": "1234567890", "posted_at": "ISO 8601", "cache_status": "refreshed", "is_deleted": false, "deleted_detected_at": null, "deletion_reason": null, "metrics": { "likes": 42, "retweets": 12, "replies": 5, "quotes": 2, "bookmarks": 8, "impressions": 1500, "urlClicks": 23, "profileClicks": 7, "tracked_clicks": 19, "fetched_at": "2026-02-22T10:00:00Z" } }, { "platform": "reddit", "status": "success", "post_url": "https://reddit.com/...", "post_id": "abc123", "posted_at": "ISO 8601", "cache_status": "fresh", "is_deleted": false, "deleted_detected_at": null, "deletion_reason": null, "metrics": { "score": 156, "upvotes": 200, "upvoteRatio": 0.78, "comments": 34, "fetched_at": "2026-02-22T09:58:00Z" } } ] } } ``` **errors:** - 404 LAUNCH_001: launch not found **notes:** - metrics.tracked_clicks appears when the launch was created with track_links: clicks on its tracked link for that platform, counted on the platform's first post so summing posts never double-counts. - metrics refresh on a per-platform cycle. YouTube statistics are refreshed for you every 10 minutes in the background, so calling more often returns the same numbers from cache. other platforms are fetched live from their APIs when the cached copy is older than 5 minutes; calling this endpoint triggers that refresh automatically. - fetched_at is the most recent timestamp when metrics were collected. next_refresh_at is the earliest time a call could return new numbers (fetched_at + 10 min for YouTube, + 5 min for other platforms) - polling before it only re-reads the cache. - cache_status per platform: fresh (inside the refresh cycle, from cache), refreshed (fetched from platform API on this call), stale (rate limited or no token, returning older data), unavailable (no data exists). omitted for non-success posts (failed, pending). - rate-limited platforms return stale cached data instead of failing - the response always includes the best available metrics. - metric fields vary by platform: Twitter (likes, retweets, replies, quotes, bookmarks, impressions, urlClicks, profileClicks), LinkedIn (impressions, uniqueImpressions, likes, comments, shares, clicks, engagement), Facebook (reactions, likes, comments, shares - likes mirrors reactions.total), Instagram (views, reach, engagement, saved, likes, comments, shares, totalInteractions; a reel adds avgWatchTimeMs, totalWatchTimeMs, and skipRate, the percent of plays that skipped in the first 3 seconds), Reddit (score, upvotes, upvoteRatio, comments), TikTok (views, likes, comments, shares) - facebook has no impressions or reach: those come from the platform's insights endpoint, which dropspace does not request. facebook story metrics are unavailable for the same reason - story insights are wired for instagram only. - a null metric means the platform did not disclose it, which is different from 0: youtube returns likes: null when the owner hides the like count and comments: null when comments are disabled. - an instagram story reports views, reach, replies, and frames only while it is live: Meta serves story insights for 24 hours with no backfill. views and replies are summed across frames and reach is the widest single frame. views is null when Meta does not report it, and a story under 5 viewers returns story_insights_withheld: true instead of numbers (withheld, not zero). - is_deleted indicates whether the post was detected as removed from the platform. when true, deleted_detected_at contains the detection timestamp and deletion_reason contains the reason. possible values: not_found (post no longer exists), gone (permanently removed, HTTP 410), creator_deleted (deleted by creator), moderation_removed (removed by moderation), account_deleted (account was deleted), spam_filtered (caught by spam filter). deletion is detected during metrics refresh. #### GET /comments/queue every comment still waiting for you across your recent Instagram and YouTube posts, newest first, with a suggested action **response (200):** ```json { "data": { "comments": [ { "id": "18062216473153229", "text": "what bpm do you start at for a set like this?", "author_username": "djnova", "author_display_name": "djnova", "author_profile_image_url": null, "like_count": 2, "reply_count": 0, "published_at": "2026-10-27T21:14:03.000Z", "hidden": false, "platform": "instagram", "post_id": "18118432868104282", "post_url": "https://www.instagram.com/reel/DfAJhNWDpoh/", "launch_id": "8f1c2d4e-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "launch_name": "load the next track early", "kind": "question", "suggested_action": "reply", "reply_draft": "I usually start around 122 and build from there. Try it and tell me how it feels.", "may_be_answered": false } ], "counts": { "question": 1, "feedback": 0, "praise": 0, "spam": 0, "attack": 0, "other": 0, "unclassified": 0 }, "total_waiting": 1, "posts_read": 30, "posts_in_window": 112, "post_issues": [], "window_days": 30, "as_of": "2026-10-28T22:00:00.000Z" } } ``` **errors:** - 401 AUTH_001: missing or invalid api key - 403 AUTH_002: partner creator paused or partner suspended **notes:** - reads up to 30 of your Instagram and YouTube posts made through dropspace in the last 30 days (the newest 50 comments on each) and returns up to 200 comments, newest first. When the window has more than 30 posts (posts_in_window), the ones with comments are read first, by the comment counts dropspace last read, newest first. total_waiting counts every waiting comment found; post_issues lists posts that could not be read (reconnect, quota) with why. Posts on dropspace's own accounts, stories, and posts made outside dropspace are not included. - waiting means: not hidden, not written by your account, not answered by your account (a reply from it, made in the app or here), and not replied to, hidden, deleted, or dismissed through dropspace. - kind, suggested_action, and reply_draft are Claude's read of the comment text: questions and feedback suggest a reply (with a draft to edit), spam and attacks suggest hide, praise and other suggest dismiss. Each comment is sorted once and remembered; kind is null for comments not sorted yet (new ones are sorted up to 100 per load, 20 batches a day). - may_be_answered is true when YouTube returned only some of a comment's replies and none of those was yours: it stays in the queue, but you may have answered it already. - comment reads share the short page cache of GET /launches/{id}/comments, so a comment answered elsewhere can take about a minute to leave. #### POST /comments/actions hide or dismiss up to 50 comments from the comment queue **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | action | string | yes | hide (Instagram only, send scope) or dismiss (write scope): dismiss means seen, nothing to do; nothing changes on the platform | | comments | array | yes | 1-50 items of { platform, post_id, comment_id }, as GET /comments/queue returns them | **response (200):** ```json { "data": { "action": "hide", "succeeded": 1, "failed": 1, "results": [ { "platform": "instagram", "post_id": "18118432868104282", "comment_id": "18062216473153229", "ok": true, "detail": null }, { "platform": "youtube", "post_id": "dQw4w9WgXcQ", "comment_id": "UgxKREWxIgDrw", "ok": false, "detail": "hiding comments is available for instagram only - not youtube" } ] } } ``` **errors:** - 400 SERVER_002: invalid json body, unknown action, or more than 50 comments - 401 AUTH_001: missing or invalid api key - 403 AUTH_002: missing the send scope (hide) or write scope (dismiss), or partner creator paused **notes:** - only comments on your own posts made through dropspace are acted on; anything else, and hide on YouTube, comes back with ok: false and detail saying why. - hides are sent one a second, so 50 take about a minute. Every item has its own result; the request does not stop at the first failure. - to reply or delete one comment, use POST /launches/{id}/comments/replies and DELETE /launches/{id}/comments/{commentId} with the comment's launch_id and post_id; those also take it off the queue. #### GET /launches/:id/comments per-post comments for a launch's published posts (newest first) **query parameters:** | name | type | required | description | |------|------|----------|-------------| | post_id | string | no | read one post's comments only (required when paginating with cursor) | | platform | string | no | only posts on this platform (e.g. youtube) | | cursor | string | no | opaque next_cursor from a previous page - paginates ONE post, so post_id is required with it | | limit | number | no | comments per post, 1-100 (default 25) | **response (200):** ```json { "data": { "launch_id": "uuid", "posts": [ { "post_id": "dQw4w9WgXcQ", "platform": "youtube", "post_url": "https://youtube.com/watch?v=dQw4w9WgXcQ", "posted_at": "ISO 8601", "status": "ok", "detail": null, "comments": [ { "id": "UgxKREWxIg...", "text": "great launch!", "author_username": "somefan", "author_display_name": "@somefan", "author_profile_image_url": "https://yt3.ggpht.com/...", "like_count": 7, "reply_count": 2, "published_at": "ISO 8601" } ], "next_cursor": "QURTSl9p..." }, { "post_id": "7301234567890", "platform": "tiktok", "post_url": "https://tiktok.com/...", "posted_at": "ISO 8601", "status": "unsupported", "detail": "tiktok's api does not expose comment content to third-party apps - comment counts are available in analytics", "comments": [], "next_cursor": null } ] } } ``` **errors:** - 400 SERVER_002: cursor without post_id, or invalid params - 404 LAUNCH_001: launch not found **notes:** - comments are top-level only, ordered newest first, and cached for ~60 seconds per page. - platform support is explicit per post via status: ok (comments returned), unsupported (the platform's api does not expose comment content), comments_disabled (turned off on the post), post_deleted (post no longer exists), unavailable (transient: rate limited, quota, or account not connected - retry later). detail carries the human-readable reason for every non-ok status. - youtube and instagram return comment content. instagram connections made before 08/25/2026 read unsupported with a reconnect prompt until the creator reconnects instagram once; tiktok exposes comment counts only (see analytics). - to page through one post's comments, pass post_id plus the next_cursor from the previous response. next_cursor is null on the last page. #### POST /launches/:id/comments/replies reply to a comment on one of the launch's published posts, as the account that published it. body: `{ "post_id": "", "comment_id": "", "message": "<1-2,200 chars>" }`. youtube and instagram only - other platforms, and instagram connections made before 08/25/2026 (reconnect instagram once to fix), answer 400 with a human-readable message. requires the send scope. **response (201):** ```json { "data": { "reply_id": "UgzXK..." } } ``` **errors:** - 404 LAUNCH_001: launch not found, or post_id not on this launch - 400 SERVER_002: platform can't accept the reply (detail in message) **notes:** - reply_id is the platform's id for the created reply; null when the platform doesn't return one. - a successful reply invalidates the post's cached comment pages, so the next GET reflects it immediately. #### POST /launches/:id/comments/hide hide or unhide a comment on one of the launch's published posts, as the account that published it. body: `{ "post_id": "", "comment_id": "", "hidden": true | false }`. a hidden comment stays visible to its author and disappears for everyone else; `hidden: false` reverses it. instagram only - other platforms, stories, and instagram connections made before 08/25/2026 (reconnect instagram once to fix) answer 400 with a human-readable message. requires the send scope. **response (200):** ```json { "data": { "comment_id": "17895695668004550", "hidden": true } } ``` **errors:** - 404 LAUNCH_001: launch not found, or post_id not on this launch - 400 SERVER_002: platform can't hide the comment (detail in message) **notes:** - comments read back with `hidden: true` from GET /launches/:id/comments once hidden. - a successful change invalidates the post's cached comment pages, so the next GET reflects it immediately. #### DELETE /launches/:id/comments/:commentId permanently delete a comment on one of the launch's published posts, as the account that published it. pass `post_id` (the platform post id from GET comments) as a query parameter - comment ids are only meaningful per post. instagram only, and instagram lets only the media owner delete; other platforms, stories, and pre-08/25/2026 instagram connections answer 400 with a human-readable message. requires the send scope. **query parameters:** | name | type | required | description | |------|------|----------|-------------| | post_id | string | yes | the platform post id the comment lives on (from GET /launches/:id/comments) | **response (200):** ```json { "data": { "comment_id": "17895695668004550", "deleted": true } } ``` **errors:** - 404 LAUNCH_001: launch not found, or post_id not on this launch - 400 SERVER_002: platform can't delete the comment (detail in message) **notes:** - deletion is permanent - prefer hide when the comment may need to come back. #### GET /launches/analytics batch analytics for multiple launches (cache-only, no live refresh) **query parameters:** | name | type | required | description | |------|------|----------|-------------| | ids | string | yes | comma-separated launch UUIDs (max 100) | **response (200):** ```json { "data": [ { "launch_id": "uuid", "launch_name": "announcing our new feature", "launch_status": "completed", "summary": { "total": 3, "successful": 2, "failed": 1, "pending": 0 }, "platforms": [ { "platform": "twitter", "status": "success", "post_url": "https://x.com/...", "post_id": "1234567890", "posted_at": "ISO 8601", "is_deleted": false, "deleted_detected_at": null, "deletion_reason": null, "cache_status": "fresh", "metrics": { "likes": 42, "retweets": 12, "impressions": 1500, "fetched_at": "2026-02-22T10:00:00Z" } } ] } ], "errors": [ { "id": "invalid-uuid", "error": { "code": "LAUNCH_001", "message": "launch not found" } } ] } ``` **errors:** - 400 SERVER_002: ids parameter missing or empty - 400 SERVER_002: more than 100 IDs provided - 400 SERVER_005: all IDs have invalid UUID format **notes:** - returns cached analytics only - does NOT trigger a live refresh from platform APIs. use the single-launch endpoint GET /launches/:id/analytics to trigger a fresh fetch. - accepts up to 100 launch IDs. duplicate IDs are deduplicated. counts as 1 rate limit hit. - response includes partial results: valid launches in data[], invalid/not-found in errors[]. - cache_status per platform: fresh (< 5 min old), stale (older cached data), unavailable (no metrics exist). never returns refreshed (no live fetch). omitted for non-success posts (failed, pending). - metric fields vary by platform - same as single-launch analytics endpoint. #### DELETE /launches/:id/posts/:logId delete a single published post from its platform **response (200):** ```json { "data": { "success": true, "platform": "twitter", "post_id": "1234567890", "deleted_at": "ISO 8601" } } ``` **errors:** - 404 LAUNCH_001: launch not found - 404 SERVER_003: posting log not found or not deletable - 200 DELETE_NOT_SUPPORTED: platform does not support API deletion (Instagram, TikTok) - returned in response body, not as HTTP error **notes:** - requires `write` scope on your API key (included in the default scope set) - only works for Twitter, Facebook, LinkedIn, and Reddit - Instagram and TikTok do not support API deletion - the posting log must have status 'success' and a valid post_id - if the post was already deleted on the platform (404), it is treated as a successful deletion - on success, the posting log status is updated to 'deleted' - logId is the posting_log UUID from the /status endpoint #### DELETE /launches/:id/posts delete all published posts for a launch from their platforms **response (200):** ```json { "data": { "results": [ { "success": true, "platform": "twitter", "post_id": "123", "deleted_at": "ISO 8601" }, { "success": true, "platform": "linkedin", "post_id": "urn:li:share:456", "deleted_at": "ISO 8601" }, { "success": false, "platform": "instagram", "post_id": "789", "error": "platform does not support deletion", "error_code": "DELETE_NOT_SUPPORTED" } ], "no_failures": false, "deleted_count": 2, "failed_count": 0, "skipped_count": 1 } } ``` **errors:** - 404 LAUNCH_001: launch not found **notes:** - requires `write` scope on your API key (included in the default scope set) - only deletes from Twitter, Facebook, LinkedIn, and Reddit - Instagram and TikTok posts are skipped with DELETE_NOT_SUPPORTED (require manual deletion) - returns detailed results per post including success/failure status - skipped_count includes platforms that don't support API deletion ### audience follower counts and growth-over-time for the connected account's instagram, youtube, tiktok, twitter, and facebook. read from the official platform apis. requires an api key whose account has the platform connected. #### GET /audience follower counts + growth over a window for the caller's connected instagram/youtube/tiktok/twitter/facebook accounts **query parameters:** | name | type | required | description | |------|------|----------|-------------| | window_days | integer | no | growth window in days (default 30, max 365) | | include | string | no | instagram_insights adds Instagram growth analytics to each Instagram entry: follows and unfollows per day, estimated followers over time, weekly growth velocity, the share of reach from non-followers, and contact-button taps. Days are Pacific time and the last 7 are still revised by Instagram (settled: false). Read on request and stored, newest days first, up to 31 days per call: a longer window fills over a few calls (days_pending says how many are left). Accounts under 100 followers get available: false. | **response (200):** ```json { "data": { "platforms": [ { "platform": "instagram", "entity_id": null, "current": { "followers": 10432, "following": 812, "media": 340, "captured_at": "2026-07-22T06:00:00Z" }, "series": [ { "captured_at": "2026-06-22T06:00:00Z", "followers": 9800 }, { "captured_at": "2026-07-22T06:00:00Z", "followers": 10432 } ], "growth": { "window_days": 30, "follower_delta": 632, "follower_pct": 0.0645, "baseline_captured_at": "2026-06-22T06:00:00Z", "effective_window_days": 30 } } ], "as_of": "2026-07-22T18:00:00Z" } } ``` **errors:** - 401 AUTH_001: missing or invalid api key **notes:** - covers instagram, youtube, tiktok, twitter, and facebook - the platforms whose follower counts the official apis expose. other connected platforms (linkedin, reddit) are omitted. - facebook follower counts are reported per connected page (each page reports its own followers); twitter reports followers, following, and tweet count. - counts are read on-demand when you call this endpoint and cached for 15 minutes. youtube is the exception, because each youtube read spends a shared daily quota: while you keep reading an account, its youtube count is refreshed in the background about once an hour, and an on-demand read re-fetches at most every 6 hours if that refresh is delayed. each fresh read records a history point - so a newly connected account appears on its first call and builds growth history from there. - growth is computed from the earliest and latest snapshot inside the window; follower_pct is a fraction (0.0645 = 6.45%). delta and pct are null until at least one snapshot exists in the window. - history points are recorded when you read (at most one per account per day), so the oldest point inside the window can be much younger than window_days. baseline_captured_at is when the delta is measured from, and effective_window_days is the span it actually covers - read it before comparing growth across accounts. - youtube history points are kept for 30 days (YouTube's API storage limit), so youtube growth reaches back about 30 days even when window_days is larger; effective_window_days says how far it actually reaches. other platforms keep their full history. - current.captured_at is when the returned count was actually read from the platform, never when it was served: a cached or background-refreshed count keeps its original read time, a youtube count returned while youtube's daily quota is exhausted carries the time of the last successful read, and if the platform call fails with nothing cached it is when the newest stored point was captured. - data collection uses each platform's official read api, not scraping - so it only covers accounts the creator has connected. - include=instagram_insights adds instagram_insights to each instagram entry. days[] has follows, unfollows (accounts that unfollowed or left Instagram), net, and reach split into reach_followers and reach_non_followers, per Pacific-time day. followers_over_time is an estimate: the current count minus each later day's net. velocity compares the last 7 days' net, as a share of the count at the start of that week, with the average of the earlier full weeks in the window. non_follower_reach_share sums daily reach, so an account reached on two days counts twice. profile_links_taps covers the last 30 days of the window at most. Instagram reports none of this for accounts under 100 followers: available is false with unavailable_reason. #### GET /audience/demographics who your Instagram audience is: age, gender, country, and city of your followers and of the accounts that engaged with you **query parameters:** | name | type | required | description | |------|------|----------|-------------| | timeframe | string | no | period for the engaged audience: this_week, this_month (default), or last_90_days. Followers are always the current followers. Any other value returns 400. | **response (200):** ```json { "data": { "timeframe": "this_month", "accounts": [ { "connection_id": "3b9c1f0e-2d4a-4e8b-9c7d-6f5e4d3c2b1a", "entity_id": null, "username": "spin.sensei.app", "followers": { "available": true, "unavailable_reason": null, "age": [{ "value": "25-34", "count": 100 }, { "value": "35-44", "count": 70 }, { "value": "45-54", "count": 38 }], "gender": [{ "value": "M", "count": 213 }, { "value": "U", "count": 27 }, { "value": "F", "count": 21 }], "country": [{ "value": "US", "count": 49 }, { "value": "IN", "count": 29 }, { "value": "IR", "count": 16 }], "city": [{ "value": "New York, New York", "count": 16 }, { "value": "Bangalore, Karnataka", "count": 7 }], "total": 261 }, "engaged": { "available": true, "unavailable_reason": null, "age": [{ "value": "25-34", "count": 196 }, { "value": "18-24", "count": 172 }, { "value": "13-17", "count": 38 }], "gender": [{ "value": "M", "count": 379 }, { "value": "U", "count": 84 }, { "value": "F", "count": 36 }], "country": [{ "value": "US", "count": 70 }, { "value": "IN", "count": 56 }, { "value": "GB", "count": 32 }], "city": [{ "value": "London, England", "count": 7 }, { "value": "Sydney, New South Wales", "count": 2 }], "total": 499 } } ], "as_of": "2026-10-27T22:00:00Z" } } ``` **errors:** - 400 SERVER_002: timeframe is not this_week, this_month, or last_90_days - 401 AUTH_001: missing or invalid api key - 403 AUTH_002: partner creator paused or partner suspended **notes:** - followers describes the account's current followers; engaged describes the accounts that engaged with its content in timeframe. Each split (age, gender, country, city) is largest first. The example is shortened; a full answer has every age range and up to 45 countries and cities. - gender values are F, M, and U (unknown); country is an ISO 3166-1 alpha-2 code; city is "City, Region". total is the number of people Instagram has demographic data for (the gender split's sum), which can be lower than the follower count. - Instagram reports only the top 45 rows of a split and nothing for accounts under 100 followers: a section then has available: false with unavailable_reason. An engaged section can also be unavailable when too few accounts engaged in the period, and a single split (often city) comes back empty when it has too few people to report. - cached about 12 hours per account and period; a failed Instagram read is asked again after 30 minutes, and unavailable_reason says what happened (reconnect the account for an expired token). #### GET /audience/best-hours when your Instagram followers are online: the average per hour and per weekday and hour over the last 30 days, and the busiest hours **query parameters:** | name | type | required | description | |------|------|----------|-------------| | timezone | string | no | IANA timezone every hour in the answer is expressed in, e.g. America/New_York. Default: your autopilot timezone, else UTC. The answer's timezone is the canonical name. An unknown zone returns 400. | **response (200):** ```json { "data": { "timezone": "America/New_York", "accounts": [ { "connection_id": "3b9c1f0e-2d4a-4e8b-9c7d-6f5e4d3c2b1a", "entity_id": null, "username": "spin.sensei.app", "available": true, "unavailable_reason": null, "days_counted": 28, "hourly": [76.4, 81.6, 87.4, 89.9, 91, 97.5, 100.6, 106.2, 108.4, 110.9, 117.1, 119.3, 117.1, 118, 113.3, 108.9, 101.6, 91.4, 82.4, 74.6, 70.1, 68.4, 65.4, 70.9], "grid": [ [78, 88, 93.3, 98.8, 96.8, 108.5, 116.3, 117.8, 117.5, 118.8, 127.3, 131.3, 129.5, 126, 125.8, 119, 110, 100, 86.5, 81.5, 77.3, 77.5, 72.5, 74.5], [82.5, 85.5, 87.8, 89.8, 88.5, 99.3, 99.3, 99.5, 108.8, 113.8, 115, 115.8, 114.5, 116, 110.8, 106.3, 98.8, 81.8, 75.5, 66.8, 60.5, 66.3, 62.5, 66], [79, 85.5, 88, 86.8, 87.5, 95.5, 97.3, 107.8, 106.3, 110, 112.3, 117, 112, 112.5, 108.3, 102.8, 100.3, 90.3, 79.5, 73.3, 69.3, 67.3, 62.8, 75.5], [78.5, 84.5, 89.8, 88.3, 91.5, 94.8, 98, 100.5, 108.3, 108, 121.3, 118.8, 112, 115.3, 106.8, 101.5, 97.5, 88.5, 76.3, 76, 74, 68.5, 64.8, 65.8], [72.3, 81, 82.3, 84.3, 85, 95.3, 101.5, 108.3, 109, 114.8, 119, 115.3, 114.5, 117.8, 111.5, 107.8, 102.3, 93, 84.8, 76.5, 67.3, 64.3, 63.3, 66.8], [72.3, 70.3, 85.3, 92, 92, 94.8, 92.8, 103, 103.5, 105.5, 106.3, 116.5, 113.3, 118.5, 111.3, 112, 98, 89.3, 85, 74.8, 71, 70, 66.8, 70.5], [72.3, 76.8, 85.5, 89.8, 96, 94.5, 99, 106.5, 105.8, 105.5, 118.8, 120.5, 124.3, 119.8, 118.5, 113.3, 104.8, 97, 89.5, 73.5, 71.3, 65.3, 65, 77.3] ], "best_hours": [{ "hour": 11, "avg_online": 119.3 }, { "hour": 13, "avg_online": 118 }, { "hour": 9, "avg_online": 110.9 }] } ], "best_hours": [{ "hour": 11, "avg_online": 119.3 }, { "hour": 13, "avg_online": 118 }, { "hour": 9, "avg_online": 110.9 }], "as_of": "2026-10-27T22:00:00Z" } } ``` **errors:** - 400 SERVER_002: timezone is not an IANA timezone - 401 AUTH_001: missing or invalid api key - 403 AUTH_002: partner creator paused or partner suspended **notes:** - read from Instagram's online_followers insight for the last 30 days, through yesterday. days_counted is how many of those days Instagram returned hourly numbers for. - hourly[h] is the average followers online in hour h (0-23) of the day; grid[d][h] is the same for weekday d (0 = sunday). Both are in timezone; null means no sample for that slot. - best_hours lists the busiest hours, busiest first, each at least two hours from the others (up to 5). The top-level best_hours sums every connected Instagram account's averages. - cached about 12 hours per account. Instagram reports this only for accounts with 100 or more followers: available is false with unavailable_reason otherwise. When Instagram refuses or fails the read, unavailable_reason says so (reconnect the account for an expired token), and it is asked again after 30 minutes. - to post at these hours, set posting_times with PATCH /autopilot (times are HH:MM in the autopilot timezone). #### GET /analytics/reels-read each connected Instagram account with its latest reels read (pro and premium) **response (200):** ```json { "data": { "accounts": [ { "connection_id": "3b9c1f0e-2d4a-4e8b-9c7d-6f5e4d3c2b1a", "username": "spin.sensei.app", "next_refresh_at": null } ] } } ``` **errors:** - 401 AUTH_001: missing or invalid api key - 403 AUTH_002: plan without reels reads (pro and premium have them), or partner creator paused **notes:** - returns stored reads only and makes no Instagram or Claude request. read is absent when no read was made for the account in the last 30 days; next_refresh_at is when POST can make a new one (null when it can now). The read object has the same shape as POST's. #### POST /analytics/reels-read Claude's read of an Instagram account's recent reels: what the top, middle, and bottom reels by views have in common (pro and premium) **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | connection_id | string | no | the Instagram connection to read (from GET /analytics/reels-read or GET /connections); default: the first connected Instagram account | **response (200):** ```json { "data": { "status": "ok", "read": { "connection_id": "3b9c1f0e-2d4a-4e8b-9c7d-6f5e4d3c2b1a", "username": "spin.sensei.app", "generated_at": "2026-10-28T19:55:55.883Z", "window_days": 90, "reel_count": 60, "oldest_posted_at": "2026-10-13T16:02:11+0000", "tiers": { "top": [ { "id": "18145276189580095", "permalink": "https://www.instagram.com/reel/Deb-7VZEacG/", "posted_at": "2026-10-13T14:06:28+0000", "caption": "when two kicks start drifting, guessing which way to nudge u", "views": 2150, "reach": 1664, "likes": 33, "comments": 1, "shares": 1, "saves": 13, "avg_watch_time_ms": 17838, "skip_rate": 40.3 } ], "middle": [ { "id": "17939836590379472", "permalink": "https://www.instagram.com/reel/DeejdIlD4Qo/", "posted_at": "2026-10-14T14:04:10+0000", "caption": "two vocals at once actually wreck a blend faster than almost", "views": 229, "reach": 194, "likes": 4, "comments": 0, "shares": 1, "saves": 0, "avg_watch_time_ms": 7006, "skip_rate": 49.5 } ], "bottom": [ { "id": "17973853455158302", "permalink": "https://www.instagram.com/reel/DerZkm7DrIM/", "posted_at": "2026-10-19T13:47:55+0000", "caption": "each new track gets pitched up to match the one before it, n", "views": 161, "reach": 139, "likes": 0, "comments": 0, "shares": 1, "saves": 0, "avg_watch_time_ms": 5438, "skip_rate": 69.8 } ] }, "read": { "summary": "Your top reels hold viewers far longer than the rest, averaging 10.7s to 20.4s of watch time against mostly 4s to 10s for the middle and bottom. The same caption shows up in both your top and bottom groups, so the caption text alone does not explain the gap. Whatever happens in the first seconds of the video, which these numbers cannot show, seems to matter more.", "working": [ { "pattern": "Low early skips and longer watch time go with your highest views.", "evidence": "Your top 10 all have avg watch of 10.7s or more and 3-second skip rates between 30.9% and 50.4%, for example 10-26 'if you wait until the last minute' at 20.4s and 35.6%, and 10-22 with the same caption at 19.4s and 30.9%. Most bottom reels sit at 4.1s to 10.2s with skips up to 73.5% (10-27 'wait to hear the one': 4.1s, 73.5%, 151 views)." } ], "not_working": [ { "pattern": "The same caption can land in your top or your bottom, so the difference is outside the caption.", "evidence": "'test nudge' got 2150 views and 1915 views (10-13, 10-26) but only 158 and 146 views on 10-28, with skip rates rising from 40.3% to 51.6%. The 'rising whoosh' filter caption got 1578 views and 135 views on the same day, 10-25, with skips of 38% vs 50.5%." } ], "next": [ "Pull up the 10-13 'test nudge' reel (40.3% skips) and the 10-28 version (51.6% skips) side by side. Note what differs in the first 3 seconds, then reuse the opening of the stronger one on your next two reels.", "Make two more tempo-reading reels in the style of the 10-25 bpm counting tip (27 saves) and the 10-18 half-tempo tip (20.2s avg watch). Track whether saves stay above 10.", "Stop posting the same caption twice on one day. Space repeats at least a week apart and compare each repeat's 3-second skip rate with the original." ] }, "model": "claude-opus-5-5" }, "refreshed": true, "next_refresh_at": "2026-10-29T15:00:00.000Z", "reel_count": null, "needed": null, "reason": null } } ``` **errors:** - 400 SERVER_002: invalid json body, or connection_id is not a uuid - 401 AUTH_001: missing or invalid api key - 403 AUTH_002: plan without reels reads (pro and premium have them), or partner creator paused **notes:** - connection_id is optional: without it, the first connected Instagram account is read. The body can be empty. - reads the latest reels (up to 60) posted in the last 90 days, with their views, reach, likes, comments, shares, saves, avg watch time, and 3-second skip rate, ranks them by views, and sends the top, middle (around the median), and bottom ten, with captions, to Claude. A very active account's 60 reels can cover less than 90 days; oldest_posted_at says how far back the read reaches. Reels whose numbers Instagram did not return are left out; when more than a fifth are missing, the read is not made (status unavailable). The example is shortened to one reel per tier and one finding per list. - Claude sees captions and numbers only, never the videos, and is told to say when the numbers show no clear pattern. working has 2 to 4 findings, not_working 1 to 3, next exactly 3. - status ok: read is present (it is omitted for the other statuses). Each account gets 3 paid attempts a day, failed ones included; after that status is unavailable until tomorrow. refreshed is false when a read made in the last 24 hours was returned instead of a new one (one new read per account a day). status not_enough_reels: fewer than needed (8) reels in the window, with reel_count. status unavailable: reason says why (no Instagram account, reconnect, Instagram or Claude failed, a read already running). - takes 30 to 60 seconds. Reads are kept 30 days and returned by GET. #### GET /analytics/trial-reels Instagram trial reel results: totals across every trial reel you posted through dropspace, one entry per reel, and net followers since the first one **response (200):** ```json { "data": { "totals": { "trial_reels": 9, "views": 8012, "reach": 6140, "likes": 142, "comments": 0, "shares": 13, "saves": 59, "avg_watch_time_ms": 3660, "skip_rate": 64.2 }, "followers_since_first": { "net": 33, "since_day": "2026-10-23", "days_counted": 4, "days_in_span": 4 }, "reels": [ { "launch_id": "8f1c2d4e-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "launch_name": "verci boiler room v4 02", "post_id": "18118432868104282", "post_url": "https://www.instagram.com/reel/DfAJhNWDpoh/", "posted_at": "2026-10-27T15:45:12Z", "graduation_strategy": "MANUAL", "views": 1912, "reach": 1650, "likes": 31, "comments": 0, "shares": 4, "saves": 12, "avg_watch_time_ms": 3660, "skip_rate": 72.9, "metrics_fetched_at": "2026-10-27T21:00:00Z" } ] } } ``` **errors:** - 401 AUTH_001: missing or invalid api key - 403 AUTH_002: partner creator paused or partner suspended **notes:** - covers launches whose instagram_settings.post_type was trial_reel and whose Instagram post succeeded, newest first; reels lists up to 100, totals cover every one. - reads stored numbers only and makes no Instagram request, so it can be polled. each reel's numbers are the ones last read from Instagram (metrics_fetched_at): the metrics sweep refreshes recent posts, and GET /launches/{id}/analytics refreshes one launch. - reach is summed per reel, so an account reached by two reels counts twice. avg_watch_time_ms (milliseconds per play) and skip_rate (percent of plays that skipped in the first 3 seconds) are views-weighted across reels that report them, and null when none do. - followers_since_first.net is follows minus unfollows on your Instagram accounts from the Pacific day of the first trial reel through yesterday, from the daily counts GET /audience?include=instagram_insights stores. days_counted says how many of days_in_span had a stored count; net is null when none did. it is every new follower in that span, not only ones the trial reels brought. ### trial reels trial reel programs: dropspace reshares your strongest Instagram reels as trial reels (shown to non-followers first) at your best hours, then checks each at 1, 3, and 7 days. pro and premium. #### GET /trial-reels every connected Instagram account with its trial reel program, the hours it posts at, and its reshared reels **response (200):** ```json { "data": { "accounts": [ { "connection_id": "3b8f6a1e-2c4d-4e5f-8a9b-0c1d2e3f4a5b", "username": "spin.sensei.app", "eligible": true, "ineligible_reason": null, "program": { "id": "9d2e7c4a-1b3f-4a5e-8c6d-7e8f9a0b1c2d", "enabled": true, "auto_approve": false, "reels_per_day": 1, "skip_caption_terms": ["giveaway"], "timezone": "America/New_York", "edit_kinds": ["reshare", "background"], "last_scan_at": "2026-10-29T14:02:11.000Z", "last_run_at": "2026-10-29T19:05:02.000Z", "last_note": "posted a trial reel this hour" }, "posting_hours": [19], "items": [ { "id": "5f4e3d2c-1b0a-4f9e-8d7c-6b5a4f3e2d1c", "status": "posted", "kind": "background", "prepare_status": "ready", "prepare_error": null, "variant_url": "https://.supabase.co/storage/v1/object/public/generated-media//trial-reels//background-1.mp4", "edit_summary": null, "approved": true, "reason": "1,933 views, 2.4x your median of 806", "caption": "load the next track early", "source": { "media_id": "18118432868104282", "permalink": "https://www.instagram.com/reel/DfAJhNWDpoh/", "caption": "load the next track early @djnova", "views": 1933, "posted_at": "2026-08-30T18:12:00.000Z" }, "win_target": 967, "launch_id": "8f1c2d4e-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "post_id": "18062216473153229", "post_url": "https://www.instagram.com/reel/DgB1x2Yz3Ab/", "claimed_at": "2026-10-29T19:05:01.000Z", "posted_at": "2026-10-29T19:06:40.000Z", "checkpoints": {}, "outcome_at": null, "failure": null, "created_at": "2026-10-29T14:02:11.000Z" } ] } ] } } ``` **errors:** - 401 AUTH_001: missing or invalid api key - 403 AUTH_002: not on the pro or premium plan, or partner creator paused **notes:** - program is absent until the account's program is first saved. eligible is false (with Instagram's rule in ineligible_reason) for personal accounts and accounts under 200 followers: Instagram refuses trial reels from them. - posting_hours are hours of the day (0-23) in the program's timezone: the account's best hours from Instagram's followers-online numbers (GET /audience/best-hours), else 9, 13, and 18 when Instagram has none (under 100 followers). One reel per posting hour. On a day a posting hour passes without a post (another post was too close), a later one of the account's best hours that day is used instead, still up to reels_per_day. - items are the newest 100 that are not removed. status: queued (waiting; posts only once approved), posting (a launch was made, waiting for Instagram), posted (live as a trial reel, being checked), won, retired (no win by day 7, or deleted on Instagram), failed (failure says why). - kind is reshare (the original video, as posted) or an edited version: background (the reel scaled onto a colored background), subtitles (word-timed subtitles burned in; only for reels with speech and no captions already on screen, otherwise the item fails with that reason), hook (Claude picks the strongest line from later in the reel, which plays first, then the whole reel; refused when no line would open it better), or sticker (a reply-to-comment card near the top; Claude writes the comment and a first-name commenter, so it is not a real viewer's comment). edit_summary says what an edit adds once known: the sticker's comment or the hook's opening line. An edited version is rendered once it is approved (background right away; subtitles, hook, and sticker by the next hourly run) (prepare_status pending, running, then ready with variant_url, or failed with prepare_error; three attempts) and posts only when ready. - checkpoints holds the numbers read at 1, 3, and 7 days (d1, d3, d7). An item wins when its views reach win_target: half the original reel's views, or 10,000, whichever is lower. Winners stay trial reels: share one to your followers from the Instagram app. #### PUT /trial-reels/programs/:connectionId turn the trial reel program for one Instagram account on or off, or change its settings **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | enabled | boolean | no | turn the program on or off. Turning it on the first time scans the account's reels (see the scan endpoint) and returns the result as scan. Needs the send scope. | | auto_approve | boolean | no | post queued reels without approving each one (reels with no caption still wait for one). Turning it on approves what is already waiting. Needs the send scope. (default: false) | | reels_per_day | integer | no | trial reels a day, 1-3, one per posting hour (default: 1) | | skip_caption_terms | array | no | up to 20 words or phrases (50 characters each); a reel whose caption contains one is never queued | | timezone | string | no | IANA timezone the posting hours are in, e.g. America/New_York (default: UTC) | | edit_kinds | array | no | kinds new reels take in turn: reshare (the original video), background (on a colored background), subtitles (burned-in, for reels with speech and no captions on screen), hook (a strong later line played first), and sticker (a reply card with a comment Claude writes); a kind that has not shipped yet answers 400 (default: ["reshare"]) | **response (200):** ```json { "data": { "program": { "id": "9d2e7c4a-1b3f-4a5e-8c6d-7e8f9a0b1c2d", "enabled": true, "auto_approve": false, "reels_per_day": 1, "skip_caption_terms": [], "timezone": "America/New_York", "edit_kinds": ["reshare"], "last_scan_at": "2026-10-29T14:02:11.000Z", "last_run_at": null, "last_note": "scan found 41 reels with views; queued 7 at 1.5x the median of 806 views or more" }, "scan": { "queued": 7, "rated": 41, "median_views": 806, "note": "scan found 41 reels with views; queued 7 at 1.5x the median of 806 views or more" } } } ``` **errors:** - 400 SERVER_002: invalid body, unknown timezone, or the account can't post trial reels - 401 AUTH_001: missing or invalid api key - 403 AUTH_002: missing the send scope (enabled or auto_approve true), or not on pro or premium - 404 SERVER_003: no active Instagram connection with this id **notes:** - connectionId is the Instagram account's connection_id from GET /trial-reels (or GET /connections). - nothing posts until a queued reel is approved (PATCH /trial-reels/items/{id}, approve-all, or auto_approve). Approved reels post one per posting hour, up to reels_per_day, never within 2 hours of another post on the same account: dropspace's own (scheduled or published) and any other post Instagram lists, such as one made by hand or another app's trial reels. - each trial reel is a launch on your account, so it counts toward your plan's launches and shows on your launches page. - the reel is posted as Instagram's trial reel (graduation MANUAL): only non-followers see it until you share it to followers in the Instagram app. @mentions are taken out of the reused caption. #### POST /trial-reels/programs/:connectionId/scan scan the account's reels again and queue the strong ones **response (200):** ```json { "data": { "queued": 2, "rated": 43, "median_views": 812, "note": "scan found 43 reels with views; queued 2 at 1.5x the median of 812 views or more" } } ``` **errors:** - 400 SERVER_002: Instagram refused the read (reconnect the account) - 401 AUTH_001: missing or invalid api key - 403 AUTH_002: not on the pro or premium plan - 404 SERVER_003: no program for this connection (save one with PUT first) - 409 SERVER_009: a scan for this account is already running - 429 RATE_001: scanned less than 10 minutes ago **notes:** - reads the account's reels from the last 180 days (up to 100) with their views. A reel is queued when its views are at least 1.5x the median, it is older than 21 days, it has a video file Instagram will hand over, its caption has no skip term, it is on the profile grid (a trial reel, from dropspace or another app, is never reshared), and it was never queued before (removed and failed reels do not come back). Up to 20 wait at once. - needs at least 5 reels with views older than 21 days; note says why when nothing was queued. One scan per account every 10 minutes. - with auto_approve on, the new reels are approved (they will post) only when the key has the send scope; with the write scope alone they wait for approval. #### POST /trial-reels/programs/:connectionId/approve-all approve every waiting reel with a caption on one account's program **response (200):** ```json { "data": { "approved": 6 } } ``` **errors:** - 401 AUTH_001: missing or invalid api key - 403 AUTH_002: missing the send scope, or not on pro or premium - 404 SERVER_003: no program for this connection **notes:** - approved reels post at the next posting hours, so this needs the send scope. #### PATCH /trial-reels/items/:id approve or hold a waiting reel, or change its caption **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | approved | boolean | no | true lets it post at the next posting hour (send scope); false holds it | | caption | string | no | the caption the trial reel posts with, up to 2,200 characters; an approved reel needs one | | kind | string | no | reshare, background, subtitles, hook, or sticker: which version posts. A new kind holds the reel (like a new caption) and starts its edit over; one version per kind per reel. | **response (200):** ```json { "data": { "id": "5f4e3d2c-1b0a-4f9e-8d7c-6b5a4f3e2d1c", "status": "queued", "kind": "reshare", "prepare_status": "none", "prepare_error": null, "variant_url": null, "edit_summary": null, "approved": true, "reason": "1,933 views, 2.4x your median of 806", "caption": "load the next track early", "source": { "media_id": "18118432868104282", "permalink": "https://www.instagram.com/reel/DfAJhNWDpoh/", "caption": "load the next track early @djnova", "views": 1933, "posted_at": "2026-08-30T18:12:00.000Z" }, "win_target": 967, "launch_id": null, "post_id": null, "post_url": null, "claimed_at": null, "posted_at": null, "checkpoints": {}, "outcome_at": null, "failure": null, "created_at": "2026-10-29T14:02:11.000Z" } } ``` **errors:** - 400 SERVER_002: nothing to change, caption too long, or approving with no caption - 401 AUTH_001: missing or invalid api key - 403 AUTH_002: missing the send scope (approved: true), or not on pro or premium - 404 SERVER_003: trial reel not found - 409 SERVER_009: the reel is no longer waiting **notes:** - only waiting (queued) reels change; one that started posting answers 409. - a new caption holds the reel (approved becomes false) unless approved: true is sent with it, so what posts is always a caption someone approved. #### DELETE /trial-reels/items/:id remove a waiting reel from the queue for good **response (200):** ```json { "data": { "id": "5f4e3d2c-1b0a-4f9e-8d7c-6b5a4f3e2d1c", "status": "removed", "kind": "reshare", "prepare_status": "none", "prepare_error": null, "variant_url": null, "edit_summary": null, "approved": false, "reason": "1,933 views, 2.4x your median of 806", "caption": "load the next track early", "source": { "media_id": "18118432868104282", "permalink": "https://www.instagram.com/reel/DfAJhNWDpoh/", "caption": "load the next track early @djnova", "views": 1933, "posted_at": "2026-08-30T18:12:00.000Z" }, "win_target": 967, "launch_id": null, "post_id": null, "post_url": null, "claimed_at": null, "posted_at": null, "checkpoints": {}, "outcome_at": null, "failure": null, "created_at": "2026-10-29T14:02:11.000Z" } } ``` **errors:** - 401 AUTH_001: missing or invalid api key - 403 AUTH_002: not on the pro or premium plan - 404 SERVER_003: trial reel not found - 409 SERVER_009: the reel is no longer waiting **notes:** - a removed reel is never queued again by a scan. ### songs songs for song to reels (pro and premium): add a song from a YouTube or SoundCloud link or a public audio file url (the web app uploads a file), read it back with its waveform once the import is done, get its lyrics with word timings, and make 9:16 lyric reels from a snippet (5 to 30 seconds): the snippet's audio under clips from the media library or a gradient, with the lyrics burned in; a finished reel lands in the media library and can become a draft launch. the audio is fetched and converted on the server (AAC in m4a, cut at 15 minutes), so a new song is `pending` until it is `ready` or `failed`; a reel renders the same way. at most 100 songs per user; reels count against the plan's song reels per billing period (pro 30, premium 100). #### GET /songs your songs, newest first, without waveforms **query parameters:** | name | type | required | description | |------|------|----------|-------------| | page | integer | no | page number (default: 1) | | page_size | integer | no | songs per page, 1-100 (default: 50) | **response (200):** ```json { "success": true, "data": [ { "id": "7d2c1b4e-3f6a-4c8d-9e0f-1a2b3c4d5e6f", "title": "heart of mine", "source": "upload", "source_url": null, "status": "ready", "error": null, "duration_seconds": 212.4, "size_bytes": 3412011, "audio_url": "https://.../songs//songs//audio.m4a", "lyrics_status": "ready", "created_at": "2026-11-01T18:02:11.000Z", "updated_at": "2026-11-01T18:03:40.000Z" } ], "pagination": { "page": 1, "page_size": 50, "total": 1, "total_pages": 1 } } ``` **errors:** - 401 AUTH_001: missing or invalid api key #### POST /songs add a song from a link or a public audio file url. the song comes back `pending`; read `GET /songs/:id` until it is `ready` or `failed` **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | link_url | string | no | a YouTube or SoundCloud link (one of link_url or audio_url). the page's title names the song unless `title` is given. YouTube can refuse downloads from a server; the song then fails with an error that says to upload the file instead | | audio_url | string | no | a public https url of an audio file: mp3, m4a, wav, aac, flac, or ogg, up to 50 MB (one of link_url or audio_url) | | title | string | no | the title, up to 200 characters | **response (201):** ```json { "success": true, "data": { "id": "7d2c1b4e-3f6a-4c8d-9e0f-1a2b3c4d5e6f", "title": "untitled song", "source": "link", "source_url": "https://soundcloud.com/ynho/heart-of-mine", "status": "pending", "error": null, "duration_seconds": null, "size_bytes": null, "audio_url": null, "lyrics_status": "none", "peaks": null, "created_at": "2026-11-01T18:02:11.000Z", "updated_at": "2026-11-01T18:02:11.000Z" } } ``` **errors:** - 400 SERVER_002: no link_url or audio_url, or a link to another site - 401 AUTH_001: missing or invalid api key - 403 BILLING_006: songs are part of the pro and premium plans - 409 SERVER_009: you have 100 songs; delete some to add more #### GET /songs/:id one song with its waveform: `peaks` is 400 numbers from 0 to 1 across the song once it is ready, for picking a snippet **response (200):** ```json { "success": true, "data": { "id": "7d2c1b4e-3f6a-4c8d-9e0f-1a2b3c4d5e6f", "title": "heart of mine", "source": "upload", "source_url": null, "status": "ready", "error": null, "duration_seconds": 212.4, "size_bytes": 3412011, "audio_url": "https://.../songs//songs//audio.m4a", "lyrics_status": "none", "peaks": [0.12, 0.3, 0.41, "..."], "created_at": "2026-11-01T18:02:11.000Z", "updated_at": "2026-11-01T18:03:40.000Z" } } ``` **errors:** - 401 AUTH_001: missing or invalid api key - 404 SERVER_003: song not found #### PATCH /songs/:id rename a song **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | title | string | yes | the new title, up to 200 characters | **response (200):** ```json { "success": true, "data": { "id": "7d2c1b4e-3f6a-4c8d-9e0f-1a2b3c4d5e6f", "title": "heart of mine (demo)", "source": "upload", "source_url": null, "status": "ready", "error": null, "duration_seconds": 212.4, "size_bytes": 3412011, "audio_url": "https://.../audio.m4a", "lyrics_status": "none", "peaks": [0.12, 0.3, "..."], "created_at": "2026-11-01T18:02:11.000Z", "updated_at": "2026-11-01T18:10:02.000Z" } } ``` **errors:** - 400 SERVER_002: title missing or too long - 401 AUTH_001: missing or invalid api key - 404 SERVER_003: song not found #### DELETE /songs/:id delete a song and its audio file **response (200):** ```json { "success": true, "data": { "id": "7d2c1b4e-3f6a-4c8d-9e0f-1a2b3c4d5e6f", "deleted": true } } ``` **errors:** - 401 AUTH_001: missing or invalid api key - 404 SERVER_003: song not found #### GET /songs/:id/lyrics the song's lyrics with word timings (seconds from the start), transcribed on the first request and kept after. a long song can take up to a minute the first time. a song with no recognizable words comes back with empty `words` **response (200):** ```json { "success": true, "data": { "song_id": "7d2c1b4e-3f6a-4c8d-9e0f-1a2b3c4d5e6f", "language": "en", "text": "you're moving on. it really could have been us.", "words": [ { "text": "you're", "start": 9.52, "end": 10.2 }, { "text": "moving", "start": 10.2, "end": 10.61 } ] } } ``` **errors:** - 401 AUTH_001: missing or invalid api key - 404 SERVER_003: song not found - 409 SERVER_009: the song is not ready yet - 500 SERVER_001: the lyrics could not be read; try again #### POST /songs/:id/retry run a failed (or stuck) import again. three attempts in all **response (200):** ```json { "success": true, "data": { "id": "7d2c1b4e-3f6a-4c8d-9e0f-1a2b3c4d5e6f", "title": "heart of mine", "source": "link", "source_url": "https://soundcloud.com/ynho/heart-of-mine", "status": "pending", "error": null, "duration_seconds": null, "size_bytes": null, "audio_url": null, "lyrics_status": "none", "peaks": null, "created_at": "2026-11-01T18:02:11.000Z", "updated_at": "2026-11-01T18:12:30.000Z" } } ``` **errors:** - 401 AUTH_001: missing or invalid api key - 404 SERVER_003: song not found - 409 SERVER_009: the import is still running #### POST /songs/:id/reels make a 9:16 lyric reel from a snippet of a ready song: the snippet's audio under footage, with the lyric lines inside the snippet burned in and, if given, a hook line held across the whole snippet. the lyrics are read from the song on the first reel. the render runs on the server, so the reel comes back `pending`; read `GET /song-reels/:id` until it is `ready` (video_url, and it is in the media library) or `failed`. with `footage_mode` `ai` the reel first generates its b-roll (`footage_status` `generating`), then renders. counts against the plan's song reels per billing period (pro 30, premium 100): a rendered reel keeps its slot even if it is deleted; a failed render gives it back **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | snippet_start | number | yes | seconds from the start of the song | | snippet_end | number | yes | 5 to 30 seconds after snippet_start | | style | string | yes | how the lyrics look: `lower-third` (bold lines low on the frame), `center-bold`, `karaoke` (lines light up word by word), `one-word` (one big word at a time), `caps-top` (uppercase across the top) | | footage_mode | string | yes | `clips` (videos and images from the media library, the clip changing at lyric lines; cuts closer than 1.5 seconds apart are skipped), `none` (the lyrics over a gradient), or `ai` (b-roll generated from `ai_prompt`: `ai_clips` veo clips of 8 seconds, each one of the plan's AI videos, at $1.20 a clip on the AI media meter; the clips join the media library with source `ai`) | | clip_ids | string[] | no | media library ids, in order, up to 20 (footage_mode clips) | | background | string | no | `midnight`, `sunset`, `forest`, `sand`, `slate`, or `violet` (footage_mode none); midnight unless given | | hook_text | string | no | a line burned in for the whole snippet, up to 90 characters: boxed across the top, or low on the frame under `caps-top`. it also opens the launch caption | | ai_prompt | string | no | what the b-roll should show, up to 500 characters (footage_mode ai); the snippet's lyrics stand in when empty. every clip is framed as music-video b-roll with no text or speech | | ai_clips | integer | no | how many b-roll clips to generate, 1-4 (footage_mode ai); 2 unless given. two generate at a time (default: 2) | **response (201):** ```json { "success": true, "data": { "id": "9b1f0c2d-4e5a-4f6b-8c7d-0e1f2a3b4c5d", "song_id": "7d2c1b4e-3f6a-4c8d-9e0f-1a2b3c4d5e6f", "title": "heart of mine 0:42-1:12", "snippet_start": 42, "snippet_end": 72, "style": "karaoke", "footage_mode": "clips", "clip_ids": ["0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d"], "background": null, "hook_text": null, "rollout_id": null, "variant_index": null, "variant_note": null, "ai_prompt": null, "footage_status": null, "lines": [ { "start": 0.4, "end": 1.9, "text": "you're moving on." } ], "render_status": "pending", "render_error": null, "video_url": null, "thumbnail_url": null, "duration_seconds": null, "media_library_id": null, "launch_id": null, "created_at": "2026-11-02T01:10:00.000Z", "updated_at": "2026-11-02T01:10:00.000Z" } } ``` **errors:** - 400 SERVER_002: the snippet doesn't fit, or no clips were picked - 401 AUTH_001: missing or invalid api key - 403 BILLING_006: songs are part of the pro and premium plans, or the plan has no AI videos (footage_mode ai) - 404 SERVER_003: song not found - 409 SERVER_009: the song is not ready yet - 429 MEDIA_001: this billing period's song reels, or its AI videos (footage_mode ai), are used up #### POST /songs/:id/reels/rollout make `count` (2-10) versions of one reel at once, as one rollout: the same snippet, lines, and footage, the style and then the clip order (or the background, with no footage) changing per version, so no two versions are the same reel. every version is a reel of its own (rollout_id, variant_index, variant_note) that counts against the plan's song reels; when fewer slots are left than `count`, nothing is made and the error says how many are left. the versions render in turn on the server. then keep the best with `POST /song-reels/:id/keep`, or launch them all (as trial reels, if you like) with `POST /song-reels/:id/launch`. a rollout with `footage_mode` `ai` generates its b-roll once and shares it **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | count | integer | yes | how many versions, 2-10 | | styles | string[] | no | the lyric styles the versions cycle through; all five when omitted | | snippet_start | number | yes | seconds from the start of the song | | snippet_end | number | yes | 5 to 30 seconds after snippet_start | | style | string | yes | the first version's style; the versions cycle through `styles` from there | | footage_mode | string | yes | `clips`, `none`, or `ai`, with `clip_ids`, `background`, `hook_text`, `ai_prompt`, and `ai_clips` as for `POST /songs/:id/reels` | **response (201):** ```json { "success": true, "data": { "rollout_id": "1f2e3d4c-5b6a-4798-8a9b-0c1d2e3f4a5b", "count": 2, "reels": [ { "id": "9b1f0c2d-4e5a-4f6b-8c7d-0e1f2a3b4c5d", "song_id": "7d2c1b4e-3f6a-4c8d-9e0f-1a2b3c4d5e6f", "title": "heart of mine 0:42-1:12", "snippet_start": 42, "snippet_end": 72, "style": "karaoke", "footage_mode": "clips", "clip_ids": ["0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d", "1b2c3d4e-5f6a-4b7c-9d0e-1f2a3b4c5d6e"], "background": null, "hook_text": "wait for the drop", "rollout_id": "1f2e3d4c-5b6a-4798-8a9b-0c1d2e3f4a5b", "variant_index": 1, "variant_note": "karaoke · clips 1, 2", "ai_prompt": null, "footage_status": null, "lines": [], "render_status": "pending", "render_error": null, "video_url": null, "thumbnail_url": null, "duration_seconds": null, "media_library_id": null, "launch_id": null, "created_at": "2026-11-02T01:10:00.000Z", "updated_at": "2026-11-02T01:10:00.000Z" }, { "id": "8a0e9b1c-3d4e-4f5a-9b6c-7d8e9f0a1b2c", "song_id": "7d2c1b4e-3f6a-4c8d-9e0f-1a2b3c4d5e6f", "title": "heart of mine 0:42-1:12", "snippet_start": 42, "snippet_end": 72, "style": "one-word", "footage_mode": "clips", "clip_ids": ["0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d", "1b2c3d4e-5f6a-4b7c-9d0e-1f2a3b4c5d6e"], "background": null, "hook_text": "wait for the drop", "rollout_id": "1f2e3d4c-5b6a-4798-8a9b-0c1d2e3f4a5b", "variant_index": 2, "variant_note": "one word · clips 1, 2", "ai_prompt": null, "footage_status": null, "lines": [], "render_status": "pending", "render_error": null, "video_url": null, "thumbnail_url": null, "duration_seconds": null, "media_library_id": null, "launch_id": null, "created_at": "2026-11-02T01:10:00.000Z", "updated_at": "2026-11-02T01:10:00.000Z" } ] } } ``` **errors:** - 400 SERVER_002: the snippet doesn't fit, no clips were picked, count is outside 2-10, or fewer distinct versions are possible than count (one style and one clip make one reel; the message says how many) - 401 AUTH_001: missing or invalid api key - 403 BILLING_006: songs are part of the pro and premium plans - 404 SERVER_003: song not found - 409 SERVER_009: the song is not ready yet - 429 MEDIA_001: fewer song reels are left this billing period than count; the message says how many #### GET /songs/:id/reels the song's reels, newest first **query parameters:** | name | type | required | description | |------|------|----------|-------------| | page | integer | no | page number (default: 1) | | page_size | integer | no | reels per page, 1-100 (default: 50) | **response (200):** ```json { "success": true, "data": [ { "id": "9b1f0c2d-4e5a-4f6b-8c7d-0e1f2a3b4c5d", "song_id": "7d2c1b4e-3f6a-4c8d-9e0f-1a2b3c4d5e6f", "title": "heart of mine 0:42-1:12", "snippet_start": 42, "snippet_end": 72, "style": "karaoke", "footage_mode": "clips", "clip_ids": ["0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d"], "background": null, "hook_text": null, "rollout_id": null, "variant_index": null, "variant_note": null, "ai_prompt": null, "footage_status": null, "lines": [], "render_status": "ready", "render_error": null, "video_url": "https://.../generated-media//song-reels//reel.mp4", "thumbnail_url": "https://.../thumb.jpg", "duration_seconds": 30, "media_library_id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9", "launch_id": null, "created_at": "2026-11-02T01:10:00.000Z", "updated_at": "2026-11-02T01:11:30.000Z" } ], "pagination": { "page": 1, "page_size": 50, "total": 1, "total_pages": 1 } } ``` **errors:** - 401 AUTH_001: missing or invalid api key - 404 SERVER_003: song not found #### GET /song-reels every lyric reel, newest first (a rollout's versions in order) **query parameters:** | name | type | required | description | |------|------|----------|-------------| | song_id | string | no | only this song's reels | | rollout_id | string | no | only this rollout's versions | | page | integer | no | page number (default: 1) | | page_size | integer | no | reels per page, 1-100 (default: 50) | **response (200):** ```json { "success": true, "data": [ { "id": "9b1f0c2d-4e5a-4f6b-8c7d-0e1f2a3b4c5d", "song_id": "7d2c1b4e-3f6a-4c8d-9e0f-1a2b3c4d5e6f", "title": "heart of mine 0:42-1:12", "snippet_start": 42, "snippet_end": 72, "style": "lower-third", "footage_mode": "none", "clip_ids": [], "background": "sunset", "hook_text": null, "rollout_id": null, "variant_index": null, "variant_note": null, "ai_prompt": null, "footage_status": null, "lines": [], "render_status": "ready", "render_error": null, "video_url": "https://.../reel.mp4", "thumbnail_url": "https://.../thumb.jpg", "duration_seconds": 30, "media_library_id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9", "launch_id": null, "created_at": "2026-11-02T01:10:00.000Z", "updated_at": "2026-11-02T01:11:30.000Z" } ], "pagination": { "page": 1, "page_size": 50, "total": 1, "total_pages": 1 } } ``` **errors:** - 401 AUTH_001: missing or invalid api key #### GET /song-reels/:id one reel, with the lyric lines inside its snippet and their timing (seconds from the snippet's start) **response (200):** ```json { "success": true, "data": { "id": "9b1f0c2d-4e5a-4f6b-8c7d-0e1f2a3b4c5d", "song_id": "7d2c1b4e-3f6a-4c8d-9e0f-1a2b3c4d5e6f", "title": "heart of mine 0:42-1:12", "snippet_start": 42, "snippet_end": 72, "style": "karaoke", "footage_mode": "clips", "clip_ids": ["0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d"], "background": null, "hook_text": null, "rollout_id": null, "variant_index": null, "variant_note": null, "ai_prompt": null, "footage_status": null, "lines": [ { "start": 0.4, "end": 1.9, "text": "you're moving on." } ], "render_status": "ready", "render_error": null, "video_url": "https://.../reel.mp4", "thumbnail_url": "https://.../thumb.jpg", "duration_seconds": 30, "media_library_id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9", "launch_id": null, "created_at": "2026-11-02T01:10:00.000Z", "updated_at": "2026-11-02T01:11:30.000Z" } } ``` **errors:** - 401 AUTH_001: missing or invalid api key - 404 SERVER_003: reel not found #### DELETE /song-reels/:id delete a reel. its media library item and any launch made from it stay **response (200):** ```json { "success": true, "data": { "id": "9b1f0c2d-4e5a-4f6b-8c7d-0e1f2a3b4c5d", "deleted": true } } ``` **errors:** - 401 AUTH_001: missing or invalid api key - 404 SERVER_003: reel not found #### POST /song-reels/:id/retry render a failed (or stuck) reel again. three attempts in all **response (200):** ```json { "success": true, "data": { "id": "9b1f0c2d-4e5a-4f6b-8c7d-0e1f2a3b4c5d", "song_id": "7d2c1b4e-3f6a-4c8d-9e0f-1a2b3c4d5e6f", "title": "heart of mine 0:42-1:12", "snippet_start": 42, "snippet_end": 72, "style": "karaoke", "footage_mode": "clips", "clip_ids": ["0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d"], "background": null, "hook_text": null, "rollout_id": null, "variant_index": null, "variant_note": null, "ai_prompt": null, "footage_status": null, "lines": [], "render_status": "pending", "render_error": null, "video_url": null, "thumbnail_url": null, "duration_seconds": null, "media_library_id": null, "launch_id": null, "created_at": "2026-11-02T01:10:00.000Z", "updated_at": "2026-11-02T01:20:00.000Z" } } ``` **errors:** - 401 AUTH_001: missing or invalid api key - 404 SERVER_003: reel not found - 409 SERVER_009: the render is still running #### POST /song-reels/:id/launch a draft launch carrying the reel as its video (attached to instagram) and the hook line and the snippet's lyrics as the instagram caption; finish it with PATCH /launches/:id (platforms, caption, schedule) and publish it as usual. with `trial_reel` true the draft is an Instagram trial reel (shown to non-followers first; the account must be a professional account with 200 or more followers, refused with the reason otherwise). the draft is one of the plan's launches for the period, like any launch you create. a reel keeps one launch: a second call returns the same launch_id **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | trial_reel | boolean | no | post it as an Instagram trial reel (instagram_settings post_type trial_reel, graduation MANUAL) | **response (201):** ```json { "success": true, "data": { "id": "9b1f0c2d-4e5a-4f6b-8c7d-0e1f2a3b4c5d", "song_id": "7d2c1b4e-3f6a-4c8d-9e0f-1a2b3c4d5e6f", "title": "heart of mine 0:42-1:12", "snippet_start": 42, "snippet_end": 72, "style": "karaoke", "footage_mode": "clips", "clip_ids": ["0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d"], "background": null, "hook_text": null, "rollout_id": null, "variant_index": null, "variant_note": null, "ai_prompt": null, "footage_status": null, "lines": [], "render_status": "ready", "render_error": null, "video_url": "https://.../reel.mp4", "thumbnail_url": "https://.../thumb.jpg", "duration_seconds": 30, "media_library_id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9", "launch_id": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f", "created_at": "2026-11-02T01:10:00.000Z", "updated_at": "2026-11-02T01:25:00.000Z" } } ``` **errors:** - 400 SERVER_002: the Instagram account cannot post a trial reel (the message says why) - 401 AUTH_001: missing or invalid api key - 404 SERVER_003: reel not found - 409 SERVER_009: the reel is not ready yet #### POST /song-reels/:id/keep keep this version of a rollout: the rollout's other versions are deleted along with their media library items and files. a version that already has a launch stays (the launch needs its video). returns the kept reel **response (200):** ```json { "success": true, "data": { "id": "9b1f0c2d-4e5a-4f6b-8c7d-0e1f2a3b4c5d", "song_id": "7d2c1b4e-3f6a-4c8d-9e0f-1a2b3c4d5e6f", "title": "heart of mine 0:42-1:12", "snippet_start": 42, "snippet_end": 72, "style": "karaoke", "footage_mode": "clips", "clip_ids": ["0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d"], "background": null, "hook_text": "wait for the drop", "rollout_id": "1f2e3d4c-5b6a-4798-8a9b-0c1d2e3f4a5b", "variant_index": 1, "variant_note": "karaoke", "ai_prompt": null, "footage_status": null, "lines": [], "render_status": "ready", "render_error": null, "video_url": "https://.../reel.mp4", "thumbnail_url": "https://.../thumb.jpg", "duration_seconds": 30, "media_library_id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9", "launch_id": null, "created_at": "2026-11-02T01:10:00.000Z", "updated_at": "2026-11-02T01:30:00.000Z" } } ``` **errors:** - 400 SERVER_002: this reel is not one of a rollout's versions - 401 AUTH_001: missing or invalid api key - 404 SERVER_003: reel not found ### reports saved reports: markdown an AI app saves through the MCP workflows (Instagram audit, best-times plan, winners report) or your own client saves through the API. the reports page in the app lists them. every plan. #### GET /reports your saved reports, newest first, without their bodies **query parameters:** | name | type | required | description | |------|------|----------|-------------| | page | integer | no | page number (default: 1) | | page_size | integer | no | reports per page, 1-100 (default: 50) | **response (200):** ```json { "success": true, "data": [ { "id": "4c1e8f2a-7b3d-4e5f-9a0b-1c2d3e4f5a6b", "title": "Instagram audit - 10/29/2026", "kind": "audit", "source": "mcp", "created_at": "2026-10-29T18:02:11.000Z" } ], "pagination": { "page": 1, "page_size": 50, "total": 1, "total_pages": 1 } } ``` **errors:** - 401 AUTH_001: missing or invalid api key #### POST /reports save a markdown report **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | title | string | yes | 1-200 characters | | body | string | yes | the report in markdown, up to 100,000 characters | | kind | string | no | audit, best_times, winners, or other (default: other) | | source | string | no | mcp (saved by an AI app through the MCP server) or api (default: api) | **response (201):** ```json { "data": { "id": "4c1e8f2a-7b3d-4e5f-9a0b-1c2d3e4f5a6b", "title": "Instagram audit - 10/29/2026", "kind": "audit", "source": "mcp", "created_at": "2026-10-29T18:02:11.000Z", "body": "## Where the account stands 260 followers, +34 in 30 days..." } } ``` **errors:** - 400 SERVER_002: invalid json, missing title or body, or too long - 401 AUTH_001: missing or invalid api key - 403 AUTH_002: missing the write scope - 409 SERVER_009: 200 reports saved already **notes:** - at most 200 saved reports per user; a save past that answers 409 and nothing is deleted for you. delete old ones with DELETE /reports/{id}. - the body is stored as written. the reports page renders headings, lists, bold, and links; raw HTML is shown as text. #### GET /reports/:id one saved report with its markdown body **response (200):** ```json { "data": { "id": "4c1e8f2a-7b3d-4e5f-9a0b-1c2d3e4f5a6b", "title": "Instagram audit - 10/29/2026", "kind": "audit", "source": "mcp", "created_at": "2026-10-29T18:02:11.000Z", "body": "## Where the account stands 260 followers, +34 in 30 days..." } } ``` **errors:** - 401 AUTH_001: missing or invalid api key - 404 SERVER_003: report not found #### DELETE /reports/:id delete a saved report **response (200):** ```json { "data": { "id": "4c1e8f2a-7b3d-4e5f-9a0b-1c2d3e4f5a6b", "deleted": true } } ``` **errors:** - 401 AUTH_001: missing or invalid api key - 403 AUTH_002: missing the write scope - 404 SERVER_003: report not found ### reddit reddit-specific discovery. find where your ICP lives and where THIS account has standing to post, before you fan a launch out. requires an api key whose account has reddit connected. #### POST /reddit/subreddits/suggest ICP-matched subreddit discovery. pass a product brief; get back verified, real subreddits with a promo-policy classification and a green/yellow/red standing badge for THIS account. `standing.level` answers 'can this account post here without getting filtered': green means it has participated here; yellow means build standing first; red means a hard blocker (banned promo, private sub, or a weak account). `standing.promo_share` is the account's promotional share of its footprint in the community (null when there's no footprint yet). prefer green subs, and earn standing in yellow ones by commenting before you post. the launch fan-out enforces reddit's 90:10 promo norm at publish time, so posting where you have no standing is skipped, not sent. **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | brief | string (10-2000 chars) | yes | what your product is and who it's for | **response (200):** ```json { "data": { "icp_summary": "indie developers shipping side projects", "suggestions": [ { "name": "SideProject", "title": "Side Project", "subscribers": 180000, "over18": false, "subreddit_type": "public", "promo_policy": "allowed", "promo_evidence": "self-promotion saturday thread", "standing": { "level": "green", "tier": "active", "promo_posts_here": 1, "contributions_here": 14, "promo_share": 0.06, "reasons": ["you're active here (14 comments)"] }, "reason": "indie devs share and get feedback on side projects here" } ] } } ``` **errors:** - 400 PLATFORM_001: api key's account has no reddit connection - 400 SERVER_002: brief missing or out of range **notes:** - api-key auth only: needs the caller's reddit oauth token to verify subs + compute standing. wallet/mpp agents get an empty result. - nothing here posts; it only reads. rate-limited per account (the discovery sweep is expensive on reddit's shared 60/min quota). ### media library the user's own photos and videos. autopilot posts them when its media_source is library or mixed, choosing each item by its description. #### GET /media-library list media library items, newest first **query parameters:** | name | type | required | description | |------|------|----------|-------------| | page | integer | no | page number (default: 1) | | page_size | integer (1-100) | no | items per page (default: 50) | **response (200):** ```json { "data": [ { "id": "uuid", "url": "https://...", "type": "image", "mime_type": "image/jpeg", "width": 1080, "height": 1350, "size_bytes": 482113, "thumbnail_url": null, "filename": "desk.jpg", "description": "the product on a wooden desk next to a laptop", "source": "upload", "use_count": 2, "last_used_at": "ISO 8601 | null", "created_at": "ISO 8601", "updated_at": "ISO 8601" } ], "pagination": { "page": 1, "page_size": 50, "total": 1, "total_pages": 1 } } ``` #### POST /media-library add photos or videos by public URL (1-20 per call; a URL already in the library is skipped; 500 items max). images without a description are captioned on autopilot's next run; give videos a description or a thumbnail_url **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | items | object[] (1-20) | yes | each: url (public http(s)), type (image | video), and optional description (max 500), thumbnail_url, mime_type, width, height, size_bytes, filename | **response (201):** ```json { "data": { "added": [ { "id": "uuid", "url": "https://...", "type": "image", "mime_type": "image/jpeg", "width": 1080, "height": 1350, "size_bytes": 482113, "thumbnail_url": null, "filename": "desk.jpg", "description": "the product on a wooden desk next to a laptop", "source": "upload", "use_count": 2, "last_used_at": "ISO 8601 | null", "created_at": "ISO 8601", "updated_at": "ISO 8601" } ] } } ``` #### PATCH /media-library/{id} set an item's description (what it shows); null clears it **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | description | string (max 500) | null | yes | what the media shows | **response (200):** ```json { "data": { "id": "uuid", "url": "https://...", "type": "image", "mime_type": "image/jpeg", "width": 1080, "height": 1350, "size_bytes": 482113, "thumbnail_url": null, "filename": "desk.jpg", "description": "the product on a wooden desk next to a laptop", "source": "upload", "use_count": 2, "last_used_at": "ISO 8601 | null", "created_at": "ISO 8601", "updated_at": "ISO 8601" } } ``` #### DELETE /media-library/{id} remove an item from the library; launches that already posted it keep their media **response (200):** ```json { "data": { "id": "uuid", "deleted": true } } ``` #### POST /scene-clips cut a long video into 2 to 8 second clips at its scene changes, up to 40 a job, each with a thumbnail, for the media library (footage for lyric reels and autopilot). the job comes back `pending`; read `GET /scene-clips/:id` until it is `ready` or `failed`. pro and premium; at most 10 jobs a day and one running at a time **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | media_library_id | string | no | a video in the media library (one of media_library_id, link_url, or video_url) | | link_url | string | no | a YouTube link, up to 15 minutes, fetched at up to 720p (one of the three). the page's title names the clips. YouTube can refuse downloads from a server; the job then fails with an error that says to upload the video instead | | video_url | string | no | a public https url of an mp4 or mov up to 512 MB (one of the three). only the first 15 minutes are scanned | **response (201):** ```json { "success": true, "data": { "id": "3f6a4c8d-9e0f-4a2b-8c3d-4e5f6a7b8c9d", "source": "link", "source_url": "https://www.youtube.com/watch?v=abc123", "source_title": null, "source_media_id": null, "source_duration_seconds": null, "status": "pending", "error": null, "attempts": 1, "clips_made": 0, "clips_added": 0, "clips": [], "note": null, "created_at": "2026-11-02T18:02:11.000Z", "updated_at": "2026-11-02T18:02:11.000Z" } } ``` **errors:** - 400 SERVER_002: no source, more than one, a link to another site, or a library item that isn't a video - 401 AUTH_001: missing or invalid api key - 403 BILLING_006: cutting clips is part of the pro and premium plans - 404 SERVER_003: media item not found - 409 SERVER_009: one video is being cut already; wait for it to finish - 429 MEDIA_001: 10 jobs started in the last day #### GET /scene-clips list clip jobs, newest first. reading a ready job adds its clips to the media library **query parameters:** | name | type | required | description | |------|------|----------|-------------| | page | integer | no | page number (default: 1) | | page_size | integer (1-100) | no | jobs per page (default: 50) | **response (200):** ```json { "success": true, "data": [ { "id": "3f6a4c8d-9e0f-4a2b-8c3d-4e5f6a7b8c9d", "source": "library", "source_url": "https://.../library/abc/summer-tour.mp4", "source_title": "summer tour", "source_media_id": "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d", "source_duration_seconds": 184.2, "status": "ready", "error": null, "attempts": 1, "clips_made": 23, "clips_added": 23, "clips": [{ "start": 0, "end": 4.2, "video_url": "https://.../scene-clips/3f6a.../clip-01.mp4", "thumbnail_url": "https://.../scene-clips/3f6a.../clip-01.jpg", "media_library_id": "7d2c1b4e-3f6a-4c8d-9e0f-1a2b3c4d5e6f" }], "note": null, "created_at": "2026-11-02T18:02:11.000Z", "updated_at": "2026-11-02T18:06:40.000Z" } ], "pagination": { "page": 1, "page_size": 50, "total": 1, "total_pages": 1 } } ``` **errors:** - 401 AUTH_001: missing or invalid api key #### GET /scene-clips/:id one clip job. `status` runs pending, running, ready, or failed (`error` says why; a job whose row has not moved for 30 minutes reads as failed, and the backend touches the row while it works, so a long run never does). once ready, `clips` carries each clip's video_url, thumbnail_url, and media_library_id; `note` says when some clips are waiting for room in the library (500 items) and land on a later read **response (200):** ```json { "success": true, "data": { "id": "3f6a4c8d-9e0f-4a2b-8c3d-4e5f6a7b8c9d", "source": "link", "source_url": "https://www.youtube.com/watch?v=abc123", "source_title": "summer tour recap", "source_media_id": null, "source_duration_seconds": 612.5, "status": "ready", "error": null, "attempts": 1, "clips_made": 40, "clips_added": 12, "clips": [{ "start": 0, "end": 6.1, "video_url": "https://.../clip-01.mp4", "thumbnail_url": "https://.../clip-01.jpg", "media_library_id": "7d2c1b4e-3f6a-4c8d-9e0f-1a2b3c4d5e6f" }], "note": "28 clips are waiting for room in your media library (500 items); delete some and open this again", "created_at": "2026-11-02T18:02:11.000Z", "updated_at": "2026-11-02T18:09:02.000Z" } } ``` **errors:** - 401 AUTH_001: missing or invalid api key - 404 SERVER_003: clip job not found #### DELETE /scene-clips/:id delete a clip job. clips already in the media library stay; files nothing points at are removed **response (200):** ```json { "success": true, "data": { "id": "3f6a4c8d-9e0f-4a2b-8c3d-4e5f6a7b8c9d", "deleted": true } } ``` **errors:** - 401 AUTH_001: missing or invalid api key - 404 SERVER_003: clip job not found #### POST /scene-clips/:id/retry cut a failed (or stuck) job again, under the same caps as a new job (one at a time, 10 a day). three attempts in all: `attempts` counts them **response (200):** ```json { "success": true, "data": { "id": "3f6a4c8d-9e0f-4a2b-8c3d-4e5f6a7b8c9d", "source": "url", "source_url": "https://cdn.example.com/tour.mp4", "source_title": "tour", "source_media_id": null, "source_duration_seconds": null, "status": "pending", "error": null, "attempts": 2, "clips_made": 0, "clips_added": 0, "clips": [], "note": null, "created_at": "2026-11-02T18:02:11.000Z", "updated_at": "2026-11-02T18:40:00.000Z" } } ``` **errors:** - 401 AUTH_001: missing or invalid api key - 404 SERVER_003: clip job not found - 409 SERVER_009: the job is still running, another job is running, or it has failed three times - 429 MEDIA_001: 10 jobs started in the last day ### personas manage AI writing personas for content generation. #### GET /personas list personas with pagination **query parameters:** | name | type | required | description | |------|------|----------|-------------| | page | integer | no | page number (default: 1) | | page_size | integer (1-100) | no | items per page (default: 50) | **response (200):** ```json { "data": [ { "id": "uuid", "name": "string", "persona_analysis": "object | null", "build_status": "idle|building|complete|error", "build_progress": 0, "build_started_at": "ISO 8601 | null", "build_error": "string | null", "last_analyzed_at": "ISO 8601 | null", "created_at": "ISO 8601", "updated_at": "ISO 8601" } ], "pagination": { "page": 1, "page_size": 50, "total": 3, "total_pages": 1 } } ``` #### POST /personas create a new persona **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | name | string (1-100 chars) | yes | persona name | **errors:** - 409 SERVER_009: duplicate persona name - 429 PERSONA_001: persona creation limit reached #### GET /personas/:id get persona with all writing samples **errors:** - 404 PERSONA_002: persona not found **notes:** - includes persona_analysis, persona_analysis_structured, custom_samples, twitter_samples, reddit_samples, facebook_samples, instagram_samples, tiktok_samples, linkedin_samples, brand_kit (omitted when unset) #### PATCH /personas/:id update name and/or writing samples **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | name | string (1-100 chars) | no | persona name | | custom_samples | string[] (max 50) | no | plain-text writing samples | | twitter_samples | object[] (max 50) | no | Twitter writing samples. each item carries its prose in `text`; a plain string is accepted and read as the prose itself | | reddit_samples | object[] (max 50) | no | Reddit writing samples. each item carries its prose in `body`; a plain string is accepted and read as the prose itself | | facebook_samples | object[] (max 50) | no | Facebook writing samples. each item carries its prose in `message`; a plain string is accepted and read as the prose itself | | instagram_samples | object[] (max 50) | no | Instagram writing samples. each item carries its prose in `caption`; a plain string is accepted and read as the prose itself | | tiktok_samples | object[] (max 50) | no | TikTok writing samples. each item carries its prose in `description`; a plain string is accepted and read as the prose itself | | linkedin_samples | object[] (max 50) | no | LinkedIn writing samples. each item carries its prose in `commentary`; a plain string is accepted and read as the prose itself | | brand_kit | object | null | no | how autopilot image slides look when autopilot posts as this persona: text_color and outline_color (#RRGGBB), logo_url (public http(s) image), logo_position (top-left | top-right | bottom-left | bottom-right, default top-right). omitted fields keep white text on a black outline; null clears the kit | **errors:** - 404 PERSONA_002: persona not found - 409 SERVER_009: duplicate persona name #### DELETE /personas/:id delete a persona **response:** 204 no content **errors:** - 404 PERSONA_002: persona not found - 422 SERVER_005: persona in use by launches **notes:** - cannot delete if used by any launches #### POST /personas/:id/analyze trigger AI persona analysis (async) **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | platforms | string[] | no | which platforms to analyze. omit to analyze every platform this persona has samples for; pass [] for none | | include_custom_samples | boolean | no | include custom_samples in the analysis (default: false) | **response (202):** ```json { "data": { "started": true, "persona_id": "uuid" } } ``` **errors:** - 404 PERSONA_002: persona not found - 409 SERVER_009: already building - 429 PERSONA_003: persona build limit reached **notes:** - listen for persona.analyzed webhook when complete #### POST /personas/:id/finalize commit the pending analysis (after analyze) - content generation reads the committed analysis **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | persona_analysis | string (max 2000) | no | optionally edited analysis text (defaults to the pending analysis) | | persona_analysis_structured | object | no | optionally edited structured analysis | **response (200):** ```json { "data": { "success": true, "analysis_limit": { "used": 1, "limit": 3, "remaining": 2 } } } ``` **errors:** - 400 PERSONA_004: persona is not ready for review - 404 PERSONA_002: persona not found **notes:** - free - usage was charged at analyze. call once build_status is ready_for_review #### POST /personas/:id/discard discard the pending analysis (preserves any previously committed analysis) **response (200):** ```json { "data": { "success": true } } ``` **errors:** - 400 PERSONA_004: persona is not ready for review - 404 PERSONA_002: persona not found **notes:** - free - analyze usage is not refunded ### autopilot the autonomous content engine. autopilot studies your post performance nightly, drafts new posts in your voice, and schedules them on your connected accounts. review-first by default; pro and premium plans only. #### GET /autopilot get autopilot status and config **response (200):** ```json { "data": { "configured": true, "config": { "enabled": true, "mode": "review", "platforms": ["twitter", "linkedin"], "formats": ["text", "visual"], "persona_id": "uuid | null", "posting_times": { "times": ["09:30", "17:30"], "days": [1, 2, 3, 4, 5] }, "run_hour": 2, "timezone": "America/New_York", "product_url": "string | null", "product_image_url": "string | null", "product_brief": "string | null", "content_pillars": ["string"] | null, "directives": ["string"] | null, "min_queue": 7, "max_posts_per_day": 4, "track_links": false, "reuse_media": false, "media_source": "ai", "platform_settings": {}, "paused": false, "pause_reason": null, "created_at": "ISO 8601", "updated_at": "ISO 8601" }, "queued_ideas": 9, "pending_review": 3 } } ``` **notes:** - config is null until the first PATCH /autopilot call #### PATCH /autopilot create or update the autopilot config (merge: only the fields you send change) **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | enabled | boolean | no | turn nightly runs on/off | | mode | string | no | "review" (drafts wait for approval) or "auto" (publishes on schedule). YouTube posts always hold for review regardless of mode - approve them via the review endpoints (default: review) | | platforms | array of strings | no | platforms to post on - each needs a connected account. valid: facebook, linkedin, twitter, reddit, instagram, tiktok, youtube | | formats | array of strings | no | "text", "visual" (AI image slides), and/or "video" (default: ["text"]) | | persona_id | uuid | null | no | voice persona for drafts | | posting_times | object | no | { "times": ["HH:MM"], "days": [0-6] } in the config timezone (0 = sunday) | | run_hour | integer (0-23) | no | local hour the nightly run fires (default: 2) | | timezone | string (IANA) | no | timezone for posting times and runs (default: America/New_York) | | product_url | string | null | no | product link included in posts | | product_image_url | string | null | no | reference image for slideshow (visual) posts - each slide is generated from it so it shows the real product. omitted: filled from product_url's page when product_url changes. empty or null: no reference | | product_brief | string (max 2000) | null | no | what you're building, who it's for | | content_pillars | array of strings (max 10) | null | no | themes the engine rotates across | | directives | array of strings (max 10, each max 200 chars) | null | no | standing per-post rules the planner honors in every draft (e.g. "no emojis", "always end with a question") - null clears them | | min_queue | integer (1-30) | no | ideas to keep queued (default: 7) | | max_posts_per_day | integer (1-10) | no | daily posting cap (default: 4) | | track_links | boolean | no | wrap the post's outbound product link in a dropspace-owned tracked redirect, so click-throughs count toward what autopilot learns from (default: false) | | reuse_media | boolean | no | reuse the day's first finished AI slideshow or video across your other platforms' posts that day - identical media, per-platform captions; claims spend no generation quota (default: false) | | media_source | "ai" | "library" | "mixed" | no | where image and video posts get media: ai (generated slides and video), library (only the media library), mixed (library first, AI when nothing fits). an empty library falls back to AI (default: ai) | | platform_settings | object | no | per-platform posting settings stamped into every autopilot launch. tiktok: privacy_level (defaults to SELF_ONLY - posts publish visible only to the account until widened), allow_comments/allow_duet/allow_stitch, commercial disclosure (is_commercial + is_your_brand/is_branded_content). youtube: privacy_status (defaults to private), made_for_kids; AI-content disclosure is always on for autopilot videos (default: {}) | **errors:** - 400 SERVER_002: invalid timezone or no usable connected account for a platform - 403 BILLING_006: plan does not include autopilot **notes:** - posts created by autopilot consume your monthly launch limit - visual and video formats also consume your monthly AI media limits #### GET /autopilot/review list drafts awaiting approval (review mode) **query parameters:** | name | type | required | description | |------|------|----------|-------------| | page | integer | no | page number (default: 1) | | page_size | integer (1-50) | no | items per page (default: 20) | **response (200):** ```json { "data": [ { "launch_id": "uuid", "hook": "string", "platform": "twitter", "format": "text", "content": "string | null", "reddit_title": "string | null", "scheduled_date": "ISO 8601 | null", "media_count": 0, "created_at": "ISO 8601" } ], "pagination": { "page": 1, "page_size": 20, "total": 3, "total_pages": 1 } } ``` **notes:** - each item is a draft launch - edit it first with PATCH /launches/:id if needed #### POST /autopilot/review/:launchId/approve approve a draft - schedules it for publishing **response (200):** ```json { "data": { "launch_id": "uuid", "status": "scheduled", "scheduled_date": "ISO 8601" } } ``` **errors:** - 404 SERVER_003: draft not found **notes:** - a stale scheduled_date rolls forward to the next posting slot automatically #### POST /autopilot/review/:launchId/reject reject a draft - deletes it and refunds the launch slot **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | feedback | string (max 500) | no | what to avoid next time - feeds future generation | **response (200):** ```json { "data": { "launch_id": "uuid", "rejected": true } } ``` **errors:** - 404 SERVER_003: draft not found #### POST /autopilot/run trigger a manual run now (instead of waiting for the nightly run) **response (202):** ```json { "data": { "queued": true, "run_date": "2026-06-12" } } ``` **errors:** - 400 SERVER_002: autopilot is not enabled - 403 BILLING_006: plan does not include autopilot - 404 SERVER_003: autopilot is not set up yet - 429 RATE_001: manual runs are limited to 2 per hour **notes:** - the insight and idea steps that already completed today are skipped; the scheduling step runs again, filling only slots that have no post yet, so a manual run after a fix (media added, a platform unticked) tops up the same day rather than double-posting #### GET /autopilot/runs recent run history (one row per run step) **query parameters:** | name | type | required | description | |------|------|----------|-------------| | limit | integer (1-30) | no | rows to return (default: 9) | **response (200):** ```json { "data": [ { "run_date": "2026-06-12", "step": "schedule_slots", "status": "completed", "stats": { "created": 3, "slots": 4 }, "error": null, "started_at": "ISO 8601", "completed_at": "ISO 8601" } ] } ``` ### recommendations actionable insights computed from your organic post performance. dropspace sweeps your recent posts' metrics every 30 minutes, detects winners and underperformers against your own baseline, and turns them into one-click actions: repost, cross-post, boost, promote into autopilot, or pause what isn't working. #### GET /recommendations list recommendations **query parameters:** | name | type | required | description | |------|------|----------|-------------| | page | integer | no | page number (default: 1) | | page_size | integer (1-100) | no | results per page (default: 20) | | status | "pending" | "applied" | "dismissed" | "expired" | no | filter by status | | launch_id | UUID | no | only recommendations for this launch | **response (200):** ```json { "success": true, "data": [ { "id": "uuid", "rec_type": "repost_winner", "status": "pending", "platform": "twitter", "launch_id": "uuid", "posting_log_id": "uuid", "payload": { "launch_name": "july feature drop", "post_url": "https://x.com/...", "score": 42.5, "baseline": 6 }, "score": 42.5, "baseline": 6, "expires_at": "ISO 8601", "applied_launch_id": null, "applied_at": null, "dismissed_at": null, "created_at": "ISO 8601", "updated_at": "ISO 8601" } ], "pagination": { "page": 1, "page_size": 20, "total": 3, "total_pages": 1 } } ``` **notes:** - rec_type is one of: repost_winner, cross_post, boost_winner, promote_winner, pause_underperformer - recommendations expire automatically (default 14 days), act on them while the post is still riding #### POST /recommendations/:id/apply apply a recommendation (executes its one-click action) **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | ad_account_id | string | no | meta ad account id: required for boost_winner recommendations, ignored otherwise | **response (200):** ```json { "success": true, "data": { "recommendation": { "id": "uuid", "rec_type": "repost_winner", "status": "applied", "...": "..." }, "outcome": { "kind": "launch_draft", "launch_id": "uuid" } } } ``` **errors:** - 400 SERVER_002: missing ad_account_id for a boost recommendation - 404 SERVER_003: recommendation not found or not owned - 409 SERVER_009: already applied/dismissed, expired, or autopilot not enabled **notes:** - repost_winner and cross_post create a DRAFT launch seeded with the winning content. nothing publishes until you schedule it - boost_winner creates a PAUSED boost with the suggested budget. activate it separately to start spend - promote_winner queues the winner into your autopilot queue (autopilot must be enabled) - pause_underperformer adds a standing directive to your autopilot config #### POST /recommendations/:id/dismiss dismiss a recommendation **response (200):** ```json { "success": true, "data": { "id": "uuid", "rec_type": "repost_winner", "status": "dismissed", "...": "..." } } ``` **errors:** - 404 SERVER_003: recommendation not found or not owned - 409 SERVER_009: recommendation is already applied or dismissed ### media generate AI images and videos for your launches. #### POST /media/generate submit an image or video generation job **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | type | "image" | "video" | "script_video" | yes | generation type | | launch_id | UUID | yes | associated launch | | platform | string | yes | target platform for the asset - always required, any supported platform. script_video only accepts "instagram" or "tiktok" | | prompt | string | no | generation prompt - either prompt or product_description is required; script_video requires an explicit prompt. the effective prompt (yours, or the one derived from product_description) must be 10-2000 chars or the request fails with a 400 | | product_description | string | no | product context - used to derive a prompt when prompt is omitted | | options.aspect_ratio | "1:1" | "16:9" | "9:16" | "4:3" | "3:4" | "3:2" | "2:3" | "5:4" | "4:5" | "21:9" | no | aspect ratio (image supports all 10; video/script_video only supports 16:9 and 9:16) | | options.duration_seconds | 4 | 6 | 8 | no | video duration (default: 8) | | options.seed | number | no | generation seed for reproducible output - forwarded to the model for text-to-image and video jobs; ignored when reference_image_url is set (the edit model does not support seeds) | | options.negative_prompt | string | no | things to steer the model away from - video generation only (the image model does not support negative prompts; ignored for image jobs) | | reference_image_url | URL | no | URL of a reference image for style/composition guidance (image type only, uses edit endpoint) | | use_product_image | boolean | no | use the first image from the launch's product page (scraped from product_url) as the reference image - image type only, ignored when reference_image_url is set, 400 when the launch has no product image | **response (202):** ```json { "data": { "status": "processing", "job_id": "uuid", "fal_request_id": "string", "generation_type": "image", "usage": { "used": 3, "limit": 50, "remaining": 47 }, "plan": "pro" } } ``` **errors:** - 403 AUTH_002: plan doesn't include media generation - 404 LAUNCH_001: launch not found or not owned - 429 MEDIA_001: monthly media generation limit reached - 429 RATE_001: concurrency limit reached **notes:** - type 'script_video' generates a video from a text script (requires platform 'instagram' or 'tiktok' and an explicit prompt). type 'video' generates from a visual/cinematic prompt #### GET /media/:jobId poll media generation status **response (200):** ```json { "data": { "id": "uuid", "generation_type": "image", "prompt": "...", "result_url": "https://cdn.dropspace.dev/...", "reference_image_url": "https://cdn.dropspace.dev/... | null", "status": "processing|completed|failed", "progress": 75, "model_id": "string", "error_message": "string | null", "launch_id": "uuid", "created_at": "ISO 8601", "completed_at": "ISO 8601 | null" } } ``` **errors:** - 404 SERVER_003: media job not found **notes:** - listen for media.ready webhook when status becomes completed ### account your profile, and the billing portal hand-off. #### GET /profile the authenticated user's profile **response (200):** ```json { "data": { "id": "uuid", "email": "you@example.com", "full_name": "string | null", "avatar_url": "string | null", "role": "user", "has_completed_onboarding": true, "created_at": "ISO 8601", "updated_at": "ISO 8601" } } ``` **errors:** - 404 SERVER_004: profile not found #### PATCH /profile update your display name **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | full_name | string (1-120 chars) | yes | display name | **response (200):** ```json { "data": { "id": "uuid", "full_name": "string | null", "avatar_url": "string | null" } } ``` #### POST /billing/portal a Stripe billing-portal session for managing your subscription. falls back to the web billing page when no Stripe customer exists yet. **response (200):** ```json { "data": { "url": "https://billing.stripe.com/session/..." } } ``` ### connections view and disconnect your OAuth platform connections. #### POST /connections/:platform/connect start an OAuth connect for a platform. returns the URL to send the user to, plus the `state` that identifies the flow on return. **query parameters:** | name | type | required | description | |------|------|----------|-------------| | platform | string | yes | platform to connect (path parameter) | **response (200):** ```json { "data": { "auth_url": "https://...", "state": "string" } } ``` **errors:** - 400 SERVER_003: invalid platform #### GET /connections list your OAuth platform connections **query parameters:** | name | type | required | description | |------|------|----------|-------------| | page | integer | no | page number (default: 1) | | page_size | integer (1-100) | no | items per page (default: 50) | **response (200):** ```json { "data": [ { "id": "uuid", "platform": "twitter", "entity_id": "string", "account_info": { "username": "...", "display_name": "..." }, "account_type": "personal", "is_active": true, "inactive_reason": "string | null", "expires_at": "ISO 8601 | null", "created_at": "ISO 8601", "updated_at": "ISO 8601" } ], "pagination": { "page": 1, "page_size": 50, "total": 5, "total_pages": 1 } } ``` **notes:** - connecting a new platform happens via the dashboard OAuth flow; use DELETE to disconnect - `inactive_reason` (1.51.0) says why a connection is inactive, in the words its `connection.expired` webhook carried (on Instagram and Facebook that includes Meta's Graph code, subcode, and message). text for people to read, not a value to branch on: `meta_deauthorize` is the only fixed value. `null` while the connection is live, and on a connection retired before 10/20/2026. - every connection that has not been removed is listed, including dead ones. `is_active` is false once the token is invalidated (the moment the `connection.expired` webhook fires) and true again if the connection recovers (`connection.restored`, within 72 hours). read `is_active` to decide whether a connection works, not `expires_at` - that is the access token's own expiry, which dropspace refreshes routinely. #### DELETE /connections/:platform disconnect an OAuth platform connection (or a single account on it). `:platform` is the `platform` field from GET /connections (e.g. twitter, facebook) **query parameters:** | name | type | required | description | |------|------|----------|-------------| | account_id | string | no | optional query param: disconnect only THIS account (its `entity_id` from GET /connections) instead of the whole platform. supported on instagram/tiktok/youtube/twitter/reddit; disconnecting the primary promotes another connected account to primary so your posting keys stay valid | **response (200):** ```json { "data": { "message": "twitter disconnected successfully" } } ``` **errors:** - 404 PLATFORM_001: connection not found - 500 PLATFORM_004: unable to disconnect - please try again **notes:** - reconnect by re-running the OAuth flow from the dashboard or the in-app connect button - omit `account_id` to disconnect every account on the platform; pass it to remove just one #### PATCH /connections/:platform/profile edit the connected account's profile on the platform. body: `{ "description": "" }`. youtube only today - sets the channel description (max 1,000 characters; an empty string clears it). instagram's api can read a profile but not write it, facebook page "about" needs a permission dropspace does not hold, and tiktok / x / linkedin expose no profile write - those answer 400 with the reason. requires the write scope. **response (200):** ```json { "data": { "platform": "youtube", "description": "weekly drops, behind the scenes, and launch-day streams." } } ``` **errors:** - 404 PLATFORM_001: platform not connected - 400 SERVER_002: platform can't edit the profile (detail in message) **notes:** - the change is made on youtube itself via channels.update (50 quota units); the stored profile in GET /connections is refreshed at the same time. ### dropspace check which official dropspace accounts are connected and available for posting. #### GET /dropspace/status check which official dropspace accounts are connected **response (200):** ```json { "data": { "platforms": [ { "platform": "facebook", "connected": true, "account_name": "dropspace" }, { "platform": "linkedin", "connected": true, "account_name": "dropspace" }, { "platform": "twitter", "connected": true, "account_name": "@dropspace" }, { "platform": "instagram", "connected": true, "account_name": "@dropspace" }, { "platform": "tiktok", "connected": false }, { "platform": "youtube", "connected": false } ], "connected_platforms": ["facebook", "linkedin", "twitter", "instagram"], "timestamp": "ISO 8601" } } ``` **notes:** - always returns all 7 auto-post platforms in canonical order - account_name is only present when connected and account info exists - connected_platforms lists valid values for the dropspace_platforms field when creating launches - checks is_active flag and token expiry from the database - does not perform live health checks ### API keys manage your API keys for authentication. #### GET /keys/me get the current API key's info **response (200):** ```json { "data": { "id": "uuid", "name": "my integration", "key_prefix": "ds_live_abc...", "scopes": ["read", "write", "generate", "send"], "created_at": "ISO 8601" } } ``` **notes:** - no scope required - any valid API key can check its own permissions #### GET /keys list all API keys **response (200):** ```json { "data": [ { "id": "uuid", "name": "my integration", "key_prefix": "ds_live_abc...", "scopes": ["read", "write", "generate", "send"], "last_used_at": "ISO 8601 | null", "revoked_at": "ISO 8601 | null", "created_at": "ISO 8601" } ] } ``` #### POST /keys create a new API key (max 10) **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | name | string (1-100 chars) | yes | key name | | scopes | string[] | no | permission scopes. available: read, write, generate, send, admin. defaults to ['read','write'] when omitted - pass the full list explicitly if the key needs 'generate', 'send', or 'admin'. | **response (201):** ```json { "data": { "key": "ds_live_abc123...", "api_key": { "id": "uuid", "name": "my integration", "key_prefix": "ds_live_abc...", "scopes": ["read", "write", "generate", "send"], "created_at": "ISO 8601" } } } ``` **notes:** - the full key is shown only once - store it securely #### PATCH /keys/:id rename an API key **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | name | string (1-100 chars) | yes | new key name | **response (200):** ```json { "data": { "id": "uuid", "name": "renamed key", "key_prefix": "ds_live_abc...", "last_used_at": "ISO 8601 | null", "revoked_at": "ISO 8601 | null", "created_at": "ISO 8601" } } ``` **errors:** - 404 SERVER_003: api key not found - 409 SERVER_009: an api key with this name already exists #### POST /keys/:id/rotate rotate an API key — issues a replacement and revokes the old one. the new raw key is returned exactly ONCE in this response and is never retrievable again, so store it before the call returns. **response (200):** ```json { "data": { "key": "ds_live_...", "api_key": { "id": "uuid", "name": "string", "key_prefix": "ds_live_abcd", "created_at": "ISO 8601" } } } ``` **notes:** - requires the `admin` scope. - the previous key stops working as soon as this returns. #### DELETE /keys/:id revoke an API key **response:** 204 no content **errors:** - 404 SERVER_003: api key not found ### webhooks manage webhook endpoints for event notifications. #### GET /webhooks list webhook endpoints **response (200):** ```json { "data": [ { "id": "uuid", "url": "https://your-app.com/webhooks", "events": ["launch.completed", "launch.failed", "post.deleted"], "active": true, "created_at": "ISO 8601", "updated_at": "ISO 8601", "consecutive_failures": 0, "disabled_at": null, "disabled_reason": null } ] } ``` **notes:** - after 10 consecutive failed delivery attempts the endpoint is auto-disabled (active: false, disabled_reason set) and the account owner is emailed - re-enable via PATCH /webhooks/:id with { "active": true }. this resets the failure counter #### POST /webhooks create a webhook endpoint (max 10) **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | url | HTTPS URL | yes | webhook delivery URL (public https). a Slack incoming webhook URL (https://hooks.slack.com/services/...) receives each event as a readable Slack message instead of the signed JSON envelope | | events | string[] | yes | event types to subscribe to | **response (201):** ```json { "data": { "id": "uuid", "url": "https://your-app.com/webhooks", "events": ["launch.completed", "launch.failed", "post.deleted"], "secret": "a1b2c3d4...", "active": true, "consecutive_failures": 0, "disabled_at": null, "disabled_reason": null } } ``` **errors:** - 400 SERVER_002: invalid webhook url or events - 400 SERVER_002: maximum 10 webhook endpoints allowed **notes:** - the secret is shown only once - store it securely for signature verification - available events: "launch.completed" (launch finished publishing), "launch.failed" (launch failed to publish), "launch.partial" (launch partially succeeded), "media.ready" (media generation completed), "persona.analyzed" (persona analysis completed), "post.deleted" (a published post was detected as deleted from its platform), "recommendation.created" (new recommendations were generated for the account), "connection.expired" (a connected account's token was invalidated and needs reauth), "connection.restored" (an expired connection is live again after a successful refresh - no reconnect needed) - launch.completed / launch.partial / launch.failed payload: { "event": "launch.completed", "data": { "launch_id": "uuid", "launch_name": "...", "platforms": [ { "platform": "tiktok", "status": "success", "post_url": null, "error_message": null, "pending": false, "publish_mode": "draft", "caption": "the text to paste", "media_type": "video" } ] } } - publish_mode is "direct", or "draft" for a TikTok draft that sits in the creator's inbox until they post it from the TikTok app; caption then carries the composed text and is null otherwise; media_type is "video" or "photo" for a draft (null otherwise): TikTok drops a video draft's caption, so the creator pastes it, and keeps a photo draft's; pending is true while a post is accepted but not yet live, and the event fires again with the final post_url; a TikTok draft that TikTok is still processing is also pending, and the event fires again when it reaches the inbox (pending false), or as launch.partial / launch.failed if TikTok rejects it - post.deleted payload: { "event": "post.deleted", "data": { "launch_id": "uuid", "platform": "twitter", "post_url": "https://...", "post_id": "12345", "deletion_reason": "not_found", "detected_at": "2025-01-15T10:30:00Z" } } - connection.expired payload: { "event": "connection.expired", "data": { "platform": "instagram", "reason": "token_refresh_failed", "detected_at": "2026-02-18T12:00:00.000Z" } } - connection.restored payload: { "event": "connection.restored", "data": { "platform": "instagram", "reason": "token refresh succeeded after an earlier deactivation", "restored_at": "2026-09-08T19:30:00.000Z" } } - dropspace re-tries a failed refresh for 72 hours after connection.expired; a connection still dead after that needs a reconnect - deletion_reason values: not_found, gone, creator_deleted, moderation_removed, account_deleted, spam_filtered #### GET /webhooks/:id get a webhook endpoint **response (200):** ```json { "data": { "id": "uuid", "url": "https://your-app.com/webhooks", "events": ["launch.completed", "launch.failed", "post.deleted"], "active": true, "created_at": "ISO 8601", "updated_at": "ISO 8601", "consecutive_failures": 0, "disabled_at": null, "disabled_reason": null } } ``` **errors:** - 404 SERVER_003: webhook not found #### PATCH /webhooks/:id update url, events, or active status **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | url | HTTPS URL | no | new webhook URL | | events | string[] | no | new event subscriptions | | active | boolean | no | enable/disable endpoint | **errors:** - 404 SERVER_003: webhook not found - 400 SERVER_002: invalid webhook url or events #### DELETE /webhooks/:id delete a webhook endpoint **response:** 204 no content **errors:** - 404 SERVER_003: webhook not found #### POST /webhooks/:id/test send a synthetic webhook.test event to the registered URL **response (200):** ```json { "data": { "success": true, "delivery_id": "uuid", "response_status": 200, "response_body": "OK" } } ``` **errors:** - 404 SERVER_003: webhook not found **notes:** - posts a `webhook.test` event signed with the webhook's secret to the registered URL - returns the receiver's response status and a (truncated) response body for debugging - creates a delivery row that shows up in `/webhooks/:id/deliveries` - 10s timeout: `response_status: 408` indicates the receiver didn't respond in time #### POST /webhooks/:id/rotate-secret rotate the signing secret (new secret shown once) **response (200):** ```json { "data": { "id": "uuid", "secret": "a1b2c3d4..." } } ``` **errors:** - 404 SERVER_003: webhook not found **notes:** - the new secret is shown only once - update your verification code immediately - the old secret becomes invalid immediately after rotation - in-flight webhook deliveries (already queued) use the secret from enqueue time and are not affected #### GET /webhooks/:id/deliveries list delivery attempts with pagination **query parameters:** | name | type | required | description | |------|------|----------|-------------| | page | integer | no | page number (default: 1) | | page_size | integer (1-100) | no | items per page (default: 50) | **response (200):** ```json { "data": [ { "id": "uuid", "event_type": "launch.completed", "status": "delivered|pending|failed", "attempt_count": 1, "response_status": 200, "delivered_at": "ISO 8601", "failed_at": null, "next_retry_at": null, "created_at": "ISO 8601" } ], "pagination": { "page": 1, "page_size": 50, "total": 12, "total_pages": 1 } } ``` **errors:** - 404 SERVER_003: webhook not found **notes:** - `failed_at` is set on terminal failures (delivered_at and failed_at are mutually exclusive, mirroring iris). `next_retry_at` is null when QStash is handling retries opaquely. ### usage check your current plan, billing period, and usage limits. #### GET /usage get plan limits and current usage (supports agent payment auth: x402 + MPP) **response (200):** ```json { "data": { "plan": "starter", "billing_period": { "start": "ISO 8601", "end": "ISO 8601" }, "limits": { "launches_per_month": { "limit": 50, "used": 3, "remaining": 47 }, "ai_images_per_month": { "limit": 100, "used": 12, "remaining": 88 }, "ai_videos_per_month": { "limit": 20, "used": 5, "remaining": 15 }, "personas": { "limit": 10, "used": 2, "remaining": 8 }, "analyses_per_persona": { "limit": 3 }, "regenerations_per_launch": { "limit": 5 } }, "features": { "can_connect_own_accounts": true, "can_post_to_official_accounts": true, "allowed_platforms": ["facebook", "linkedin", "twitter", "reddit", "instagram", "tiktok", "youtube"] } } } ``` **notes:** - limit and remaining can be "unlimited" (string) instead of a number for higher-tier plans - personas is a lifetime limit (not per billing period) - analyses_per_persona and regenerations_per_launch are per-resource limits (no used/remaining tracking) ### credits purchase and manage credit packs for MPP (Stripe) agents. credits let agents prepay for launches in bulk, reducing per-transaction fees. #### GET /credits get credit balance and available packs (supports agent payment auth: x402 + MPP) **response (200):** ```json { "data": { "balance": 7, "packs": [ { "id": "pack_20", "credits": 20, "price_usd": "12.00" } ], "purchase_endpoint": "POST /credits/purchase" } } ``` **notes:** - returns balance 0 for x402 wallets and API key users (credits are MPP-only) - balance is cached in Redis (5-min TTL) with database as source of truth #### POST /credits/purchase purchase a credit pack via MPP payment (supports agent payment auth: x402 + MPP) **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | pack | string | no | credit pack ID to purchase (default: "pack_20") (default: "pack_20") | **response (200):** ```json { "data": { "credits_added": 20, "new_balance": 27, "pack": { "id": "pack_20", "credits": 20, "price_usd": "12.00" } } } ``` **errors:** - 400 SERVER_002: x402 wallets cannot purchase credits (MPP only) - 402 PAYMENT_001: MPP payment required - credential missing or expired - 500 PAYMENT_004: payment processed but credits could not be added (contact support) **notes:** - MPP agents only - x402 wallets are rejected with 400 (crypto has low fees already) - the MPP credential must be for the pack price ($12.00 = 1200 cents), not the per-launch price - credits never expire and are deducted automatically when creating launches - the launch fee is charged once, when the launch is created, and covers the publishing attempt: a launch that ends partial or failed is not refunded. if creating the launch fails after payment, a used credit or free-tier launch is given back automatically; a per-launch x402 or MPP payment is not reversed automatically, so contact support with the request id. - duplicate purchases with the same MPP credential are rejected (idempotent) ### boosts create paid boost campaigns on meta for organic facebook or instagram launches. boosts always start PAUSED. activate them explicitly to begin ad spend. ad budget is billed by meta to the user's connected ad account, not by dropspace. #### POST /launches/{id}/boost create a paid boost on meta for an organic facebook or instagram launch (always PAUSED on create) **request body (JSON):** | name | type | required | description | |------|------|----------|-------------| | platform | "facebook" | "instagram" | no | ad platform: facebook or instagram. IG boost requires the user's IG business account to be linked to one of their connected facebook pages (default: facebook) | | daily_budget_cents | integer (100 - 10000) | yes | daily ad budget in cents ($1 - $100 per day). billed by meta to the user's ad account. | | duration_days | integer (1 - 7) | yes | boost duration in days | | objective | "engagement" | "traffic" | no | engagement optimizes for reactions on the post; traffic optimizes for clicks on the link and needs a facebook link post, one published with a link preview and no photos or video (otherwise 409) (default: engagement) | | stop_rules | { max_cpc_cents?: integer } | no | pause the boost on meta when its average cost per click passes max_cpc_cents (10-10000), checked after 5 clicks, or when spend reaches 3x the cap with no clicks. every running boost also pauses itself when click-through over 1,000+ impressions falls below half its first-day rate. a paused boost resumes with POST /launches/{id}/boost/{boostId}/activate within its original schedule; the cost rules then count only spend and clicks after the resume | | ad_account_id | string | no | explicit meta ad account id (e.g. "act_123"); defaults to the first active account | **response (201):** ```json { "data": { "id": "uuid", "launch_id": "uuid", "platform": "facebook", "post_id": "_", "status": "paused", "objective": "engagement", "daily_budget_cents": 500, "duration_days": 3, "meta_ad_account_id": "act_123", "meta_campaign_id": "1234567890", "meta_adset_id": "1234567891", "meta_ad_id": "1234567892", "impressions": null, "clicks": null, "reach": null, "link_clicks": null, "total_spend_cents": null, "stop_reason": null, "created_at": "ISO 8601", "activated_at": null, "ended_at": null } } ``` **errors:** - 403 ADS_002: facebook ads_management scope not granted; reauthorize with ads scopes - 403 PLATFORM_001: facebook account not connected - 409 ADS_007: launch has no successful facebook post to boost. publish to facebook first - 409 ADS_001: no active facebook ad account found; set one up in meta business manager **notes:** - requires the launch to have a successful facebook post (post_id available in posting_logs). - boost is created in PAUSED status. call POST /launches/{id}/boost/{boost_id}/activate to start spending. - creative reuses the existing organic post via object_story_id, so no creative upload is required. - meta enforces ad spend on the user's ad account. dropspace never holds card data for ad budgets. #### GET /launches/{id}/boost list all boosts attached to a launch **response (200):** ```json { "data": [ { "id": "uuid", "launch_id": "uuid", "status": "active", "daily_budget_cents": 500, "duration_days": 3, "impressions": 4320, "clicks": 87, "reach": 3211, "total_spend_cents": 1230 } ] } ``` #### GET /launches/{id}/boost/{boost_id} fetch a single boost with the latest performance metrics #### POST /launches/{id}/boost/{boost_id}/activate flip a PAUSED boost to ACTIVE on meta. ad spend begins immediately **errors:** - 409 ADS_006: boost is already active - 409 SERVER_009: cannot activate a boost in a terminal status (canceled / completed / failed) **notes:** - no payment required: boost was already paid for at create time. - flips campaign → ad set → ad in order so the ad inherits ACTIVE status correctly. #### DELETE /launches/{id}/boost/{boost_id} cancel a boost: deletes the campaign on meta and soft-deletes the record **notes:** - idempotent: canceling a boost already in a terminal state returns the current row unchanged. - if meta-side DELETE fails, the dropspace row is still soft-deleted (manual cleanup may be needed). ### changelog machine-readable release history - public, no API key required. #### GET /changelog release history newest-first: version, date, type (feature/fix/breaking/docs), summary, and migration notes on breaking entries. public - the one-line X-Notice header on every response is the latest entry's summary; this is the structured history behind it **response (200):** ```json { "success": true, "data": [ { "version": "1.0.0", "date": "2026-07-21", "type": "feature", "summary": "..." } ] } ``` ## agent payments autonomous agents can use the dropspace API without accounts or subscriptions via two payment protocols: x402 (crypto, https://www.x402.org) and MPP (Stripe-based, Machine Payments Protocol). **how it works:** - x402 wallets get 5 free launch creations/month. MPP agents pay per launch or use credits - beyond the free tier, each launch creation costs $0.55 via x402 (crypto) or $0.60 via MPP (card) - the launch fee is charged once, when the launch is created, and covers the publishing attempt: a launch that ends partial or failed is not refunded. if creating the launch fails after payment, a used credit or free-tier launch is given back automatically; a per-launch x402 or MPP payment is not reversed automatically, so contact support with the request id. - x402: pays in USDC on Base (eip155:8453), settled on-chain when needed - MPP: pays via Stripe charge, settled via Payment credential - publishing, reading, and status checks are always free - agent users can only post to official dropspace accounts **supported endpoints:** GET /launches, POST /launches, GET /launches/:id, PATCH /launches/:id, DELETE /launches/:id, POST /launches/:id/publish, GET /launches/:id/status **x402 authentication:** read the PAYMENT-REQUIRED header from the 402, sign it, and replay with a PAYMENT-SIGNATURE header (x402 v2; legacy v1 X-PAYMENT still accepted). the payment proof identifies your wallet. payment is only settled for launch creations beyond the free tier. **MPP authentication:** include an `Authorization: Payment ` header with an MPP credential. if you have no payment proof, the endpoint returns 402 with x402 pricing details and an MPP WWW-Authenticate challenge. **rate limit:** 30 requests/minute per agent identity. **credit packs (MPP agents):** - MPP agents can prepay with credit packs instead of paying per launch - 20 credits for $12.00 ($0.60/credit) - matches Starter plan, lower Stripe fees - `GET /credits` - check balance and available packs - `POST /credits/purchase` - buy a pack with an MPP credential for $12.00 (1200 cents) - credits are deducted automatically on launch creation. no payment credential needed when credits are available - credits never expire. a credit comes back only if creating the launch fails - x402 wallets cannot purchase credits (crypto already has low fees) ## MCP server the dropspace MCP server lets AI agents manage launches, personas, connections, API keys, webhooks, and more. 107 tools (launches, posts, comments, personas, media, connections, boosts, credits, API keys, webhooks, dropspace, usage, account, autopilot, recommendations, reddit engagement, audience). ```bash npx -y -p @jclvsh/dropspace dropspace-mcp ``` the package ships two binaries: `dropspace-mcp` (the MCP server, shown above) and `dropspace` (a CLI for terminal use). set the DROPSPACE_API_KEY environment variable to your API key. recommended scopes for AI agents: read, write, generate, send. agent skill: the package ships skills/dropspace/SKILL.md, the dropspace workflow for agents that read skill files: https://unpkg.com/@jclvsh/dropspace/skills/dropspace/SKILL.md hosted endpoint for clients that connect to a remote MCP server: https://api.dropspace.dev/mcp (MCP streamable HTTP, POST only, Authorization: Bearer ds_live_...). initialize and tools/list answer without a key, so a client can read the tool list before signing up; calling a tool needs the key. it serves 40 of the tools, each annotated read, write, or sensitive; API key, webhook, boost, credit, reddit, media library, comment, destination, disconnect, profile, and billing tools are only in the npm package for now. the class of every hosted tool is listed at https://www.dropspace.dev/docs#mcp-hosted. listed in the official MCP registry (registry.modelcontextprotocol.io) as io.github.JCLVSH/dropspace ## CLI a command-line interface is bundled with the same package: ```bash npm install -g @jclvsh/dropspace dropspace launches list dropspace launches create -t 'my launch' --platforms twitter,reddit --publish dropspace launches analytics dropspace personas list dropspace keys me dropspace --help ``` ## OpenAPI spec OpenAPI 3.1 spec available at: https://api.dropspace.dev/openapi.json ## error codes all errors return: `{ error: { code, message } }` ### authentication | code | HTTP | description | |------|------|-------------| | AUTH_001 | 401 | invalid or revoked API key | | AUTH_002 | 403 | plan restriction (feature not available) | ### launches | code | HTTP | description | |------|------|-------------| | LAUNCH_001 | 404 | launch not found | | LAUNCH_002 | 429 | launch or regeneration limit exceeded | | LAUNCH_003 | 409 | invalid launch status for operation | | LAUNCH_004 | 409 | launch cannot be published from current status | | LAUNCH_007 | 400 | platform requirements not met | ### content moderation | code | HTTP | description | |------|------|-------------| | CONTENT_004 | 400 | content blocked by official-account safety moderation (do not resubmit the same content) | | CONTENT_005 | 503 | content safety check temporarily unavailable (retry with the same content) | ### personas | code | HTTP | description | |------|------|-------------| | PERSONA_001 | 429 | persona creation limit reached | | PERSONA_002 | 404 | persona not found | | PERSONA_003 | 429 | persona build limit reached | ### media | code | HTTP | description | |------|------|-------------| | MEDIA_001 | 429 | monthly media generation limit reached | ### uploads | code | HTTP | description | |------|------|-------------| | UPLOAD_001 | 400 | unsupported media type (allowed: jpeg, png, webp, gif, mp4, mov) | | UPLOAD_002 | 400 | media file too large (images: 5MB, videos: 512MB URL / 4MB base64) | | UPLOAD_003 | 400 | media storage upload failed | ### payment (x402 / mpp) | code | HTTP | description | |------|------|-------------| | PAYMENT_001 | 402 | payment required (free tier exceeded) | | PAYMENT_002 | 402 | payment verification failed | | PAYMENT_003 | 402 | MPP credential verification failed | ### rate limiting | code | HTTP | description | |------|------|-------------| | RATE_001 | 429 | too many requests | ### validation & server | code | HTTP | description | |------|------|-------------| | SERVER_001 | 500 | internal server error | | SERVER_002 | 400 | validation error | | SERVER_003 | 404 | resource not found | | SERVER_004 | 405 | method not allowed | | SERVER_005 | 400 | invalid input format or business rule violation | | SERVER_008 | 500 | database error | | SERVER_009 | 409 | conflict (e.g. status transition race) | ## posting error codes a failed entry in GET /launches/:id/status or /destinations carries error_code, a stable code to branch on, and error_message, text to show a person. both arrive in a 200 response. POST /launches/:id/retry re-runs every failed destination whatever its code, so fix the cause first, and check the platform before retrying a timeout or a partial thread. treat a code not listed here as a generic failure. ### accounts | code | meaning | what to do | |------|---------|------------| | AUTH_ERROR | the platform rejected the account's sign-in. | reconnect the account, then retry. | | TOKEN_EXPIRED | the account's access expired and could not be renewed. | reconnect the account, then retry. | | TOKEN_REFRESH_FAILED | renewing the account's access failed while publishing. error_message says whether the account is still connected. | retry in a few minutes. reconnect the account if error_message says to, or if it keeps failing. | | TOKEN_NOT_FOUND | the account chosen for this destination is no longer connected. | reconnect it, or choose another account. | | NO_ACCESS_TOKEN | the connection exists but holds no usable access. | reconnect the account, then retry. | | INVALID_TOKEN | the Facebook connection used is not a Page connection. | reconnect Facebook and select the Page. | | NO_ACCOUNT | nothing to post from: the launch lists the platform with no user_platform_accounts key and no dropspace official posting, or the chosen account could not be found. | connect the account and put its key in user_platform_accounts (ids come from GET /connections). | | PERMISSION_DENIED | the account has not granted a permission this post needs. | reconnect and grant the requested permissions. | | NOT_ELIGIBLE | the platform refused a post type this account cannot use yet: an Instagram trial reel from an account below Instagram's bar (a professional account with 200 or more followers). | post it as a feed reel, or grow the account past the bar. reconnecting changes nothing. | | SCOPE_NOT_AUTHORIZED | TikTok posting permission was not granted when the account connected. | reconnect TikTok and allow posting. | | NO_ORG_SELECTED | LinkedIn organization posting was requested, but the connection has no organization. | reconnect LinkedIn and pick the company page. | | USER_INFO_ERROR | LinkedIn did not return the member profile needed to post. | reconnect LinkedIn. | | DROPSPACE_TOKEN_ERROR | the dropspace official account for this platform was temporarily unavailable. | retry later. | ### limits | code | meaning | what to do | |------|---------|------------| | RATE_LIMIT | the platform rate-limited the request. | wait, then retry. | | DAILY_POST_CAP | the account reached TikTok's daily posting limit. | retry after the limit resets. | | USER_CAP_REACHED | TikTok's 24-hour cap on active creators for dropspace's app was reached, or Instagram's limit on trial reels published through its API. | retry later. | | RATE_LIMIT_CIRCUIT_BREAK | skipped without posting: an earlier subreddit in this launch hit Reddit's rate limit. | retry this subreddit on its own with POST /launches/:id/retry and { "subreddit": "" }. | ### content | code | meaning | what to do | |------|---------|------------| | NO_CONTENT | the launch has no content for this platform. | add content for it. | | VALIDATION_ERROR | the post broke a platform rule on length, a required field, or a media source. | fix what error_message names before posting again. | | DUPLICATE_CONTENT | the platform says this exact content was already posted. | change the content before posting again. | | CONTENT_RESTRICTION | the platform blocked the content: a banned link, too long, already submitted, or a removed or locked target. | change or remove what error_message names. | | MISSING_COMPANY_CONTENT | LinkedIn company posting was selected, but the company-voice content is empty. | add company content. | | CONTENT_MODERATION_FAILED | moderation blocked a post from a dropspace official account. | revise the content. | | CONTENT_MODERATION_UNAVAILABLE | the moderation check for a dropspace official account post could not run. | retry later. | ### media | code | meaning | what to do | |------|---------|------------| | UPLOAD_FAILED | uploading the media to the platform failed. | check that the file is reachable and within the platform's limits, then retry. | | CAROUSEL_ERROR | fewer than two images of an Instagram carousel uploaded. | replace the images that failed, then retry. | | INVALID_MEDIA_URL | Instagram could not fetch the media URL. | use a publicly reachable URL. | | INVALID_VIDEO_URL | the video URL could not be fetched: by TikTok, which pulls it itself, or by dropspace, which uploads it to LinkedIn and got a 4xx from it. | use a publicly reachable URL. | | INVALID_VIDEO_FORMAT | TikTok rejected the video's format. | re-encode the video (MP4 with H.264 is the safe choice). | | VIDEO_FORMAT_ERROR | Facebook rejected the video's format. | re-encode the video as MP4. | | VIDEO_TOO_LARGE | the video is over Facebook's size limit. | use a smaller file. | | MEDIA_PROCESSING_FAILED | the platform processed the media and rejected it for its format, length, or size. | fix the file. | | PROCESSING_TIMEOUT | the platform was still processing the media when dropspace stopped waiting. | check the platform first: the post may already be live, and a retry can post it twice. | ### platform rules | code | meaning | what to do | |------|---------|------------| | PLATFORM_DISABLED | posting to this platform is turned off in dropspace. | contact support. | | POLICY_BLOCK | Facebook restricted the account, often until its owner confirms their identity. | resolve it in Facebook, then retry. | | USER_BANNED | TikTok temporarily blocked the account from posting. | retry after TikTok lifts the block. | | PRIVATE_ONLY | TikTok only accepted a post to a private account here. | contact support. | | DOMAIN_NOT_VERIFIED | TikTok has not verified the domain dropspace serves media from. | contact support. there is nothing to fix on your side. | | PRIVACY_LEVEL_MISMATCH | the chosen TikTok privacy level is not available for this account. | choose a privacy level the account allows. | | SUBREDDIT_RESTRICTION | the subreddit does not allow this post type (links, text, images, or video). | change the post type or pick another subreddit. | | SUBREDDIT_ACCESS_DENIED | the subreddit does not exist, or the account is not allowed to post there. | pick another subreddit. | | TIKTOK_ | TikTok's own failure reason, such as TIKTOK_FILE_FORMAT_CHECK_FAILED, TIKTOK_DURATION_CHECK_FAILED, or TIKTOK_SPAM_RISK, including TikTok errors dropspace does not map to a named code. | read error_message. a failed media check needs a new file; TIKTOK_INTERNAL is TikTok's outage and can be retried. | | PUBLISH_FAILED | TikTok marked the post failed without giving a reason. | retry. contact support if it keeps happening. | | PROMO_RATIO_EXCEEDED | skipped: posting would break Reddit's 90:10 self-promotion norm for this account in that subreddit. | post there later, or pick another subreddit. | | SUBREDDIT_ALREADY_TARGETED | skipped: another of the creator's Reddit accounts is posting to this subreddit on this launch. | nothing to do. one account posts to each subreddit per launch. | | INVALID_OPTION | Reddit rejected a post option. | adjust what error_message names. | | REDDIT_ | a Reddit error dropspace does not map to a named code, such as REDDIT_BAD_SR_NAME. REDDIT_API_ERROR means Reddit gave no code. | read error_message. | ### delivery | code | meaning | what to do | |------|---------|------------| | PLATFORM_API_ERROR | the platform returned an error with no more specific code. | retry. contact support if it keeps happening. | | INVALID_RESPONSE | Reddit accepted the request but returned no post. | check whether the post appeared before retrying. | | NO_PUBLISH_ID | TikTok accepted the upload request but returned no publish id. | retry. contact support if it keeps happening. | | PARTIAL_THREAD | an X thread failed partway, after some of its posts went out. | check X first: a retry posts the whole thread again from the first post. | | UNKNOWN_ERROR | an unexpected error while posting. | retry. contact support if it keeps happening. | | EXCEPTION | an unexpected error during a posting attempt. | retry. contact support if it keeps happening. | | MAX_RETRIES | every automatic attempt failed without a more specific error. | retry. contact support if it keeps happening. | | TIMEOUT | a request to the platform timed out. | check whether the post appeared before retrying, to avoid a duplicate. | | PROMISE_REJECTED | a retry of this destination failed unexpectedly. | retry. contact support if it keeps happening. | ### deleting posts | code | meaning | what to do | |------|---------|------------| | DELETE_FAILED | the platform refused or failed the delete. | retry, or delete the post on the platform. | | DELETE_NOT_SUPPORTED | the platform does not allow deleting posts through its API. | delete the post on the platform. | ## changelog machine-readable history at `GET /changelog` (public, no key). recent releases: - 1.73.0 (2026-11-09, feature): the hosted MCP endpoint (https://api.dropspace.dev/mcp) answers initialize and tools/list without a key, so an MCP client or a directory can read the server info and the tool list before signing up. calling a tool still needs Authorization: Bearer with an API key, and the key's scopes apply as before. keyless requests are rate limited per IP; any other keyless request gets the same 401. - 1.72.0 (2026-11-09, feature): Pause a scheduled launch: PATCH /launches/{id} with status "paused". The launch keeps its scheduled_date and its accounts, is not published while paused, stays paused through other edits, and reads status "paused" everywhere a launch is returned (GET /launches?status=paused lists them). Send status "scheduled" to resume it; if the scheduled time has passed, send a new scheduled_date in the same request or the resume is refused with 400. Only a scheduled launch can be paused. MCP update_launch and the CLI --status flag take "paused" too. - 1.71.4 (2026-11-09, fix): A YouTube Short is uploaded under the title you sent. " #Shorts" used to be added to it, which took 8 of the 100 title characters; YouTube files a vertical video of up to 3 minutes as a Short from the file. PATCH /launches/{id} with status "cancelled" on a scheduled launch now returns it cancelled with no scheduled_date; it used to come back "scheduled". A request whose Authorization header carries no api key (a bare "Bearer", or a key sent without the Bearer scheme) gets 401 AUTH_001 on every endpoint; on the endpoints that accept x402 or MPP payment it used to get the 402 payment challenge. A request with no Authorization header still gets the 402 there. - 1.71.3 (2026-11-09, fix): A TikTok draft that TikTok is still processing when dropspace stops waiting (about 5 minutes for a video, 7.5 for photos) is now a success, and launch webhooks report it as status "success" with pending true and publish_mode "draft", the way a direct post held for moderation is. GET /launches/{id} shows it as a success with no post_url, as it does a delivered draft. It used to be reported as failed with PROCESSING_TIMEOUT, although TikTok went on to deliver it, and retrying it put a second copy in the inbox. The launch event fires again when the draft reaches the creator's inbox (pending false), or as launch.partial / launch.failed if TikTok rejects it. A launch retry that fails again now ends as failed or partial with the platform's own error; it used to end as failed with "publishing process was interrupted". - 1.71.2 (2026-11-06, fix): A TikTok draft (sent to the creator's inbox) that fails now reads publish_mode "draft" with its media_type ("video" or "photo") in launch.failed and launch.partial, as a successful draft does. It used to read publish_mode "direct" and media_type null. A draft that times out keeps the error PROCESSING_TIMEOUT, "may still be publishing": TikTok may still put it in the inbox. - 1.71.1 (2026-11-03, fix): POST /scene-clips with link_url takes a YouTube video of up to 15 minutes (it was 30) and fetches it at up to 720p; the scan was always the first 15 minutes, and a 9:16 reel crops the middle of the frame, so the clips look the same. A longer link now fails with the error that names the limit; a longer or sharper video goes in as a library upload (up to 512 MB) with media_library_id. MCP (local package 2.52.1) and CLI descriptions say the same. - 1.71.0 (2026-11-02, feature): Lyric reels, part four of song to reels (pro and premium). hook_text (up to 90 characters) is burned in across the whole snippet, boxed across the top (low under caps-top), and opens the launch caption. POST /songs/{id}/reels/rollout makes count (2 to 10) versions of one snippet at once as one rollout: the same lines and footage, the style and then the clip order (or background) changing per version, never two the same; every version is a reel (rollout_id, variant_index, variant_note) that counts against the plan's song reels, answered with 429 MEDIA_001 and the number left when fewer fit. The versions render in turn on the server. POST /song-reels/{id}/keep keeps one version and deletes the others with their media library items (a version with a launch stays); GET /song-reels?rollout_id= lists a rollout. POST /song-reels/{id}/launch takes { trial_reel: true } to make the draft an Instagram trial reel (shown to non-followers first; refused with the reason when the account is not eligible). footage_mode ai generates b-roll: ai_clips (1 to 4, default 2) veo clips of 8 seconds from ai_prompt (or the lyrics), each one of the plan's AI videos (403 on plans without them, 429 MEDIA_001 past the cap); footage_status reads generating, ready, or failed, and the reel renders once the clips land (they join the media library with source ai). A rollout generates its b-roll once and shares it. MCP (local package 2.52.0) and CLI: song-reels rollout, keep, launch --trial, make --hook --ai. - 1.70.0 (2026-11-02, feature): Scene clips, the third part of song to reels (pro and premium): POST /scene-clips cuts a long video into 2 to 8 second clips at its scene changes, up to 40 a job, each with a thumbnail, as footage for lyric reels and autopilot. Give one source: media_library_id (a video in the media library), link_url (a YouTube link, up to 30 minutes), or video_url (a public mp4 or mov, up to 512 MB); only the first 15 minutes are scanned. The scan and cut run on the server, so the job comes back pending; GET /scene-clips/{id} reads it until ready (clips with video_url, thumbnail_url, and media_library_id; reading a ready job adds the clips to the media library with source scene, as many as fit under its 500-item cap, the rest on a later read, with a note saying so) or failed (error). GET /scene-clips lists jobs, DELETE removes one (clips already in the library stay), POST /scene-clips/{id}/retry cuts again (three attempts). At most 10 jobs a day and one running at a time per user (409 SERVER_009 and 429 MEDIA_001). MCP (local package 2.51.0) and CLI: scene-clips.