developer API v1

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

getting started

create an API key

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

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

base URL

https://api.dropspace.dev

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

make your first request

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

quick example

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

step 1 — create a launch

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

step 2 — review and optionally regenerate

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

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

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

step 3 — publish

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

step 4 — check status

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

reddit — targeting subreddits

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

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

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

authentication & rate limits

bearer token

every request requires an Authorization header:

Authorization: Bearer ds_live_YOUR_KEY

invalid or revoked keys return 401 with error code AUTH_001.

rate limits

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

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

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

response headers

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

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

scopes

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

available scopes

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

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

missing scope error

requests without the required scope receive a 403 response:

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

recommended scopes by use case

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

audit logging

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

audited operations

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

idempotency

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

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

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

key requirements

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

replay detection

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

MCP server

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

install

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

hosted endpoint

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

https://api.dropspace.dev/mcp

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

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

available tools

categorytoolscount
launcheslist_launches, get_launch, get_launch_status, get_launch_analytics, get_batch_launch_analytics, create_launch, update_launch, delete_launch, generate_content, retry_launch_content, publish_launch, retry_launch, get_launch_destinations13
postsdelete_post, delete_all_posts2
commentslist_launch_comments, reply_to_comment, hide_comment, delete_comment4
personaslist_personas, get_persona, create_persona, update_persona, delete_persona, analyze_persona, finalize_persona, discard_persona_analysis8
mediagenerate_media, get_media_job, list_media_library, add_to_media_library, update_media_library_item, delete_media_library_item6
connectionslist_connections, disconnect_connection, update_connection_profile3
boostslist_boosts, get_boost, get_boost_performance, create_boost, activate_boost, cancel_boost6
creditsget_credit_balance, purchase_credit_pack2
API keysget_current_api_key, list_api_keys, rename_api_key, create_api_key, revoke_api_key, rotate_api_key6
webhookslist_webhooks, get_webhook, list_webhook_deliveries, create_webhook, update_webhook, delete_webhook, test_webhook, rotate_webhook_secret8
dropspaceget_dropspace_status1
usageget_usage, get_changelog2
accountget_profile, update_profile, create_billing_portal_session3
autopilotget_autopilot, list_autopilot_review, list_autopilot_runs, reject_autopilot_draft, configure_autopilot, approve_autopilot_draft, run_autopilot_now7
recommendationslist_recommendations, apply_recommendation, dismiss_recommendation3
reddit engagementsuggest_subreddits1
audienceget_audience1

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

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

CLI

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

install

npm install -g @jclvsh/dropspace

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

commands

groupcommandscount
launcheslist, create, get, update, delete, publish, retry, generate, retry-content, analytics, batch-analytics, status, destinations, delete-post, delete-posts, comments list, comments reply, comments hide, comments delete19
personaslist, create, get, update, delete, analyze, finalize, discard8
mediagenerate, job, library list, library add, library describe, library delete6
connectionslist, disconnect, profile3
autopilotstatus, configure, review, approve, reject, run, runs7
recommendationslist, apply, dismiss3
boostscreate, list, get, activate, cancel, performance6
audiencefollowers and growth across connected accounts1
redditsuggest1
creditsbalance, purchase2
keysme, list, create, rename, revoke, rotate6
webhookslist, create, get, update, delete, test, rotate-secret, deliveries8
statusdropspace official account connections1
usageplan, limits, and billing period1
changelogrecent API changes1
profileget, update2
billingportal1

examples

# list your launches
dropspace launches list

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

# check posting status
dropspace launches status <id>

# get analytics
dropspace launches analytics <id>

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

# check your API key
dropspace keys me

# table output
dropspace --format table launches list

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

agent skill

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

install for Claude Code

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

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

API reference

launches

create, manage, and publish launches across multiple platforms.

GET/launches

list launches with pagination

parameters & response

query parameters

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

response

200 OK
{
  "data": [
    {
      "id": "uuid",
      "name": "string",
      "status": "draft|manual|trigger|scheduled|running|completed|partial|failed|cancelled",
      "platforms": ["twitter", "reddit"],
      "scheduled_date": "ISO 8601 | null",
      "product_url": "string | null",
      "product_description": "string | null",
      "persona_id": "uuid | null",
      "media_mode": "images | video | null",
      "media_assets": [
        { "type": "image", "url": "https://...", "fal_request_id": "..." }
      ] | null,
      "dropspace_platforms": ["twitter"] | null,
      "user_platform_accounts": {
        "twitter": "token-uuid",
        "linkedin:personal": "token-uuid",
        "facebook:page:123456": "token-uuid"
      } | null,
      "created_at": "ISO 8601",
      "updated_at": "ISO 8601"
    }
  ],
  "pagination": { "page": 1, "page_size": 50, "total": 42, "total_pages": 1 }
}
POST/launches

create a launch with AI-generated or custom content

parameters & response

request body

nametypedescription
titlerequired
string (1-200 chars)launch title
product_descriptionoptional
string (1-2000 chars)product description: required for AI generation unless product_url, custom_content, or platform_contents is provided
platformsrequired
string[] (1-10)target platforms - can be omitted when dropspace_platforms or user_platform_accounts is provided (inferred from their union)
product_urloptional
URLproduct URL: scraped to generate content; satisfies the description requirement on its own
track_linksoptional
booleanswap product_url in the post copy for a tracked dropspace.dev redirect on twitter, linkedin, facebook, reddit, and youtube (one link per posted item); clicks by people, not link-preview crawlers, appear as metrics.tracked_clicks in analytics. instagram, tiktok, and snapchat keep the plain URL. API-key launches only: x402 and MPP launches post to dropspace's official accounts and ignore it. default false
save_to_libraryoptional
booleanadd the launch's uploaded (non-AI) media_assets to the media library, where autopilot can post them. API-key launches only. default false
scheduled_dateoptional
ISO 8601schedule for future (≥ 15 min from now)
persona_idoptional
UUIDwriting style persona to use
dropspace_platformsoptional
string[]post via dropspace official accounts: every auto-post platform except reddit (to post to reddit, target your own account with user_platform_accounts). published content begins with a "posted from dropspace user:" attribution line, inside the first tweet of a twitter thread (see attribution_mode)
attribution_modeoptional
"handle" | "anonymous"how official-account posts credit you: "handle" (default) signs with your connected @handle on that platform when you have one, "anonymous" always uses the plain note
user_platform_accountsoptional
objectmap of platform key → token_id (UUID). most platforms use a simple key: "twitter", "reddit", "instagram", "tiktok", "youtube" (your primary/only account on that platform). to target a SPECIFIC account when you have more than one on the same platform, use a composite key: Instagram/TikTok/YouTube/Twitter/Reddit use "instagram:<account_id>" / "tiktok:<account_id>" / "youtube:<account_id>" / "twitter:<account_id>" / "reddit:<account_id>" (the account's entity_id from GET /connections); LinkedIn uses "linkedin:personal" or "linkedin:organization:<org_id>". a Facebook post goes to a PAGE: "facebook:page:<page_id>" names one (the page's entity_id from GET /connections), and a bare "facebook" key works when you have exactly one page - send a connection id as the value (the page's row or your own) and dropspace resolves the page; with several pages the request returns a 400 listing them. multiple keys allowed - e.g. post to two Instagram accounts, or personal + org - by listing several keys at once. for Reddit, each targeted account runs its own subreddit fan-out. an entry with an unsupported key, or a token id that isn't one of your connected accounts, returns a 400
mediaoptional
arrayinline media upload - each item is either `{ source: "url", url: "https://..." }` to fetch from a URL, or `{ source: "base64", data: "...", filename: "photo.jpg", mime_type: "image/jpeg" }` for raw data. the server uploads to storage and populates media_assets automatically. mutually exclusive with media_assets. max 10 items (9 images + 1 video). images: jpeg, png, webp, gif (5MB). videos: mp4, mov (512MB via URL, 4MB via base64). when provided, media_attach_platforms and media_mode are auto-inferred if not set. **for videos >~50 MB, prefer pre-uploading to your own storage and passing `media_assets` instead: the inline `media` URL path runs inside the POST request lifecycle (bounded by the 5-min route maxDuration) and is sensitive to slow upstreams and Vercel function memory**. URL items share one ~270s total fetch budget and are fetched one at a time. a client that times out does not cancel the request - send an `Idempotency-Key` header so a retry cannot create a second launch
media_assetsoptional
arraypre-uploaded media objects (id, url, type, filename, size, mime_type). mutually exclusive with media
media_attach_platformsoptional
string[]platforms to attach media to (subset of platforms). auto-inferred from selected platforms when using media
media_modeoptional
"images" | "video"media mode for the launch. auto-inferred from media types when using media
platform_contentsoptional
objectpre-written content per platform - each value needs `content` (string). for Twitter, you can alternatively provide `thread` (string[], each ≤ 280 chars or ≤ 25,000 for X Premium accounts, max 6 tweets) instead of `content` - mutually exclusive with `content`. Reddit requires `title` (string, max 300 chars) and supports an optional `metadata` object: `metadata.subreddits` (string[], max 10, names without the `r/` prefix) fans the launch out to one post per subreddit: each gets its own posting log, readable per-community via `GET /launches/:id/destinations` and retryable via `POST /launches/:id/retry` + `{ "subreddit": "<name>" }`; `metadata.first_comment` (string, max 1,500 chars) is posted as a comment under each Reddit post once it goes live. Product Hunt and Hacker News support an optional `title` field (max 60 and 80 chars respectively). TikTok supports a `tiktok_settings` object. `publish_mode` selects delivery: "direct" (default - publishes immediately to the profile) or "draft" (uploads to the TikTok inbox so the creator finishes editing and publishes in the TikTok app). for direct posts, `privacy_level` is required before publishing ("PUBLIC_TO_EVERYONE", "FOLLOWER_OF_CREATOR", "MUTUAL_FOLLOW_FRIENDS", or "SELF_ONLY"); in draft mode privacy is chosen in-app and `privacy_level` is not required. optional booleans (direct mode): `allow_comments`, `allow_duet`, `allow_stitch`, `is_commercial`, `is_your_brand`, `is_branded_content`, `auto_add_music`. `cover_timestamp_ms` (integer, 0-600000) picks the VIDEO cover frame in milliseconds from the start - video posts only, since photo posts take their cover by index; omit it for the first frame. when provided, AI content generation is skipped. per-platform character limits are enforced: Twitter 280/tweet (25,000 for X Premium) × 6 tweets, LinkedIn 3,000, Instagram 2,200, Reddit 3,000, Facebook 3,000, TikTok 4,000, YouTube 5,000 (description, title max 100), Product Hunt 500, Hacker News 2,000, Substack 3,000. YouTube requires video and supports an optional `title` field (max 100 chars) plus a `youtube_settings` object: `thumbnail_url` (https URL to a custom video thumbnail - applied after upload via the YouTube thumbnails.set API; requires a VERIFIED YouTube channel and is best-effort, so a thumbnail failure never fails the post), `privacy_status` ("public", "unlisted", or "private"), `category_id` (e.g. "28"), `tags` (string[]), `post_type` ("auto" | "short" | "regular" - auto-detects Shorts), `made_for_kids` (boolean), and `contains_synthetic_media` (boolean - the YouTube altered/synthetic content disclosure, required for realistic AI-generated or altered video). Instagram and Facebook each take a settings object for the 24-hour story surface: `instagram_settings` / `facebook_settings` with `post_type` ("feed" - the default - or "story"). Instagram alone takes a third `post_type`, "carousel": ONE carousel holding every attached image and the launch's video, in the order of your media, where "feed" would post the video as a reel and leave the images out. a carousel is 2 to 10 items (Instagram's limit) and a launch carries at most one video, so that is up to 9 images and a video; fewer than 2 or more than 10 fails validation. every other platform on the same launch still posts one media type. a story publishes EVERY attached image as its own frame, in order (a video wins unless `media_mode` is "images"; a video pool publishes one frame per video), so five images give a five-frame story. each frame is a separate publish and a published story cannot be unpublished, so if a later frame fails the earlier ones stay live and the posting log records `story_frame_count` against `story_frames_requested`, with `story_frame_ids` listing them all - `post_id` and `post_url` point at the first frame. neither platform's API accepts a caption, text overlay, sticker, or link on a story, so `content` is ignored, and story posts read unsupported from GET /launches/:id/comments (viewers reply by direct message). instagram story analytics carry `views`, `reach` and `replies` while the story is live; Meta serves story insights only for 24 hours and withholds them entirely under 5 viewers, so an expired or low-view story reports no metrics rather than zeros. mutually exclusive with custom_content
custom_contentoptional
string | string[]single text distributed to all selected platforms, or array of tweet strings when twitter is selected. string form is validated against the most restrictive platform limit. array form creates a twitter thread (each ≤280 chars or ≤25,000 for X Premium, max 6) and consolidates for other platforms. mutually exclusive with platform_contents. when provided, AI content generation is skipped
custom_content_reddit_titleoptional
string (max 300 chars)reddit post title - required when using custom_content with reddit in platforms
publishoptional
booleanimmediately publish after creation. returns 202 instead of 201. mutually exclusive with scheduled_date. requires both `write` and `send` scopes (API key auth).
waitoptional
booleanwait for publishing to complete and return post URLs inline (returns 200 with posting_status). requires publish: true. response includes posting_status with per-platform results. typical wait: 10-30s for text platforms (Twitter, LinkedIn, Reddit, Facebook), up to 60s+ with Instagram or TikTok

