developer API v1

build on dropspace — create content, generate media, and automate multi-platform publishing from your own apps.

getting started

create an API key

go to settings → API keys in your dropspace dashboard and click create key. give it a name, then copy the key immediately — it is shown only once.

store your key securely. keys begin with ds_live_ and cannot be retrieved after creation. if you lose it, revoke and create a new one.

base URL

https://api.dropspace.dev

all timestamps in API responses use ISO 8601 format in UTC (e.g. 2026-02-18T12:00:00.000Z).

make your first request

curl https://api.dropspace.dev/launches \
  -H "Authorization: Bearer ds_live_YOUR_KEY"

quick example

create a launch with generated content, review it, optionally regenerate, edit, and publish.

step 1 — create a launch

curl -X POST https://api.dropspace.dev/launches \
  -H "Authorization: Bearer ds_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "announcing our new feature",
    "product_url": "https://example.com/feature",
    "product_description": "a blazing-fast widget for your dashboard",
    "platforms": ["linkedin", "twitter", "reddit"]
  }'

step 2 — review and optionally regenerate

# regenerate content for specific platforms:
curl -X POST https://api.dropspace.dev/launches/LAUNCH_ID/generate-content \
  -H "Authorization: Bearer ds_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "platforms": ["twitter"] }'

# or edit content directly via PATCH:
curl -X PATCH https://api.dropspace.dev/launches/LAUNCH_ID \
  -H "Authorization: Bearer ds_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "platform_contents": { "twitter": { "content": "custom tweet..." } } }'

# or use thread array for explicit tweet boundaries:
curl -X PATCH https://api.dropspace.dev/launches/LAUNCH_ID \
  -H "Authorization: Bearer ds_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "platform_contents": { "twitter": { "thread": ["tweet 1", "tweet 2", "tweet 3"] } } }'

step 3 — publish

curl -X POST https://api.dropspace.dev/launches/LAUNCH_ID/publish \
  -H "Authorization: Bearer ds_live_YOUR_KEY"

step 4 — check status

curl https://api.dropspace.dev/launches/LAUNCH_ID/status \
  -H "Authorization: Bearer ds_live_YOUR_KEY"

reddit — targeting subreddits

pass metadata.subreddits (max 10) to post once per subreddit, and metadata.first_comment to add a comment under each post once it goes live. every community has its own promotional rules, and posting where your account has no history gets filtered, so target a few genuinely relevant subreddits rather than blasting.

each subreddit gets its own posting log. read them back from GET /launches/:id/destinations, which names the subredditon every row — /status reports them all as the same platform. retry one community with POST /launches/:id/retry {"subreddit":"webdev"}— the ones that already posted are left alone.

# one post per subreddit. read each destination back from GET /launches/{id}/destinations,
# and retry one with POST /launches/{id}/retry {"subreddit":"webdev"}.
# each community has its own promo rules, so pick a few relevant ones.
curl -X POST https://api.dropspace.dev/launches \
  -H "Authorization: Bearer ds_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "faster dashboard widget",
    "product_description": "a blazing-fast widget for modern dashboards",
    "platforms": ["reddit"],
    "platform_contents": {
      "reddit": {
        "title": "what we learned virtualizing a 10k-row dashboard",
        "content": "we spent six months on render performance. the short version: windowing beat memoization every time.",
        "metadata": {
          "subreddits": ["SideProject", "webdev"],
          "first_comment": "happy to go deeper on the profiling setup if anyone wants it."
        }
      }
    }
  }'

authentication & rate limits

bearer token

every request requires an Authorization header:

Authorization: Bearer ds_live_YOUR_KEY

invalid or revoked keys return 401 with error code AUTH_001.

rate limits

limitvaluescope
general API60 req/minper API key
content generation30 req/minper user
media generation5 req/minper user
video concurrency2 per user / 20 globalconcurrent jobs

when rate-limited you receive a 429 response with a Retry-After header (seconds):

{
  "error": {
    "code": "RATE_001",
    "message": "too many requests. please try again in 12 seconds."
  },
  "retryAfter": 12,
  "limit": 60,
  "remaining": 0
}

response headers

X-Request-ID is set on every response. The X-RateLimit-* headers are set on 429 rate-limited responses, alongside Retry-After.

headerdescription
X-RateLimit-Limitrequests allowed per window
X-RateLimit-Remainingrequests remaining
X-RateLimit-ResetUnix timestamp in milliseconds when window resets (not seconds)
X-Request-IDcorrelation ID for support and Sentry, included on every response
X-Dropspace-Noticeone-line notice of the latest API change, on most v1 responses - agents should surface it when it changes. deprecated operations additionally carry Deprecation and Sunset headers

scopes

api keys can be created with specific scopes to limit their permissions. this provides defense-in-depth — even if a key is compromised, it can only perform operations within its scopes.

available scopes

scopedescription
readview launches, personas, media, and connections
writecreate, update, and soft-delete launches and personas
generateAI content and media generation
sendpublish and retry launches, reply to and moderate comments, deliver webhooks
adminmanage api keys and webhooks

keys created without a scopes list get read and write. the legacy publish and delete scopes were retired on 07/20/2026: use send in place of publish, and write in place of delete.

missing scope error

requests without the required scope receive a 403 response:

{
  "error": {
    "code": "AUTH_002",
    "message": "api key missing required scope: send"
  }
}

recommended scopes by use case

use casescopes
AI agents / MCPread, write, generate
CI/CD publishingread, send
monitoring / dashboardsread
full access (admin tools)all scopes

audit logging

destructive and sensitive operations performed via the API are automatically logged for security auditing.

audited operations

operationdescription
delete launchpermanently removes a launch
delete personapermanently removes a persona
publish launchpublishes content to connected platforms
create api keygenerates a new api key
revoke api keypermanently disables an api key
create webhookregisters a new webhook endpoint
delete webhookremoves a webhook endpoint
rotate webhook secretgenerates a new signing secret for a webhook

idempotency

mutating endpoints (POST /launches, POST /credits/purchase) accept an Idempotency-Key request header. on a timed-out network retry, the second call with the same key returns the cached response without re-executing, critical for agent-pay endpoints where re-execution would double-charge USDC or double-charge a Stripe credit pack.

idempotency is opt-in: requests without the header execute fresh. cached responses ship for 24 hours and only 2xx outcomes are cached (errors re-execute on retry).

curl -X POST https://api.dropspace.dev/launches \
  -H "Authorization: Bearer ds_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: my-unique-key-12345" \
  -d '{"title": "Q2 launch", "platforms": ["twitter"], ...}'

