Install
openclaw skills install @petricbranko/opentweet-x-posterPost to X (Twitter), Bluesky and LinkedIn using the OpenTweet API. Create posts, cross-post the same content to all three networks in one call, schedule posts, publish threads, write and schedule long-form X Articles from markdown, upload media, run an evergreen queue, search inspiration tweets, repurpose them with AI, run AI-qualified DM outreach campaigns with human-approved sending, and read engagement-weighted analytics, all autonomously.
openclaw skills install @petricbranko/opentweet-x-posterYou can post to X (Twitter), Bluesky and LinkedIn using the OpenTweet REST API. All requests go to https://opentweet.io with the user's API key.
Every request needs this header:
Authorization: Bearer $OPENTWEET_API_KEY
Content-Type: application/json
For file uploads, use Content-Type: multipart/form-data instead.
ALWAYS verify the connection first:
GET https://opentweet.io/api/v1/me
Returns subscription status, daily post limits, post counts, and connected X accounts. Check subscription.has_access is true and limits.remaining_posts_today > 0 before scheduling or publishing.
Pro users get 1 X account, Advanced 3, Agency 10. Use the x_account_id parameter to target a specific account.
GET https://opentweet.io/api/v1/accounts
Returns: { "accounts": [{ "id": "...", "x_handle": "@handle", "x_name": "Display Name", "is_primary": true, "nickname": null }] }
Add x_account_id to any POST/PUT body or GET query parameter to target a specific X account:
{ "text": "...", "x_account_id": "account_id_here" }GET /api/v1/posts?x_account_id=account_id_here{ "schedules": [...], "x_account_id": "account_id_here" }GET /api/v1/analytics/overview?x_account_id=account_id_hereGET /api/v1/evergreen/posts?x_account_id=account_id_herePOST /api/v1/analytics/best-times/analyze body { "x_account_id": "..." }When x_account_id is omitted, the primary account is used. Single-account users never need to specify it.
OpenTweet can publish the same content to X, Bluesky and LinkedIn. Every post-creating endpoint takes an optional platforms array, and every post response carries a per-network results array.
GET https://opentweet.io/api/v1/cross-posting
Returns which networks are connected, which can actually publish right now, whether auto cross-post is on, and where a post that omits platforms would go:
{
"auto_cross_post": false,
"default_targets": ["x"],
"deliverable_targets": ["x"],
"platforms": [
{ "platform": "x", "connected": true, "usable": true,
"accounts": [{ "account_id": "...", "handle": "handle", "display_name": "Name", "is_primary": true }],
"limits": { "max_text_length": 280, "text_unit": "weighted", "max_images": 4 } },
{ "platform": "bluesky", "connected": true, "usable": false, "needs_reconnect": true,
"accounts": [...], "limits": {...} },
{ "platform": "linkedin", "connected": true, "usable": true,
"accounts": [...],
"limits": { "max_text_length": 3000, "text_unit": "codeunit", "max_images": 20, "max_videos": 0 } }
]
}
Read these two together before posting whenever the user has not said which network they mean:
default_targets is what a post that omits platforms is ATTEMPTED on.deliverable_targets is the subset that will actually LAND, filtered to networks with a usable account.Anything in default_targets but not in deliverable_targets is going to fail. That is deliberate: an unusable network stays in the attempt so the failure is recorded in results, instead of being quietly dropped and letting the post roll up as a clean success nobody questions. Reading only default_targets would have you tell the user their post goes to X and Bluesky while the Bluesky authorisation is dead. Say which networks will land and which are expected to fail.
connected and usable are DIFFERENT questions and you must not conflate them:
connected only means an account row exists.usable means OpenTweet believes it can publish there right now.A user whose Bluesky authorisation died is connected: true, usable: false, needs_reconnect: true. Telling them "Bluesky is connected" would be misleading. Say it needs reconnecting at https://opentweet.io/settings, and do not promise a publish that is going to fail. Targeting a network with usable: false comes back as a failed or skipped entry in results. The same applies to a LinkedIn account whose 60-day sign-in has lapsed.
POST https://opentweet.io/api/v1/posts
Body: { "text": "Shipped it.", "publish_now": true, "platforms": ["x", "bluesky", "linkedin"] }
platforms accepts any non-empty subset of "x", "bluesky" and "linkedin", so ["bluesky"] posts to Bluesky only and ["x", "linkedin"] skips Bluesky. Unknown ids are dropped. An explicit list is ALWAYS honoured exactly, whatever the auto cross-post setting says.
For bulk creates, platforms works per post inside the posts array and also at the top level as a default for the whole batch.
platforms meansLeaving platforms out is NOT the same as sending ["x"]. An unspecified post follows the user's server-side auto cross-post setting:
If the user asked for X only, send "platforms": ["x"]. Do not rely on the omission, because on an account with auto cross-post on that also posts to Bluesky and LinkedIn.
Post responses include results, one entry per network the post was sent to:
"results": [
{ "platform": "x", "status": "published", "url": "https://x.com/handle/status/123" },
{ "platform": "bluesky", "status": "skipped", "skip_reason": "No Bluesky account connected" },
{ "platform": "linkedin", "status": "published", "post_id": "urn:li:share:7300000000000000000",
"url": "https://www.linkedin.com/feed/update/urn:li:share:7300000000000000000/" }
]
status is "published", "failed", or "skipped".
skipped is NOT a failure. The post went out, one target was dropped, and skip_reason says why. Read skip_reason under both spellings: skip_reason and skipReason.failed carries error with the network's rejection reason.url from each published entry.Character limits differ: X allows 280 (up to 25,000 with X Premium), Bluesky caps at 300 graphemes, LinkedIn allows 3,000 characters (an emoji counts as 2). Text that fits X can still be rejected by Bluesky, which comes back as a failed entry for Bluesky while X publishes fine.
skipped LinkedIn entry with a skip_reason. Up to 20 images work.results, LinkedIn's post_id is a URN like urn:li:share:7300000000000000000 and url is https://www.linkedin.com/feed/update/<urn>/.GET /api/v1/cross-posting shows LinkedIn as usable: false, needs_reconnect: true.POST https://opentweet.io/api/v1/posts
Body: { "text": "Your tweet text" }
Optionally add "scheduled_date": "2026-05-01T10:00:00Z" to schedule it (requires active subscription, date must be in the future). Add "platforms": ["x", "bluesky", "linkedin"] (or any subset) to pin which networks it goes to. See Cross-Posting above for what happens when you leave platforms out.
POST https://opentweet.io/api/v1/posts
Body: { "text": "Hello from the API!", "publish_now": true }
Creates the post AND publishes in one request. Cannot combine with scheduled_date or bulk posts. Response includes status: "posted", x_post_id, and url (the real X post URL) on success, plus a results array with the outcome for every network it went to. Add "platforms": ["x", "bluesky", "linkedin"] to publish to all three at once.
POST https://opentweet.io/api/v1/posts
Body: {
"text": "Check out this screenshot!",
"media_urls": ["https://url-from-upload-endpoint"]
}
Upload media first via POST /api/v1/upload, then pass the returned URL(s) in media_urls.
POST https://opentweet.io/api/v1/posts
Body: {
"text": "First tweet of the thread",
"is_thread": true,
"thread_tweets": ["Second tweet", "Third tweet"]
}
POST https://opentweet.io/api/v1/posts
Body: {
"text": "Thread intro with image",
"is_thread": true,
"thread_tweets": ["Second tweet", "Third tweet"],
"media_urls": ["https://intro-image-url"],
"thread_media": [["https://img-for-tweet-2"], []]
}
thread_media is an array of arrays. Each inner array contains media URLs for the corresponding tweet in thread_tweets. Use [] for tweets with no media.
POST https://opentweet.io/api/v1/posts
Body: {
"text": "Shared with the community!",
"community_id": "1234567890",
"share_with_followers": true
}
POST https://opentweet.io/api/v1/posts
Body: {
"text": "This will get a boost.",
"scheduled_date": "2026-05-01T10:00:00Z",
"auto_retweet_enabled": true,
"auto_retweet_offset_minutes": 240
}
After the post publishes, OpenTweet automatically retweets it from the same account auto_retweet_offset_minutes later. Works on PUT too. Range: 1–10080 minutes (up to 7 days). Both fields can also be set via PUT /api/v1/posts/{id}.
POST https://opentweet.io/api/v1/posts
Body: {
"text": "Hot take about AI agents.",
"scheduled_date": "2026-05-01T10:00:00Z",
"auto_plug_enabled": true,
"auto_plug_threshold": 50,
"auto_plug_text": "Enjoyed this? I share more every week → link.com/newsletter"
}
After the post publishes, OpenTweet checks its like count every 5 minutes. When like_count >= auto_plug_threshold, it automatically posts auto_plug_text as a reply to the original tweet — turning viral reach into subscribers or leads.
auto_plug_threshold — likes needed to trigger (default: 20, no upper limit)auto_plug_text — the reply content, max 280 chars (required when auto_plug_enabled: true)auto_plug_done: true and auto_plug_tweet_id are set after sendingPUT /api/v1/posts/{id} too (set before or after publishing)POST https://opentweet.io/api/v1/posts
Body: {
"posts": [
{ "text": "Tweet 1", "scheduled_date": "2026-05-01T10:00:00Z" },
{ "text": "Tweet 2", "scheduled_date": "2026-05-01T14:00:00Z" }
]
}
POST https://opentweet.io/api/v1/posts/{id}/schedule
Body: { "scheduled_date": "2026-05-01T10:00:00Z" }
The date must be in the future. Use ISO 8601 format.
POST https://opentweet.io/api/v1/posts/{id}/publish
No body needed. Publishes right now to the networks the post was created with, or to whatever auto cross-post resolves to if it was created without platforms. Response includes status: "posted", x_post_id, url (the real X post URL), and a results array with the per-network outcome.
POST https://opentweet.io/api/v1/posts/batch-schedule
Body: {
"schedules": [
{ "post_id": "id1", "scheduled_date": "2026-05-02T09:00:00Z" },
{ "post_id": "id2", "scheduled_date": "2026-05-03T14:00:00Z" }
],
"community_id": "optional-community-id",
"share_with_followers": true,
"x_account_id": "optional-account-id"
}
GET https://opentweet.io/api/v1/posts?status=scheduled&page=1&limit=20
Status options: scheduled, posted, draft, failed, evergreen (returns evergreen pool source posts).
GET https://opentweet.io/api/v1/posts/{id}
PUT https://opentweet.io/api/v1/posts/{id}
Body: {
"text": "Updated text",
"media_urls": ["https://..."],
"scheduled_date": "2026-05-01T10:00:00Z",
"auto_retweet_enabled": true,
"auto_retweet_offset_minutes": 120
}
All fields optional. Cannot update already-published posts. Set scheduled_date to null to unschedule (convert back to draft).
DELETE https://opentweet.io/api/v1/posts/{id}
Default: if the post was already published, OpenTweet also deletes it from X. To delete only locally and leave the X post live, append ?delete_from_x=false. Response includes x_deleted and (if it failed) x_delete_error.
POST https://opentweet.io/api/v1/upload
Content-Type: multipart/form-data
Body: file=@your-image.png
Returns: { "url": "https://..." }
Supported formats: JPG, PNG, GIF, WebP (max 5MB), MP4, MOV (max 20MB).
Workflow: Upload first, then use the returned URL in media_urls or thread_media when creating/updating posts.
Generate images and videos with Grok Imagine (xAI) directly from a prompt. The generated file is permanently stored and the URL can be used in media_urls when creating a tweet. Requires XAI_API_KEY to be configured on the server.
POST https://opentweet.io/api/v1/generate/image
Body: {
"prompt": "A vibrant product launch announcement graphic",
"aspect_ratio": "16:9",
"resolution": "1k"
}
prompt — required, max 1000 charsaspect_ratio — optional: "1:1" (default), "16:9", "9:16", "4:3", "3:4"resolution — optional: "1k" (default) or "2k"Returns: { "url": "https://...", "prompt": "...", "aspect_ratio": "16:9", "resolution": "1k" }
Response is synchronous — the URL is ready to use immediately.
POST https://opentweet.io/api/v1/generate/video
Body: {
"prompt": "A product spinning on a pedestal with dramatic lighting",
"aspect_ratio": "16:9",
"resolution": "480p",
"duration": 5
}
prompt — required, max 1000 charsaspect_ratio — optional: "16:9" (default), "9:16", "1:1", "4:3", "3:4"resolution — optional: "480p" (default) or "720p"duration — optional: 5 (default) to 10 secondsReturns (202): { "job_id": "...", "status": "processing", "message": "..." }
Video generation is asynchronous — poll the status endpoint until complete.
GET https://opentweet.io/api/v1/generate/video/{job_id}
Returns one of:
{ "job_id": "...", "status": "processing" } — still generating, poll again in 10 seconds{ "job_id": "...", "status": "completed", "url": "https://..." } — ready, use the URL{ "job_id": "...", "status": "failed", "error": "..." } — generation failedTypical generation time: 1–3 minutes. Poll every 10 seconds.
Write and publish long-form X Articles from markdown. OpenTweet converts markdown to X's rich-text article format at publish time: #/##/### headings, bold, italic, strikethrough, links, bullet/numbered lists, > quotes, inline images via  (uploaded to X automatically), a cover image, and embedded posts (an x.com status URL on its own line). Fenced code blocks are preserved as plain text.
Requires an active paid subscription (not trial). The connected X account must have X Premium (any tier since January 2026) to publish Articles — if not, publishing fails and failed_reason carries X's exact error.
Article statuses: draft, scheduled, publishing, published, failed.
POST https://opentweet.io/api/v1/articles
Body: {
"title": "How We Grew to 10k Users",
"content_markdown": "# The short version\n\nWe shipped **every week**...",
"cover_image_url": "https://url-from-upload-endpoint",
"scheduled_date": "2026-07-15T14:00:00Z"
}
title and content_markdown are required.scheduled_date schedules it; "publish_now": true publishes immediately (mutually exclusive with scheduled_date).x_account_id for multi-account users.Returns 201 with the article object. A published article includes article_url (the live X link) and x_post_id.
GET https://opentweet.io/api/v1/articles?status=scheduled&page=1&limit=20
Status filter options: draft, scheduled, publishing, published, failed.
GET https://opentweet.io/api/v1/articles/{id}
PUT https://opentweet.io/api/v1/articles/{id} # body: any of title, content_markdown, cover_image_url, scheduled_date, x_account_id
DELETE https://opentweet.io/api/v1/articles/{id}
Published articles cannot be edited. Set scheduled_date to null to unschedule back to draft. Updating a failed article resets it to draft/scheduled.
POST https://opentweet.io/api/v1/articles/{id}/publish
No body needed. Returns the article with status: "published" and article_url, or 502 with code: "publish_failed" and X's error in failed_reason.
Run outreach campaigns that find qualified X profiles, qualify them with AI, draft a personalized DM for each, and send only after a human approves. This is not a mass messenger: sending is human-in-the-loop, drip-scheduled, rate-limited, and honors opt-outs. Available on the Advanced and Agency plans only (403 on Pro). Requires a paid API key (not the free taste key).
How a campaign flows: you create it with an ideal-customer description and one or more lead sources, discovery finds and AI-qualifies candidates into leads, you review and approve the drafted messages, then the sender drips approved DMs from the connected X account within the campaign's caps. Replies are tracked and tagged, and opt-outs are auto-suppressed. Actual sending is gated server-side and paced, so approving a lead queues it, it does not fire instantly.
POST https://opentweet.io/api/v1/dm-campaigns
Body: {
"name": "Shopify founders Q3",
"x_account_id": "account_id_here",
"sources": [
{ "type": "search", "value": "shopify analytics", "max_candidates": 500 },
{ "type": "followers_of", "value": "somecompetitor", "max_candidates": 500 },
{ "type": "tweet_engagers", "value": "https://x.com/user/status/123", "max_candidates": 300 },
{ "type": "own_followers", "value": "", "max_candidates": 500 }
],
"icp_prompt": "Shopify store owners or ecommerce founders, 200-20k followers, English, active in the last 2 weeks, complaining about ad costs or analytics. Not agencies.",
"score_threshold": 60,
"message_brief": { "offer": "a $29/mo analytics tool for Shopify", "tone": "casual", "cta": "worth a look?", "follow_up_enabled": false },
"limits": { "dms_per_day": 25, "active_hours_start": 9, "active_hours_end": 18, "timezone": "America/New_York" },
"accept_policy": true
}
accept_policy must be true (it records acceptance of X's acceptable-use terms; the connected account bears the outreach risk). Returns { "campaign": {...} }. Source types: search (keyword query), followers_of (a handle), tweet_engagers (a tweet URL), own_followers.
GET https://opentweet.io/api/v1/dm-campaigns
Returns { "campaigns": [...] }, each with a stats funnel (found, qualified, approved, sent, replied).
GET https://opentweet.io/api/v1/dm-campaigns/{id}
POST https://opentweet.io/api/v1/dm-campaigns/{id}/discover
Sets the campaign to discovering and returns immediately. Discovery and AI qualification run in the background, then the campaign moves to reviewing with leads ready. Poll the campaign until its status is reviewing.
GET https://opentweet.io/api/v1/dm-campaigns/{id}/leads?state=qualified&page=1
State filter includes qualified (awaiting approval), approved, sent, replied, skipped. Each lead has a score, a reason, an angle_hook, and a drafted final_message.
PATCH https://opentweet.io/api/v1/dm-campaigns/{id}/leads/{leadId}
Body: { "action": "approve", "final_message": "optional edited message" }
action is approve (queues the drafted or provided message for drip sending), skip, or edit (change the draft without approving). Approving does NOT send immediately; the sender paces it within the campaign's daily cap and active hours.
GET https://opentweet.io/api/v1/dm-campaigns/{id}/inbox
Returns { "conversations": [{ "lead": {...}, "reply_tag": "interested|question|not_interested|opt_out", "events": [...] }] }. Opt-outs are auto-added to the do-not-contact list.
The evergreen queue keeps a pool of timeless tweets and republishes them on a schedule with cooldown gaps so the same post doesn't repeat too often. Source posts stay as templates; the scheduler clones them as regular posts at the configured times. Requires an active paid subscription (not available on trial). Pro: 10 pool / 2 per day. Advanced: 999 pool / 10 per day.
GET https://opentweet.io/api/v1/evergreen/settings
Returns: enabled, posts_per_day, posting_times (["09:00","17:00"]), default_cooldown_days, plus pool counts and your plan limits.
PUT https://opentweet.io/api/v1/evergreen/settings
Body: {
"enabled": true,
"posts_per_day": 2,
"posting_times": ["09:00", "17:00"],
"default_cooldown_days": 14
}
All fields optional. posting_times must be "HH:mm" strings. default_cooldown_days is 1–90. posts_per_day capped to your plan's daily limit.
GET https://opentweet.io/api/v1/evergreen/posts?page=1&limit=20&paused=false
Filter paused=true or paused=false. Each item includes cooldown_days, last_posted_at, times_posted, paused.
Mode 1 — convert an existing post:
POST https://opentweet.io/api/v1/evergreen/posts
Body: { "post_id": "507f1f77bcf86cd799439011", "cooldown_days": 14 }
Mode 2 — create a new evergreen post directly:
POST https://opentweet.io/api/v1/evergreen/posts
Body: {
"text": "Timeless tweet text",
"category": "Tips",
"cooldown_days": 21,
"is_thread": false,
"media_urls": ["https://..."]
}
GET https://opentweet.io/api/v1/evergreen/posts/{id}
PUT https://opentweet.io/api/v1/evergreen/posts/{id} # body: { "cooldown_days": 30, "paused": true }
DELETE https://opentweet.io/api/v1/evergreen/posts/{id} # converts back to a draft (does not hard-delete)
GET also returns recent_posts — the last 5 published clones with their X URLs.
GET https://opentweet.io/api/v1/evergreen/history?page=1&limit=20&source_id=optional
Lists published clones. Filter by source_id to see the history of a single evergreen post.
Search X for tweets and have AI rewrite them in the user's voice. Both endpoints require an active subscription. Search has a daily cap (Pro: 50/day, Advanced: 200/day, trial: 2/day). Repurpose counts against the AI generation daily quota.
GET https://opentweet.io/api/v1/inspiration/search?q=AI%20agents&max_results=20&sort_order=relevancy&lang=en&has_media=true&min_likes=100&min_retweets=10
Required: q. Optional filters: max_results, sort_order (relevancy or recency), lang, has_media, min_likes, min_retweets. Response includes data (tweets), meta.result_count, and usage (searches_used / remaining / daily_limit).
POST https://opentweet.io/api/v1/inspiration/repurpose
Body: {
"tweet_text": "Original tweet text to remix",
"tweet_author": "@someone",
"instructions": "Make it punchier and add a call to action",
"tone": "casual",
"save_as_draft": true
}
Returns repurposed.text, category, key_topics, plus draft.id when save_as_draft is true (default). Honors the user's voice profile and content pillars automatically. Optional x_account_id tags the saved draft.
GET https://opentweet.io/api/v1/analytics/overview
Returns posting stats (total posts, publishing rate, active days, avg posts/week, most active day/hour, threads, media posts), streaks (current, longest), trends (this week vs last, this month vs last, best month), category breakdown, and recent activity (daily counts for last 7 and 30 days).
GET https://opentweet.io/api/v1/analytics/tweets?period=30
Returns per-tweet engagement: likes, retweets, replies, quotes, impressions, bookmarks, engagement rate. Also includes top/worst performers, content type stats, engagement timeline, and best hours/days. Period: 7-365 days or "all".
GET https://opentweet.io/api/v1/analytics/best-times
Two analysis modes:
engagement_weighted — uses real per-tweet engagement to score every hour×day cell. Returns heatmap, confidence, top_windows, best_day, best_hour, worst_day, worst_hour, insights. Only available after running an analysis.frequency_only — fallback based purely on when the user has posted. Returned when no engagement profile exists yet (needs ≥3 published posts).Both modes also return legacy hour_distribution, day_distribution, best_hours, best_days keys for backward compatibility.
POST https://opentweet.io/api/v1/analytics/best-times/analyze
Body: {} # optional: { "x_account_id": "..." }
Pulls the user's recent published tweets from X, computes engagement-weighted windows, and stores the profile. Has a built-in cooldown — if a recent analysis is still fresh, returns 429 with next_available_at. Returns success, profile (status ready / analyzing / insufficient_posts).
First: verify your connection works:
GET /api/v1/me — check authenticated is true, subscription.has_access is truePost a tweet right now (one step):
GET /api/v1/me — check limits.can_post is truePOST /api/v1/posts with { "text": "...", "publish_now": true }Post a tweet with an image:
GET /api/v1/me — check limitsPOST /api/v1/upload with the image file — get back a URLPOST /api/v1/posts with { "text": "...", "media_urls": ["<url>"], "publish_now": true }Schedule a tweet:
GET /api/v1/me — check limits.remaining_posts_today > 0POST /api/v1/posts with { "text": "...", "scheduled_date": "2026-05-01T10:00:00Z" } — you MUST make this HTTP callposts[0].status === "scheduled" and show the user the id and scheduled_date from the responseSchedule a tweet with auto-retweet boost:
GET /api/v1/me — check limits.remaining_posts_today > 0POST /api/v1/posts with text, scheduled_date, auto_retweet_enabled: true, auto_retweet_offset_minutes: 240id and scheduled_date from the responseSchedule a tweet with auto-plug (monetise viral reach):
GET /api/v1/me — check limits.remaining_posts_today > 0POST /api/v1/posts with text, scheduled_date, auto_plug_enabled: true, auto_plug_threshold: 50, auto_plug_text: "..."id and confirm auto-plug is armedSchedule a week of content:
GET /api/v1/me — check remaining limitPOST /api/v1/posts with "posts": [...] array, each with a scheduled_datePost a tweet with an AI-generated image:
GET /api/v1/me — check limitsPOST /api/v1/generate/image with { "prompt": "...", "aspect_ratio": "16:9" } — get back a URL immediatelyPOST /api/v1/posts with { "text": "...", "media_urls": ["<url from step 2>"], "publish_now": true }Post a tweet with an AI-generated video:
GET /api/v1/me — check limitsPOST /api/v1/generate/video with { "prompt": "...", "aspect_ratio": "16:9", "duration": 5 } — get back a job_idGET /api/v1/generate/video/{job_id} every 10 seconds until status is "completed" — get the urlPOST /api/v1/posts with { "text": "...", "media_urls": ["<url from step 3>"], "publish_now": true }Write and schedule a long-form X Article:
GET /api/v1/me — check subscription.has_access is true (articles need a paid plan)POST /api/v1/upload with an image — get back a URLPOST /api/v1/articles with { "title": "...", "content_markdown": "# ...", "cover_image_url": "<url>", "scheduled_date": "2026-07-15T14:00:00Z" }status: "scheduled" and show the user the article idGET /api/v1/articles/{id} — article_url is the live X linkRun a DM outreach campaign (human-approved):
GET /api/v1/me — confirm the plan is Advanced or Agency (DM Campaigns return 403 otherwise)POST /api/v1/dm-campaigns with the ICP, sources, message brief, limits, and accept_policy: truePOST /api/v1/dm-campaigns/{id}/discover, then poll GET /api/v1/dm-campaigns/{id} until status is reviewingGET /api/v1/dm-campaigns/{id}/leads?state=qualified — show the user the qualified leads and their drafted messagesPATCH .../leads/{leadId} with action: approve. Never auto-approve every lead.GET /api/v1/dm-campaigns/{id}/inbox later to read repliesFind inspiration and repurpose it:
GET /api/v1/inspiration/search?q=...&min_likes=500 — pick a tweetPOST /api/v1/inspiration/repurpose with tweet_text, tweet_author, save_as_draft: trueid can then be scheduled with POST /api/v1/posts/{id}/scheduleSet up an evergreen queue from existing drafts:
PUT /api/v1/evergreen/settings with { "enabled": true, "posts_per_day": 2, "posting_times": ["09:00","17:00"] }POST /api/v1/evergreen/posts with { "post_id": "...", "cooldown_days": 14 }GET /api/v1/evergreen/history later to see what got publishedTune posting times based on engagement:
POST /api/v1/analytics/best-times/analyze — wait for profile.status: "ready" (poll if analyzing)GET /api/v1/analytics/best-times — read top_windows and best_hour / best_dayGET /api/v1/me before scheduling or publishing to check limits and connected accounts.GET /api/v1/accounts and pass x_account_id to target a specific account.id field from the API response. If you cannot show a real 24-character MongoDB ObjectId from the response, the call was not made.status field: "draft", "scheduled", "posted", or "failed".url field with the real X post URL. Always use this URL — never construct your own.platforms does NOT mean "X only". It hands targeting to the user's auto cross-post setting. Send "platforms": ["x"] when the user asked for X only.platforms list is always honoured exactly, even when auto cross-post is on.results array before reporting an outcome. A skipped entry is not a failure, a failed entry is, and a post can be published on one network and failed on another.GET /api/v1/cross-posting when you need to know where an unspecified post will land, or when the user asks what they are connected to. default_targets is what gets attempted, deliverable_targets is what actually lands, and the difference between them is a set of expected failures you should mention up front.GET /api/v1/cross-posting, connected and usable are different. Never report a connected: true, usable: false network as working; it needs reconnecting first.status is "posted" AND url is present.status is "scheduled" AND scheduled_date is present in the response.scheduled_date or publish_now in POST /api/v1/posts requires a subscription.media_urls or thread_media.urlLimit payload means the post was saved as a draft instead of published.code: "publish_failed" carries X's exact rejection reason.auto_retweet_offset_minutes must be 1–10080 (up to 7 days) when auto_retweet_enabled is true.Publishing is irreversible — once a tweet is posted to X it cannot be undone via the API (DELETE removes it locally and from X, but reposts are not the same tweet).
/publish or using publish_now: true, always tell the user which post(s) you are about to publish and ask for confirmation.POST /api/v1/articles/{id}/publish or publish_now: true on an article, show the user the title and ask for confirmation. A published article cannot be edited.scheduled_date in the future, it is meant to be published at that time by the scheduler — not right now./publish on a post that has a future scheduled_date unless the user explicitly asks you to publish it immediately.isEvergreen: true is a recurring template. The scheduler publishes clones, not the source itself./publish directly on an evergreen source post (the API will reject it). Add it to the queue with POST /api/v1/evergreen/posts and let the scheduler run./publish on each one without explicit user approval for the full list.save_as_draft: true unless the user has reviewed.For complete documentation: https://opentweet.io/api/v1/docs