response

201 Created
// 201 (create only): the full launch object is echoed back
{
  "data": {
    "id": "uuid",
    "name": "announcing our new feature",
    "status": "trigger",
    "platforms": ["linkedin", "twitter", "reddit"],
    "scheduled_date": null,
    "product_url": "https://example.com",
    "product_description": "a tool that does X",
    "persona_id": null,
    "media_mode": "images",
    "dropspace_platforms": ["twitter"],
    "user_platform_accounts": { "linkedin": "uuid", "reddit": "uuid" },
    "platform_contents": {
      "twitter": { "content": "..." },
      "linkedin": { "content": "..." },
      "reddit": { "content": "...", "title": "..." }
    },
    "media_assets": [
      { "id": "uuid", "url": "https://...", "type": "image", "filename": "photo.jpg", "size": 12345, "mime_type": "image/jpeg" }
    ],
    "error_message": null,
    "started_at": null,
    "completed_at": null,
    "created_at": "2026-03-25T12:00:00Z",
    "updated_at": "2026-03-25T12:00:00Z"
  }
}

// 200 (publish: true, wait: true): adds posting_status with per-platform results
{
  "data": {
    "id": "uuid",
    "name": "announcing our new feature",
    "status": "completed",
    "platforms": ["linkedin", "twitter"],
    "scheduled_date": null,
    "product_url": "https://example.com",
    "product_description": "a tool that does X",
    "persona_id": null,
    "media_mode": null,
    "dropspace_platforms": null,
    "user_platform_accounts": { "linkedin": "uuid", "twitter": "uuid" },
    "platform_contents": { "twitter": { "content": "..." }, "linkedin": { "content": "..." } },
    "media_assets": null,
    "error_message": null,
    "started_at": "2026-03-25T12:00:05Z",
    "completed_at": "2026-03-25T12:00:30Z",
    "created_at": "2026-03-25T12:00:00Z",
    "updated_at": "2026-03-25T12:00:30Z",
    "posting_status": {
      "twitter": { "status": "success", "post_url": "https://x.com/user/status/123" },
      "linkedin": { "status": "success", "post_url": "https://linkedin.com/feed/update/..." }
    }
  }
}

errors

statuscodedescription
400SERVER_002validation error
400UPLOAD_001unsupported media type
400UPLOAD_002media file too large
400UPLOAD_003media storage upload failed
400LAUNCH_007platform requirements not met (e.g. Instagram/TikTok need media)
404SERVER_003persona not found
429LAUNCH_002plan launch limit reached
429RATE_001content generation rate limit
400CONTENT_004content blocked by official-account safety moderation (genuine rejection: do not resubmit the same content)
503CONTENT_005content safety check temporarily unavailable (transient: safe to retry with the same content)

notes

  • the media field lets you upload images/videos inline via URL or base64 without pre-uploading to storage - the server handles upload and returns media_assets in the response
  • media and media_assets are mutually exclusive - use media for inline upload, or media_assets if you've already uploaded files to storage
  • **operational tip**: the inline `media` URL fetch runs inside the POST request (bound by the 5-min route maxDuration) and streams the body through to storage. for videos >~50 MB or slow source hosts, pre-uploading to your own storage and passing `media_assets` is more reliable: it lets the publish worker pull from a fast intra-region URL when it actually posts to the platform, instead of bottlenecking the create request
  • content is generated automatically via Claude using your description, scraped website data, and persona style
  • custom_content and platform_contents are mutually exclusive - use one or the other (or neither for full AI generation)
  • product_url is optional but enhances AI generation by providing scraped website data for additional context
  • if platform_contents is provided, those platforms skip AI generation - only platforms without a truthy content field are generated
  • custom_content as a string distributes the same text to all platforms - validated against the lowest character limit. as an array (string[]), it creates a numbered twitter thread and joins tweets with double newlines for other platforms. array form requires twitter in platforms
  • partial coverage is supported: provide content for some platforms and let AI generate the rest
  • for Twitter threads, use `platform_contents.twitter.thread: ["tweet 1", "tweet 2"]` instead of `content` - each tweet ≤ 280 chars (≤ 25,000 for X Premium accounts), max 6 tweets. mutually exclusive with `content`
  • for Reddit, `platform_contents.reddit.metadata.subreddits` (string[], max 10) posts the launch once per subreddit: each destination gets its own posting log, and posts are paced automatically to respect Reddit's shared rate limit. omit it to post to the default community. read the per-destination outcome from `GET /launches/:id/destinations` (it names the `subreddit` on each row - `GET /launches/:id/status` reports them all as the same `platform`), and retry one community with `POST /launches/:id/retry` + `{ "subreddit": "<name>" }`. **each subreddit has its own rules**: many restrict or ban promotional posts, some require flair, and posting where the account has no history gets filtered by AutoMod or the spam filter. prefer a few genuinely relevant subreddits over a broad blast; the web app surfaces per-community rules and account standing before posting
  • for Reddit multi-account, each connected reddit account can target its OWN subreddits: set `platform_contents["reddit:<account_id>"].metadata.subreddits` for a specific account (falls back to the base `reddit` block). a subreddit is posted by at most ONE of your connected reddit accounts per launch - if two accounts list the same community, the first (primary first) posts it and the other is skipped with `SUBREDDIT_ALREADY_TARGETED`
  • for Reddit, `platform_contents.reddit.metadata.first_comment` (string, max 1,500 chars) posts a comment under each Reddit post once it is confirmed live - useful for the link or extra context when the post itself should stay native. a first-comment failure never affects the launch result
  • media is distributed to selected platforms with per-platform limits (Instagram/Facebook: 10, Reddit: 20, TikTok: 35)
  • Instagram requires at least one image, video, or AI-generated video. TikTok requires video or photos
  • TikTok direct posts (`tiktok_settings.publish_mode: "direct"`, the default) require `tiktok_settings.privacy_level` to be set before publishing (TikTok Content Sharing Guidelines). set it at creation time via `platform_contents.tiktok.tiktok_settings` or update it later via PATCH. branded content (`is_branded_content: true`) cannot use SELF_ONLY privacy. with `publish_mode: "draft"` the post is uploaded to the creator's TikTok inbox to finish and publish in the TikTok app, so `privacy_level` is not required
  • when `publish: true`, the launch is created and immediately queued for publishing - returns **202** instead of 201. if publish validation fails after creation, returns 201 with a `publish_error` field containing `{ code, message }` - retry via `POST /launches/:id/publish`
  • `publish` and `scheduled_date` are mutually exclusive - use one or the other
  • `publish: true` requires at least one posting account (`user_platform_accounts` or `dropspace_platforms`). with API key auth, both `write` and `send` scopes are required
  • `wait: true` runs the publisher synchronously and returns 200 with `posting_status` containing per-platform results and post URLs. the request stays open until all platforms finish (typically 10-30s, up to 60s+ with Instagram/TikTok). if any platform fails, `posting_status` still shows successes - check `status` field per platform
GET/launches/:id

get a single launch with posting status

parameters & response

response

200 OK
{
  "data": {
    "id": "uuid",
    "name": "string",
    "status": "completed",
    "platforms": ["linkedin", "twitter"],
    "platform_contents": { "twitter": { "content": "..." } },
    "media_assets": [],
    "dropspace_platforms": ["twitter"] | null,
    "user_platform_accounts": {
      "twitter": "token-uuid",
      "linkedin:personal": "token-uuid"
    } | null,
    "posting_status": {
      "twitter": { "status": "success", "post_url": "https://x.com/...", "posted_at": "ISO 8601" },
      "linkedin": { "status": "failed", "error_message": "rate limited by platform" }
    },
    "started_at": "ISO 8601",
    "completed_at": "ISO 8601"
  }
}

errors

statuscodedescription
404LAUNCH_001launch not found
PATCH/launches/:id

update a draft/scheduled/cancelled launch

parameters & response

request body