key requirements

  • 1–255 characters, allowlist [A-Za-z0-9._-]
  • scoped per-(api-key, endpoint): same key value across different endpoints is safe
  • invalid keys (too long, bad chars) → 400 SERVER_BAD_REQUEST (don't retry: fix the value)
  • concurrent retry while first call still in-flight → 409 SERVER_CONFLICT with Retry-After: 1

replay detection

replayed responses carry the Idempotent-Replayed: true response header so the client can distinguish a fresh execution from a cached replay.

MCP server

the model context protocol(MCP) lets AI agents interact with external tools. the dropspace MCP server gives agents like Claude Code and Cursor the ability to manage launches, personas, autopilot, API keys, webhooks, and more — 90 tools, all from your editor.

install

install the MCP server
npx -y -p @jclvsh/dropspace dropspace-mcp

hosted endpoint

clients that connect to a remote MCP server over HTTPS can use the hosted endpoint instead of the npm package. it speaks MCP streamable HTTP and authenticates with the same API key, sent in the Authorization: Bearer ds_live_... header. the key's scopes still apply: a key with only the read scope can only call read tools.

https://api.dropspace.dev/mcp

the hosted endpoint serves a subset of the tools, each labeled by what it can do. API key, webhook, boost, credit, reddit, media library, comment, destination, disconnect, profile, and billing tools are only in the npm package for now.

classwhat it doestools
readretrieves information and changes nothingget_audience, get_autopilot, get_batch_launch_analytics, get_changelog, get_dropspace_status, get_launch, get_launch_analytics, get_launch_status, get_media_job, get_persona, get_usage, list_autopilot_review, list_autopilot_runs, list_connections, list_launches, list_personas, list_recommendations
writechanges data inside dropspace onlyanalyze_persona, apply_recommendation, create_persona, discard_persona_analysis, dismiss_recommendation, finalize_persona, generate_content, generate_media, retry_launch_content, update_persona
sensitivepublishes, schedules, or deletes on a connected platform, deletes a launch or persona (no restore), or turns on something that will publishapprove_autopilot_draft, configure_autopilot, create_launch, delete_all_posts, delete_launch, delete_persona, delete_post, publish_launch, reject_autopilot_draft, retry_launch, run_autopilot_now, update_connection_profile, update_launch

available tools

categorytoolscount
launcheslist_launches, get_launch, get_launch_status, get_launch_analytics, get_batch_launch_analytics, create_launch, update_launch, delete_launch, generate_content, retry_launch_content, publish_launch, retry_launch, get_launch_destinations13
postsdelete_post, delete_all_posts2
commentslist_launch_comments, reply_to_comment, hide_comment, delete_comment, list_comment_queue, act_on_comments6
personaslist_personas, get_persona, create_persona, update_persona, delete_persona, analyze_persona, finalize_persona, discard_persona_analysis8
mediagenerate_media, get_media_job, list_media_library, add_to_media_library, update_media_library_item, delete_media_library_item6
connectionslist_connections, disconnect_connection, update_connection_profile3
boostslist_boosts, get_boost, get_boost_performance, create_boost, activate_boost, cancel_boost6
creditsget_credit_balance, purchase_credit_pack2
API keysget_current_api_key, list_api_keys, rename_api_key, create_api_key, revoke_api_key, rotate_api_key6
webhookslist_webhooks, get_webhook, list_webhook_deliveries, create_webhook, update_webhook, delete_webhook, test_webhook, rotate_webhook_secret8
dropspaceget_dropspace_status1
usageget_usage, get_changelog2
accountsave_report, list_reports, get_report, get_profile, update_profile, create_billing_portal_session6
autopilotget_autopilot, list_autopilot_review, list_autopilot_runs, reject_autopilot_draft, configure_autopilot, approve_autopilot_draft, run_autopilot_now7
recommendationslist_recommendations, apply_recommendation, dismiss_recommendation3
reddit engagementsuggest_subreddits1
audienceget_audience, get_trial_reel_analytics, get_best_posting_hours, get_audience_demographics, get_reels_read, generate_reels_read, list_trial_reels, update_trial_reel_program, scan_trial_reels, update_trial_reel10

recommended scopes for AI agents: read, write, generate. add publish if the agent should be able to publish launches directly.

for machine-readable API references, see dropspace.dev/llms.txt (plain text for LLMs) and dropspace.dev/openapi.json (OpenAPI 3.1 spec).

CLI

the dropspace CLI lets you manage launches, personas, and more from your terminal. same API, same tools as the MCP server — just a different interface.

install

npm install -g @jclvsh/dropspace

or run directly with npx: npx @jclvsh/dropspace launches list

commands

groupcommandscount
launcheslist, create, get, update, delete, publish, retry, generate, retry-content, analytics, batch-analytics, status, destinations, delete-post, delete-posts, comments list, comments reply, comments hide, comments delete19
personaslist, create, get, update, delete, analyze, finalize, discard8
mediagenerate, job, library list, library add, library describe, library delete6
connectionslist, disconnect, profile3
autopilotstatus, configure, review, approve, reject, run, runs7
recommendationslist, apply, dismiss3
boostscreate, list, get, activate, cancel, performance6
audiencefollowers and growth across connected accounts1
trial-reelsInstagram trial reel results1
best-hourswhen your Instagram followers are online1
demographicswho your Instagram followers and engaged audience are1
reels-readClaude's read of your recent Instagram reels1
comment-queuelist, hide, dismiss3
trial-reel-programlist, set, scan, approve, approve-all, remove6
reportslist, get, delete3
redditsuggest1
creditsbalance, purchase2
keysme, list, create, rename, revoke, rotate6
webhookslist, create, get, update, delete, test, rotate-secret, deliveries8
statusdropspace official account connections1
usageplan, limits, and billing period1
changelogrecent API changes1
profileget, update2
billingportal1

examples

# list your launches
dropspace launches list

# create and publish in one command
dropspace launches create -t "my launch" --platforms twitter,reddit --publish

# check posting status
dropspace launches status <id>

# get analytics
dropspace launches analytics <id>

# manage personas
dropspace personas list
dropspace personas create -n "my brand voice"

# check your API key
dropspace keys me

# table output
dropspace --format table launches list

run dropspace --help or dropspace launches --help for the full command reference.

workflows

ready-made prompts for an AI app connected to the dropspace MCP server (the local package serves them as MCP prompts; any app can take the text below). each one reads your numbers through the dropspace tools and ends by saving the result to your reports.

instagram audit instagram_audit

where the account stands: growth, who it reaches, what its reels have in common, what to fix first

Audit my Instagram account using the dropspace tools.
1. Call get_audience with include_instagram_insights true and window_days 30: followers, net growth per day, follows vs unfollows, and the share of reach from non-followers.
2. Call get_audience_demographics: who follows and who engages (age, gender, top countries and cities).
3. Call get_reels_read; if there is no read from the last 7 days, call generate_reels_read. Use its findings on what the strong and weak reels share.
4. Call get_best_posting_hours for when followers are online.
Write the audit in markdown with these sections: Where the account stands (numbers, with dates), Who it reaches, What works, What doesn't, The three changes to make this week. Quote numbers exactly as the tools returned them and say when a tool had no data.
Then call save_report with kind "audit" and a title like "Instagram audit - <today's date>".

best-times plan best_times_plan

a weekly posting schedule built from when the followers are online

Build my weekly Instagram posting plan using the dropspace tools.
1. Call get_best_posting_hours with my timezone: the hourly averages, the weekday-by-hour grid, and the busiest hours.
2. Call get_autopilot to see the posting times autopilot uses now, if it is set up.
3. Call list_launches for the last 30 days to see when I actually posted.
Write the plan in markdown: a table of days and times (at most 2 a day, at least 2 hours apart), why each time was picked (followers online at that hour), and what to change from the current schedule. Say plainly if Instagram had no online-followers numbers (accounts under 100 followers) and fall back to 9 am, 1 pm, and 6 pm.
Then call save_report with kind "best_times" and a title like "Posting plan - week of <date>".

winners report winners_report

the posts and trial reels that won lately, what they share, and what to make next

Find my recent winners using the dropspace tools.
1. Call list_launches for the last 30 days, then get_launch_analytics on the posted ones to get views, likes, comments, shares, and saves.
2. Call get_trial_reel_analytics and list_trial_reels for trial reels and their 1, 3, and 7-day results (won or retired).
3. Call get_reels_read for the latest read of the account's reels.
Rank posts by views relative to the account's usual (the median of the posts you read). Write the report in markdown: the top five with their numbers and links, what they share (topic, hook, format, length, posting time), the bottom three and what they share, and three posts to make next. Use only the numbers the tools returned.
Then call save_report with kind "winners" and a title like "Winners - <month>".

agent skill

the npm package ships a skill file that teaches an agent the dropspace workflow: check connections and usage, create a launch, review the copy, publish, read analytics, and ask before anything that spends money. tool names in it are checked against the package on every release.

install for Claude Code

mkdir -p ~/.claude/skills/dropspace && curl -fsSL https://unpkg.com/@jclvsh/dropspace/skills/dropspace/SKILL.md -o ~/.claude/skills/dropspace/SKILL.md

other agents that read skill files (Codex, OpenClaw) take the same file from node_modules/@jclvsh/dropspace/skills/dropspace/SKILL.md or the URL above. pair it with the MCP server or the CLI.

API reference

launches

create, manage, and publish launches across multiple platforms.

GET/launches

list launches with pagination

parameters & response

query parameters

nametypedescription
pageoptional
integerpage number (default: 1)
page_sizeoptional
integer (1-100)items per page (default: 50)
statusoptional
stringfilter by launch status (draft, manual, trigger, scheduled, running, completed, partial, failed, cancelled)

response

200 OK
{
  "data": [
    {
      "id": "uuid",
      "name": "string",
      "status": "draft|manual|trigger|scheduled|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

parameters & response

request body

nametypedescription
titlerequired
string (1-200 chars)launch title
product_descriptionoptional
string (1-2000 chars)product description: required for AI generation unless product_url, custom_content, or platform_contents is provided
platformsrequired
string[] (1-10)target platforms - can be omitted when dropspace_platforms or user_platform_accounts is provided (inferred from their union)
product_urloptional
URLproduct URL: scraped to generate content; satisfies the description requirement on its own
track_linksoptional
booleanswap 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_libraryoptional
booleanadd the launch's uploaded (non-AI) media_assets to the media library, where autopilot can post them. API-key launches only. default false
scheduled_dateoptional
ISO 8601, any offsetschedule 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_idoptional
UUIDwriting style persona to use
dropspace_platformsoptional
string[]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_modeoptional
"handle" | "anonymous"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_accountsoptional
objectmap 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:<account_id>" / "tiktok:<account_id>" / "youtube:<account_id>" / "twitter:<account_id>" / "reddit:<account_id>" (the account's entity_id from GET /connections); LinkedIn uses "linkedin:personal" or "linkedin:organization:<org_id>". a Facebook post goes to a PAGE: "facebook:page:<page_id>" 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
mediaoptional
arrayinline 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_assetsoptional
arraypre-uploaded media objects (id, url, type, filename, size, mime_type). mutually exclusive with media
media_attach_platformsoptional
string[]platforms to attach media to (subset of platforms). auto-inferred from selected platforms when using media
media_modeoptional
"images" | "video"media mode for the launch. auto-inferred from media types when using media
platform_contentsoptional
objectpre-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": "<name>" }`; `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_contentoptional
string | string[]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_titleoptional
string (max 300 chars)reddit post title - required when using custom_content with reddit in platforms
publishoptional
booleanimmediately publish after creation. returns 202 instead of 201. mutually exclusive with scheduled_date. requires both `write` and `send` scopes (API key auth).
waitoptional
booleanwait 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 Created
// 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

statuscodedescription
400SERVER_002validation error
400UPLOAD_001unsupported media type
400UPLOAD_002media file too large
400UPLOAD_003media storage upload failed
400LAUNCH_007platform requirements not met (e.g. Instagram/TikTok need media)
404SERVER_003persona not found
429LAUNCH_002plan launch limit reached
429RATE_001content generation rate limit
400CONTENT_004content blocked by official-account safety moderation (genuine rejection: do not resubmit the same content)
503CONTENT_005content 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": "<name>" }`. **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:<account_id>"].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

parameters & response

response

200 OK
{
  "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

statuscodedescription
404LAUNCH_001launch not found
PATCH/launches/:id

update a draft/scheduled/cancelled launch

parameters & response

request body

nametypedescription
scheduled_dateoptional
ISO 8601, any offset | nullschedule 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`
statusoptional
"draft" | "manual" | "trigger" | "scheduled" | "cancelled"update launch status
platformsoptional
string[] (1-10)target platforms for this launch
nameoptional
stringlaunch name/title (max 200 chars)
product_descriptionoptional
stringproduct description (max 10,000 chars)
product_urloptional
stringproduct URL (empty string to clear)
persona_idoptional
string | nullpersona ID for content generation, null to clear
platform_contentsoptional
objectper-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_accountsoptional
objectmap 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:<account_id>" / "tiktok:<account_id>" / "youtube:<account_id>" / "twitter:<account_id>" / "reddit:<account_id>" (the account's entity_id from GET /connections); LinkedIn uses "linkedin:personal" or "linkedin:organization:<org_id>". a Facebook post goes to a PAGE: "facebook:page:<page_id>" 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_platformsoptional
string[]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_modeoptional
"handle" | "anonymous"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
mediaoptional
arrayinline media upload - same format as create. replaces existing media. mutually exclusive with media_assets
media_assetsoptional
arraypre-uploaded media objects. replaces existing media. mutually exclusive with media
media_attach_platformsoptional
string[]platforms to attach media to. auto-inferred when using media
media_modeoptional
"images" | "video"media mode. auto-inferred when using media

errors

statuscodedescription
400UPLOAD_001unsupported media type
400UPLOAD_002media file too large
400UPLOAD_003media storage upload failed
404LAUNCH_001launch not found
409LAUNCH_003cannot 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"
  • 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)

parameters & response

errors

statuscodedescription
409LAUNCH_003cannot delete a launch that is currently running
404LAUNCH_001launch not found
POST/launches/:id/publish

queue launch for publishing (async)

parameters & response

response

202 Accepted
{ "data": { "message": "publish queued" } }

errors

statuscodedescription
404LAUNCH_001launch not found
422LAUNCH_007launch has no platforms configured for posting
400LAUNCH_007platform requirements not met - see errors array for details
409LAUNCH_004launch is not in a publishable state or already publishing
403AUTH_002plan 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": "<name>" }` 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": "<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.

parameters & response

response

202 Accepted
{
  "data": {
    "message": "retry queued",
    "platforms": ["reddit", "tiktok"]
  }
}

errors

statuscodedescription
404LAUNCH_001launch not found
400LAUNCH_003no failed platforms to retry
409LAUNCH_004launch is already running
POST/launches/:id/retry-content

retry content generation for failed platforms

parameters & response

request body

nametypedescription
platformsoptional
string[]filter to specific platforms

response

200 OK
{
  "data": {
    "retried": ["twitter", "reddit"],
    "succeeded": ["twitter"],
    "still_failing": ["reddit"],
    "rate_limited": []
  }
}

errors

statuscodedescription
400SERVER_002no failed platforms to retry
404LAUNCH_001launch not found
429LAUNCH_002per-launch regeneration limit reached
429RATE_001content generation rate limit
POST/launches/:id/generate-content

regenerate AI content for all or specific platforms

parameters & response

request body

nametypedescription
platformsoptional
string[] (1-10)platforms to regenerate (defaults to all)
generate_video_scriptsoptional
["instagram", "tiktok"] subsetgenerate video scripts for these platforms
linkedin_versionoptional
"personal" | "company"which LinkedIn voice to regenerate - personal profile or company page (default: personal)

response

200 OK
{
  "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

statuscodedescription
400SERVER_002no product_description or platforms
404LAUNCH_001launch not found
409LAUNCH_003launch is running/completed/partial
429RATE_001content generation rate limit
503SERVER_001content 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

parameters & response

response

200 OK
{
  "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

statuscodedescription
404LAUNCH_001launch 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": "<name>" }` to retry a single community.

parameters & response

response

200 OK
{
  "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

statuscodedescription
404LAUNCH_001launch not found
GET/launches/:id/analytics

publishing analytics with per-post engagement metrics (live refresh)

parameters & response

response

200 OK
{
  "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

statuscodedescription
404LAUNCH_001launch 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

parameters & response

response

200 OK
{
  "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

statuscodedescription
401AUTH_001missing or invalid api key
403AUTH_002partner 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

parameters & response

request body

nametypedescription
actionrequired
stringhide (Instagram only, send scope) or dismiss (write scope): dismiss means seen, nothing to do; nothing changes on the platform
commentsrequired
array1-50 items of { platform, post_id, comment_id }, as GET /comments/queue returns them

response

200 OK
{
  "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

statuscodedescription
400SERVER_002invalid json body, unknown action, or more than 50 comments
401AUTH_001missing or invalid api key
403AUTH_002missing 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)

parameters & response

query parameters

nametypedescription
post_idoptional
stringread one post's comments only (required when paginating with cursor)
platformoptional
stringonly posts on this platform (e.g. youtube)
cursoroptional
stringopaque next_cursor from a previous page - paginates ONE post, so post_id is required with it
limitoptional
numbercomments per post, 1-100 (default 25)

response

200 OK
{
  "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

statuscodedescription
400SERVER_002cursor without post_id, or invalid params
404LAUNCH_001launch 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": "<platform post id>", "comment_id": "<comment id from GET comments>", "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.

parameters & response

response

201 Created
{
  "data": {
    "reply_id": "UgzXK..."
  }
}

errors

statuscodedescription
404LAUNCH_001launch not found, or post_id not on this launch
400SERVER_002platform 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": "<platform post id>", "comment_id": "<comment id from GET comments>", "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.

parameters & response

response

200 OK
{
  "data": {
    "comment_id": "17895695668004550",
    "hidden": true
  }
}

errors

statuscodedescription
404LAUNCH_001launch not found, or post_id not on this launch
400SERVER_002platform 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.

parameters & response

query parameters

nametypedescription
post_idrequired
stringthe platform post id the comment lives on (from GET /launches/:id/comments)

response

200 OK
{
  "data": {
    "comment_id": "17895695668004550",
    "deleted": true
  }
}

errors

statuscodedescription
404LAUNCH_001launch not found, or post_id not on this launch
400SERVER_002platform 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)

parameters & response

query parameters

nametypedescription
idsrequired
stringcomma-separated launch UUIDs (max 100)

response

200 OK
{
  "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

statuscodedescription
400SERVER_002ids parameter missing or empty
400SERVER_002more than 100 IDs provided
400SERVER_005all 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

parameters & response

response

200 OK
{
  "data": {
    "success": true,
    "platform": "twitter",
    "post_id": "1234567890",
    "deleted_at": "ISO 8601"
  }
}

errors

statuscodedescription
404LAUNCH_001launch not found
404SERVER_003posting log not found or not deletable
200DELETE_NOT_SUPPORTEDplatform 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

parameters & response

response

200 OK
{
  "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

statuscodedescription
404LAUNCH_001launch 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

parameters & response

query parameters

nametypedescription
window_daysoptional
integergrowth window in days (default 30, max 365)
includeoptional
stringinstagram_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 OK
{
  "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

statuscodedescription
401AUTH_001missing 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

parameters & response

query parameters

nametypedescription
timeframeoptional
stringperiod 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 OK
{
  "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

statuscodedescription
400SERVER_002timeframe is not this_week, this_month, or last_90_days
401AUTH_001missing or invalid api key
403AUTH_002partner 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

parameters & response

query parameters

nametypedescription
timezoneoptional
stringIANA 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 OK
{
  "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

statuscodedescription
400SERVER_002timezone is not an IANA timezone
401AUTH_001missing or invalid api key
403AUTH_002partner 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)

parameters & response

response

200 OK
{
  "data": {
    "accounts": [
      {
        "connection_id": "3b9c1f0e-2d4a-4e8b-9c7d-6f5e4d3c2b1a",
        "username": "spin.sensei.app",
                "next_refresh_at": null
      }
    ]
  }
}

errors

statuscodedescription
401AUTH_001missing or invalid api key
403AUTH_002plan 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)

parameters & response

request body

nametypedescription
connection_idoptional
stringthe Instagram connection to read (from GET /analytics/reels-read or GET /connections); default: the first connected Instagram account

response

200 OK
{
  "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

statuscodedescription
400SERVER_002invalid json body, or connection_id is not a uuid
401AUTH_001missing or invalid api key
403AUTH_002plan 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

parameters & response

response

200 OK
{
  "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

statuscodedescription
401AUTH_001missing or invalid api key
403AUTH_002partner 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

parameters & response

response

200 OK
{
  "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://<project>.supabase.co/storage/v1/object/public/generated-media/<user>/trial-reels/<item>/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

statuscodedescription
401AUTH_001missing or invalid api key
403AUTH_002not 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

parameters & response

request body

nametypedescription
enabledoptional
booleanturn 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_approveoptional
booleanpost 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_dayoptional
integertrial reels a day, 1-3, one per posting hour (default: 1)
skip_caption_termsoptional
arrayup to 20 words or phrases (50 characters each); a reel whose caption contains one is never queued
timezoneoptional
stringIANA timezone the posting hours are in, e.g. America/New_York (default: UTC)
edit_kindsoptional
arraykinds 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 OK
{
  "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

statuscodedescription
400SERVER_002invalid body, unknown timezone, or the account can't post trial reels
401AUTH_001missing or invalid api key
403AUTH_002missing the send scope (enabled or auto_approve true), or not on pro or premium
404SERVER_003no 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

parameters & response

response

200 OK
{
  "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

statuscodedescription
400SERVER_002Instagram refused the read (reconnect the account)
401AUTH_001missing or invalid api key
403AUTH_002not on the pro or premium plan
404SERVER_003no program for this connection (save one with PUT first)
409SERVER_009a scan for this account is already running
429RATE_001scanned 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

parameters & response

response

200 OK
{
  "data": { "approved": 6 }
}

errors

statuscodedescription
401AUTH_001missing or invalid api key
403AUTH_002missing the send scope, or not on pro or premium
404SERVER_003no 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

parameters & response

request body

nametypedescription
approvedoptional
booleantrue lets it post at the next posting hour (send scope); false holds it
captionoptional
stringthe caption the trial reel posts with, up to 2,200 characters; an approved reel needs one
kindoptional
stringreshare, 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 OK
{
  "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

statuscodedescription
400SERVER_002nothing to change, caption too long, or approving with no caption
401AUTH_001missing or invalid api key
403AUTH_002missing the send scope (approved: true), or not on pro or premium
404SERVER_003trial reel not found
409SERVER_009the 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

parameters & response

response

200 OK
{
  "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

statuscodedescription
401AUTH_001missing or invalid api key
403AUTH_002not on the pro or premium plan
404SERVER_003trial reel not found
409SERVER_009the reel is no longer waiting

notes

  • a removed reel is never queued again by a scan.

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

parameters & response

query parameters

nametypedescription
pageoptional
integerpage number (default: 1)
page_sizeoptional
integerreports per page, 1-100 (default: 50)

response

200 OK
{
  "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

statuscodedescription
401AUTH_001missing or invalid api key
POST/reports

save a markdown report

parameters & response

request body

nametypedescription
titlerequired
string1-200 characters
bodyrequired
stringthe report in markdown, up to 100,000 characters
kindoptional
stringaudit, best_times, winners, or other (default: other)
sourceoptional
stringmcp (saved by an AI app through the MCP server) or api (default: api)

response

201 Created
{
  "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

statuscodedescription
400SERVER_002invalid json, missing title or body, or too long
401AUTH_001missing or invalid api key
403AUTH_002missing the write scope
409SERVER_009200 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

parameters & response

response

200 OK
{
  "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

statuscodedescription
401AUTH_001missing or invalid api key
404SERVER_003report not found
DELETE/reports/:id

delete a saved report

parameters & response

response

200 OK
{
  "data": { "id": "4c1e8f2a-7b3d-4e5f-9a0b-1c2d3e4f5a6b", "deleted": true }
}

errors

statuscodedescription
401AUTH_001missing or invalid api key
403AUTH_002missing the write scope
404SERVER_003report 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.

parameters & response

request body

nametypedescription
briefrequired
string (10-2000 chars)what your product is and who it's for

response

200 OK
{
  "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

statuscodedescription
400PLATFORM_001api key's account has no reddit connection
400SERVER_002brief 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

parameters & response

query parameters

nametypedescription
pageoptional
integerpage number (default: 1)
page_sizeoptional
integer (1-100)items per page (default: 50)

response

200 OK
{
  "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

parameters & response

request body

nametypedescription
itemsrequired
object[] (1-20)each: url (public http(s)), type (image | video), and optional description (max 500), thumbnail_url, mime_type, width, height, size_bytes, filename

response

201 Created
{
  "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

parameters & response

request body

nametypedescription
descriptionrequired
string (max 500) | nullwhat the media shows

response

200 OK
{
  "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

parameters & response

response

200 OK
{
  "data": { "id": "uuid", "deleted": true }
}

personas

manage AI writing personas for content generation.

GET/personas

list personas with pagination

parameters & response

query parameters

nametypedescription
pageoptional
integerpage number (default: 1)
page_sizeoptional
integer (1-100)items per page (default: 50)

response

200 OK
{
  "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

parameters & response

request body

nametypedescription
namerequired
string (1-100 chars)persona name

errors

statuscodedescription
409SERVER_009duplicate persona name
429PERSONA_001persona creation limit reached
GET/personas/:id

get persona with all writing samples

parameters & response

errors

statuscodedescription
404PERSONA_002persona 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

parameters & response

request body

nametypedescription
nameoptional
string (1-100 chars)persona name
custom_samplesoptional
string[] (max 50)plain-text writing samples
twitter_samplesoptional
object[] (max 50)Twitter writing samples. each item carries its prose in `text`; a plain string is accepted and read as the prose itself
reddit_samplesoptional
object[] (max 50)Reddit writing samples. each item carries its prose in `body`; a plain string is accepted and read as the prose itself
facebook_samplesoptional
object[] (max 50)Facebook writing samples. each item carries its prose in `message`; a plain string is accepted and read as the prose itself
instagram_samplesoptional
object[] (max 50)Instagram writing samples. each item carries its prose in `caption`; a plain string is accepted and read as the prose itself
tiktok_samplesoptional
object[] (max 50)TikTok writing samples. each item carries its prose in `description`; a plain string is accepted and read as the prose itself
linkedin_samplesoptional
object[] (max 50)LinkedIn writing samples. each item carries its prose in `commentary`; a plain string is accepted and read as the prose itself
brand_kitoptional
object | nullhow 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

statuscodedescription
404PERSONA_002persona not found
409SERVER_009duplicate persona name
DELETE/personas/:id

delete a persona

parameters & response

errors

statuscodedescription
404PERSONA_002persona not found
422SERVER_005persona in use by launches

notes

  • cannot delete if used by any launches
POST/personas/:id/analyze

trigger AI persona analysis (async)

parameters & response

request body

nametypedescription
platformsoptional
string[]which platforms to analyze. omit to analyze every platform this persona has samples for; pass [] for none
include_custom_samplesoptional
booleaninclude custom_samples in the analysis (default: false)

response

202 Accepted
{ "data": { "started": true, "persona_id": "uuid" } }

errors

statuscodedescription
404PERSONA_002persona not found
409SERVER_009already building
429PERSONA_003persona 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

parameters & response

request body

nametypedescription
persona_analysisoptional
string (max 2000)optionally edited analysis text (defaults to the pending analysis)
persona_analysis_structuredoptional
objectoptionally edited structured analysis

response

200 OK
{ "data": { "success": true, "analysis_limit": { "used": 1, "limit": 3, "remaining": 2 } } }

errors

statuscodedescription
400PERSONA_004persona is not ready for review
404PERSONA_002persona 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)

parameters & response

response

200 OK
{ "data": { "success": true } }

errors

statuscodedescription
400PERSONA_004persona is not ready for review
404PERSONA_002persona 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

parameters & response

response

200 OK
{
  "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)

parameters & response

request body

nametypedescription
enabledoptional
booleanturn nightly runs on/off
modeoptional
string"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)
platformsoptional
array of stringsplatforms to post on - each needs a connected account. valid: facebook, linkedin, twitter, reddit, instagram, tiktok, youtube
formatsoptional
array of strings"text", "visual" (AI image slides), and/or "video" (default: ["text"])
persona_idoptional
uuid | nullvoice persona for drafts
posting_timesoptional
object{ "times": ["HH:MM"], "days": [0-6] } in the config timezone (0 = sunday)
run_houroptional
integer (0-23)local hour the nightly run fires (default: 2)
timezoneoptional
string (IANA)timezone for posting times and runs (default: America/New_York)
product_urloptional
string | nullproduct link included in posts
product_image_urloptional
string | nullreference 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_briefoptional
string (max 2000) | nullwhat you're building, who it's for
content_pillarsoptional
array of strings (max 10) | nullthemes the engine rotates across
directivesoptional
array of strings (max 10, each max 200 chars) | nullstanding per-post rules the planner honors in every draft (e.g. "no emojis", "always end with a question") - null clears them
min_queueoptional
integer (1-30)ideas to keep queued (default: 7)
max_posts_per_dayoptional
integer (1-10)daily posting cap (default: 4)
track_linksoptional
booleanwrap the post's outbound product link in a dropspace-owned tracked redirect, so click-throughs count toward what autopilot learns from (default: false)
reuse_mediaoptional
booleanreuse 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_sourceoptional
"ai" | "library" | "mixed"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_settingsoptional
objectper-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

statuscodedescription
400SERVER_002invalid timezone or no usable connected account for a platform
403BILLING_006plan 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)

parameters & response

query parameters

nametypedescription
pageoptional
integerpage number (default: 1)
page_sizeoptional
integer (1-50)items per page (default: 20)

response

200 OK
{
  "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

parameters & response

response

200 OK
{
  "data": { "launch_id": "uuid", "status": "scheduled", "scheduled_date": "ISO 8601" }
}

errors

statuscodedescription
404SERVER_003draft 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

parameters & response

request body

nametypedescription
feedbackoptional
string (max 500)what to avoid next time - feeds future generation

response

200 OK
{
  "data": { "launch_id": "uuid", "rejected": true }
}

errors

statuscodedescription
404SERVER_003draft not found
POST/autopilot/run

trigger a manual run now (instead of waiting for the nightly run)

parameters & response

response

202 Accepted
{
  "data": { "queued": true, "run_date": "2026-06-12" }
}

errors

statuscodedescription
400SERVER_002autopilot is not enabled
403BILLING_006plan does not include autopilot
404SERVER_003autopilot is not set up yet
429RATE_001manual 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)

parameters & response

query parameters

nametypedescription
limitoptional
integer (1-30)rows to return (default: 9)

response

200 OK
{
  "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

parameters & response

query parameters

nametypedescription
pageoptional
integerpage number (default: 1)
page_sizeoptional
integer (1-100)results per page (default: 20)
statusoptional
"pending" | "applied" | "dismissed" | "expired"filter by status
launch_idoptional
UUIDonly recommendations for this launch

response

200 OK
{
  "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)

parameters & response

request body

nametypedescription
ad_account_idoptional
stringmeta ad account id: required for boost_winner recommendations, ignored otherwise

response

200 OK
{
  "success": true,
  "data": {
    "recommendation": { "id": "uuid", "rec_type": "repost_winner", "status": "applied", "...": "..." },
    "outcome": { "kind": "launch_draft", "launch_id": "uuid" }
  }
}

errors

statuscodedescription
400SERVER_002missing ad_account_id for a boost recommendation
404SERVER_003recommendation not found or not owned
409SERVER_009already 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

parameters & response

response

200 OK
{
  "success": true,
  "data": { "id": "uuid", "rec_type": "repost_winner", "status": "dismissed", "...": "..." }
}

errors

statuscodedescription
404SERVER_003recommendation not found or not owned
409SERVER_009recommendation is already applied or dismissed

media

generate AI images and videos for your launches.

POST/media/generate

submit an image or video generation job

parameters & response

request body

nametypedescription
typerequired
"image" | "video" | "script_video"generation type
launch_idrequired
UUIDassociated launch
platformrequired
stringtarget platform for the asset - always required, any supported platform. script_video only accepts "instagram" or "tiktok"
promptoptional
stringgeneration 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_descriptionoptional
stringproduct context - used to derive a prompt when prompt is omitted
options.aspect_ratiooptional
"1:1" | "16:9" | "9:16" | "4:3" | "3:4" | "3:2" | "2:3" | "5:4" | "4:5" | "21:9"aspect ratio (image supports all 10; video/script_video only supports 16:9 and 9:16)
options.duration_secondsoptional
4 | 6 | 8video duration (default: 8)
options.seedoptional
numbergeneration 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_promptoptional
stringthings to steer the model away from - video generation only (the image model does not support negative prompts; ignored for image jobs)
reference_image_urloptional
URLURL of a reference image for style/composition guidance (image type only, uses edit endpoint)
use_product_imageoptional
booleanuse 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 Accepted
{
  "data": {
    "status": "processing",
    "job_id": "uuid",
    "fal_request_id": "string",
    "generation_type": "image",
    "usage": { "used": 3, "limit": 50, "remaining": 47 },
    "plan": "pro"
  }
}

errors

statuscodedescription
403AUTH_002plan doesn't include media generation
404LAUNCH_001launch not found or not owned
429MEDIA_001monthly media generation limit reached
429RATE_001concurrency 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

parameters & response

response

200 OK
{
  "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

statuscodedescription
404SERVER_003media 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

parameters & response

response

200 OK
{
  "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

statuscodedescription
404SERVER_004profile not found
PATCH/profile

update your display name

parameters & response

request body

nametypedescription
full_namerequired
string (1-120 chars)display name

response

200 OK
{ "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.

parameters & response

response

200 OK
{ "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.

parameters & response

query parameters

nametypedescription
platformrequired
stringplatform to connect (path parameter)

response

200 OK
{ "data": { "auth_url": "https://...", "state": "string" } }

errors

statuscodedescription
400SERVER_003invalid platform
GET/connections

list your OAuth platform connections

parameters & response

query parameters

nametypedescription
pageoptional
integerpage number (default: 1)
page_sizeoptional
integer (1-100)items per page (default: 50)

response

200 OK
{
  "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)

parameters & response

query parameters

nametypedescription
account_idoptional
stringoptional 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 OK
{
  "data": { "message": "twitter disconnected successfully" }
}

errors

statuscodedescription
404PLATFORM_001connection not found
500PLATFORM_004unable 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": "<text>" }`. 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.

parameters & response

response

200 OK
{
  "data": {
    "platform": "youtube",
    "description": "weekly drops, behind the scenes, and launch-day streams."
  }
}

errors

statuscodedescription
404PLATFORM_001platform not connected
400SERVER_002platform 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

parameters & response

response

200 OK
{
  "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

parameters & response

response

200 OK
{
  "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

parameters & response

response

200 OK
{
  "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)

parameters & response

request body

nametypedescription
namerequired
string (1-100 chars)key name
scopesoptional
string[]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 Created
{
  "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

parameters & response

request body

nametypedescription
namerequired
string (1-100 chars)new key name

response

200 OK
{
  "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

statuscodedescription
404SERVER_003api key not found
409SERVER_009an 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.

parameters & response

response

200 OK
{
  "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

parameters & response

errors

statuscodedescription
404SERVER_003api key not found

webhooks

manage webhook endpoints for event notifications.

GET/webhooks

list webhook endpoints

parameters & response

response

200 OK
{
  "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)

parameters & response

request body

nametypedescription
urlrequired
HTTPS URLwebhook 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
eventsrequired
string[]event types to subscribe to

response

201 Created
{
  "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

statuscodedescription
400SERVER_002invalid webhook url or events
400SERVER_002maximum 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
  • 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

parameters & response

response

200 OK
{
  "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

statuscodedescription
404SERVER_003webhook not found
PATCH/webhooks/:id

update url, events, or active status

parameters & response

request body

nametypedescription
urloptional
HTTPS URLnew webhook URL
eventsoptional
string[]new event subscriptions
activeoptional
booleanenable/disable endpoint

errors

statuscodedescription
404SERVER_003webhook not found
400SERVER_002invalid webhook url or events
DELETE/webhooks/:id

delete a webhook endpoint

parameters & response

errors

statuscodedescription
404SERVER_003webhook not found
POST/webhooks/:id/test

send a synthetic webhook.test event to the registered URL

parameters & response

response

200 OK
{
  "data": {
    "success": true,
    "delivery_id": "uuid",
    "response_status": 200,
    "response_body": "OK"
  }
}

errors

statuscodedescription
404SERVER_003webhook 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)

parameters & response

response

200 OK
{
  "data": {
    "id": "uuid",
    "secret": "a1b2c3d4..."
  }
}

errors

statuscodedescription
404SERVER_003webhook 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

parameters & response

query parameters

nametypedescription
pageoptional
integerpage number (default: 1)
page_sizeoptional
integer (1-100)items per page (default: 50)

response

200 OK
{
  "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

statuscodedescription
404SERVER_003webhook 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

parameters & response

response

200 OK
{
  "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

parameters & response

response

200 OK
{
  "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

parameters & response

request body

nametypedescription
packoptional
stringcredit pack ID to purchase (default: "pack_20") (default: "pack_20")

response

200 OK
{
  "data": {
    "credits_added": 20,
    "new_balance": 27,
    "pack": { "id": "pack_20", "credits": 20, "price_usd": "12.00" }
  }
}

errors

statuscodedescription
400SERVER_002x402 wallets cannot purchase credits (MPP only)
402PAYMENT_001MPP payment required - credential missing or expired
500PAYMENT_004payment 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)

parameters & response

request body

nametypedescription
platformoptional
"facebook" | "instagram"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_centsrequired
integer (100 - 10000)daily ad budget in cents ($1 - $100 per day). billed by meta to the user's ad account.
duration_daysrequired
integer (1 - 7)boost duration in days
objectiveoptional
"engagement" | "traffic"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_rulesoptional
{ max_cpc_cents?: integer }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_idoptional
stringexplicit meta ad account id (e.g. "act_123"); defaults to the first active account

response

201 Created
{
  "data": {
    "id": "uuid",
    "launch_id": "uuid",
    "platform": "facebook",
    "post_id": "<page_id>_<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

statuscodedescription
403ADS_002facebook ads_management scope not granted; reauthorize with ads scopes
403PLATFORM_001facebook account not connected
409ADS_007launch has no successful facebook post to boost. publish to facebook first
409ADS_001no 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

parameters & response

response

200 OK
{
  "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

parameters & response

errors

statuscodedescription
409ADS_006boost is already active
409SERVER_009cannot 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

parameters & response

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

parameters & response

response

200 OK
{
  "success": true,
  "data": [
    { "version": "1.0.0", "date": "2026-07-21", "type": "feature", "summary": "..." }
  ]
}

webhook delivery

a Slack incoming webhook URL, one starting with https://hooks.slack.com/services/, receives each event as a readable message in that channel, with links to the launch and its posts. every other URL receives the signed JSON envelope below.

event types

eventfired when
launch.completedall platforms posted successfully. each platform carries publish_mode ("direct", or "draft" for a TikTok draft that sits in the creator's inbox until they post it from the app) and, for a draft, caption and media_type ("video" or "photo"; null for a direct post): 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; the event fires again with the final post_url
launch.failedall platforms failed
launch.partialsome platforms succeeded, some failed
media.readyimage or video generation completed
persona.analyzedAI persona analysis finished
post.deleteda published post was detected as deleted from its platform
connection.expireda connected account's token was invalidated (revoked or unrefreshable) and needs reauth
connection.restoreda connection that had expired is live again: dropspace re-tries the failed refresh for 72 hours and reactivates the account when the platform accepts it, so no reconnect is needed
recommendation.creatednew recommendations were generated for the account (GET /recommendations lists them)

payload format

every delivery wraps the event-specific data in an envelope:

{
  "id": "evt_a1b2c3d4e5f6...",
  "event": "launch.completed",
  "timestamp": "2026-02-18T12:00:00.000Z",
  "created_at": "2026-02-18T12:00:00.000Z",
  "data": { ... }
}

event payloads

launch.completed / launch.failed / launch.partial

{
  "launch_id": "uuid",
  "launch_name": "announcing our new feature",
  "platforms": [
    { "platform": "twitter", "status": "success", "post_url": "https://x.com/..." },
    { "platform": "reddit", "status": "failed", "error_message": "rate limited" }
  ]
}

media.ready

{
  "generation_type": "image",
  "launch_id": "uuid",
  "platform": "instagram"
}

persona.analyzed

{
  "persona_id": "uuid",
  "persona_name": "my brand voice",
  "samples_analyzed": 42
}

post.deleted

{
  "launch_id": "uuid",
  "platform": "twitter",
  "post_url": "https://x.com/...",
  "post_id": "12345",
  "deletion_reason": "not_found",
  "detected_at": "2026-02-18T12:00:00.000Z"
}

headers

headerdescription
Content-Typeapplication/json
X-Dropspace-Signaturehex-encoded HMAC-SHA256 of {timestamp}.{body}
X-Dropspace-Timestampunix timestamp (seconds), reject if older than 5 minutes
X-Dropspace-Deliveryunique delivery ID for idempotency

signature verification

verify the X-Dropspace-Signature header using your webhook secret to ensure the request is from dropspace:

import crypto from "crypto";

const MAX_AGE_SECONDS = 300; // reject anything older than 5 minutes

function verifySignature(secret, body, timestamp, signature) {
  // replay protection: reject stale timestamps
  const ts = parseInt(timestamp, 10);
  if (isNaN(ts) || Math.abs(Math.floor(Date.now() / 1000) - ts) > MAX_AGE_SECONDS) {
    return false;
  }

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${body}`)
    .digest("hex");

  const sigBuf = Buffer.from(signature, "hex");
  const expBuf = Buffer.from(expected, "hex");
  if (sigBuf.length !== expBuf.length) return false;

  return crypto.timingSafeEqual(sigBuf, expBuf);
}

// in your handler:
app.post("/webhooks", (req, res) => {
  const sig = req.headers["x-dropspace-signature"];
  const ts = req.headers["x-dropspace-timestamp"];
  if (!verifySignature(WEBHOOK_SECRET, req.rawBody, ts, sig)) {
    return res.status(401).json({ error: "invalid signature" });
  }

  const event = JSON.parse(req.rawBody);
  const deliveryId = req.headers["x-dropspace-delivery"];
  // use deliveryId for idempotency
  console.log(`received ${event.event}`);
  res.status(200).json({ received: true });
});

retry behavior

  • up to 3 retries with exponential backoff (via QStash)
  • 30-second timeout per delivery attempt
  • return 2xxto acknowledge — any other status triggers retry
  • use the X-Dropspace-Delivery header as an idempotency key to avoid processing the same event twice

agent payments

autonomous agents can use the dropspace API without accounts or subscriptions via two payment protocols: x402 (crypto) and MPP (Stripe).

discoverable on

  • •agentic.market - consumer marketplace for x402-paid services
  • •CDP bazaar - Coinbase's x402 discovery API (filter for resource: https://api.dropspace.dev/launches)
  • •/.well-known/x402 - structured marketplace metadata for crawlers and agents
agent payments are only supported on launch endpoints. all other endpoints require API key authentication.

how it works

  • •x402 wallets get 5 free launches/month using official dropspace accounts
  • •beyond the free tier, each launch creation costs $0.55 via x402 (crypto) or $0.60 via MPP (card). publishing is always free
  • •payment proofs are verified for agent identification; settlement only occurs when the free tier is exceeded
  • •agent users can only post to official dropspace accounts (not connected user accounts)
  • •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.

protocols

x402 (crypto)MPP (Stripe)
headerPAYMENT-SIGNATURE: <base64>Authorization: Payment <base64url>
payment methodUSDC on BaseStripe card charge
settlementon-chain transferStripe charge
402 challengeJSON body x402 objectWWW-Authenticate: Payment header
identitywallet addresspayer identity from credential

rate limits

agent-authenticated requests are rate-limited to 30 requests/minute per agent identity. for x402, identity is the wallet address. for MPP, identity is the payer credential source. exceeding this limit returns a 429 with a Retry-After header.

supported endpoints

endpointpayment required
POST /launchesafter 5 free launches/month for x402 ($0.55 x402 / $0.60 MPP)
GET /launchesfree
GET /launches/:idfree
PATCH /launches/:idfree
DELETE /launches/:idfree
POST /launches/:id/publishfree (paid at creation, not publish)
GET /launches/:id/statusfree

example flow

1. request with no payment proof

curl -X POST https://api.dropspace.dev/launches \
  -H "Content-Type: application/json" \
  -d '{
    "title": "My Product Launch",
    "product_description": "An amazing product",
    "platforms": ["twitter", "reddit"]
  }'

2. 402 response (includes both challenges)

the response body contains both x402 and mpp challenge objects. for MPP, a WWW-Authenticate: Payment <challenge> response header is also included.

{
  "error": {
    "code": "PAYMENT_001",
    "message": "payment required: $0.55 (x402) or $0.60 (MPP) per launch x402 wallets get 5 free launches/month"
  },
  "x402": {
    "version": 2,
    "scheme": "exact",
    "network": "eip155:8453",
    "asset": "USDC",
    "amount": "0.50",
    "receiver": "0x...",
    "resource": "POST /launches"
  },
  "mpp": {
    "version": 1,
    "method": "stripe",
    "intent": "charge",
    "currency": "usd",
    "amount_cents": 50,
    "resource": "POST /launches"
  }
}

3a. pay with x402 (crypto): read the PAYMENT-REQUIRED header from the 402, sign it, and replay with a PAYMENT-SIGNATURE header (x402 v2; use the x402 client lib to produce a conformant payload).

curl -X POST https://api.dropspace.dev/launches \
  -H "Content-Type: application/json" \
  -H "PAYMENT-SIGNATURE: <base64 x402 v2 payload>" \
  -d '{
    "title": "My Product Launch",
    "product_description": "An amazing product",
    "platforms": ["twitter", "reddit"]
  }'

3b. pay with MPP (Stripe)

curl -X POST https://api.dropspace.dev/launches \
  -H "Content-Type: application/json" \
  -H "Authorization: Payment <base64url-encoded-credential>" \
  -d '{
    "title": "My Product Launch",
    "product_description": "An amazing product",
    "platforms": ["twitter", "reddit"]
  }'

credit packs

MPP (Stripe) agents can purchase credit packs to prepay for launches in bulk. instead of paying $0.60 per launch individually, buy 20 credits for $12.00 in a single Stripe charge. credits are deducted automatically when creating launches - no payment credential needed for each request.

credit packs are for MPP (Stripe) agents only. x402 (crypto) agents already have low transaction fees on Base and use per-launch USDC settlement.

pricing

packcreditspriceper launch
pack_2020$12.00$0.60

how it works

  • check balance - GET /credits returns your current credit balance and available packs
  • purchase - POST /credits/purchase with an MPP credential for $12.00 adds 20 credits
  • auto-deduction - when you create a launch with credits available, 1 credit is deducted instead of charging Stripe
  • out of credits - if credits run out, the next launch returns 402 with purchase instructions. buy another pack to continue
  • refunds - a credit comes back automatically only if creating the launch fails. a launch that ends partial or failed keeps its credit

purchase example

send an MPP credential for $12.00 (1200 cents) to the purchase endpoint:

curl -X POST https://api.dropspace.dev/credits/purchase \
  -H "Content-Type: application/json" \
  -H "Authorization: Payment <base64url-credential-for-1200-cents>" \
  -d '{ "pack": "pack_20" }'

# response:
# {
#   "data": {
#     "credits_added": 20,
#     "new_balance": 20,
#     "pack": { "id": "pack_20", "credits": 20, "price_usd": "12.00" }
#   }
# }

check balance

curl https://api.dropspace.dev/credits \
  -H "Authorization: Payment <base64url-credential>"

# response:
# {
#   "data": {
#     "balance": 7,
#     "packs": [{ "id": "pack_20", "credits": 20, "price_usd": "12.00" }],
#     "purchase_endpoint": "POST /credits/purchase"
#   }
# }

error handling

error response shape

{
  "error": {
    "code": "LAUNCH_001",
    "message": "launch not found"
  }
}

error codes

authentication

codeHTTPmeaning
AUTH_001401invalid or revoked API key
AUTH_002403plan restriction (feature not available)

launches

codeHTTPmeaning
LAUNCH_001404launch not found
LAUNCH_002429launch or regeneration limit exceeded
LAUNCH_003409invalid launch status for operation
LAUNCH_004409launch cannot be published from current status
LAUNCH_007400platform requirements not met

content moderation

codeHTTPmeaning
CONTENT_004400content blocked by official-account safety moderation (do not resubmit the same content)
CONTENT_005503content safety check temporarily unavailable (retry with the same content)

personas

codeHTTPmeaning
PERSONA_001429persona creation limit reached
PERSONA_002404persona not found
PERSONA_003429persona build limit reached

media

codeHTTPmeaning
MEDIA_001429monthly media generation limit reached

uploads

codeHTTPmeaning
UPLOAD_001400unsupported media type (allowed: jpeg, png, webp, gif, mp4, mov)
UPLOAD_002400media file too large (images: 5MB, videos: 512MB URL / 4MB base64)
UPLOAD_003400media storage upload failed

payment (x402 / mpp)

codeHTTPmeaning
PAYMENT_001402payment required (free tier exceeded)
PAYMENT_002402payment verification failed
PAYMENT_003402MPP credential verification failed

rate limiting

codeHTTPmeaning
RATE_001429too many requests

validation & server

codeHTTPmeaning
SERVER_001500internal server error
SERVER_002400validation error
SERVER_003404resource not found
SERVER_004405method not allowed
SERVER_005400invalid input format or business rule violation
SERVER_008500database error
SERVER_009409conflict (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

codemeaningwhat to do
AUTH_ERRORthe platform rejected the account's sign-in.reconnect the account, then retry.
TOKEN_EXPIREDthe account's access expired and could not be renewed.reconnect the account, then retry.
TOKEN_REFRESH_FAILEDrenewing 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_FOUNDthe account chosen for this destination is no longer connected.reconnect it, or choose another account.
NO_ACCESS_TOKENthe connection exists but holds no usable access.reconnect the account, then retry.
INVALID_TOKENthe Facebook connection used is not a Page connection.reconnect Facebook and select the Page.
NO_ACCOUNTnothing 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_DENIEDthe account has not granted a permission this post needs.reconnect and grant the requested permissions.
NOT_ELIGIBLEthe 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_AUTHORIZEDTikTok posting permission was not granted when the account connected.reconnect TikTok and allow posting.
NO_ORG_SELECTEDLinkedIn organization posting was requested, but the connection has no organization.reconnect LinkedIn and pick the company page.
USER_INFO_ERRORLinkedIn did not return the member profile needed to post.reconnect LinkedIn.
DROPSPACE_TOKEN_ERRORthe dropspace official account for this platform was temporarily unavailable.retry later.

limits

codemeaningwhat to do
RATE_LIMITthe platform rate-limited the request.wait, then retry.
DAILY_POST_CAPthe account reached TikTok's daily posting limit.retry after the limit resets.
USER_CAP_REACHEDTikTok'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_BREAKskipped 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": "<name>" }.

content

codemeaningwhat to do
NO_CONTENTthe launch has no content for this platform.add content for it.
VALIDATION_ERRORthe post broke a platform rule on length, a required field, or a media source.fix what error_message names before posting again.
DUPLICATE_CONTENTthe platform says this exact content was already posted.change the content before posting again.
CONTENT_RESTRICTIONthe 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_CONTENTLinkedIn company posting was selected, but the company-voice content is empty.add company content.
CONTENT_MODERATION_FAILEDmoderation blocked a post from a dropspace official account.revise the content.
CONTENT_MODERATION_UNAVAILABLEthe moderation check for a dropspace official account post could not run.retry later.

media

codemeaningwhat to do
UPLOAD_FAILEDuploading the media to the platform failed.check that the file is reachable and within the platform's limits, then retry.
CAROUSEL_ERRORfewer than two images of an Instagram carousel uploaded.replace the images that failed, then retry.
INVALID_MEDIA_URLInstagram could not fetch the media URL.use a publicly reachable URL.
INVALID_VIDEO_URLthe 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_FORMATTikTok rejected the video's format.re-encode the video (MP4 with H.264 is the safe choice).
VIDEO_FORMAT_ERRORFacebook rejected the video's format.re-encode the video as MP4.
VIDEO_TOO_LARGEthe video is over Facebook's size limit.use a smaller file.
MEDIA_PROCESSING_FAILEDthe platform processed the media and rejected it for its format, length, or size.fix the file.
PROCESSING_TIMEOUTthe 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

codemeaningwhat to do
PLATFORM_DISABLEDposting to this platform is turned off in dropspace.contact support.
POLICY_BLOCKFacebook restricted the account, often until its owner confirms their identity.resolve it in Facebook, then retry.
USER_BANNEDTikTok temporarily blocked the account from posting.retry after TikTok lifts the block.
PRIVATE_ONLYTikTok only accepted a post to a private account here.contact support.
DOMAIN_NOT_VERIFIEDTikTok has not verified the domain dropspace serves media from.contact support. there is nothing to fix on your side.
PRIVACY_LEVEL_MISMATCHthe chosen TikTok privacy level is not available for this account.choose a privacy level the account allows.
SUBREDDIT_RESTRICTIONthe subreddit does not allow this post type (links, text, images, or video).change the post type or pick another subreddit.
SUBREDDIT_ACCESS_DENIEDthe subreddit does not exist, or the account is not allowed to post there.pick another subreddit.
TIKTOK_<REASON>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_FAILEDTikTok marked the post failed without giving a reason.retry. contact support if it keeps happening.
PROMO_RATIO_EXCEEDEDskipped: 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_TARGETEDskipped: 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_OPTIONReddit rejected a post option.adjust what error_message names.
REDDIT_<CODE>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

codemeaningwhat to do
PLATFORM_API_ERRORthe platform returned an error with no more specific code.retry. contact support if it keeps happening.
INVALID_RESPONSEReddit accepted the request but returned no post.check whether the post appeared before retrying.
NO_PUBLISH_IDTikTok accepted the upload request but returned no publish id.retry. contact support if it keeps happening.
PARTIAL_THREADan 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_ERRORan unexpected error while posting.retry. contact support if it keeps happening.
EXCEPTIONan unexpected error during a posting attempt.retry. contact support if it keeps happening.
MAX_RETRIESevery automatic attempt failed without a more specific error.retry. contact support if it keeps happening.
TIMEOUTa request to the platform timed out.check whether the post appeared before retrying, to avoid a duplicate.
PROMISE_REJECTEDa retry of this destination failed unexpectedly.retry. contact support if it keeps happening.

deleting posts

codemeaningwhat to do
DELETE_FAILEDthe platform refused or failed the delete.retry, or delete the post on the platform.
DELETE_NOT_SUPPORTEDthe platform does not allow deleting posts through its API.delete the post on the platform.