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 — 76 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 | 4 |
| 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 | get_profile, update_profile, create_billing_portal_session | 3 |
| 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 | 1 |
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 |
| 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.
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 | schedule for future (≥ 15 min from now) |
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 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 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 | null | schedule for future (≥ 15 min from now), null to unschedule |
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. 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, 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, engagement, saved, likes, comments, shares), 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.
/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) |
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.
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
- steps that already completed today are skipped, so a manual run tops up rather than double-posts
/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,
"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
- 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)
- 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 |
| 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. |
| 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. | 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. |