nametypedescription
scheduled_dateoptional
ISO 8601 | nullschedule 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
stringlaunch name/title (max 200 chars)
product_descriptionoptional
stringproduct description (max 10,000 chars)
product_urloptional
stringproduct URL (empty string to clear)
persona_idoptional
string | nullpersona ID for content generation, null to clear
platform_contentsoptional
objectper-platform content update (deep-merged with existing) - fields you include replace the old value, omitted fields are preserved. for Twitter, you can use `thread` (string[], each ≤ 280 chars or ≤ 25,000 for X Premium, max 6) instead of `content` - mutually exclusive with `content`. Reddit `title` is optional (existing title preserved if omitted; max 300 chars if provided). Product Hunt and Hacker News support an optional `title` field (max 60 and 80 chars respectively). TikTok `tiktok_settings` can be set or updated here (deep-merged) - see POST docs for field details. per-platform character limits are enforced: Twitter 280/tweet (25,000 for X Premium) × 6 tweets, LinkedIn 3,000, Instagram 2,200, Reddit 3,000, Facebook 3,000, TikTok 4,000, YouTube 5,000 (description, title max 100), Product Hunt 500, Hacker News 2,000, Substack 3,000. YouTube `title` is optional on update (existing title preserved if omitted)
user_platform_accountsoptional
objectmap of platform key → token_id (UUID). most platforms use a simple key: "twitter", "reddit", "instagram", "tiktok", "youtube" (your primary/only account on that platform). to target a SPECIFIC account when you have more than one on the same platform, use a composite key: Instagram/TikTok/YouTube/Twitter/Reddit use "instagram:<account_id>" / "tiktok:<account_id>" / "youtube:<account_id>" / "twitter:<account_id>" / "reddit:<account_id>" (the account's entity_id from GET /connections); LinkedIn uses "linkedin:personal" or "linkedin:organization:<org_id>". a Facebook post goes to a PAGE: "facebook:page:<page_id>" names one (the page's entity_id from GET /connections), and a bare "facebook" key works when you have exactly one page - send a connection id as the value (the page's row or your own) and dropspace resolves the page; with several pages the request returns a 400 listing them. multiple keys allowed - e.g. post to two Instagram accounts, or personal + org - by listing several keys at once. for Reddit, each targeted account runs its own subreddit fan-out. an entry with an unsupported key, or a token id that isn't one of your connected accounts, returns a 400
dropspace_platformsoptional
string[]post via dropspace official accounts: every auto-post platform except reddit (to post to reddit, target your own account with user_platform_accounts). published content begins with a "posted from dropspace user:" attribution line, inside the first tweet of a twitter thread (see attribution_mode)
attribution_modeoptional
"handle" | "anonymous"how official-account posts credit you: "handle" (default) signs with your connected @handle on that platform when you have one, "anonymous" always uses the plain note
mediaoptional
arrayinline media upload - same format as create. replaces existing media. mutually exclusive with media_assets
media_assetsoptional
arraypre-uploaded media objects. replaces existing media. mutually exclusive with media
media_attach_platformsoptional
string[]platforms to attach media to. auto-inferred when using media
media_modeoptional
"images" | "video"media mode. auto-inferred when using media

errors

statuscodedescription
400UPLOAD_001unsupported media type
400UPLOAD_002media file too large
400UPLOAD_003media storage upload failed
404LAUNCH_001launch not found
409LAUNCH_003cannot update a running, completed, or partial launch

notes

  • all fields are optional but at least one must be provided
  • a running launch can only be updated to set status to cancelled
  • unrecognized fields return a 400 error
  • status is auto-derived for editable launches (draft, manual, trigger, scheduled). the server determines status based on your launch configuration: has posting accounts (user_platform_accounts or dropspace_platforms) + scheduled_date → scheduled. has posting accounts + no scheduled_date → trigger. has content + no posting accounts → manual. you cannot force "manual" on a launch with posting accounts. it will auto-transition to "trigger". to unschedule a launch, PATCH with { "scheduled_date": null }, status auto-becomes "trigger"
  • platform_contents merges per platform - existing platforms not included in the update are preserved. within a platform, fields you include replace the old value
  • for Twitter threads, use `platform_contents.twitter.thread: ["tweet 1", "tweet 2"]` instead of `content` - each tweet ≤ 280 chars (≤ 25,000 for X Premium), max 6 tweets
  • media and media_assets replace existing media entirely (no merging)
  • TikTok `tiktok_settings` is deep-merged - you can update individual fields (e.g. only `privacy_level`) without overwriting the rest
DELETE/launches/:id

delete a launch (any status except running)

parameters & response

errors

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

queue launch for publishing (async)

parameters & response

response

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

errors

statuscodedescription
404LAUNCH_001launch not found
422LAUNCH_007launch has no platforms configured for posting
400LAUNCH_007platform requirements not met - see errors array for details
409LAUNCH_004launch is not in a publishable state or already publishing
403AUTH_002plan restriction on own connected accounts (dropspace official accounts are always allowed)

notes

  • the launch transitions to running - poll /status or listen for launch.completed / launch.failed / post.deleted webhooks
  • when validation fails (LAUNCH_007), the response includes a `details` array listing all issues that must be fixed before publishing
  • platform validation checks: character limits (all platforms), Reddit title ≤ 300 chars, Reddit video mode needs video (thumbnail auto-generated if not provided), Reddit image mode needs images, Instagram reel needs video, Instagram carousel needs ≥ 2 images, Instagram post_type "carousel" needs 2 to 10 images and videos combined, TikTok requires privacy_level in tiktok_settings (unless publish_mode is "draft"), TikTok video mode needs video, TikTok photo mode needs images, YouTube requires video, YouTube title ≤ 100 chars, YouTube description ≤ 5,000 chars
  • **video size**: per-platform limit is 512 MB. YouTube uploads stream chunked through the worker (memory-bounded). multi-platform launches that share the same video pre-download it once; for very large files (>~300 MB) prefer publishing to one platform at a time, since the pre-download buffer doubles peak memory until each poster releases its handle
POST/launches/:id/retry

retry failed platforms only. optional body `{ "subreddit": "<name>" }` narrows the retry to that ONE Reddit destination (get names from `GET /launches/:id/destinations`), leaving sibling subreddits that already succeeded untouched; add `"account_id": "<id>"` alongside it to target a specific Reddit account when the launch posted from more than one; omit the body to retry every failed platform.

parameters & response

response

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

errors

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

retry content generation for failed platforms

parameters & response

request body

nametypedescription
platformsoptional
string[]filter to specific platforms

response

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

errors

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

regenerate AI content for all or specific platforms

parameters & response

request body

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

response

200 OK
{
  "data": {
    "id": "uuid",
    "name": "announcing our new feature",
    "platform_contents": {
      "twitter": { "content": "1/ Fresh new thread..." },
      "linkedin": { "content": "New version..." }
    }
  },
  "generation": {
    "platforms_generated": ["linkedin", "twitter"],
    "failures": null
  }
}

errors

statuscodedescription
400SERVER_002no product_description or platforms
404LAUNCH_001launch not found
409LAUNCH_003launch is running/completed/partial
429RATE_001content generation rate limit
503SERVER_001content generation unavailable

notes

  • existing content for other platforms is preserved
  • media, video sources, and generated videos are never overwritten
GET/launches/:id/status

detailed posting logs per platform

parameters & response

response

200 OK
{
  "data": {
    "launch_id": "uuid",
    "launch_status": "completed",
    "posting_logs": [
      {
        "id": "uuid",
        "platform": "twitter",
        "status": "success",
        "post_url": "https://x.com/...",
        "post_id": "string | null",
        "error_message": "string | null",
        "error_code": "string | null",
        "attempt_count": 1,
        "posted_at": "ISO 8601",
        "created_at": "ISO 8601"
      }
    ]
  }
}

errors

statuscodedescription
404LAUNCH_001launch not found

notes

  • launch_status is "completed" when every destination posted, "partial" when some posted and some failed, and "failed" when none posted.
  • a platform in the launch's platforms that has no account to post from (no user_platform_accounts key for it and no dropspace official posting) gets a failed log with error_code "NO_ACCOUNT" instead of being skipped, so a launch that posted elsewhere reads "partial". when none of the platforms has an account, nothing is posted, no logs are written, and the launch is marked "completed" as ready for manual posting.
  • error_code is a stable machine-readable code for a failed log. see posting error codes in the error handling docs for the full list and what to do about each one.
GET/launches/:id/destinations

one row per destination, naming the account and subreddit. use this instead of /status when a launch fans out to several destinations on one platform (Reddit subreddits, LinkedIn personal+organization, Facebook pages): /status reports every one of them as the same `platform` with no way to tell which is which, and a failed destination has no post_url to infer it from. `subreddit` is the community name (Reddit fan-out rows only, null elsewhere); `account_type` identifies every destination. pair with `POST /launches/:id/retry` + `{ "subreddit": "<name>" }` to retry a single community.

parameters & response

response

200 OK
{
  "data": [
    {
      "posting_log_id": "uuid",
      "platform": "reddit",
      "account_type": "user:subreddit:webdev",
      "subreddit": "webdev",
      "status": "success",
      "post_url": "https://reddit.com/r/webdev/comments/...",
      "post_id": "string | null",
      "error_message": "string | null",
      "error_code": "string | null",
      "attempt_count": 1,
      "posted_at": "ISO 8601",
      "created_at": "ISO 8601"
    },
    {
      "posting_log_id": "uuid",
      "platform": "reddit",
      "account_type": "user:subreddit:SideProject",
      "subreddit": "SideProject",
      "status": "failed",
      "post_url": null,
      "post_id": null,
      "error_message": "post removed by automod",
      "error_code": "POST_REMOVED",
      "attempt_count": 1,
      "posted_at": null,
      "created_at": "ISO 8601"
    }
  ]
}

errors

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

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

parameters & response

response

200 OK
{
  "data": {
    "launch_id": "uuid",
    "launch_name": "announcing our new feature",
    "launch_status": "completed",
    "summary": { "total": 3, "successful": 3, "failed": 0, "pending": 0 },
    "fetched_at": "2026-02-22T10:00:00Z",
    "next_refresh_at": "2026-02-22T10:05:00Z",
    "platforms": [
      {
        "platform": "twitter",
        "status": "success",
        "post_url": "https://x.com/...",
        "post_id": "1234567890",
        "posted_at": "ISO 8601",
        "cache_status": "refreshed",
        "is_deleted": false,
        "deleted_detected_at": null,
        "deletion_reason": null,
        "metrics": {
          "likes": 42,
          "retweets": 12,
          "replies": 5,
          "quotes": 2,
          "bookmarks": 8,
          "impressions": 1500,
          "urlClicks": 23,
          "profileClicks": 7,
          "tracked_clicks": 19,
          "fetched_at": "2026-02-22T10:00:00Z"
        }
      },
      {
        "platform": "reddit",
        "status": "success",
        "post_url": "https://reddit.com/...",
        "post_id": "abc123",
        "posted_at": "ISO 8601",
        "cache_status": "fresh",
        "is_deleted": false,
        "deleted_detected_at": null,
        "deletion_reason": null,
        "metrics": {
          "score": 156,
          "upvotes": 200,
          "upvoteRatio": 0.78,
          "comments": 34,
          "fetched_at": "2026-02-22T09:58:00Z"
        }
      }
    ]
  }
}

errors

statuscodedescription
404LAUNCH_001launch not found

notes

  • metrics.tracked_clicks appears when the launch was created with track_links: clicks on its tracked link for that platform, counted on the platform's first post so summing posts never double-counts.
  • metrics refresh on a per-platform cycle. YouTube statistics are refreshed for you every 10 minutes in the background, so calling more often returns the same numbers from cache. other platforms are fetched live from their APIs when the cached copy is older than 5 minutes; calling this endpoint triggers that refresh automatically.
  • fetched_at is the most recent timestamp when metrics were collected. next_refresh_at is the earliest time a call could return new numbers (fetched_at + 10 min for YouTube, + 5 min for other platforms) - polling before it only re-reads the cache.
  • cache_status per platform: fresh (inside the refresh cycle, from cache), refreshed (fetched from platform API on this call), stale (rate limited or no token, returning older data), unavailable (no data exists). omitted for non-success posts (failed, pending).
  • rate-limited platforms return stale cached data instead of failing - the response always includes the best available metrics.
  • metric fields vary by platform: Twitter (likes, retweets, replies, quotes, bookmarks, impressions, urlClicks, profileClicks), LinkedIn (impressions, uniqueImpressions, likes, comments, shares, clicks, engagement), Facebook (reactions, likes, comments, shares - likes mirrors reactions.total), Instagram (views, 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.
GET/launches/:id/comments

per-post comments for a launch's published posts (newest first)

parameters & response

query parameters

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

response

200 OK
{
  "data": {
    "launch_id": "uuid",
    "posts": [
      {
        "post_id": "dQw4w9WgXcQ",
        "platform": "youtube",
        "post_url": "https://youtube.com/watch?v=dQw4w9WgXcQ",
        "posted_at": "ISO 8601",
        "status": "ok",
        "detail": null,
        "comments": [
          {
            "id": "UgxKREWxIg...",
            "text": "great launch!",
            "author_username": "somefan",
            "author_display_name": "@somefan",
            "author_profile_image_url": "https://yt3.ggpht.com/...",
            "like_count": 7,
            "reply_count": 2,
            "published_at": "ISO 8601"
          }
        ],
        "next_cursor": "QURTSl9p..."
      },
      {
        "post_id": "7301234567890",
        "platform": "tiktok",
        "post_url": "https://tiktok.com/...",
        "posted_at": "ISO 8601",
        "status": "unsupported",
        "detail": "tiktok's api does not expose comment content to third-party apps - comment counts are available in analytics",
        "comments": [],
        "next_cursor": null
      }
    ]
  }
}

errors

statuscodedescription
400SERVER_002cursor without post_id, or invalid params
404LAUNCH_001launch not found

notes

  • comments are top-level only, ordered newest first, and cached for ~60 seconds per page.
  • platform support is explicit per post via status: ok (comments returned), unsupported (the platform's api does not expose comment content), comments_disabled (turned off on the post), post_deleted (post no longer exists), unavailable (transient: rate limited, quota, or account not connected - retry later). detail carries the human-readable reason for every non-ok status.
  • youtube and instagram return comment content. instagram connections made before 08/25/2026 read unsupported with a reconnect prompt until the creator reconnects instagram once; tiktok exposes comment counts only (see analytics).
  • to page through one post's comments, pass post_id plus the next_cursor from the previous response. next_cursor is null on the last page.
POST/launches/:id/comments/replies

reply to a comment on one of the launch's published posts, as the account that published it. body: `{ "post_id": "<platform post id>", "comment_id": "<comment id from GET comments>", "message": "<1-2,200 chars>" }`. youtube and instagram only - other platforms, and instagram connections made before 08/25/2026 (reconnect instagram once to fix), answer 400 with a human-readable message. requires the send scope.

parameters & response

response

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

errors

statuscodedescription
404LAUNCH_001launch not found, or post_id not on this launch
400SERVER_002platform can't accept the reply (detail in message)

notes

  • reply_id is the platform's id for the created reply; null when the platform doesn't return one.
  • a successful reply invalidates the post's cached comment pages, so the next GET reflects it immediately.
POST/launches/:id/comments/hide

hide or unhide a comment on one of the launch's published posts, as the account that published it. body: `{ "post_id": "<platform post id>", "comment_id": "<comment id from GET comments>", "hidden": true | false }`. a hidden comment stays visible to its author and disappears for everyone else; `hidden: false` reverses it. instagram only - other platforms, stories, and instagram connections made before 08/25/2026 (reconnect instagram once to fix) answer 400 with a human-readable message. requires the send scope.

parameters & response

response

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

errors

statuscodedescription
404LAUNCH_001launch not found, or post_id not on this launch
400SERVER_002platform can't hide the comment (detail in message)

notes

  • comments read back with `hidden: true` from GET /launches/:id/comments once hidden.
  • a successful change invalidates the post's cached comment pages, so the next GET reflects it immediately.
DELETE/launches/:id/comments/:commentId

permanently delete a comment on one of the launch's published posts, as the account that published it. pass `post_id` (the platform post id from GET comments) as a query parameter - comment ids are only meaningful per post. instagram only, and instagram lets only the media owner delete; other platforms, stories, and pre-08/25/2026 instagram connections answer 400 with a human-readable message. requires the send scope.

parameters & response

query parameters

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

response

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

errors

statuscodedescription
404LAUNCH_001launch not found, or post_id not on this launch
400SERVER_002platform can't delete the comment (detail in message)

notes

  • deletion is permanent - prefer hide when the comment may need to come back.
GET/launches/analytics

batch analytics for multiple launches (cache-only, no live refresh)

parameters & response

query parameters

nametypedescription
idsrequired
stringcomma-separated launch UUIDs (max 100)

response

200 OK
{
  "data": [
    {
      "launch_id": "uuid",
      "launch_name": "announcing our new feature",
      "launch_status": "completed",
      "summary": { "total": 3, "successful": 2, "failed": 1, "pending": 0 },
      "platforms": [
        {
          "platform": "twitter",
          "status": "success",
          "post_url": "https://x.com/...",
          "post_id": "1234567890",
          "posted_at": "ISO 8601",
          "is_deleted": false,
          "deleted_detected_at": null,
          "deletion_reason": null,
          "cache_status": "fresh",
          "metrics": {
            "likes": 42,
            "retweets": 12,
            "impressions": 1500,
            "fetched_at": "2026-02-22T10:00:00Z"
          }
        }
      ]
    }
  ],
  "errors": [
    { "id": "invalid-uuid", "error": { "code": "LAUNCH_001", "message": "launch not found" } }
  ]
}

errors

statuscodedescription
400SERVER_002ids parameter missing or empty
400SERVER_002more than 100 IDs provided
400SERVER_005all IDs have invalid UUID format

notes

  • returns cached analytics only - does NOT trigger a live refresh from platform APIs. use the single-launch endpoint GET /launches/:id/analytics to trigger a fresh fetch.
  • accepts up to 100 launch IDs. duplicate IDs are deduplicated. counts as 1 rate limit hit.
  • response includes partial results: valid launches in data[], invalid/not-found in errors[].
  • cache_status per platform: fresh (< 5 min old), stale (older cached data), unavailable (no metrics exist). never returns refreshed (no live fetch). omitted for non-success posts (failed, pending).
  • metric fields vary by platform - same as single-launch analytics endpoint.
DELETE/launches/:id/posts/:logId

delete a single published post from its platform

parameters & response

response

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

errors

statuscodedescription
404LAUNCH_001launch not found
404SERVER_003posting log not found or not deletable
200DELETE_NOT_SUPPORTEDplatform does not support API deletion (Instagram, TikTok) - returned in response body, not as HTTP error

notes

  • requires `write` scope on your API key (included in the default scope set)
  • only works for Twitter, Facebook, LinkedIn, and Reddit - Instagram and TikTok do not support API deletion
  • the posting log must have status 'success' and a valid post_id
  • if the post was already deleted on the platform (404), it is treated as a successful deletion
  • on success, the posting log status is updated to 'deleted'
  • logId is the posting_log UUID from the /status endpoint
DELETE/launches/:id/posts

delete all published posts for a launch from their platforms

parameters & response

response

200 OK
{
  "data": {
    "results": [
      { "success": true, "platform": "twitter", "post_id": "123", "deleted_at": "ISO 8601" },
      { "success": true, "platform": "linkedin", "post_id": "urn:li:share:456", "deleted_at": "ISO 8601" },
      { "success": false, "platform": "instagram", "post_id": "789", "error": "platform does not support deletion", "error_code": "DELETE_NOT_SUPPORTED" }
    ],
    "no_failures": false,
    "deleted_count": 2,
    "failed_count": 0,
    "skipped_count": 1
  }
}

errors

statuscodedescription
404LAUNCH_001launch not found

notes

  • requires `write` scope on your API key (included in the default scope set)
  • only deletes from Twitter, Facebook, LinkedIn, and Reddit
  • Instagram and TikTok posts are skipped with DELETE_NOT_SUPPORTED (require manual deletion)
  • returns detailed results per post including success/failure status
  • skipped_count includes platforms that don't support API deletion

audience

follower counts and growth-over-time for the connected account's instagram, youtube, tiktok, twitter, and facebook. read from the official platform apis. requires an api key whose account has the platform connected.

GET/audience

follower counts + growth over a window for the caller's connected instagram/youtube/tiktok/twitter/facebook accounts

parameters & response

query parameters

nametypedescription
window_daysoptional
integergrowth window in days (default 30, max 365)

response

200 OK
{
  "data": {
    "platforms": [
      {
        "platform": "instagram",
        "entity_id": null,
        "current": { "followers": 10432, "following": 812, "media": 340, "captured_at": "2026-07-22T06:00:00Z" },
        "series": [
          { "captured_at": "2026-06-22T06:00:00Z", "followers": 9800 },
          { "captured_at": "2026-07-22T06:00:00Z", "followers": 10432 }
        ],
        "growth": {
          "window_days": 30,
          "follower_delta": 632,
          "follower_pct": 0.0645,
          "baseline_captured_at": "2026-06-22T06:00:00Z",
          "effective_window_days": 30
        }
      }
    ],
    "as_of": "2026-07-22T18:00:00Z"
  }
}

errors

statuscodedescription
401AUTH_001missing or invalid api key

notes

  • covers instagram, youtube, tiktok, twitter, and facebook - the platforms whose follower counts the official apis expose. other connected platforms (linkedin, reddit) are omitted.
  • facebook follower counts are reported per connected page (each page reports its own followers); twitter reports followers, following, and tweet count.
  • counts are read on-demand when you call this endpoint and cached for 15 minutes. youtube is the exception, because each youtube read spends a shared daily quota: while you keep reading an account, its youtube count is refreshed in the background about once an hour, and an on-demand read re-fetches at most every 6 hours if that refresh is delayed. each fresh read records a history point - so a newly connected account appears on its first call and builds growth history from there.
  • growth is computed from the earliest and latest snapshot inside the window; follower_pct is a fraction (0.0645 = 6.45%). delta and pct are null until at least one snapshot exists in the window.
  • history points are recorded when you read (at most one per account per day), so the oldest point inside the window can be much younger than window_days. baseline_captured_at is when the delta is measured from, and effective_window_days is the span it actually covers - read it before comparing growth across accounts.
  • youtube history points are kept for 30 days (YouTube's API storage limit), so youtube growth reaches back about 30 days even when window_days is larger; effective_window_days says how far it actually reaches. other platforms keep their full history.
  • current.captured_at is when the returned count was actually read from the platform, never when it was served: a cached or background-refreshed count keeps its original read time, a youtube count returned while youtube's daily quota is exhausted carries the time of the last successful read, and if the platform call fails with nothing cached it is when the newest stored point was captured.
  • data collection uses each platform's official read api, not scraping - so it only covers accounts the creator has connected.

reddit

reddit-specific discovery. find where your ICP lives and where THIS account has standing to post, before you fan a launch out. requires an api key whose account has reddit connected.

POST/reddit/subreddits/suggest

ICP-matched subreddit discovery. pass a product brief; get back verified, real subreddits with a promo-policy classification and a green/yellow/red standing badge for THIS account. `standing.level` answers 'can this account post here without getting filtered': green means it has participated here; yellow means build standing first; red means a hard blocker (banned promo, private sub, or a weak account). `standing.promo_share` is the account's promotional share of its footprint in the community (null when there's no footprint yet). prefer green subs, and earn standing in yellow ones by commenting before you post. the launch fan-out enforces reddit's 90:10 promo norm at publish time, so posting where you have no standing is skipped, not sent.

parameters & response

request body

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

response

200 OK
{
  "data": {
    "icp_summary": "indie developers shipping side projects",
    "suggestions": [
      {
        "name": "SideProject",
        "title": "Side Project",
        "subscribers": 180000,
        "over18": false,
        "subreddit_type": "public",
        "promo_policy": "allowed",
        "promo_evidence": "self-promotion saturday thread",
        "standing": {
          "level": "green",
          "tier": "active",
          "promo_posts_here": 1,
          "contributions_here": 14,
          "promo_share": 0.06,
          "reasons": ["you're active here (14 comments)"]
        },
        "reason": "indie devs share and get feedback on side projects here"
      }
    ]
  }
}

errors

statuscodedescription
400PLATFORM_001api key's account has no reddit connection
400SERVER_002brief missing or out of range

notes

  • api-key auth only: needs the caller's reddit oauth token to verify subs + compute standing. wallet/mpp agents get an empty result.
  • nothing here posts; it only reads. rate-limited per account (the discovery sweep is expensive on reddit's shared 60/min quota).

media library

the user's own photos and videos. autopilot posts them when its media_source is library or mixed, choosing each item by its description.

GET/media-library

list media library items, newest first

parameters & response

query parameters

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

response

200 OK
{
  "data": [
    {
      "id": "uuid",
      "url": "https://...",
      "type": "image",
      "mime_type": "image/jpeg",
      "width": 1080,
      "height": 1350,
      "size_bytes": 482113,
      "thumbnail_url": null,
      "filename": "desk.jpg",
      "description": "the product on a wooden desk next to a laptop",
      "source": "upload",
      "use_count": 2,
      "last_used_at": "ISO 8601 | null",
      "created_at": "ISO 8601",
      "updated_at": "ISO 8601"
    }
  ],
  "pagination": { "page": 1, "page_size": 50, "total": 1, "total_pages": 1 }
}
POST/media-library

add photos or videos by public URL (1-20 per call; a URL already in the library is skipped; 500 items max). images without a description are captioned on autopilot's next run; give videos a description or a thumbnail_url

parameters & response

request body

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

response

201 Created
{
  "data": {
    "added": [
      {
      "id": "uuid",
      "url": "https://...",
      "type": "image",
      "mime_type": "image/jpeg",
      "width": 1080,
      "height": 1350,
      "size_bytes": 482113,
      "thumbnail_url": null,
      "filename": "desk.jpg",
      "description": "the product on a wooden desk next to a laptop",
      "source": "upload",
      "use_count": 2,
      "last_used_at": "ISO 8601 | null",
      "created_at": "ISO 8601",
      "updated_at": "ISO 8601"
    }
    ]
  }
}
PATCH/media-library/{id}

set an item's description (what it shows); null clears it

parameters & response

request body

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

response

200 OK
{
  "data": {
      "id": "uuid",
      "url": "https://...",
      "type": "image",
      "mime_type": "image/jpeg",
      "width": 1080,
      "height": 1350,
      "size_bytes": 482113,
      "thumbnail_url": null,
      "filename": "desk.jpg",
      "description": "the product on a wooden desk next to a laptop",
      "source": "upload",
      "use_count": 2,
      "last_used_at": "ISO 8601 | null",
      "created_at": "ISO 8601",
      "updated_at": "ISO 8601"
    }
}
DELETE/media-library/{id}

remove an item from the library; launches that already posted it keep their media

parameters & response

response

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

personas

manage AI writing personas for content generation.

GET/personas

list personas with pagination

parameters & response

query parameters

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

response

200 OK
{
  "data": [
    {
      "id": "uuid",
      "name": "string",
      "persona_analysis": "object | null",
      "build_status": "idle|building|complete|error",
      "build_progress": 0,
      "build_started_at": "ISO 8601 | null",
      "build_error": "string | null",
      "last_analyzed_at": "ISO 8601 | null",
      "created_at": "ISO 8601",
      "updated_at": "ISO 8601"
    }
  ],
  "pagination": { "page": 1, "page_size": 50, "total": 3, "total_pages": 1 }
}
POST/personas

create a new persona

parameters & response

request body

nametypedescription
namerequired
string (1-100 chars)persona name

errors

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

get persona with all writing samples

parameters & response

errors

statuscodedescription
404PERSONA_002persona not found

notes

  • includes persona_analysis, persona_analysis_structured, custom_samples, twitter_samples, reddit_samples, facebook_samples, instagram_samples, tiktok_samples, linkedin_samples, brand_kit (omitted when unset)
PATCH/personas/:id

update name and/or writing samples

parameters & response

request body

nametypedescription
nameoptional
string (1-100 chars)persona name
custom_samplesoptional
string[] (max 50)plain-text writing samples
twitter_samplesoptional
object[] (max 50)Twitter writing samples. each item carries its prose in `text`; a plain string is accepted and read as the prose itself
reddit_samplesoptional
object[] (max 50)Reddit writing samples. each item carries its prose in `body`; a plain string is accepted and read as the prose itself
facebook_samplesoptional
object[] (max 50)Facebook writing samples. each item carries its prose in `message`; a plain string is accepted and read as the prose itself
instagram_samplesoptional
object[] (max 50)Instagram writing samples. each item carries its prose in `caption`; a plain string is accepted and read as the prose itself
tiktok_samplesoptional
object[] (max 50)TikTok writing samples. each item carries its prose in `description`; a plain string is accepted and read as the prose itself
linkedin_samplesoptional
object[] (max 50)LinkedIn writing samples. each item carries its prose in `commentary`; a plain string is accepted and read as the prose itself
brand_kitoptional
object | nullhow autopilot image slides look when autopilot posts as this persona: text_color and outline_color (#RRGGBB), logo_url (public http(s) image), logo_position (top-left | top-right | bottom-left | bottom-right, default top-right). omitted fields keep white text on a black outline; null clears the kit

errors

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

delete a persona

parameters & response

errors

statuscodedescription
404PERSONA_002persona not found
422SERVER_005persona in use by launches

notes

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

trigger AI persona analysis (async)

parameters & response

request body

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

response

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

errors

statuscodedescription
404PERSONA_002persona not found
409SERVER_009already building
429PERSONA_003persona build limit reached

notes

  • listen for persona.analyzed webhook when complete
POST/personas/:id/finalize

commit the pending analysis (after analyze) - content generation reads the committed analysis

parameters & response

request body

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

response

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

errors

statuscodedescription
400PERSONA_004persona is not ready for review
404PERSONA_002persona not found

notes

  • free - usage was charged at analyze. call once build_status is ready_for_review
POST/personas/:id/discard

discard the pending analysis (preserves any previously committed analysis)

parameters & response

response

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

errors

statuscodedescription
400PERSONA_004persona is not ready for review
404PERSONA_002persona not found

notes

  • free - analyze usage is not refunded

autopilot

the autonomous content engine. autopilot studies your post performance nightly, drafts new posts in your voice, and schedules them on your connected accounts. review-first by default; pro and premium plans only.

GET/autopilot

get autopilot status and config

parameters & response

response

200 OK
{
  "data": {
    "configured": true,
    "config": {
      "enabled": true,
      "mode": "review",
      "platforms": ["twitter", "linkedin"],
      "formats": ["text", "visual"],
      "persona_id": "uuid | null",
      "posting_times": { "times": ["09:30", "17:30"], "days": [1, 2, 3, 4, 5] },
      "run_hour": 2,
      "timezone": "America/New_York",
      "product_url": "string | null",
      "product_image_url": "string | null",
      "product_brief": "string | null",
      "content_pillars": ["string"] | null,
      "directives": ["string"] | null,
      "min_queue": 7,
      "max_posts_per_day": 4,
      "track_links": false,
      "reuse_media": false,
      "media_source": "ai",
      "platform_settings": {},
      "paused": false,
      "pause_reason": null,
      "created_at": "ISO 8601",
      "updated_at": "ISO 8601"
    },
    "queued_ideas": 9,
    "pending_review": 3
  }
}

notes

  • config is null until the first PATCH /autopilot call
PATCH/autopilot

create or update the autopilot config (merge: only the fields you send change)

parameters & response

request body

nametypedescription
enabledoptional
booleanturn nightly runs on/off
modeoptional
string"review" (drafts wait for approval) or "auto" (publishes on schedule). YouTube posts always hold for review regardless of mode - approve them via the review endpoints (default: review)
platformsoptional
array of stringsplatforms to post on - each needs a connected account. valid: facebook, linkedin, twitter, reddit, instagram, tiktok, youtube
formatsoptional
array of strings"text", "visual" (AI image slides), and/or "video" (default: ["text"])
persona_idoptional
uuid | nullvoice persona for drafts
posting_timesoptional
object{ "times": ["HH:MM"], "days": [0-6] } in the config timezone (0 = sunday)
run_houroptional
integer (0-23)local hour the nightly run fires (default: 2)
timezoneoptional
string (IANA)timezone for posting times and runs (default: America/New_York)
product_urloptional
string | nullproduct link included in posts
product_image_urloptional
string | nullreference image for slideshow (visual) posts - each slide is generated from it so it shows the real product. omitted: filled from product_url's page when product_url changes. empty or null: no reference
product_briefoptional
string (max 2000) | nullwhat you're building, who it's for
content_pillarsoptional
array of strings (max 10) | nullthemes the engine rotates across
directivesoptional
array of strings (max 10, each max 200 chars) | nullstanding per-post rules the planner honors in every draft (e.g. "no emojis", "always end with a question") - null clears them
min_queueoptional
integer (1-30)ideas to keep queued (default: 7)
max_posts_per_dayoptional
integer (1-10)daily posting cap (default: 4)
track_linksoptional
booleanwrap the post's outbound product link in a dropspace-owned tracked redirect, so click-throughs count toward what autopilot learns from (default: false)
reuse_mediaoptional
booleanreuse the day's first finished AI slideshow or video across your other platforms' posts that day - identical media, per-platform captions; claims spend no generation quota (default: false)
media_sourceoptional
"ai" | "library" | "mixed"where image and video posts get media: ai (generated slides and video), library (only the media library), mixed (library first, AI when nothing fits). an empty library falls back to AI (default: ai)
platform_settingsoptional
objectper-platform posting settings stamped into every autopilot launch. tiktok: privacy_level (defaults to SELF_ONLY - posts publish visible only to the account until widened), allow_comments/allow_duet/allow_stitch, commercial disclosure (is_commercial + is_your_brand/is_branded_content). youtube: privacy_status (defaults to private), made_for_kids; AI-content disclosure is always on for autopilot videos (default: {})

errors

statuscodedescription
400SERVER_002invalid timezone or no usable connected account for a platform
403BILLING_006plan does not include autopilot

notes

  • posts created by autopilot consume your monthly launch limit
  • visual and video formats also consume your monthly AI media limits
GET/autopilot/review

list drafts awaiting approval (review mode)

parameters & response

query parameters

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

response

200 OK
{
  "data": [
    {
      "launch_id": "uuid",
      "hook": "string",
      "platform": "twitter",
      "format": "text",
      "content": "string | null",
      "reddit_title": "string | null",
      "scheduled_date": "ISO 8601 | null",
      "media_count": 0,
      "created_at": "ISO 8601"
    }
  ],
  "pagination": { "page": 1, "page_size": 20, "total": 3, "total_pages": 1 }
}

notes

  • each item is a draft launch - edit it first with PATCH /launches/:id if needed
POST/autopilot/review/:launchId/approve

approve a draft - schedules it for publishing

parameters & response

response

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

errors

statuscodedescription
404SERVER_003draft not found

notes

  • a stale scheduled_date rolls forward to the next posting slot automatically
POST/autopilot/review/:launchId/reject

reject a draft - deletes it and refunds the launch slot

parameters & response

request body

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

response

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

errors

statuscodedescription
404SERVER_003draft not found
POST/autopilot/run

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

parameters & response

response

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

errors

statuscodedescription
400SERVER_002autopilot is not enabled
403BILLING_006plan does not include autopilot
404SERVER_003autopilot is not set up yet
429RATE_001manual runs are limited to 2 per hour

notes

  • steps that already completed today are skipped, so a manual run tops up rather than double-posts
GET/autopilot/runs

recent run history (one row per run step)

parameters & response

query parameters

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

response

200 OK
{
  "data": [
    {
      "run_date": "2026-06-12",
      "step": "schedule_slots",
      "status": "completed",
      "stats": { "created": 3, "slots": 4 },
      "error": null,
      "started_at": "ISO 8601",
      "completed_at": "ISO 8601"
    }
  ]
}

recommendations

actionable insights computed from your organic post performance. dropspace sweeps your recent posts' metrics every 30 minutes, detects winners and underperformers against your own baseline, and turns them into one-click actions: repost, cross-post, boost, promote into autopilot, or pause what isn't working.

GET/recommendations

list recommendations

parameters & response

query parameters

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

response

200 OK
{
  "success": true,
  "data": [
    {
      "id": "uuid",
      "rec_type": "repost_winner",
      "status": "pending",
      "platform": "twitter",
      "launch_id": "uuid",
      "posting_log_id": "uuid",
      "payload": {
        "launch_name": "july feature drop",
        "post_url": "https://x.com/...",
        "score": 42.5,
        "baseline": 6
      },
      "score": 42.5,
      "baseline": 6,
      "expires_at": "ISO 8601",
      "applied_launch_id": null,
      "applied_at": null,
      "dismissed_at": null,
      "created_at": "ISO 8601",
      "updated_at": "ISO 8601"
    }
  ],
  "pagination": { "page": 1, "page_size": 20, "total": 3, "total_pages": 1 }
}

notes

  • rec_type is one of: repost_winner, cross_post, boost_winner, promote_winner, pause_underperformer
  • recommendations expire automatically (default 14 days), act on them while the post is still riding
POST/recommendations/:id/apply

apply a recommendation (executes its one-click action)

parameters & response

request body

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

response

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

errors

statuscodedescription
400SERVER_002missing ad_account_id for a boost recommendation
404SERVER_003recommendation not found or not owned
409SERVER_009already applied/dismissed, expired, or autopilot not enabled

notes

  • repost_winner and cross_post create a DRAFT launch seeded with the winning content. nothing publishes until you schedule it
  • boost_winner creates a PAUSED boost with the suggested budget. activate it separately to start spend
  • promote_winner queues the winner into your autopilot queue (autopilot must be enabled)
  • pause_underperformer adds a standing directive to your autopilot config
POST/recommendations/:id/dismiss

dismiss a recommendation

parameters & response

response

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

errors

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

media

generate AI images and videos for your launches.

POST/media/generate

submit an image or video generation job

parameters & response

request body

nametypedescription
typerequired
"image" | "video" | "script_video"generation type
launch_idrequired
UUIDassociated launch
platformrequired
stringtarget platform for the asset - always required, any supported platform. script_video only accepts "instagram" or "tiktok"
promptoptional
stringgeneration prompt - either prompt or product_description is required; script_video requires an explicit prompt. the effective prompt (yours, or the one derived from product_description) must be 10-2000 chars or the request fails with a 400
product_descriptionoptional
stringproduct context - used to derive a prompt when prompt is omitted
options.aspect_ratiooptional
"1:1" | "16:9" | "9:16" | "4:3" | "3:4" | "3:2" | "2:3" | "5:4" | "4:5" | "21:9"aspect ratio (image supports all 10; video/script_video only supports 16:9 and 9:16)
options.duration_secondsoptional
4 | 6 | 8video duration (default: 8)
options.seedoptional
numbergeneration seed for reproducible output - forwarded to the model for text-to-image and video jobs; ignored when reference_image_url is set (the edit model does not support seeds)
options.negative_promptoptional
stringthings to steer the model away from - video generation only (the image model does not support negative prompts; ignored for image jobs)
reference_image_urloptional
URLURL of a reference image for style/composition guidance (image type only, uses edit endpoint)
use_product_imageoptional
booleanuse the first image from the launch's product page (scraped from product_url) as the reference image - image type only, ignored when reference_image_url is set, 400 when the launch has no product image

response

202 Accepted
{
  "data": {
    "status": "processing",
    "job_id": "uuid",
    "fal_request_id": "string",
    "generation_type": "image",
    "usage": { "used": 3, "limit": 50, "remaining": 47 },
    "plan": "pro"
  }
}

errors

statuscodedescription
403AUTH_002plan doesn't include media generation
404LAUNCH_001launch not found or not owned
429MEDIA_001monthly media generation limit reached
429RATE_001concurrency limit reached

notes

  • type 'script_video' generates a video from a text script (requires platform 'instagram' or 'tiktok' and an explicit prompt). type 'video' generates from a visual/cinematic prompt
GET/media/:jobId

poll media generation status

parameters & response

response

200 OK
{
  "data": {
    "id": "uuid",
    "generation_type": "image",
    "prompt": "...",
    "result_url": "https://cdn.dropspace.dev/...",
    "reference_image_url": "https://cdn.dropspace.dev/... | null",
    "status": "processing|completed|failed",
    "progress": 75,
    "model_id": "string",
    "error_message": "string | null",
    "launch_id": "uuid",
    "created_at": "ISO 8601",
    "completed_at": "ISO 8601 | null"
  }
}

errors

statuscodedescription
404SERVER_003media job not found

notes

  • listen for media.ready webhook when status becomes completed

account

your profile, and the billing portal hand-off.

GET/profile

the authenticated user's profile

parameters & response

response

200 OK
{
  "data": {
    "id": "uuid",
    "email": "you@example.com",
    "full_name": "string | null",
    "avatar_url": "string | null",
    "role": "user",
    "has_completed_onboarding": true,
    "created_at": "ISO 8601",
    "updated_at": "ISO 8601"
  }
}

errors

statuscodedescription
404SERVER_004profile not found
PATCH/profile

update your display name

parameters & response

request body

nametypedescription
full_namerequired
string (1-120 chars)display name

response

200 OK
{ "data": { "id": "uuid", "full_name": "string | null", "avatar_url": "string | null" } }
POST/billing/portal

a Stripe billing-portal session for managing your subscription. falls back to the web billing page when no Stripe customer exists yet.

parameters & response

response

200 OK
{ "data": { "url": "https://billing.stripe.com/session/..." } }

connections

view and disconnect your OAuth platform connections.

POST/connections/:platform/connect

start an OAuth connect for a platform. returns the URL to send the user to, plus the `state` that identifies the flow on return.

parameters & response

query parameters

nametypedescription
platformrequired
stringplatform to connect (path parameter)

response

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

errors

statuscodedescription
400SERVER_003invalid platform
GET/connections

list your OAuth platform connections

parameters & response

query parameters

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

response

200 OK
{
  "data": [
    {
      "id": "uuid",
      "platform": "twitter",
      "entity_id": "string",
      "account_info": { "username": "...", "display_name": "..." },
      "account_type": "personal",
      "is_active": true,
      "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.
DELETE/connections/:platform

disconnect an OAuth platform connection (or a single account on it). `:platform` is the `platform` field from GET /connections (e.g. twitter, facebook)

parameters & response

query parameters

nametypedescription
account_idoptional
stringoptional query param: disconnect only THIS account (its `entity_id` from GET /connections) instead of the whole platform. supported on instagram/tiktok/youtube/twitter/reddit; disconnecting the primary promotes another connected account to primary so your posting keys stay valid

response

200 OK
{
  "data": { "message": "twitter disconnected successfully" }
}

errors

statuscodedescription
404PLATFORM_001connection not found
500PLATFORM_004unable to disconnect - please try again

notes

  • reconnect by re-running the OAuth flow from the dashboard or the in-app connect button
  • omit `account_id` to disconnect every account on the platform; pass it to remove just one
PATCH/connections/:platform/profile

edit the connected account's profile on the platform. body: `{ "description": "<text>" }`. youtube only today - sets the channel description (max 1,000 characters; an empty string clears it). instagram's api can read a profile but not write it, facebook page "about" needs a permission dropspace does not hold, and tiktok / x / linkedin expose no profile write - those answer 400 with the reason. requires the write scope.

parameters & response

response

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

errors

statuscodedescription
404PLATFORM_001platform not connected
400SERVER_002platform can't edit the profile (detail in message)

notes

  • the change is made on youtube itself via channels.update (50 quota units); the stored profile in GET /connections is refreshed at the same time.

dropspace

check which official dropspace accounts are connected and available for posting.

GET/dropspace/status

check which official dropspace accounts are connected

parameters & response

response

200 OK
{
  "data": {
    "platforms": [
      { "platform": "facebook", "connected": true, "account_name": "dropspace" },
      { "platform": "linkedin", "connected": true, "account_name": "dropspace" },
      { "platform": "twitter", "connected": true, "account_name": "@dropspace" },
      { "platform": "instagram", "connected": true, "account_name": "@dropspace" },
      { "platform": "tiktok", "connected": false },
      { "platform": "youtube", "connected": false }
    ],
    "connected_platforms": ["facebook", "linkedin", "twitter", "instagram"],
    "timestamp": "ISO 8601"
  }
}

notes

  • always returns all 7 auto-post platforms in canonical order
  • account_name is only present when connected and account info exists
  • connected_platforms lists valid values for the dropspace_platforms field when creating launches
  • checks is_active flag and token expiry from the database - does not perform live health checks

API keys

manage your API keys for authentication.

GET/keys/me

get the current API key's info

parameters & response

response

200 OK
{
  "data": {
    "id": "uuid",
    "name": "my integration",
    "key_prefix": "ds_live_abc...",
    "scopes": ["read", "write", "generate", "send"],
    "created_at": "ISO 8601"
  }
}

notes

  • no scope required - any valid API key can check its own permissions
GET/keys

list all API keys

parameters & response

response

200 OK
{
  "data": [
    {
      "id": "uuid",
      "name": "my integration",
      "key_prefix": "ds_live_abc...",
      "scopes": ["read", "write", "generate", "send"],
      "last_used_at": "ISO 8601 | null",
      "revoked_at": "ISO 8601 | null",
      "created_at": "ISO 8601"
    }
  ]
}
POST/keys

create a new API key (max 10)

parameters & response

request body

nametypedescription
namerequired
string (1-100 chars)key name
scopesoptional
string[]permission scopes. available: read, write, generate, send, admin. defaults to ['read','write'] when omitted - pass the full list explicitly if the key needs 'generate', 'send', or 'admin'.

response

201 Created
{
  "data": {
    "key": "ds_live_abc123...",
    "api_key": {
      "id": "uuid",
      "name": "my integration",
      "key_prefix": "ds_live_abc...",
      "scopes": ["read", "write", "generate", "send"],
      "created_at": "ISO 8601"
    }
  }
}

notes

  • the full key is shown only once - store it securely
PATCH/keys/:id

rename an API key

parameters & response

request body

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

response

200 OK
{
  "data": {
    "id": "uuid",
    "name": "renamed key",
    "key_prefix": "ds_live_abc...",
    "last_used_at": "ISO 8601 | null",
    "revoked_at": "ISO 8601 | null",
    "created_at": "ISO 8601"
  }
}

errors

statuscodedescription
404SERVER_003api key not found
409SERVER_009an api key with this name already exists
POST/keys/:id/rotate

rotate an API key — issues a replacement and revokes the old one. the new raw key is returned exactly ONCE in this response and is never retrievable again, so store it before the call returns.

parameters & response

response

200 OK
{
  "data": {
    "key": "ds_live_...",
    "api_key": { "id": "uuid", "name": "string", "key_prefix": "ds_live_abcd", "created_at": "ISO 8601" }
  }
}

notes

  • requires the `admin` scope.
  • the previous key stops working as soon as this returns.
DELETE/keys/:id

revoke an API key

parameters & response

errors

statuscodedescription
404SERVER_003api key not found

webhooks

manage webhook endpoints for event notifications.

GET/webhooks

list webhook endpoints

parameters & response

response

200 OK
{
  "data": [
    {
      "id": "uuid",
      "url": "https://your-app.com/webhooks",
      "events": ["launch.completed", "launch.failed", "post.deleted"],
      "active": true,
      "created_at": "ISO 8601",
      "updated_at": "ISO 8601",
      "consecutive_failures": 0,
      "disabled_at": null,
      "disabled_reason": null
    }
  ]
}

notes

  • after 10 consecutive failed delivery attempts the endpoint is auto-disabled (active: false, disabled_reason set) and the account owner is emailed
  • re-enable via PATCH /webhooks/:id with { "active": true }. this resets the failure counter
POST/webhooks

create a webhook endpoint (max 10)

parameters & response

request body

nametypedescription
urlrequired
HTTPS URLwebhook delivery URL (public https). a Slack incoming webhook URL (https://hooks.slack.com/services/...) receives each event as a readable Slack message instead of the signed JSON envelope
eventsrequired
string[]event types to subscribe to

response

201 Created
{
  "data": {
    "id": "uuid",
    "url": "https://your-app.com/webhooks",
    "events": ["launch.completed", "launch.failed", "post.deleted"],
    "secret": "a1b2c3d4...",
    "active": true,
    "consecutive_failures": 0,
    "disabled_at": null,
    "disabled_reason": null
  }
}

errors

statuscodedescription
400SERVER_002invalid webhook url or events
400SERVER_002maximum 10 webhook endpoints allowed

notes

  • the secret is shown only once - store it securely for signature verification
  • available events: "launch.completed" (launch finished publishing), "launch.failed" (launch failed to publish), "launch.partial" (launch partially succeeded), "media.ready" (media generation completed), "persona.analyzed" (persona analysis completed), "post.deleted" (a published post was detected as deleted from its platform), "recommendation.created" (new recommendations were generated for the account), "connection.expired" (a connected account's token was invalidated and needs reauth), "connection.restored" (an expired connection is live again after a successful refresh - no reconnect needed)
  • post.deleted payload: { "event": "post.deleted", "data": { "launch_id": "uuid", "platform": "twitter", "post_url": "https://...", "post_id": "12345", "deletion_reason": "not_found", "detected_at": "2025-01-15T10:30:00Z" } }
  • connection.expired payload: { "event": "connection.expired", "data": { "platform": "instagram", "reason": "token_refresh_failed", "detected_at": "2026-02-18T12:00:00.000Z" } }
  • connection.restored payload: { "event": "connection.restored", "data": { "platform": "instagram", "reason": "token refresh succeeded after an earlier deactivation", "restored_at": "2026-09-08T19:30:00.000Z" } } - dropspace re-tries a failed refresh for 72 hours after connection.expired; a connection still dead after that needs a reconnect
  • deletion_reason values: not_found, gone, creator_deleted, moderation_removed, account_deleted, spam_filtered
GET/webhooks/:id

get a webhook endpoint

parameters & response

response

200 OK
{
  "data": {
    "id": "uuid",
    "url": "https://your-app.com/webhooks",
    "events": ["launch.completed", "launch.failed", "post.deleted"],
    "active": true,
    "created_at": "ISO 8601",
    "updated_at": "ISO 8601",
    "consecutive_failures": 0,
    "disabled_at": null,
    "disabled_reason": null
  }
}

errors

statuscodedescription
404SERVER_003webhook not found
PATCH/webhooks/:id

update url, events, or active status

parameters & response

request body

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

errors

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

delete a webhook endpoint

parameters & response

errors

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

send a synthetic webhook.test event to the registered URL

parameters & response

response

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

errors

statuscodedescription
404SERVER_003webhook not found

notes

  • posts a `webhook.test` event signed with the webhook's secret to the registered URL
  • returns the receiver's response status and a (truncated) response body for debugging
  • creates a delivery row that shows up in `/webhooks/:id/deliveries`
  • 10s timeout: `response_status: 408` indicates the receiver didn't respond in time
POST/webhooks/:id/rotate-secret

rotate the signing secret (new secret shown once)

parameters & response

response

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

errors

statuscodedescription
404SERVER_003webhook not found

notes

  • the new secret is shown only once - update your verification code immediately
  • the old secret becomes invalid immediately after rotation
  • in-flight webhook deliveries (already queued) use the secret from enqueue time and are not affected
GET/webhooks/:id/deliveries

list delivery attempts with pagination

parameters & response

query parameters

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

response

200 OK
{
  "data": [
    {
      "id": "uuid",
      "event_type": "launch.completed",
      "status": "delivered|pending|failed",
      "attempt_count": 1,
      "response_status": 200,
      "delivered_at": "ISO 8601",
      "failed_at": null,
      "next_retry_at": null,
      "created_at": "ISO 8601"
    }
  ],
  "pagination": { "page": 1, "page_size": 50, "total": 12, "total_pages": 1 }
}

errors

statuscodedescription
404SERVER_003webhook not found

notes

  • `failed_at` is set on terminal failures (delivered_at and failed_at are mutually exclusive, mirroring iris). `next_retry_at` is null when QStash is handling retries opaquely.

usage

check your current plan, billing period, and usage limits.

GET/usage

get plan limits and current usage

parameters & response

response

200 OK
{
  "data": {
    "plan": "starter",
    "billing_period": {
      "start": "ISO 8601",
      "end": "ISO 8601"
    },
    "limits": {
      "launches_per_month": { "limit": 50, "used": 3, "remaining": 47 },
      "ai_images_per_month": { "limit": 100, "used": 12, "remaining": 88 },
      "ai_videos_per_month": { "limit": 20, "used": 5, "remaining": 15 },
      "personas": { "limit": 10, "used": 2, "remaining": 8 },
      "analyses_per_persona": { "limit": 3 },
      "regenerations_per_launch": { "limit": 5 }
    },
    "features": {
      "can_connect_own_accounts": true,
      "can_post_to_official_accounts": true,
      "allowed_platforms": ["facebook", "linkedin", "twitter", "reddit", "instagram", "tiktok", "youtube"]
    }
  }
}

notes

  • limit and remaining can be "unlimited" (string) instead of a number for higher-tier plans
  • personas is a lifetime limit (not per billing period)
  • analyses_per_persona and regenerations_per_launch are per-resource limits (no used/remaining tracking)

credits

purchase and manage credit packs for MPP (Stripe) agents. credits let agents prepay for launches in bulk, reducing per-transaction fees.

GET/credits

get credit balance and available packs

parameters & response

response

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

notes

  • returns balance 0 for x402 wallets and API key users (credits are MPP-only)
  • balance is cached in Redis (5-min TTL) with database as source of truth
POST/credits/purchase

purchase a credit pack via MPP payment

parameters & response

request body

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

response

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

errors

statuscodedescription
400SERVER_002x402 wallets cannot purchase credits (MPP only)
402PAYMENT_001MPP payment required - credential missing or expired
500PAYMENT_004payment processed but credits could not be added (contact support)

notes

  • MPP agents only - x402 wallets are rejected with 400 (crypto has low fees already)
  • the MPP credential must be for the pack price ($12.00 = 1200 cents), not the per-launch price
  • credits never expire and are deducted automatically when creating launches
  • the launch fee is charged once, when the launch is created, and covers the publishing attempt: a launch that ends partial or failed is not refunded. if creating the launch fails after payment, a used credit or free-tier launch is given back automatically; a per-launch x402 or MPP payment is not reversed automatically, so contact support with the request id.
  • duplicate purchases with the same MPP credential are rejected (idempotent)

boosts

create paid boost campaigns on meta for organic facebook or instagram launches. boosts always start PAUSED. activate them explicitly to begin ad spend. ad budget is billed by meta to the user's connected ad account, not by dropspace.

POST/launches/{id}/boost

create a paid boost on meta for an organic facebook or instagram launch (always PAUSED on create)

parameters & response

request body

nametypedescription
platformoptional
"facebook" | "instagram"ad platform: facebook or instagram. IG boost requires the user's IG business account to be linked to one of their connected facebook pages (default: facebook)
daily_budget_centsrequired
integer (100 - 10000)daily ad budget in cents ($1 - $100 per day). billed by meta to the user's ad account.
duration_daysrequired
integer (1 - 7)boost duration in days
objectiveoptional
"engagement" | "traffic"engagement optimizes for reactions on the post; traffic optimizes for clicks on the link and needs a facebook link post, one published with a link preview and no photos or video (otherwise 409) (default: engagement)
stop_rulesoptional
{ max_cpc_cents?: integer }pause the boost on meta when its average cost per click passes max_cpc_cents (10-10000), checked after 5 clicks, or when spend reaches 3x the cap with no clicks. every running boost also pauses itself when click-through over 1,000+ impressions falls below half its first-day rate. a paused boost resumes with POST /launches/{id}/boost/{boostId}/activate within its original schedule; the cost rules then count only spend and clicks after the resume
ad_account_idoptional
stringexplicit meta ad account id (e.g. "act_123"); defaults to the first active account

response

201 Created
{
  "data": {
    "id": "uuid",
    "launch_id": "uuid",
    "platform": "facebook",
    "post_id": "<page_id>_<post_id>",
    "status": "paused",
    "objective": "engagement",
    "daily_budget_cents": 500,
    "duration_days": 3,
    "meta_ad_account_id": "act_123",
    "meta_campaign_id": "1234567890",
    "meta_adset_id": "1234567891",
    "meta_ad_id": "1234567892",
    "impressions": null,
    "clicks": null,
    "reach": null,
    "link_clicks": null,
    "total_spend_cents": null,
    "stop_reason": null,
    "created_at": "ISO 8601",
    "activated_at": null,
    "ended_at": null
  }
}

errors

statuscodedescription
403ADS_002facebook ads_management scope not granted; reauthorize with ads scopes
403PLATFORM_001facebook account not connected
409ADS_007launch has no successful facebook post to boost. publish to facebook first
409ADS_001no active facebook ad account found; set one up in meta business manager

notes

  • requires the launch to have a successful facebook post (post_id available in posting_logs).
  • boost is created in PAUSED status. call POST /launches/{id}/boost/{boost_id}/activate to start spending.
  • creative reuses the existing organic post via object_story_id, so no creative upload is required.
  • meta enforces ad spend on the user's ad account. dropspace never holds card data for ad budgets.
GET/launches/{id}/boost

list all boosts attached to a launch

parameters & response

response

200 OK
{
  "data": [
    {
      "id": "uuid",
      "launch_id": "uuid",
      "status": "active",
      "daily_budget_cents": 500,
      "duration_days": 3,
      "impressions": 4320,
      "clicks": 87,
      "reach": 3211,
      "total_spend_cents": 1230
    }
  ]
}
GET/launches/{id}/boost/{boost_id}

fetch a single boost with the latest performance metrics

POST/launches/{id}/boost/{boost_id}/activate

flip a PAUSED boost to ACTIVE on meta. ad spend begins immediately

parameters & response

errors

statuscodedescription
409ADS_006boost is already active
409SERVER_009cannot activate a boost in a terminal status (canceled / completed / failed)

notes

  • no payment required: boost was already paid for at create time.
  • flips campaign → ad set → ad in order so the ad inherits ACTIVE status correctly.
DELETE/launches/{id}/boost/{boost_id}

cancel a boost: deletes the campaign on meta and soft-deletes the record

parameters & response

notes

  • idempotent: canceling a boost already in a terminal state returns the current row unchanged.
  • if meta-side DELETE fails, the dropspace row is still soft-deleted (manual cleanup may be needed).

changelog

machine-readable release history - public, no API key required.

GET/changelog

release history newest-first: version, date, type (feature/fix/breaking/docs), summary, and migration notes on breaking entries. public - the one-line X-Notice header on every response is the latest entry's summary; this is the structured history behind it

parameters & response

response

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

webhook delivery

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

event types

eventfired when
launch.completedall platforms posted successfully
launch.failedall platforms failed
launch.partialsome platforms succeeded, some failed
media.readyimage or video generation completed
persona.analyzedAI persona analysis finished
post.deleteda published post was detected as deleted from its platform
connection.expireda connected account's token was invalidated (revoked or unrefreshable) and needs reauth
connection.restoreda connection that had expired is live again: dropspace re-tries the failed refresh for 72 hours and reactivates the account when the platform accepts it, so no reconnect is needed
recommendation.creatednew recommendations were generated for the account (GET /recommendations lists them)

payload format

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

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

event payloads

launch.completed / launch.failed / launch.partial

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

media.ready

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

persona.analyzed

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

post.deleted

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

headers

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

signature verification

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

import crypto from "crypto";

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

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

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

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

  return crypto.timingSafeEqual(sigBuf, expBuf);
}

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

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

retry behavior

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

agent payments

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

discoverable on

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

how it works

  • •x402 wallets get 5 free launches/month using official dropspace accounts
  • •beyond the free tier, each launch creation costs $0.55 via x402 (crypto) or $0.60 via MPP (card). publishing is always free
  • •payment proofs are verified for agent identification; settlement only occurs when the free tier is exceeded
  • •agent users can only post to official dropspace accounts (not connected user accounts)
  • •the launch fee is charged once, when the launch is created, and covers the publishing attempt: a launch that ends partial or failed is not refunded. if creating the launch fails after payment, a used credit or free-tier launch is given back automatically; a per-launch x402 or MPP payment is not reversed automatically, so contact support with the request id.

protocols

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

rate limits

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

supported endpoints

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

example flow

1. request with no payment proof

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

2. 402 response (includes both challenges)

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

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

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

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

3b. pay with MPP (Stripe)

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

credit packs

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

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

pricing

packcreditspriceper launch
pack_2020$12.00$0.60

how it works

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

purchase example

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

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

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

check balance

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

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

error handling

error response shape

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

error codes

authentication

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

launches

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

content moderation

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

personas

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

media

codeHTTPmeaning
MEDIA_001429monthly media generation limit reached

uploads

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

payment (x402 / mpp)

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

rate limiting

codeHTTPmeaning
RATE_001429too many requests

validation & server

codeHTTPmeaning
SERVER_001500internal server error
SERVER_002400validation error
SERVER_003404resource not found
SERVER_004405method not allowed
SERVER_005400invalid input format or business rule violation
SERVER_008500database error
SERVER_009409conflict (e.g. status transition race)

posting error codes

a failed entry in GET /launches/:id/status or /destinations carries error_code, a stable code to branch on, and error_message, text to show a person. both arrive in a 200 response. POST /launches/:id/retry re-runs every failed destination whatever its code, so fix the cause first, and check the platform before retrying a timeout or a partial thread. treat a code not listed here as a generic failure.

accounts

codemeaningwhat to do
AUTH_ERRORthe platform rejected the account's sign-in.reconnect the account, then retry.
TOKEN_EXPIREDthe account's access expired and could not be renewed.reconnect the account, then retry.
TOKEN_REFRESH_FAILEDrenewing the account's access failed while publishing. error_message says whether the account is still connected.retry in a few minutes. reconnect the account if error_message says to, or if it keeps failing.
TOKEN_NOT_FOUNDthe account chosen for this destination is no longer connected.reconnect it, or choose another account.
NO_ACCESS_TOKENthe connection exists but holds no usable access.reconnect the account, then retry.
INVALID_TOKENthe Facebook connection used is not a Page connection.reconnect Facebook and select the Page.
NO_ACCOUNTnothing to post from: the launch lists the platform with no user_platform_accounts key and no dropspace official posting, or the chosen account could not be found.connect the account and put its key in user_platform_accounts (ids come from GET /connections).
PERMISSION_DENIEDthe account has not granted a permission this post needs.reconnect and grant the requested permissions.
SCOPE_NOT_AUTHORIZEDTikTok posting permission was not granted when the account connected.reconnect TikTok and allow posting.
NO_ORG_SELECTEDLinkedIn organization posting was requested, but the connection has no organization.reconnect LinkedIn and pick the company page.
USER_INFO_ERRORLinkedIn did not return the member profile needed to post.reconnect LinkedIn.
DROPSPACE_TOKEN_ERRORthe dropspace official account for this platform was temporarily unavailable.retry later.

limits

codemeaningwhat to do
RATE_LIMITthe platform rate-limited the request.wait, then retry.
DAILY_POST_CAPthe account reached TikTok's daily posting limit.retry after the limit resets.
USER_CAP_REACHEDTikTok's 24-hour cap on active creators for dropspace's app was reached.retry later.
RATE_LIMIT_CIRCUIT_BREAKskipped without posting: an earlier subreddit in this launch hit Reddit's rate limit.retry this subreddit on its own with POST /launches/:id/retry and { "subreddit": "<name>" }.

content

codemeaningwhat to do
NO_CONTENTthe launch has no content for this platform.add content for it.
VALIDATION_ERRORthe post broke a platform rule on length, a required field, or a media source.fix what error_message names before posting again.
DUPLICATE_CONTENTthe platform says this exact content was already posted.change the content before posting again.
CONTENT_RESTRICTIONthe platform blocked the content: a banned link, too long, already submitted, or a removed or locked target.change or remove what error_message names.
MISSING_COMPANY_CONTENTLinkedIn company posting was selected, but the company-voice content is empty.add company content.
CONTENT_MODERATION_FAILEDmoderation blocked a post from a dropspace official account.revise the content.
CONTENT_MODERATION_UNAVAILABLEthe moderation check for a dropspace official account post could not run.retry later.

media

codemeaningwhat to do
UPLOAD_FAILEDuploading the media to the platform failed.check that the file is reachable and within the platform's limits, then retry.
CAROUSEL_ERRORfewer than two images of an Instagram carousel uploaded.replace the images that failed, then retry.
INVALID_MEDIA_URLInstagram could not fetch the media URL.use a publicly reachable URL.
INVALID_VIDEO_URLthe video URL could not be fetched: by TikTok, which pulls it itself, or by dropspace, which uploads it to LinkedIn and got a 4xx from it.use a publicly reachable URL.
INVALID_VIDEO_FORMATTikTok rejected the video's format.re-encode the video (MP4 with H.264 is the safe choice).
VIDEO_FORMAT_ERRORFacebook rejected the video's format.re-encode the video as MP4.
VIDEO_TOO_LARGEthe video is over Facebook's size limit.use a smaller file.
MEDIA_PROCESSING_FAILEDthe platform processed the media and rejected it for its format, length, or size.fix the file.
PROCESSING_TIMEOUTthe platform was still processing the media when dropspace stopped waiting.check the platform first: the post may already be live, and a retry can post it twice.

platform rules

codemeaningwhat to do
PLATFORM_DISABLEDposting to this platform is turned off in dropspace.contact support.
POLICY_BLOCKFacebook restricted the account, often until its owner confirms their identity.resolve it in Facebook, then retry.
USER_BANNEDTikTok temporarily blocked the account from posting.retry after TikTok lifts the block.
PRIVATE_ONLYTikTok only accepted a post to a private account here.contact support.
DOMAIN_NOT_VERIFIEDTikTok has not verified the domain dropspace serves media from.contact support. there is nothing to fix on your side.
PRIVACY_LEVEL_MISMATCHthe chosen TikTok privacy level is not available for this account.choose a privacy level the account allows.
SUBREDDIT_RESTRICTIONthe subreddit does not allow this post type (links, text, images, or video).change the post type or pick another subreddit.
SUBREDDIT_ACCESS_DENIEDthe subreddit does not exist, or the account is not allowed to post there.pick another subreddit.
TIKTOK_<REASON>TikTok's own failure reason, such as TIKTOK_FILE_FORMAT_CHECK_FAILED, TIKTOK_DURATION_CHECK_FAILED, or TIKTOK_SPAM_RISK, including TikTok errors dropspace does not map to a named code.read error_message. a failed media check needs a new file; TIKTOK_INTERNAL is TikTok's outage and can be retried.
PUBLISH_FAILEDTikTok marked the post failed without giving a reason.retry. contact support if it keeps happening.
PROMO_RATIO_EXCEEDEDskipped: posting would break Reddit's 90:10 self-promotion norm for this account in that subreddit.post there later, or pick another subreddit.
SUBREDDIT_ALREADY_TARGETEDskipped: another of the creator's Reddit accounts is posting to this subreddit on this launch.nothing to do. one account posts to each subreddit per launch.
INVALID_OPTIONReddit rejected a post option.adjust what error_message names.
REDDIT_<CODE>a Reddit error dropspace does not map to a named code, such as REDDIT_BAD_SR_NAME. REDDIT_API_ERROR means Reddit gave no code.read error_message.

delivery

codemeaningwhat to do
PLATFORM_API_ERRORthe platform returned an error with no more specific code.retry. contact support if it keeps happening.
INVALID_RESPONSEReddit accepted the request but returned no post.check whether the post appeared before retrying.
NO_PUBLISH_IDTikTok accepted the upload request but returned no publish id.retry. contact support if it keeps happening.
PARTIAL_THREADan X thread failed partway, after some of its posts went out.check X first: a retry posts the whole thread again from the first post.
UNKNOWN_ERRORan unexpected error while posting.retry. contact support if it keeps happening.
EXCEPTIONan unexpected error during a posting attempt.retry. contact support if it keeps happening.
MAX_RETRIESevery automatic attempt failed without a more specific error.retry. contact support if it keeps happening.
TIMEOUTa request to the platform timed out.check whether the post appeared before retrying, to avoid a duplicate.
PROMISE_REJECTEDa retry of this destination failed unexpectedly.retry. contact support if it keeps happening.

deleting posts

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