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.devall 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_KEYinvalid or revoked keys return 401 with error code AUTH_001.
rate limits
| limit | value | scope |
|---|---|---|
| general API | 60 req/min | per API key |
| content generation | 30 req/min | per user |
| media generation | 5 req/min | per user |
| video concurrency | 2 per user / 20 global | concurrent 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.
| header | description |
|---|---|
| X-RateLimit-Limit | requests allowed per window |
| X-RateLimit-Remaining | requests remaining |
| X-RateLimit-Reset | Unix timestamp in milliseconds when window resets (not seconds) |
| X-Request-ID | correlation ID for support and Sentry, included on every response |
| X-Dropspace-Notice | one-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
| scope | description |
|---|---|
| read | view launches, personas, media, and connections |
| write | create, update, and soft-delete launches and personas |
| generate | AI content and media generation |
| send | publish and retry launches, reply to and moderate comments, deliver webhooks |
| admin | manage 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 case | scopes |
|---|---|
| AI agents / MCP | read, write, generate |
| CI/CD publishing | read, send |
| monitoring / dashboards | read |
| full access (admin tools) | all scopes |
audit logging
destructive and sensitive operations performed via the API are automatically logged for security auditing.
audited operations
| operation | description |
|---|---|
| delete launch | permanently removes a launch |
| delete persona | permanently removes a persona |
| publish launch | publishes content to connected platforms |
| create api key | generates a new api key |
| revoke api key | permanently disables an api key |
| create webhook | registers a new webhook endpoint |
| delete webhook | removes a webhook endpoint |
| rotate webhook secret | generates 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
npx -y -p @jclvsh/dropspace dropspace-mcphosted 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/mcpthe 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.
| class | what it does | tools |
|---|---|---|
| read | retrieves information and changes nothing | get_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 |
| write | changes data inside dropspace only | analyze_persona, apply_recommendation, create_persona, discard_persona_analysis, dismiss_recommendation, finalize_persona, generate_content, generate_media, retry_launch_content, update_persona |
| sensitive | publishes, schedules, or deletes on a connected platform, deletes a launch or persona (no restore), or turns on something that will publish | approve_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
| category | tools | count |
|---|---|---|
| launches | list_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_destinations | 13 |
| posts | delete_post, delete_all_posts | 2 |
| comments | list_launch_comments, reply_to_comment, hide_comment, delete_comment, list_comment_queue, act_on_comments | 6 |
| personas | list_personas, get_persona, create_persona, update_persona, delete_persona, analyze_persona, finalize_persona, discard_persona_analysis | 8 |
| media | generate_media, get_media_job, list_media_library, add_to_media_library, update_media_library_item, delete_media_library_item | 6 |
| connections | list_connections, disconnect_connection, update_connection_profile | 3 |
| boosts | list_boosts, get_boost, get_boost_performance, create_boost, activate_boost, cancel_boost | 6 |
| credits | get_credit_balance, purchase_credit_pack | 2 |
| API keys | get_current_api_key, list_api_keys, rename_api_key, create_api_key, revoke_api_key, rotate_api_key | 6 |
| webhooks | list_webhooks, get_webhook, list_webhook_deliveries, create_webhook, update_webhook, delete_webhook, test_webhook, rotate_webhook_secret | 8 |
| dropspace | get_dropspace_status | 1 |
| usage | get_usage, get_changelog | 2 |
| account | save_report, list_reports, get_report, get_profile, update_profile, create_billing_portal_session | 6 |
| autopilot | get_autopilot, list_autopilot_review, list_autopilot_runs, reject_autopilot_draft, configure_autopilot, approve_autopilot_draft, run_autopilot_now | 7 |
| recommendations | list_recommendations, apply_recommendation, dismiss_recommendation | 3 |
| reddit engagement | suggest_subreddits | 1 |
| audience | get_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_reel | 10 |
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/dropspaceor run directly with npx: npx @jclvsh/dropspace launches list
commands
| group | commands | count |
|---|---|---|
| launches | list, 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 delete | 19 |
| personas | list, create, get, update, delete, analyze, finalize, discard | 8 |
| media | generate, job, library list, library add, library describe, library delete | 6 |
| connections | list, disconnect, profile | 3 |
| autopilot | status, configure, review, approve, reject, run, runs | 7 |
| recommendations | list, apply, dismiss | 3 |
| boosts | create, list, get, activate, cancel, performance | 6 |
| audience | followers and growth across connected accounts | 1 |
| trial-reels | Instagram trial reel results | 1 |
| best-hours | when your Instagram followers are online | 1 |
| demographics | who your Instagram followers and engaged audience are | 1 |
| reels-read | Claude's read of your recent Instagram reels | 1 |
| comment-queue | list, hide, dismiss | 3 |
| trial-reel-program | list, set, scan, approve, approve-all, remove | 6 |
| reports | list, get, delete | 3 |
| suggest | 1 | |
| credits | balance, purchase | 2 |
| keys | me, list, create, rename, revoke, rotate | 6 |
| webhooks | list, create, get, update, delete, test, rotate-secret, deliveries | 8 |
| status | dropspace official account connections | 1 |
| usage | plan, limits, and billing period | 1 |
| changelog | recent API changes | 1 |
| profile | get, update | 2 |
| billing | portal | 1 |
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 listrun 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.mdother 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.
/launcheslist launches with pagination
parameters & response
query parameters
| name | type | description |
|---|---|---|
pageoptional | integer | page number (default: 1) |
page_sizeoptional | integer (1-100) | items per page (default: 50) |
statusoptional | string | filter by launch status (draft, manual, trigger, scheduled, running, completed, partial, failed, cancelled) |
response
{
"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 }
}/launchescreate a launch with AI-generated or custom content
parameters & response
request body
| name | type | description |
|---|---|---|
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 | URL | product URL: scraped to generate content; satisfies the description requirement on its own |
track_linksoptional | boolean | swap product_url in the post copy for a tracked dropspace.dev redirect on twitter, linkedin, facebook, reddit, and youtube (one link per posted item); clicks by people, not link-preview crawlers, appear as metrics.tracked_clicks in analytics. instagram, tiktok, and snapchat keep the plain URL. API-key launches only: x402 and MPP launches post to dropspace's official accounts and ignore it. default false |
save_to_libraryoptional | boolean | add the launch's uploaded (non-AI) media_assets to the media library, where autopilot can post them. API-key launches only. default false |
scheduled_dateoptional | ISO 8601, any offset | schedule for future (≥ 15 min from now). any ISO 8601 offset works: `2026-12-01T12:00:00Z`, `…T12:00:00.000Z`, or `2026-12-01T08:00:00-04:00` |
persona_idoptional | UUID | writing 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 | object | map of platform key → token_id (UUID). most platforms use a simple key: "twitter", "reddit", "instagram", "tiktok", "youtube" (your primary/only account on that platform). to target a SPECIFIC account when you have more than one on the same platform, use a composite key: Instagram/TikTok/YouTube/Twitter/Reddit use "instagram:<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 | array | inline media upload - each item is either `{ source: "url", url: "https://..." }` to fetch from a URL, or `{ source: "base64", data: "...", filename: "photo.jpg", mime_type: "image/jpeg" }` for raw data. the server uploads to storage and populates media_assets automatically. mutually exclusive with media_assets. max 10 items (9 images + 1 video). images: jpeg, png, webp, gif (5MB). videos: mp4, mov (512MB via URL, 4MB via base64). when provided, media_attach_platforms and media_mode are auto-inferred if not set. **for videos >~50 MB, prefer pre-uploading to your own storage and passing `media_assets` instead: the inline `media` URL path runs inside the POST request lifecycle (bounded by the 5-min route maxDuration) and is sensitive to slow upstreams and Vercel function memory**. URL items share one ~270s total fetch budget and are fetched one at a time. a client that times out does not cancel the request - send an `Idempotency-Key` header (up to 255 characters of letters, digits, `.`, `_`, and `-`; anything else answers 400) so a retry cannot create a second launch |
media_assetsoptional | array | pre-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 | object | pre-written content per platform - each value needs `content` (string). for Twitter, you can alternatively provide `thread` (string[], each ≤ 280 chars or ≤ 25,000 for X Premium accounts, max 6 tweets) instead of `content` - mutually exclusive with `content`. Reddit requires `title` (string, max 300 chars) and supports an optional `metadata` object: `metadata.subreddits` (string[], max 10, names without the `r/` prefix) fans the launch out to one post per subreddit: each gets its own posting log, readable per-community via `GET /launches/:id/destinations` and retryable via `POST /launches/:id/retry` + `{ "subreddit": "<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 | boolean | immediately publish after creation. returns 202 instead of 201. mutually exclusive with scheduled_date. requires both `write` and `send` scopes (API key auth). |
waitoptional | boolean | wait for publishing to complete and return post URLs inline (returns 200 with posting_status). requires publish: true. response includes posting_status with per-platform results. typical wait: 10-30s for text platforms (Twitter, LinkedIn, Reddit, Facebook), up to 60s+ with Instagram or TikTok |
response
// 201 (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
| status | code | description |
|---|---|---|
| 400 | SERVER_002 | validation error |
| 400 | UPLOAD_001 | unsupported media type |
| 400 | UPLOAD_002 | media file too large |
| 400 | UPLOAD_003 | media storage upload failed |
| 400 | LAUNCH_007 | platform requirements not met (e.g. Instagram/TikTok need media) |
| 404 | SERVER_003 | persona not found |
| 429 | LAUNCH_002 | plan launch limit reached |
| 429 | RATE_001 | content generation rate limit |
| 400 | CONTENT_004 | content blocked by official-account safety moderation (genuine rejection: do not resubmit the same content) |
| 503 | CONTENT_005 | content safety check temporarily unavailable (transient: safe to retry with the same content) |
notes
- the media field lets you upload images/videos inline via URL or base64 without pre-uploading to storage - the server handles upload and returns media_assets in the response
- media and media_assets are mutually exclusive - use media for inline upload, or media_assets if you've already uploaded files to storage
- **operational tip**: the inline `media` URL fetch runs inside the POST request (bound by the 5-min route maxDuration) and streams the body through to storage. for videos >~50 MB or slow source hosts, pre-uploading to your own storage and passing `media_assets` is more reliable: it lets the publish worker pull from a fast intra-region URL when it actually posts to the platform, instead of bottlenecking the create request
- content is generated automatically via Claude using your description, scraped website data, and persona style
- custom_content and platform_contents are mutually exclusive - use one or the other (or neither for full AI generation)
- product_url is optional but enhances AI generation by providing scraped website data for additional context
- if platform_contents is provided, those platforms skip AI generation - only platforms without a truthy content field are generated
- custom_content as a string distributes the same text to all platforms - validated against the lowest character limit. as an array (string[]), it creates a numbered twitter thread and joins tweets with double newlines for other platforms. array form requires twitter in platforms
- partial coverage is supported: provide content for some platforms and let AI generate the rest
- for Twitter threads, use `platform_contents.twitter.thread: ["tweet 1", "tweet 2"]` instead of `content` - each tweet ≤ 280 chars (≤ 25,000 for X Premium accounts), max 6 tweets. mutually exclusive with `content`
- for Reddit, `platform_contents.reddit.metadata.subreddits` (string[], max 10) posts the launch once per subreddit: each destination gets its own posting log, and posts are paced automatically to respect Reddit's shared rate limit. omit it to post to the default community. read the per-destination outcome from `GET /launches/:id/destinations` (it names the `subreddit` on each row - `GET /launches/:id/status` reports them all as the same `platform`), and retry one community with `POST /launches/:id/retry` + `{ "subreddit": "<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
/launches/:idget a single launch with posting status
parameters & response
response
{
"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
| status | code | description |
|---|---|---|
| 404 | LAUNCH_001 | launch not found |
/launches/:idupdate a draft/scheduled/cancelled launch
parameters & response
request body
| name | type | description |
|---|---|---|
scheduled_dateoptional | ISO 8601, any offset | null | schedule for future (≥ 15 min from now), null to unschedule. any ISO 8601 offset works: `2026-12-01T12:00:00Z` or `2026-12-01T08:00:00-04:00` |
statusoptional | "draft" | "manual" | "trigger" | "scheduled" | "cancelled" | update launch status |
platformsoptional | string[] (1-10) | target platforms for this launch |
nameoptional | string | launch name/title (max 200 chars) |
product_descriptionoptional | string | product description (max 10,000 chars) |
product_urloptional | string | product URL (empty string to clear) |
persona_idoptional | string | null | persona ID for content generation, null to clear |
platform_contentsoptional | object | per-platform content update (deep-merged with existing) - fields you include replace the old value, omitted fields are preserved. a `*_settings` object (`instagram_settings`, `tiktok_settings`, `youtube_settings`, `snapchat_settings`, `facebook_settings`) merges one level deeper, so one setting can be sent without the rest; every other value (a string, `images`, reddit `metadata`) replaces whole. for Twitter, you can use `thread` (string[], each ≤ 280 chars or ≤ 25,000 for X Premium, max 6) instead of `content` - mutually exclusive with `content`. Reddit `title` is optional (existing title preserved if omitted; max 300 chars if provided). Product Hunt and Hacker News support an optional `title` field (max 60 and 80 chars respectively). TikTok `tiktok_settings` can be set or updated here (deep-merged) - see POST docs for field details. per-platform character limits are enforced: Twitter 280/tweet (25,000 for X Premium) × 6 tweets, LinkedIn 3,000, Instagram 2,200, Reddit 3,000, Facebook 3,000, TikTok 4,000, YouTube 5,000 (description, title max 100), Product Hunt 500, Hacker News 2,000, Substack 3,000. YouTube `title` is optional on update (existing title preserved if omitted) |
user_platform_accountsoptional | object | map of platform key → token_id (UUID). most platforms use a simple key: "twitter", "reddit", "instagram", "tiktok", "youtube" (your primary/only account on that platform). to target a SPECIFIC account when you have more than one on the same platform, use a composite key: Instagram/TikTok/YouTube/Twitter/Reddit use "instagram:<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 | array | inline media upload - same format as create. replaces existing media. mutually exclusive with media_assets |
media_assetsoptional | array | pre-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
| status | code | description |
|---|---|---|
| 400 | UPLOAD_001 | unsupported media type |
| 400 | UPLOAD_002 | media file too large |
| 400 | UPLOAD_003 | media storage upload failed |
| 404 | LAUNCH_001 | launch not found |
| 409 | LAUNCH_003 | cannot update a running, completed, or partial launch |
notes
- all fields are optional but at least one must be provided
- a running launch can only be updated to set status to cancelled
- unrecognized fields return a 400 error
- status is auto-derived for editable launches (draft, manual, trigger, scheduled). the server determines status based on your launch configuration: has posting accounts (user_platform_accounts or dropspace_platforms) + scheduled_date → scheduled. has posting accounts + no scheduled_date → trigger. has content + no posting accounts → manual. you cannot force "manual" on a launch with posting accounts. it will auto-transition to "trigger". to unschedule a launch, PATCH with { "scheduled_date": null }, status auto-becomes "trigger"
- 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
/launches/:iddelete a launch (any status except running)
parameters & response
errors
| status | code | description |
|---|---|---|
| 409 | LAUNCH_003 | cannot delete a launch that is currently running |
| 404 | LAUNCH_001 | launch not found |
/launches/:id/publishqueue launch for publishing (async)
parameters & response
response
{ "data": { "message": "publish queued" } }errors
| status | code | description |
|---|---|---|
| 404 | LAUNCH_001 | launch not found |
| 422 | LAUNCH_007 | launch has no platforms configured for posting |
| 400 | LAUNCH_007 | platform requirements not met - see errors array for details |
| 409 | LAUNCH_004 | launch is not in a publishable state or already publishing |
| 403 | AUTH_002 | plan restriction on own connected accounts (dropspace official accounts are always allowed) |
notes
- the launch transitions to running - poll /status or listen for launch.completed / launch.failed / post.deleted webhooks
- when validation fails (LAUNCH_007), the response includes a `details` array listing all issues that must be fixed before publishing
- platform validation checks: character limits (all platforms), Reddit title ≤ 300 chars, Reddit video mode needs video (thumbnail auto-generated if not provided), Reddit image mode needs images, Instagram reel needs video, Instagram carousel needs ≥ 2 images, Instagram post_type "carousel" needs 2 to 10 images and videos combined, Instagram post_type "trial_reel" needs a video (photos are left out), TikTok requires privacy_level in tiktok_settings (unless publish_mode is "draft"), TikTok video mode needs video, TikTok photo mode needs images, YouTube requires video, YouTube title ≤ 100 chars, YouTube description ≤ 5,000 chars
- **video size**: per-platform limit is 512 MB. YouTube uploads stream chunked through the worker (memory-bounded). multi-platform launches that share the same video pre-download it once; for very large files (>~300 MB) prefer publishing to one platform at a time, since the pre-download buffer doubles peak memory until each poster releases its handle
/launches/:id/retryretry 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
{
"data": {
"message": "retry queued",
"platforms": ["reddit", "tiktok"]
}
}errors
| status | code | description |
|---|---|---|
| 404 | LAUNCH_001 | launch not found |
| 400 | LAUNCH_003 | no failed platforms to retry |
| 409 | LAUNCH_004 | launch is already running |
/launches/:id/retry-contentretry content generation for failed platforms
parameters & response
request body
| name | type | description |
|---|---|---|
platformsoptional | string[] | filter to specific platforms |
response
{
"data": {
"retried": ["twitter", "reddit"],
"succeeded": ["twitter"],
"still_failing": ["reddit"],
"rate_limited": []
}
}errors
| status | code | description |
|---|---|---|
| 400 | SERVER_002 | no failed platforms to retry |
| 404 | LAUNCH_001 | launch not found |
| 429 | LAUNCH_002 | per-launch regeneration limit reached |
| 429 | RATE_001 | content generation rate limit |
/launches/:id/generate-contentregenerate AI content for all or specific platforms
parameters & response
request body
| name | type | description |
|---|---|---|
platformsoptional | string[] (1-10) | platforms to regenerate (defaults to all) |
generate_video_scriptsoptional | ["instagram", "tiktok"] subset | generate video scripts for these platforms |
linkedin_versionoptional | "personal" | "company" | which LinkedIn voice to regenerate - personal profile or company page (default: personal) |
response
{
"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
| status | code | description |
|---|---|---|
| 400 | SERVER_002 | no product_description or platforms |
| 404 | LAUNCH_001 | launch not found |
| 409 | LAUNCH_003 | launch is running/completed/partial |
| 429 | RATE_001 | content generation rate limit |
| 503 | SERVER_001 | content generation unavailable |
notes
- existing content for other platforms is preserved
- media, video sources, and generated videos are never overwritten
/launches/:id/statusdetailed posting logs per platform
parameters & response
response
{
"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
| status | code | description |
|---|---|---|
| 404 | LAUNCH_001 | launch not found |
notes
- launch_status is "completed" when every destination posted, "partial" when some posted and some failed, and "failed" when none posted.
- a platform in the launch's platforms that has no account to post from (no user_platform_accounts key for it and no dropspace official posting) gets a failed log with error_code "NO_ACCOUNT" instead of being skipped, so a launch that posted elsewhere reads "partial". when none of the platforms has an account, nothing is posted, no logs are written, and the launch is marked "completed" as ready for manual posting.
- error_code is a stable machine-readable code for a failed log. see posting error codes in the error handling docs for the full list and what to do about each one.
/launches/:id/destinationsone 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
{
"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
| status | code | description |
|---|---|---|
| 404 | LAUNCH_001 | launch not found |
/launches/:id/analyticspublishing analytics with per-post engagement metrics (live refresh)
parameters & response
response
{
"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
| status | code | description |
|---|---|---|
| 404 | LAUNCH_001 | launch not found |
notes
- metrics.tracked_clicks appears when the launch was created with track_links: clicks on its tracked link for that platform, counted on the platform's first post so summing posts never double-counts.
- metrics refresh on a per-platform cycle. YouTube statistics are refreshed for you every 10 minutes in the background, so calling more often returns the same numbers from cache. other platforms are fetched live from their APIs when the cached copy is older than 5 minutes; calling this endpoint triggers that refresh automatically.
- fetched_at is the most recent timestamp when metrics were collected. next_refresh_at is the earliest time a call could return new numbers (fetched_at + 10 min for YouTube, + 5 min for other platforms) - polling before it only re-reads the cache.
- cache_status per platform: fresh (inside the refresh cycle, from cache), refreshed (fetched from platform API on this call), stale (rate limited or no token, returning older data), unavailable (no data exists). omitted for non-success posts (failed, pending).
- rate-limited platforms return stale cached data instead of failing - the response always includes the best available metrics.
- metric fields vary by platform: Twitter (likes, retweets, replies, quotes, bookmarks, impressions, urlClicks, profileClicks), LinkedIn (impressions, uniqueImpressions, likes, comments, shares, clicks, engagement), Facebook (reactions, likes, comments, shares - likes mirrors reactions.total), Instagram (views, reach, engagement, saved, likes, comments, shares, totalInteractions; a reel adds avgWatchTimeMs, totalWatchTimeMs, and skipRate, the percent of plays that skipped in the first 3 seconds), Reddit (score, upvotes, upvoteRatio, comments), TikTok (views, likes, comments, shares)
- facebook has no impressions or reach: those come from the platform's insights endpoint, which dropspace does not request. facebook story metrics are unavailable for the same reason - story insights are wired for instagram only.
- a null metric means the platform did not disclose it, which is different from 0: youtube returns likes: null when the owner hides the like count and comments: null when comments are disabled.
- an instagram story reports views, reach, replies, and frames only while it is live: Meta serves story insights for 24 hours with no backfill. views and replies are summed across frames and reach is the widest single frame. views is null when Meta does not report it, and a story under 5 viewers returns story_insights_withheld: true instead of numbers (withheld, not zero).
- is_deleted indicates whether the post was detected as removed from the platform. when true, deleted_detected_at contains the detection timestamp and deletion_reason contains the reason. possible values: not_found (post no longer exists), gone (permanently removed, HTTP 410), creator_deleted (deleted by creator), moderation_removed (removed by moderation), account_deleted (account was deleted), spam_filtered (caught by spam filter). deletion is detected during metrics refresh.
/comments/queueevery comment still waiting for you across your recent Instagram and YouTube posts, newest first, with a suggested action
parameters & response
response
{
"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
| status | code | description |
|---|---|---|
| 401 | AUTH_001 | missing or invalid api key |
| 403 | AUTH_002 | partner creator paused or partner suspended |
notes
- reads up to 30 of your Instagram and YouTube posts made through dropspace in the last 30 days (the newest 50 comments on each) and returns up to 200 comments, newest first. When the window has more than 30 posts (posts_in_window), the ones with comments are read first, by the comment counts dropspace last read, newest first. total_waiting counts every waiting comment found; post_issues lists posts that could not be read (reconnect, quota) with why. Posts on dropspace's own accounts, stories, and posts made outside dropspace are not included.
- waiting means: not hidden, not written by your account, not answered by your account (a reply from it, made in the app or here), and not replied to, hidden, deleted, or dismissed through dropspace.
- kind, suggested_action, and reply_draft are Claude's read of the comment text: questions and feedback suggest a reply (with a draft to edit), spam and attacks suggest hide, praise and other suggest dismiss. Each comment is sorted once and remembered; kind is null for comments not sorted yet (new ones are sorted up to 100 per load, 20 batches a day).
- may_be_answered is true when YouTube returned only some of a comment's replies and none of those was yours: it stays in the queue, but you may have answered it already.
- comment reads share the short page cache of GET /launches/{id}/comments, so a comment answered elsewhere can take about a minute to leave.
/comments/actionshide or dismiss up to 50 comments from the comment queue
parameters & response
request body
| name | type | description |
|---|---|---|
actionrequired | string | hide (Instagram only, send scope) or dismiss (write scope): dismiss means seen, nothing to do; nothing changes on the platform |
commentsrequired | array | 1-50 items of { platform, post_id, comment_id }, as GET /comments/queue returns them |
response
{
"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
| status | code | description |
|---|---|---|
| 400 | SERVER_002 | invalid json body, unknown action, or more than 50 comments |
| 401 | AUTH_001 | missing or invalid api key |
| 403 | AUTH_002 | missing the send scope (hide) or write scope (dismiss), or partner creator paused |
notes
- only comments on your own posts made through dropspace are acted on; anything else, and hide on YouTube, comes back with ok: false and detail saying why.
- hides are sent one a second, so 50 take about a minute. Every item has its own result; the request does not stop at the first failure.
- to reply or delete one comment, use POST /launches/{id}/comments/replies and DELETE /launches/{id}/comments/{commentId} with the comment's launch_id and post_id; those also take it off the queue.
/launches/:id/commentsper-post comments for a launch's published posts (newest first)
parameters & response
query parameters
| name | type | description |
|---|---|---|
post_idoptional | string | read one post's comments only (required when paginating with cursor) |
platformoptional | string | only posts on this platform (e.g. youtube) |
cursoroptional | string | opaque next_cursor from a previous page - paginates ONE post, so post_id is required with it |
limitoptional | number | comments per post, 1-100 (default 25) |
response
{
"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
| status | code | description |
|---|---|---|
| 400 | SERVER_002 | cursor without post_id, or invalid params |
| 404 | LAUNCH_001 | launch not found |
notes
- comments are top-level only, ordered newest first, and cached for ~60 seconds per page.
- platform support is explicit per post via status: ok (comments returned), unsupported (the platform's api does not expose comment content), comments_disabled (turned off on the post), post_deleted (post no longer exists), unavailable (transient: rate limited, quota, or account not connected - retry later). detail carries the human-readable reason for every non-ok status.
- youtube and instagram return comment content. instagram connections made before 08/25/2026 read unsupported with a reconnect prompt until the creator reconnects instagram once; tiktok exposes comment counts only (see analytics).
- to page through one post's comments, pass post_id plus the next_cursor from the previous response. next_cursor is null on the last page.
/launches/:id/comments/repliesreply 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
{
"data": {
"reply_id": "UgzXK..."
}
}errors
| status | code | description |
|---|---|---|
| 404 | LAUNCH_001 | launch not found, or post_id not on this launch |
| 400 | SERVER_002 | platform can't accept the reply (detail in message) |
notes
- reply_id is the platform's id for the created reply; null when the platform doesn't return one.
- a successful reply invalidates the post's cached comment pages, so the next GET reflects it immediately.
/launches/:id/comments/hidehide 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
{
"data": {
"comment_id": "17895695668004550",
"hidden": true
}
}errors
| status | code | description |
|---|---|---|
| 404 | LAUNCH_001 | launch not found, or post_id not on this launch |
| 400 | SERVER_002 | platform can't hide the comment (detail in message) |
notes
- comments read back with `hidden: true` from GET /launches/:id/comments once hidden.
- a successful change invalidates the post's cached comment pages, so the next GET reflects it immediately.
/launches/:id/comments/:commentIdpermanently 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
| name | type | description |
|---|---|---|
post_idrequired | string | the platform post id the comment lives on (from GET /launches/:id/comments) |
response
{
"data": {
"comment_id": "17895695668004550",
"deleted": true
}
}errors
| status | code | description |
|---|---|---|
| 404 | LAUNCH_001 | launch not found, or post_id not on this launch |
| 400 | SERVER_002 | platform can't delete the comment (detail in message) |
notes
- deletion is permanent - prefer hide when the comment may need to come back.
/launches/analyticsbatch analytics for multiple launches (cache-only, no live refresh)
parameters & response
query parameters
| name | type | description |
|---|---|---|
idsrequired | string | comma-separated launch UUIDs (max 100) |
response
{
"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
| status | code | description |
|---|---|---|
| 400 | SERVER_002 | ids parameter missing or empty |
| 400 | SERVER_002 | more than 100 IDs provided |
| 400 | SERVER_005 | all IDs have invalid UUID format |
notes
- returns cached analytics only - does NOT trigger a live refresh from platform APIs. use the single-launch endpoint GET /launches/:id/analytics to trigger a fresh fetch.
- accepts up to 100 launch IDs. duplicate IDs are deduplicated. counts as 1 rate limit hit.
- response includes partial results: valid launches in data[], invalid/not-found in errors[].
- cache_status per platform: fresh (< 5 min old), stale (older cached data), unavailable (no metrics exist). never returns refreshed (no live fetch). omitted for non-success posts (failed, pending).
- metric fields vary by platform - same as single-launch analytics endpoint.
/launches/:id/posts/:logIddelete a single published post from its platform
parameters & response
response
{
"data": {
"success": true,
"platform": "twitter",
"post_id": "1234567890",
"deleted_at": "ISO 8601"
}
}errors
| status | code | description |
|---|---|---|
| 404 | LAUNCH_001 | launch not found |
| 404 | SERVER_003 | posting log not found or not deletable |
| 200 | DELETE_NOT_SUPPORTED | platform does not support API deletion (Instagram, TikTok) - returned in response body, not as HTTP error |
notes
- requires `write` scope on your API key (included in the default scope set)
- only works for Twitter, Facebook, LinkedIn, and Reddit - Instagram and TikTok do not support API deletion
- the posting log must have status 'success' and a valid post_id
- if the post was already deleted on the platform (404), it is treated as a successful deletion
- on success, the posting log status is updated to 'deleted'
- logId is the posting_log UUID from the /status endpoint
/launches/:id/postsdelete all published posts for a launch from their platforms
parameters & response
response
{
"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
| status | code | description |
|---|---|---|
| 404 | LAUNCH_001 | launch not found |
notes
- requires `write` scope on your API key (included in the default scope set)
- only deletes from Twitter, Facebook, LinkedIn, and Reddit
- Instagram and TikTok posts are skipped with DELETE_NOT_SUPPORTED (require manual deletion)
- returns detailed results per post including success/failure status
- skipped_count includes platforms that don't support API deletion
audience
follower counts and growth-over-time for the connected account's instagram, youtube, tiktok, twitter, and facebook. read from the official platform apis. requires an api key whose account has the platform connected.
/audiencefollower counts + growth over a window for the caller's connected instagram/youtube/tiktok/twitter/facebook accounts
parameters & response
query parameters
| name | type | description |
|---|---|---|
window_daysoptional | integer | growth window in days (default 30, max 365) |
includeoptional | string | instagram_insights adds Instagram growth analytics to each Instagram entry: follows and unfollows per day, estimated followers over time, weekly growth velocity, the share of reach from non-followers, and contact-button taps. Days are Pacific time and the last 7 are still revised by Instagram (settled: false). Read on request and stored, newest days first, up to 31 days per call: a longer window fills over a few calls (days_pending says how many are left). Accounts under 100 followers get available: false. |
response
{
"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
| status | code | description |
|---|---|---|
| 401 | AUTH_001 | missing or invalid api key |
notes
- covers instagram, youtube, tiktok, twitter, and facebook - the platforms whose follower counts the official apis expose. other connected platforms (linkedin, reddit) are omitted.
- facebook follower counts are reported per connected page (each page reports its own followers); twitter reports followers, following, and tweet count.
- counts are read on-demand when you call this endpoint and cached for 15 minutes. youtube is the exception, because each youtube read spends a shared daily quota: while you keep reading an account, its youtube count is refreshed in the background about once an hour, and an on-demand read re-fetches at most every 6 hours if that refresh is delayed. each fresh read records a history point - so a newly connected account appears on its first call and builds growth history from there.
- growth is computed from the earliest and latest snapshot inside the window; follower_pct is a fraction (0.0645 = 6.45%). delta and pct are null until at least one snapshot exists in the window.
- history points are recorded when you read (at most one per account per day), so the oldest point inside the window can be much younger than window_days. baseline_captured_at is when the delta is measured from, and effective_window_days is the span it actually covers - read it before comparing growth across accounts.
- youtube history points are kept for 30 days (YouTube's API storage limit), so youtube growth reaches back about 30 days even when window_days is larger; effective_window_days says how far it actually reaches. other platforms keep their full history.
- current.captured_at is when the returned count was actually read from the platform, never when it was served: a cached or background-refreshed count keeps its original read time, a youtube count returned while youtube's daily quota is exhausted carries the time of the last successful read, and if the platform call fails with nothing cached it is when the newest stored point was captured.
- data collection uses each platform's official read api, not scraping - so it only covers accounts the creator has connected.
- include=instagram_insights adds instagram_insights to each instagram entry. days[] has follows, unfollows (accounts that unfollowed or left Instagram), net, and reach split into reach_followers and reach_non_followers, per Pacific-time day. followers_over_time is an estimate: the current count minus each later day's net. velocity compares the last 7 days' net, as a share of the count at the start of that week, with the average of the earlier full weeks in the window. non_follower_reach_share sums daily reach, so an account reached on two days counts twice. profile_links_taps covers the last 30 days of the window at most. Instagram reports none of this for accounts under 100 followers: available is false with unavailable_reason.
/audience/demographicswho your Instagram audience is: age, gender, country, and city of your followers and of the accounts that engaged with you
parameters & response
query parameters
| name | type | description |
|---|---|---|
timeframeoptional | string | period for the engaged audience: this_week, this_month (default), or last_90_days. Followers are always the current followers. Any other value returns 400. |
response
{
"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
| status | code | description |
|---|---|---|
| 400 | SERVER_002 | timeframe is not this_week, this_month, or last_90_days |
| 401 | AUTH_001 | missing or invalid api key |
| 403 | AUTH_002 | partner creator paused or partner suspended |
notes
- followers describes the account's current followers; engaged describes the accounts that engaged with its content in timeframe. Each split (age, gender, country, city) is largest first. The example is shortened; a full answer has every age range and up to 45 countries and cities.
- gender values are F, M, and U (unknown); country is an ISO 3166-1 alpha-2 code; city is "City, Region". total is the number of people Instagram has demographic data for (the gender split's sum), which can be lower than the follower count.
- Instagram reports only the top 45 rows of a split and nothing for accounts under 100 followers: a section then has available: false with unavailable_reason. An engaged section can also be unavailable when too few accounts engaged in the period, and a single split (often city) comes back empty when it has too few people to report.
- cached about 12 hours per account and period; a failed Instagram read is asked again after 30 minutes, and unavailable_reason says what happened (reconnect the account for an expired token).
/audience/best-hourswhen 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
| name | type | description |
|---|---|---|
timezoneoptional | string | IANA timezone every hour in the answer is expressed in, e.g. America/New_York. Default: your autopilot timezone, else UTC. The answer's timezone is the canonical name. An unknown zone returns 400. |
response
{
"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
| status | code | description |
|---|---|---|
| 400 | SERVER_002 | timezone is not an IANA timezone |
| 401 | AUTH_001 | missing or invalid api key |
| 403 | AUTH_002 | partner creator paused or partner suspended |
notes
- read from Instagram's online_followers insight for the last 30 days, through yesterday. days_counted is how many of those days Instagram returned hourly numbers for.
- hourly[h] is the average followers online in hour h (0-23) of the day; grid[d][h] is the same for weekday d (0 = sunday). Both are in timezone; null means no sample for that slot.
- best_hours lists the busiest hours, busiest first, each at least two hours from the others (up to 5). The top-level best_hours sums every connected Instagram account's averages.
- cached about 12 hours per account. Instagram reports this only for accounts with 100 or more followers: available is false with unavailable_reason otherwise. When Instagram refuses or fails the read, unavailable_reason says so (reconnect the account for an expired token), and it is asked again after 30 minutes.
- to post at these hours, set posting_times with PATCH /autopilot (times are HH:MM in the autopilot timezone).
/analytics/reels-readeach connected Instagram account with its latest reels read (pro and premium)
parameters & response
response
{
"data": {
"accounts": [
{
"connection_id": "3b9c1f0e-2d4a-4e8b-9c7d-6f5e4d3c2b1a",
"username": "spin.sensei.app",
"next_refresh_at": null
}
]
}
}errors
| status | code | description |
|---|---|---|
| 401 | AUTH_001 | missing or invalid api key |
| 403 | AUTH_002 | plan without reels reads (pro and premium have them), or partner creator paused |
notes
- returns stored reads only and makes no Instagram or Claude request. read is absent when no read was made for the account in the last 30 days; next_refresh_at is when POST can make a new one (null when it can now). The read object has the same shape as POST's.
/analytics/reels-readClaude'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
| name | type | description |
|---|---|---|
connection_idoptional | string | the Instagram connection to read (from GET /analytics/reels-read or GET /connections); default: the first connected Instagram account |
response
{
"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
| status | code | description |
|---|---|---|
| 400 | SERVER_002 | invalid json body, or connection_id is not a uuid |
| 401 | AUTH_001 | missing or invalid api key |
| 403 | AUTH_002 | plan without reels reads (pro and premium have them), or partner creator paused |
notes
- connection_id is optional: without it, the first connected Instagram account is read. The body can be empty.
- reads the latest reels (up to 60) posted in the last 90 days, with their views, reach, likes, comments, shares, saves, avg watch time, and 3-second skip rate, ranks them by views, and sends the top, middle (around the median), and bottom ten, with captions, to Claude. A very active account's 60 reels can cover less than 90 days; oldest_posted_at says how far back the read reaches. Reels whose numbers Instagram did not return are left out; when more than a fifth are missing, the read is not made (status unavailable). The example is shortened to one reel per tier and one finding per list.
- Claude sees captions and numbers only, never the videos, and is told to say when the numbers show no clear pattern. working has 2 to 4 findings, not_working 1 to 3, next exactly 3.
- status ok: read is present (it is omitted for the other statuses). Each account gets 3 paid attempts a day, failed ones included; after that status is unavailable until tomorrow. refreshed is false when a read made in the last 24 hours was returned instead of a new one (one new read per account a day). status not_enough_reels: fewer than needed (8) reels in the window, with reel_count. status unavailable: reason says why (no Instagram account, reconnect, Instagram or Claude failed, a read already running).
- takes 30 to 60 seconds. Reads are kept 30 days and returned by GET.
/analytics/trial-reelsInstagram 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
{
"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
| status | code | description |
|---|---|---|
| 401 | AUTH_001 | missing or invalid api key |
| 403 | AUTH_002 | partner creator paused or partner suspended |
notes
- covers launches whose instagram_settings.post_type was trial_reel and whose Instagram post succeeded, newest first; reels lists up to 100, totals cover every one.
- reads stored numbers only and makes no Instagram request, so it can be polled. each reel's numbers are the ones last read from Instagram (metrics_fetched_at): the metrics sweep refreshes recent posts, and GET /launches/{id}/analytics refreshes one launch.
- reach is summed per reel, so an account reached by two reels counts twice. avg_watch_time_ms (milliseconds per play) and skip_rate (percent of plays that skipped in the first 3 seconds) are views-weighted across reels that report them, and null when none do.
- followers_since_first.net is follows minus unfollows on your Instagram accounts from the Pacific day of the first trial reel through yesterday, from the daily counts GET /audience?include=instagram_insights stores. days_counted says how many of days_in_span had a stored count; net is null when none did. it is every new follower in that span, not only ones the trial reels brought.
trial reels
trial reel programs: dropspace reshares your strongest Instagram reels as trial reels (shown to non-followers first) at your best hours, then checks each at 1, 3, and 7 days. pro and premium.
/trial-reelsevery connected Instagram account with its trial reel program, the hours it posts at, and its reshared reels
parameters & response
response
{
"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
| status | code | description |
|---|---|---|
| 401 | AUTH_001 | missing or invalid api key |
| 403 | AUTH_002 | not on the pro or premium plan, or partner creator paused |
notes
- program is absent until the account's program is first saved. eligible is false (with Instagram's rule in ineligible_reason) for personal accounts and accounts under 200 followers: Instagram refuses trial reels from them.
- posting_hours are hours of the day (0-23) in the program's timezone: the account's best hours from Instagram's followers-online numbers (GET /audience/best-hours), else 9, 13, and 18 when Instagram has none (under 100 followers). One reel per posting hour. On a day a posting hour passes without a post (another post was too close), a later one of the account's best hours that day is used instead, still up to reels_per_day.
- items are the newest 100 that are not removed. status: queued (waiting; posts only once approved), posting (a launch was made, waiting for Instagram), posted (live as a trial reel, being checked), won, retired (no win by day 7, or deleted on Instagram), failed (failure says why).
- kind is reshare (the original video, as posted) or an edited version: background (the reel scaled onto a colored background), subtitles (word-timed subtitles burned in; only for reels with speech and no captions already on screen, otherwise the item fails with that reason), hook (Claude picks the strongest line from later in the reel, which plays first, then the whole reel; refused when no line would open it better), or sticker (a reply-to-comment card near the top; Claude writes the comment and a first-name commenter, so it is not a real viewer's comment). edit_summary says what an edit adds once known: the sticker's comment or the hook's opening line. An edited version is rendered once it is approved (background right away; subtitles, hook, and sticker by the next hourly run) (prepare_status pending, running, then ready with variant_url, or failed with prepare_error; three attempts) and posts only when ready.
- checkpoints holds the numbers read at 1, 3, and 7 days (d1, d3, d7). An item wins when its views reach win_target: half the original reel's views, or 10,000, whichever is lower. Winners stay trial reels: share one to your followers from the Instagram app.
/trial-reels/programs/:connectionIdturn the trial reel program for one Instagram account on or off, or change its settings
parameters & response
request body
| name | type | description |
|---|---|---|
enabledoptional | boolean | turn the program on or off. Turning it on the first time scans the account's reels (see the scan endpoint) and returns the result as scan. Needs the send scope. |
auto_approveoptional | boolean | post queued reels without approving each one (reels with no caption still wait for one). Turning it on approves what is already waiting. Needs the send scope. (default: false) |
reels_per_dayoptional | integer | trial reels a day, 1-3, one per posting hour (default: 1) |
skip_caption_termsoptional | array | up to 20 words or phrases (50 characters each); a reel whose caption contains one is never queued |
timezoneoptional | string | IANA timezone the posting hours are in, e.g. America/New_York (default: UTC) |
edit_kindsoptional | array | kinds new reels take in turn: reshare (the original video), background (on a colored background), subtitles (burned-in, for reels with speech and no captions on screen), hook (a strong later line played first), and sticker (a reply card with a comment Claude writes); a kind that has not shipped yet answers 400 (default: ["reshare"]) |
response
{
"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
| status | code | description |
|---|---|---|
| 400 | SERVER_002 | invalid body, unknown timezone, or the account can't post trial reels |
| 401 | AUTH_001 | missing or invalid api key |
| 403 | AUTH_002 | missing the send scope (enabled or auto_approve true), or not on pro or premium |
| 404 | SERVER_003 | no active Instagram connection with this id |
notes
- connectionId is the Instagram account's connection_id from GET /trial-reels (or GET /connections).
- nothing posts until a queued reel is approved (PATCH /trial-reels/items/{id}, approve-all, or auto_approve). Approved reels post one per posting hour, up to reels_per_day, never within 2 hours of another post on the same account: dropspace's own (scheduled or published) and any other post Instagram lists, such as one made by hand or another app's trial reels.
- each trial reel is a launch on your account, so it counts toward your plan's launches and shows on your launches page.
- the reel is posted as Instagram's trial reel (graduation MANUAL): only non-followers see it until you share it to followers in the Instagram app. @mentions are taken out of the reused caption.
/trial-reels/programs/:connectionId/scanscan the account's reels again and queue the strong ones
parameters & response
response
{
"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
| status | code | description |
|---|---|---|
| 400 | SERVER_002 | Instagram refused the read (reconnect the account) |
| 401 | AUTH_001 | missing or invalid api key |
| 403 | AUTH_002 | not on the pro or premium plan |
| 404 | SERVER_003 | no program for this connection (save one with PUT first) |
| 409 | SERVER_009 | a scan for this account is already running |
| 429 | RATE_001 | scanned less than 10 minutes ago |
notes
- reads the account's reels from the last 180 days (up to 100) with their views. A reel is queued when its views are at least 1.5x the median, it is older than 21 days, it has a video file Instagram will hand over, its caption has no skip term, it is on the profile grid (a trial reel, from dropspace or another app, is never reshared), and it was never queued before (removed and failed reels do not come back). Up to 20 wait at once.
- needs at least 5 reels with views older than 21 days; note says why when nothing was queued. One scan per account every 10 minutes.
- with auto_approve on, the new reels are approved (they will post) only when the key has the send scope; with the write scope alone they wait for approval.
/trial-reels/programs/:connectionId/approve-allapprove every waiting reel with a caption on one account's program
parameters & response
response
{
"data": { "approved": 6 }
}errors
| status | code | description |
|---|---|---|
| 401 | AUTH_001 | missing or invalid api key |
| 403 | AUTH_002 | missing the send scope, or not on pro or premium |
| 404 | SERVER_003 | no program for this connection |
notes
- approved reels post at the next posting hours, so this needs the send scope.
/trial-reels/items/:idapprove or hold a waiting reel, or change its caption
parameters & response
request body
| name | type | description |
|---|---|---|
approvedoptional | boolean | true lets it post at the next posting hour (send scope); false holds it |
captionoptional | string | the caption the trial reel posts with, up to 2,200 characters; an approved reel needs one |
kindoptional | string | reshare, background, subtitles, hook, or sticker: which version posts. A new kind holds the reel (like a new caption) and starts its edit over; one version per kind per reel. |
response
{
"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
| status | code | description |
|---|---|---|
| 400 | SERVER_002 | nothing to change, caption too long, or approving with no caption |
| 401 | AUTH_001 | missing or invalid api key |
| 403 | AUTH_002 | missing the send scope (approved: true), or not on pro or premium |
| 404 | SERVER_003 | trial reel not found |
| 409 | SERVER_009 | the reel is no longer waiting |
notes
- only waiting (queued) reels change; one that started posting answers 409.
- a new caption holds the reel (approved becomes false) unless approved: true is sent with it, so what posts is always a caption someone approved.
/trial-reels/items/:idremove a waiting reel from the queue for good
parameters & response
response
{
"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
| status | code | description |
|---|---|---|
| 401 | AUTH_001 | missing or invalid api key |
| 403 | AUTH_002 | not on the pro or premium plan |
| 404 | SERVER_003 | trial reel not found |
| 409 | SERVER_009 | the reel is no longer waiting |
notes
- a removed reel is never queued again by a scan.
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.
/reportsyour saved reports, newest first, without their bodies
parameters & response
query parameters
| name | type | description |
|---|---|---|
pageoptional | integer | page number (default: 1) |
page_sizeoptional | integer | reports per page, 1-100 (default: 50) |
response
{
"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
| status | code | description |
|---|---|---|
| 401 | AUTH_001 | missing or invalid api key |
/reportssave a markdown report
parameters & response
request body
| name | type | description |
|---|---|---|
titlerequired | string | 1-200 characters |
bodyrequired | string | the report in markdown, up to 100,000 characters |
kindoptional | string | audit, best_times, winners, or other (default: other) |
sourceoptional | string | mcp (saved by an AI app through the MCP server) or api (default: api) |
response
{
"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
| status | code | description |
|---|---|---|
| 400 | SERVER_002 | invalid json, missing title or body, or too long |
| 401 | AUTH_001 | missing or invalid api key |
| 403 | AUTH_002 | missing the write scope |
| 409 | SERVER_009 | 200 reports saved already |
notes
- at most 200 saved reports per user; a save past that answers 409 and nothing is deleted for you. delete old ones with DELETE /reports/{id}.
- the body is stored as written. the reports page renders headings, lists, bold, and links; raw HTML is shown as text.
/reports/:idone saved report with its markdown body
parameters & response
response
{
"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
| status | code | description |
|---|---|---|
| 401 | AUTH_001 | missing or invalid api key |
| 404 | SERVER_003 | report not found |
/reports/:iddelete a saved report
parameters & response
response
{
"data": { "id": "4c1e8f2a-7b3d-4e5f-9a0b-1c2d3e4f5a6b", "deleted": true }
}errors
| status | code | description |
|---|---|---|
| 401 | AUTH_001 | missing or invalid api key |
| 403 | AUTH_002 | missing the write scope |
| 404 | SERVER_003 | report not found |
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.
/reddit/subreddits/suggestICP-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
| name | type | description |
|---|---|---|
briefrequired | string (10-2000 chars) | what your product is and who it's for |
response
{
"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
| status | code | description |
|---|---|---|
| 400 | PLATFORM_001 | api key's account has no reddit connection |
| 400 | SERVER_002 | brief missing or out of range |
notes
- api-key auth only: needs the caller's reddit oauth token to verify subs + compute standing. wallet/mpp agents get an empty result.
- nothing here posts; it only reads. rate-limited per account (the discovery sweep is expensive on reddit's shared 60/min quota).
media library
the user's own photos and videos. autopilot posts them when its media_source is library or mixed, choosing each item by its description.
/media-librarylist media library items, newest first
parameters & response
query parameters
| name | type | description |
|---|---|---|
pageoptional | integer | page number (default: 1) |
page_sizeoptional | integer (1-100) | items per page (default: 50) |
response
{
"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 }
}/media-libraryadd 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
| name | type | description |
|---|---|---|
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
{
"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"
}
]
}
}/media-library/{id}set an item's description (what it shows); null clears it
parameters & response
request body
| name | type | description |
|---|---|---|
descriptionrequired | string (max 500) | null | what the media shows |
response
{
"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"
}
}/media-library/{id}remove an item from the library; launches that already posted it keep their media
parameters & response
response
{
"data": { "id": "uuid", "deleted": true }
}personas
manage AI writing personas for content generation.
/personaslist personas with pagination
parameters & response
query parameters
| name | type | description |
|---|---|---|
pageoptional | integer | page number (default: 1) |
page_sizeoptional | integer (1-100) | items per page (default: 50) |
response
{
"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 }
}/personascreate a new persona
parameters & response
request body
| name | type | description |
|---|---|---|
namerequired | string (1-100 chars) | persona name |
errors
| status | code | description |
|---|---|---|
| 409 | SERVER_009 | duplicate persona name |
| 429 | PERSONA_001 | persona creation limit reached |
/personas/:idget persona with all writing samples
parameters & response
errors
| status | code | description |
|---|---|---|
| 404 | PERSONA_002 | persona not found |
notes
- includes persona_analysis, persona_analysis_structured, custom_samples, twitter_samples, reddit_samples, facebook_samples, instagram_samples, tiktok_samples, linkedin_samples, brand_kit (omitted when unset)
/personas/:idupdate name and/or writing samples
parameters & response
request body
| name | type | description |
|---|---|---|
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 | null | how autopilot image slides look when autopilot posts as this persona: text_color and outline_color (#RRGGBB), logo_url (public http(s) image), logo_position (top-left | top-right | bottom-left | bottom-right, default top-right). omitted fields keep white text on a black outline; null clears the kit |
errors
| status | code | description |
|---|---|---|
| 404 | PERSONA_002 | persona not found |
| 409 | SERVER_009 | duplicate persona name |
/personas/:iddelete a persona
parameters & response
errors
| status | code | description |
|---|---|---|
| 404 | PERSONA_002 | persona not found |
| 422 | SERVER_005 | persona in use by launches |
notes
- cannot delete if used by any launches
/personas/:id/analyzetrigger AI persona analysis (async)
parameters & response
request body
| name | type | description |
|---|---|---|
platformsoptional | string[] | which platforms to analyze. omit to analyze every platform this persona has samples for; pass [] for none |
include_custom_samplesoptional | boolean | include custom_samples in the analysis (default: false) |
response
{ "data": { "started": true, "persona_id": "uuid" } }errors
| status | code | description |
|---|---|---|
| 404 | PERSONA_002 | persona not found |
| 409 | SERVER_009 | already building |
| 429 | PERSONA_003 | persona build limit reached |
notes
- listen for persona.analyzed webhook when complete
/personas/:id/finalizecommit the pending analysis (after analyze) - content generation reads the committed analysis
parameters & response
request body
| name | type | description |
|---|---|---|
persona_analysisoptional | string (max 2000) | optionally edited analysis text (defaults to the pending analysis) |
persona_analysis_structuredoptional | object | optionally edited structured analysis |
response
{ "data": { "success": true, "analysis_limit": { "used": 1, "limit": 3, "remaining": 2 } } }errors
| status | code | description |
|---|---|---|
| 400 | PERSONA_004 | persona is not ready for review |
| 404 | PERSONA_002 | persona not found |
notes
- free - usage was charged at analyze. call once build_status is ready_for_review
/personas/:id/discarddiscard the pending analysis (preserves any previously committed analysis)
parameters & response
response
{ "data": { "success": true } }errors
| status | code | description |
|---|---|---|
| 400 | PERSONA_004 | persona is not ready for review |
| 404 | PERSONA_002 | persona not found |
notes
- free - analyze usage is not refunded
autopilot
the autonomous content engine. autopilot studies your post performance nightly, drafts new posts in your voice, and schedules them on your connected accounts. review-first by default; pro and premium plans only.
/autopilotget autopilot status and config
parameters & response
response
{
"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
/autopilotcreate or update the autopilot config (merge: only the fields you send change)
parameters & response
request body
| name | type | description |
|---|---|---|
enabledoptional | boolean | turn 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 strings | platforms 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 | null | voice 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 | null | product link included in posts |
product_image_urloptional | string | null | reference image for slideshow (visual) posts - each slide is generated from it so it shows the real product. omitted: filled from product_url's page when product_url changes. empty or null: no reference |
product_briefoptional | string (max 2000) | null | what you're building, who it's for |
content_pillarsoptional | array of strings (max 10) | null | themes the engine rotates across |
directivesoptional | array of strings (max 10, each max 200 chars) | null | standing 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 | boolean | wrap the post's outbound product link in a dropspace-owned tracked redirect, so click-throughs count toward what autopilot learns from (default: false) |
reuse_mediaoptional | boolean | reuse the day's first finished AI slideshow or video across your other platforms' posts that day - identical media, per-platform captions; claims spend no generation quota (default: false) |
media_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 | object | per-platform posting settings stamped into every autopilot launch. tiktok: privacy_level (defaults to SELF_ONLY - posts publish visible only to the account until widened), allow_comments/allow_duet/allow_stitch, commercial disclosure (is_commercial + is_your_brand/is_branded_content). youtube: privacy_status (defaults to private), made_for_kids; AI-content disclosure is always on for autopilot videos (default: {}) |
errors
| status | code | description |
|---|---|---|
| 400 | SERVER_002 | invalid timezone or no usable connected account for a platform |
| 403 | BILLING_006 | plan does not include autopilot |
notes
- posts created by autopilot consume your monthly launch limit
- visual and video formats also consume your monthly AI media limits
/autopilot/reviewlist drafts awaiting approval (review mode)
parameters & response
query parameters
| name | type | description |
|---|---|---|
pageoptional | integer | page number (default: 1) |
page_sizeoptional | integer (1-50) | items per page (default: 20) |
response
{
"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
/autopilot/review/:launchId/approveapprove a draft - schedules it for publishing
parameters & response
response
{
"data": { "launch_id": "uuid", "status": "scheduled", "scheduled_date": "ISO 8601" }
}errors
| status | code | description |
|---|---|---|
| 404 | SERVER_003 | draft not found |
notes
- a stale scheduled_date rolls forward to the next posting slot automatically
/autopilot/review/:launchId/rejectreject a draft - deletes it and refunds the launch slot
parameters & response
request body
| name | type | description |
|---|---|---|
feedbackoptional | string (max 500) | what to avoid next time - feeds future generation |
response
{
"data": { "launch_id": "uuid", "rejected": true }
}errors
| status | code | description |
|---|---|---|
| 404 | SERVER_003 | draft not found |
/autopilot/runtrigger a manual run now (instead of waiting for the nightly run)
parameters & response
response
{
"data": { "queued": true, "run_date": "2026-06-12" }
}errors
| status | code | description |
|---|---|---|
| 400 | SERVER_002 | autopilot is not enabled |
| 403 | BILLING_006 | plan does not include autopilot |
| 404 | SERVER_003 | autopilot is not set up yet |
| 429 | RATE_001 | manual runs are limited to 2 per hour |
notes
- the insight and idea steps that already completed today are skipped; the scheduling step runs again, filling only slots that have no post yet, so a manual run after a fix (media added, a platform unticked) tops up the same day rather than double-posting
/autopilot/runsrecent run history (one row per run step)
parameters & response
query parameters
| name | type | description |
|---|---|---|
limitoptional | integer (1-30) | rows to return (default: 9) |
response
{
"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.
/recommendationslist recommendations
parameters & response
query parameters
| name | type | description |
|---|---|---|
pageoptional | integer | page number (default: 1) |
page_sizeoptional | integer (1-100) | results per page (default: 20) |
statusoptional | "pending" | "applied" | "dismissed" | "expired" | filter by status |
launch_idoptional | UUID | only recommendations for this launch |
response
{
"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
/recommendations/:id/applyapply a recommendation (executes its one-click action)
parameters & response
request body
| name | type | description |
|---|---|---|
ad_account_idoptional | string | meta ad account id: required for boost_winner recommendations, ignored otherwise |
response
{
"success": true,
"data": {
"recommendation": { "id": "uuid", "rec_type": "repost_winner", "status": "applied", "...": "..." },
"outcome": { "kind": "launch_draft", "launch_id": "uuid" }
}
}errors
| status | code | description |
|---|---|---|
| 400 | SERVER_002 | missing ad_account_id for a boost recommendation |
| 404 | SERVER_003 | recommendation not found or not owned |
| 409 | SERVER_009 | already applied/dismissed, expired, or autopilot not enabled |
notes
- repost_winner and cross_post create a DRAFT launch seeded with the winning content. nothing publishes until you schedule it
- boost_winner creates a PAUSED boost with the suggested budget. activate it separately to start spend
- promote_winner queues the winner into your autopilot queue (autopilot must be enabled)
- pause_underperformer adds a standing directive to your autopilot config
/recommendations/:id/dismissdismiss a recommendation
parameters & response
response
{
"success": true,
"data": { "id": "uuid", "rec_type": "repost_winner", "status": "dismissed", "...": "..." }
}errors
| status | code | description |
|---|---|---|
| 404 | SERVER_003 | recommendation not found or not owned |
| 409 | SERVER_009 | recommendation is already applied or dismissed |
media
generate AI images and videos for your launches.
/media/generatesubmit an image or video generation job
parameters & response
request body
| name | type | description |
|---|---|---|
typerequired | "image" | "video" | "script_video" | generation type |
launch_idrequired | UUID | associated launch |
platformrequired | string | target platform for the asset - always required, any supported platform. script_video only accepts "instagram" or "tiktok" |
promptoptional | string | generation prompt - either prompt or product_description is required; script_video requires an explicit prompt. the effective prompt (yours, or the one derived from product_description) must be 10-2000 chars or the request fails with a 400 |
product_descriptionoptional | string | product 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 | 8 | video duration (default: 8) |
options.seedoptional | number | generation seed for reproducible output - forwarded to the model for text-to-image and video jobs; ignored when reference_image_url is set (the edit model does not support seeds) |
options.negative_promptoptional | string | things to steer the model away from - video generation only (the image model does not support negative prompts; ignored for image jobs) |
reference_image_urloptional | URL | URL of a reference image for style/composition guidance (image type only, uses edit endpoint) |
use_product_imageoptional | boolean | use the first image from the launch's product page (scraped from product_url) as the reference image - image type only, ignored when reference_image_url is set, 400 when the launch has no product image |
response
{
"data": {
"status": "processing",
"job_id": "uuid",
"fal_request_id": "string",
"generation_type": "image",
"usage": { "used": 3, "limit": 50, "remaining": 47 },
"plan": "pro"
}
}errors
| status | code | description |
|---|---|---|
| 403 | AUTH_002 | plan doesn't include media generation |
| 404 | LAUNCH_001 | launch not found or not owned |
| 429 | MEDIA_001 | monthly media generation limit reached |
| 429 | RATE_001 | concurrency limit reached |
notes
- type 'script_video' generates a video from a text script (requires platform 'instagram' or 'tiktok' and an explicit prompt). type 'video' generates from a visual/cinematic prompt
/media/:jobIdpoll media generation status
parameters & response
response
{
"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
| status | code | description |
|---|---|---|
| 404 | SERVER_003 | media job not found |
notes
- listen for media.ready webhook when status becomes completed
account
your profile, and the billing portal hand-off.
/profilethe authenticated user's profile
parameters & response
response
{
"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
| status | code | description |
|---|---|---|
| 404 | SERVER_004 | profile not found |
/profileupdate your display name
parameters & response
request body
| name | type | description |
|---|---|---|
full_namerequired | string (1-120 chars) | display name |
response
{ "data": { "id": "uuid", "full_name": "string | null", "avatar_url": "string | null" } }/billing/portala Stripe billing-portal session for managing your subscription. falls back to the web billing page when no Stripe customer exists yet.
parameters & response
response
{ "data": { "url": "https://billing.stripe.com/session/..." } }connections
view and disconnect your OAuth platform connections.
/connections/:platform/connectstart 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
| name | type | description |
|---|---|---|
platformrequired | string | platform to connect (path parameter) |
response
{ "data": { "auth_url": "https://...", "state": "string" } }errors
| status | code | description |
|---|---|---|
| 400 | SERVER_003 | invalid platform |
/connectionslist your OAuth platform connections
parameters & response
query parameters
| name | type | description |
|---|---|---|
pageoptional | integer | page number (default: 1) |
page_sizeoptional | integer (1-100) | items per page (default: 50) |
response
{
"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.
/connections/:platformdisconnect 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
| name | type | description |
|---|---|---|
account_idoptional | string | optional query param: disconnect only THIS account (its `entity_id` from GET /connections) instead of the whole platform. supported on instagram/tiktok/youtube/twitter/reddit; disconnecting the primary promotes another connected account to primary so your posting keys stay valid |
response
{
"data": { "message": "twitter disconnected successfully" }
}errors
| status | code | description |
|---|---|---|
| 404 | PLATFORM_001 | connection not found |
| 500 | PLATFORM_004 | unable to disconnect - please try again |
notes
- reconnect by re-running the OAuth flow from the dashboard or the in-app connect button
- omit `account_id` to disconnect every account on the platform; pass it to remove just one
/connections/:platform/profileedit 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
{
"data": {
"platform": "youtube",
"description": "weekly drops, behind the scenes, and launch-day streams."
}
}errors
| status | code | description |
|---|---|---|
| 404 | PLATFORM_001 | platform not connected |
| 400 | SERVER_002 | platform can't edit the profile (detail in message) |
notes
- the change is made on youtube itself via channels.update (50 quota units); the stored profile in GET /connections is refreshed at the same time.
dropspace
check which official dropspace accounts are connected and available for posting.
/dropspace/statuscheck which official dropspace accounts are connected
parameters & response
response
{
"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.
/keys/meget the current API key's info
parameters & response
response
{
"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
/keyslist all API keys
parameters & response
response
{
"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"
}
]
}/keyscreate a new API key (max 10)
parameters & response
request body
| name | type | description |
|---|---|---|
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
{
"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
/keys/:idrename an API key
parameters & response
request body
| name | type | description |
|---|---|---|
namerequired | string (1-100 chars) | new key name |
response
{
"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
| status | code | description |
|---|---|---|
| 404 | SERVER_003 | api key not found |
| 409 | SERVER_009 | an api key with this name already exists |
/keys/:id/rotaterotate 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
{
"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.
/keys/:idrevoke an API key
parameters & response
errors
| status | code | description |
|---|---|---|
| 404 | SERVER_003 | api key not found |
webhooks
manage webhook endpoints for event notifications.
/webhookslist webhook endpoints
parameters & response
response
{
"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
/webhookscreate a webhook endpoint (max 10)
parameters & response
request body
| name | type | description |
|---|---|---|
urlrequired | HTTPS URL | webhook delivery URL (public https). a Slack incoming webhook URL (https://hooks.slack.com/services/...) receives each event as a readable Slack message instead of the signed JSON envelope |
eventsrequired | string[] | event types to subscribe to |
response
{
"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
| status | code | description |
|---|---|---|
| 400 | SERVER_002 | invalid webhook url or events |
| 400 | SERVER_002 | maximum 10 webhook endpoints allowed |
notes
- the secret is shown only once - store it securely for signature verification
- available events: "launch.completed" (launch finished publishing), "launch.failed" (launch failed to publish), "launch.partial" (launch partially succeeded), "media.ready" (media generation completed), "persona.analyzed" (persona analysis completed), "post.deleted" (a published post was detected as deleted from its platform), "recommendation.created" (new recommendations were generated for the account), "connection.expired" (a connected account's token was invalidated and needs reauth), "connection.restored" (an expired connection is live again after a successful refresh - no reconnect needed)
- launch.completed / launch.partial / launch.failed payload: { "event": "launch.completed", "data": { "launch_id": "uuid", "launch_name": "...", "platforms": [ { "platform": "tiktok", "status": "success", "post_url": null, "error_message": null, "pending": false, "publish_mode": "draft", "caption": "the text to paste", "media_type": "video" } ] } } - publish_mode is "direct", or "draft" for a TikTok draft that sits in the creator's inbox until they post it from the TikTok app; caption then carries the composed text and is null otherwise; media_type is "video" or "photo" for a draft (null otherwise): TikTok drops a video draft's caption, so the creator pastes it, and keeps a photo draft's; pending is true while a post is accepted but not yet live, and the event fires again with the final post_url
- 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
/webhooks/:idget a webhook endpoint
parameters & response
response
{
"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
| status | code | description |
|---|---|---|
| 404 | SERVER_003 | webhook not found |
/webhooks/:idupdate url, events, or active status
parameters & response
request body
| name | type | description |
|---|---|---|
urloptional | HTTPS URL | new webhook URL |
eventsoptional | string[] | new event subscriptions |
activeoptional | boolean | enable/disable endpoint |
errors
| status | code | description |
|---|---|---|
| 404 | SERVER_003 | webhook not found |
| 400 | SERVER_002 | invalid webhook url or events |
/webhooks/:iddelete a webhook endpoint
parameters & response
errors
| status | code | description |
|---|---|---|
| 404 | SERVER_003 | webhook not found |
/webhooks/:id/testsend a synthetic webhook.test event to the registered URL
parameters & response
response
{
"data": {
"success": true,
"delivery_id": "uuid",
"response_status": 200,
"response_body": "OK"
}
}errors
| status | code | description |
|---|---|---|
| 404 | SERVER_003 | webhook not found |
notes
- posts a `webhook.test` event signed with the webhook's secret to the registered URL
- returns the receiver's response status and a (truncated) response body for debugging
- creates a delivery row that shows up in `/webhooks/:id/deliveries`
- 10s timeout: `response_status: 408` indicates the receiver didn't respond in time
/webhooks/:id/rotate-secretrotate the signing secret (new secret shown once)
parameters & response
response
{
"data": {
"id": "uuid",
"secret": "a1b2c3d4..."
}
}errors
| status | code | description |
|---|---|---|
| 404 | SERVER_003 | webhook not found |
notes
- the new secret is shown only once - update your verification code immediately
- the old secret becomes invalid immediately after rotation
- in-flight webhook deliveries (already queued) use the secret from enqueue time and are not affected
/webhooks/:id/deliverieslist delivery attempts with pagination
parameters & response
query parameters
| name | type | description |
|---|---|---|
pageoptional | integer | page number (default: 1) |
page_sizeoptional | integer (1-100) | items per page (default: 50) |
response
{
"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
| status | code | description |
|---|---|---|
| 404 | SERVER_003 | webhook not found |
notes
- `failed_at` is set on terminal failures (delivered_at and failed_at are mutually exclusive, mirroring iris). `next_retry_at` is null when QStash is handling retries opaquely.
usage
check your current plan, billing period, and usage limits.
/usageget plan limits and current usage
parameters & response
response
{
"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.
/creditsget credit balance and available packs
parameters & response
response
{
"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
/credits/purchasepurchase a credit pack via MPP payment
parameters & response
request body
| name | type | description |
|---|---|---|
packoptional | string | credit pack ID to purchase (default: "pack_20") (default: "pack_20") |
response
{
"data": {
"credits_added": 20,
"new_balance": 27,
"pack": { "id": "pack_20", "credits": 20, "price_usd": "12.00" }
}
}errors
| status | code | description |
|---|---|---|
| 400 | SERVER_002 | x402 wallets cannot purchase credits (MPP only) |
| 402 | PAYMENT_001 | MPP payment required - credential missing or expired |
| 500 | PAYMENT_004 | payment processed but credits could not be added (contact support) |
notes
- MPP agents only - x402 wallets are rejected with 400 (crypto has low fees already)
- the MPP credential must be for the pack price ($12.00 = 1200 cents), not the per-launch price
- credits never expire and are deducted automatically when creating launches
- the launch fee is charged once, when the launch is created, and covers the publishing attempt: a launch that ends partial or failed is not refunded. if creating the launch fails after payment, a used credit or free-tier launch is given back automatically; a per-launch x402 or MPP payment is not reversed automatically, so contact support with the request id.
- duplicate purchases with the same MPP credential are rejected (idempotent)
boosts
create paid boost campaigns on meta for organic facebook or instagram launches. boosts always start PAUSED. activate them explicitly to begin ad spend. ad budget is billed by meta to the user's connected ad account, not by dropspace.
/launches/{id}/boostcreate a paid boost on meta for an organic facebook or instagram launch (always PAUSED on create)
parameters & response
request body
| name | type | description |
|---|---|---|
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 | string | explicit meta ad account id (e.g. "act_123"); defaults to the first active account |
response
{
"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
| status | code | description |
|---|---|---|
| 403 | ADS_002 | facebook ads_management scope not granted; reauthorize with ads scopes |
| 403 | PLATFORM_001 | facebook account not connected |
| 409 | ADS_007 | launch has no successful facebook post to boost. publish to facebook first |
| 409 | ADS_001 | no active facebook ad account found; set one up in meta business manager |
notes
- requires the launch to have a successful facebook post (post_id available in posting_logs).
- boost is created in PAUSED status. call POST /launches/{id}/boost/{boost_id}/activate to start spending.
- creative reuses the existing organic post via object_story_id, so no creative upload is required.
- meta enforces ad spend on the user's ad account. dropspace never holds card data for ad budgets.
/launches/{id}/boostlist all boosts attached to a launch
parameters & response
response
{
"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
}
]
}/launches/{id}/boost/{boost_id}fetch a single boost with the latest performance metrics
/launches/{id}/boost/{boost_id}/activateflip a PAUSED boost to ACTIVE on meta. ad spend begins immediately
parameters & response
errors
| status | code | description |
|---|---|---|
| 409 | ADS_006 | boost is already active |
| 409 | SERVER_009 | cannot activate a boost in a terminal status (canceled / completed / failed) |
notes
- no payment required: boost was already paid for at create time.
- flips campaign → ad set → ad in order so the ad inherits ACTIVE status correctly.
/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.
/changelogrelease 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
{
"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
| event | fired when |
|---|---|
| launch.completed | all 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.failed | all platforms failed |
| launch.partial | some platforms succeeded, some failed |
| media.ready | image or video generation completed |
| persona.analyzed | AI persona analysis finished |
| post.deleted | a published post was detected as deleted from its platform |
| connection.expired | a connected account's token was invalidated (revoked or unrefreshable) and needs reauth |
| connection.restored | a 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.created | new 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
| header | description |
|---|---|
| Content-Type | application/json |
| X-Dropspace-Signature | hex-encoded HMAC-SHA256 of {timestamp}.{body} |
| X-Dropspace-Timestamp | unix timestamp (seconds), reject if older than 5 minutes |
| X-Dropspace-Delivery | unique 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-Deliveryheader 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
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) | |
|---|---|---|
| header | PAYMENT-SIGNATURE: <base64> | Authorization: Payment <base64url> |
| payment method | USDC on Base | Stripe card charge |
| settlement | on-chain transfer | Stripe charge |
| 402 challenge | JSON body x402 object | WWW-Authenticate: Payment header |
| identity | wallet address | payer 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
| endpoint | payment required |
|---|---|
POST /launches | after 5 free launches/month for x402 ($0.55 x402 / $0.60 MPP) |
GET /launches | free |
GET /launches/:id | free |
PATCH /launches/:id | free |
DELETE /launches/:id | free |
POST /launches/:id/publish | free (paid at creation, not publish) |
GET /launches/:id/status | free |
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.
pricing
| pack | credits | price | per launch |
|---|---|---|---|
| pack_20 | 20 | $12.00 | $0.60 |
how it works
- check balance -
GET /creditsreturns your current credit balance and available packs - purchase -
POST /credits/purchasewith 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
| code | HTTP | meaning |
|---|---|---|
| AUTH_001 | 401 | invalid or revoked API key |
| AUTH_002 | 403 | plan restriction (feature not available) |
launches
| code | HTTP | meaning |
|---|---|---|
| LAUNCH_001 | 404 | launch not found |
| LAUNCH_002 | 429 | launch or regeneration limit exceeded |
| LAUNCH_003 | 409 | invalid launch status for operation |
| LAUNCH_004 | 409 | launch cannot be published from current status |
| LAUNCH_007 | 400 | platform requirements not met |
content moderation
| code | HTTP | meaning |
|---|---|---|
| CONTENT_004 | 400 | content blocked by official-account safety moderation (do not resubmit the same content) |
| CONTENT_005 | 503 | content safety check temporarily unavailable (retry with the same content) |
personas
| code | HTTP | meaning |
|---|---|---|
| PERSONA_001 | 429 | persona creation limit reached |
| PERSONA_002 | 404 | persona not found |
| PERSONA_003 | 429 | persona build limit reached |
media
| code | HTTP | meaning |
|---|---|---|
| MEDIA_001 | 429 | monthly media generation limit reached |
uploads
| code | HTTP | meaning |
|---|---|---|
| UPLOAD_001 | 400 | unsupported media type (allowed: jpeg, png, webp, gif, mp4, mov) |
| UPLOAD_002 | 400 | media file too large (images: 5MB, videos: 512MB URL / 4MB base64) |
| UPLOAD_003 | 400 | media storage upload failed |
payment (x402 / mpp)
| code | HTTP | meaning |
|---|---|---|
| PAYMENT_001 | 402 | payment required (free tier exceeded) |
| PAYMENT_002 | 402 | payment verification failed |
| PAYMENT_003 | 402 | MPP credential verification failed |
rate limiting
| code | HTTP | meaning |
|---|---|---|
| RATE_001 | 429 | too many requests |
validation & server
| code | HTTP | meaning |
|---|---|---|
| SERVER_001 | 500 | internal server error |
| SERVER_002 | 400 | validation error |
| SERVER_003 | 404 | resource not found |
| SERVER_004 | 405 | method not allowed |
| SERVER_005 | 400 | invalid input format or business rule violation |
| SERVER_008 | 500 | database error |
| SERVER_009 | 409 | conflict (e.g. status transition race) |
posting error codes
a failed entry in GET /launches/:id/status or /destinations carries error_code, a stable code to branch on, and error_message, text to show a person. both arrive in a 200 response. POST /launches/:id/retry re-runs every failed destination whatever its code, so fix the cause first, and check the platform before retrying a timeout or a partial thread. treat a code not listed here as a generic failure.
accounts
| code | meaning | what to do |
|---|---|---|
| AUTH_ERROR | the platform rejected the account's sign-in. | reconnect the account, then retry. |
| TOKEN_EXPIRED | the account's access expired and could not be renewed. | reconnect the account, then retry. |
| TOKEN_REFRESH_FAILED | renewing the account's access failed while publishing. error_message says whether the account is still connected. | retry in a few minutes. reconnect the account if error_message says to, or if it keeps failing. |
| TOKEN_NOT_FOUND | the account chosen for this destination is no longer connected. | reconnect it, or choose another account. |
| NO_ACCESS_TOKEN | the connection exists but holds no usable access. | reconnect the account, then retry. |
| INVALID_TOKEN | the Facebook connection used is not a Page connection. | reconnect Facebook and select the Page. |
| NO_ACCOUNT | nothing to post from: the launch lists the platform with no user_platform_accounts key and no dropspace official posting, or the chosen account could not be found. | connect the account and put its key in user_platform_accounts (ids come from GET /connections). |
| PERMISSION_DENIED | the account has not granted a permission this post needs. | reconnect and grant the requested permissions. |
| NOT_ELIGIBLE | the platform refused a post type this account cannot use yet: an Instagram trial reel from an account below Instagram's bar (a professional account with 200 or more followers). | post it as a feed reel, or grow the account past the bar. reconnecting changes nothing. |
| SCOPE_NOT_AUTHORIZED | TikTok posting permission was not granted when the account connected. | reconnect TikTok and allow posting. |
| NO_ORG_SELECTED | LinkedIn organization posting was requested, but the connection has no organization. | reconnect LinkedIn and pick the company page. |
| USER_INFO_ERROR | LinkedIn did not return the member profile needed to post. | reconnect LinkedIn. |
| DROPSPACE_TOKEN_ERROR | the dropspace official account for this platform was temporarily unavailable. | retry later. |
limits
| code | meaning | what to do |
|---|---|---|
| RATE_LIMIT | the platform rate-limited the request. | wait, then retry. |
| DAILY_POST_CAP | the account reached TikTok's daily posting limit. | retry after the limit resets. |
| USER_CAP_REACHED | TikTok's 24-hour cap on active creators for dropspace's app was reached, or Instagram's limit on trial reels published through its API. | retry later. |
| RATE_LIMIT_CIRCUIT_BREAK | skipped without posting: an earlier subreddit in this launch hit Reddit's rate limit. | retry this subreddit on its own with POST /launches/:id/retry and { "subreddit": "<name>" }. |
content
| code | meaning | what to do |
|---|---|---|
| NO_CONTENT | the launch has no content for this platform. | add content for it. |
| VALIDATION_ERROR | the post broke a platform rule on length, a required field, or a media source. | fix what error_message names before posting again. |
| DUPLICATE_CONTENT | the platform says this exact content was already posted. | change the content before posting again. |
| CONTENT_RESTRICTION | the platform blocked the content: a banned link, too long, already submitted, or a removed or locked target. | change or remove what error_message names. |
| MISSING_COMPANY_CONTENT | LinkedIn company posting was selected, but the company-voice content is empty. | add company content. |
| CONTENT_MODERATION_FAILED | moderation blocked a post from a dropspace official account. | revise the content. |
| CONTENT_MODERATION_UNAVAILABLE | the moderation check for a dropspace official account post could not run. | retry later. |
media
| code | meaning | what to do |
|---|---|---|
| UPLOAD_FAILED | uploading the media to the platform failed. | check that the file is reachable and within the platform's limits, then retry. |
| CAROUSEL_ERROR | fewer than two images of an Instagram carousel uploaded. | replace the images that failed, then retry. |
| INVALID_MEDIA_URL | Instagram could not fetch the media URL. | use a publicly reachable URL. |
| INVALID_VIDEO_URL | the video URL could not be fetched: by TikTok, which pulls it itself, or by dropspace, which uploads it to LinkedIn and got a 4xx from it. | use a publicly reachable URL. |
| INVALID_VIDEO_FORMAT | TikTok rejected the video's format. | re-encode the video (MP4 with H.264 is the safe choice). |
| VIDEO_FORMAT_ERROR | Facebook rejected the video's format. | re-encode the video as MP4. |
| VIDEO_TOO_LARGE | the video is over Facebook's size limit. | use a smaller file. |
| MEDIA_PROCESSING_FAILED | the platform processed the media and rejected it for its format, length, or size. | fix the file. |
| PROCESSING_TIMEOUT | the platform was still processing the media when dropspace stopped waiting. | check the platform first: the post may already be live, and a retry can post it twice. |
platform rules
| code | meaning | what to do |
|---|---|---|
| PLATFORM_DISABLED | posting to this platform is turned off in dropspace. | contact support. |
| POLICY_BLOCK | Facebook restricted the account, often until its owner confirms their identity. | resolve it in Facebook, then retry. |
| USER_BANNED | TikTok temporarily blocked the account from posting. | retry after TikTok lifts the block. |
| PRIVATE_ONLY | TikTok only accepted a post to a private account here. | contact support. |
| DOMAIN_NOT_VERIFIED | TikTok has not verified the domain dropspace serves media from. | contact support. there is nothing to fix on your side. |
| PRIVACY_LEVEL_MISMATCH | the chosen TikTok privacy level is not available for this account. | choose a privacy level the account allows. |
| SUBREDDIT_RESTRICTION | the subreddit does not allow this post type (links, text, images, or video). | change the post type or pick another subreddit. |
| SUBREDDIT_ACCESS_DENIED | the subreddit does not exist, or the account is not allowed to post there. | pick another subreddit. |
| TIKTOK_<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_FAILED | TikTok marked the post failed without giving a reason. | retry. contact support if it keeps happening. |
| PROMO_RATIO_EXCEEDED | skipped: posting would break Reddit's 90:10 self-promotion norm for this account in that subreddit. | post there later, or pick another subreddit. |
| SUBREDDIT_ALREADY_TARGETED | skipped: another of the creator's Reddit accounts is posting to this subreddit on this launch. | nothing to do. one account posts to each subreddit per launch. |
| INVALID_OPTION | Reddit rejected a post option. | adjust what error_message names. |
| REDDIT_<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
| code | meaning | what to do |
|---|---|---|
| PLATFORM_API_ERROR | the platform returned an error with no more specific code. | retry. contact support if it keeps happening. |
| INVALID_RESPONSE | Reddit accepted the request but returned no post. | check whether the post appeared before retrying. |
| NO_PUBLISH_ID | TikTok accepted the upload request but returned no publish id. | retry. contact support if it keeps happening. |
| PARTIAL_THREAD | an X thread failed partway, after some of its posts went out. | check X first: a retry posts the whole thread again from the first post. |
| UNKNOWN_ERROR | an unexpected error while posting. | retry. contact support if it keeps happening. |
| EXCEPTION | an unexpected error during a posting attempt. | retry. contact support if it keeps happening. |
| MAX_RETRIES | every automatic attempt failed without a more specific error. | retry. contact support if it keeps happening. |
| TIMEOUT | a request to the platform timed out. | check whether the post appeared before retrying, to avoid a duplicate. |
| PROMISE_REJECTED | a retry of this destination failed unexpectedly. | retry. contact support if it keeps happening. |
deleting posts
| code | meaning | what to do |
|---|---|---|
| DELETE_FAILED | the platform refused or failed the delete. | retry, or delete the post on the platform. |
| DELETE_NOT_SUPPORTED | the platform does not allow deleting posts through its API. | delete the post on the platform. |