Schedule a month of posts from a spreadsheet
Export a CSV, save each draft, and queue a month of X posts with a repeatable Python script.
Who it's for
Developers and small teams planning their publishing calendar in a spreadsheet.
What you'll have
Scheduled X jobs for your CSV rows, a local retry journal, and commands to inspect, move or cancel jobs.
Before you start
- An active ThreadMaster subscription and a key from Settings → Developer API; set THREADMASTER_API_KEY in your environment.
- Python 3 with requests installed (python -m pip install requests). Run each recipe's Python blocks in order in the same script.
- A connected X account and threads:write, publish:read and publish:write scopes.
- A calendar.csv file with date,topic,content columns; a writable directory for calendar-state.json.
Build the workflow
Export dates, topics and content
Export calendar.csv with a topic, content and an ISO-8601 date including a timezone offset in every row. Replace the example dates with future dates so the script can schedule your posts.
date,topic,content 2027-01-05T09:00:00-08:00,API lessons,"Start with clear errors. Make retries safe." 2027-01-07T09:00:00-08:00,Preview first,"Review the split before scheduling your thread."Save drafts and schedule each row
The script checks monthly quota, saves each thread id and uses a stable publishing UUID per row so retries skip saved jobs. Run one copy at a time, keeping the CSV filename, row order and calendar-state.json unchanged.
import os import requests BASE = "https://threadmaster.ai/api/public/v1" session = requests.Session() session.headers.update({"Authorization": "Bearer " + os.environ["THREADMASTER_API_KEY"]}) def api(method, path, **kwargs): response = session.request(method, BASE + path, timeout=90, **kwargs) response.raise_for_status() return response.json() import csv, json, time from pathlib import Path from datetime import datetime, timedelta, timezone from uuid import uuid5, NAMESPACE_URL source = Path("calendar.csv") journal = Path("calendar-state.json") state = json.loads(journal.read_text()) if journal.exists() else {} def save(): temporary = journal.with_suffix(".tmp") temporary.write_text(json.dumps(state, indent=2)) temporary.replace(journal) quota = api("GET", "/quota/") with source.open(newline="") as handle: for number, row in enumerate(csv.DictReader(handle), start=1): key = str(uuid5(NAMESPACE_URL, f"{source.resolve().as_uri()}#row-{number}")) saved = state.setdefault(key, {"row": row}) if saved["row"] != row: raise ValueError("Row changed: manage the saved job before reimporting") if "job_id" in saved: continue when = datetime.fromisoformat(row["date"]) if when.utcoffset() is None or when < datetime.now(timezone.utc) + timedelta(minutes=2): raise ValueError("Use an offset and a date at least two minutes ahead") if not quota["unlimited"] and quota["remaining_posts"] <= 0: raise RuntimeError("Review monthly quota before continuing") if "thread_id" not in saved: draft = api("POST", "/threads/", json={ "topic": row["topic"], "content": row["content"] }) saved["thread_id"] = draft["id"] save() while True: try: job = api("POST", "/x/posts/", json={ "thread_id": saved["thread_id"], "scheduled_for": row["date"] }, headers={"Idempotency-Key": key}) break except requests.HTTPError as exc: response = exc.response if response is None or response.status_code != 429: raise error = response.json()["error"] if error["code"] == "quota_exhausted": raise RuntimeError("Monthly publishing quota exhausted; review quota before continuing") from exc if error["code"] != "rate_limited": raise details = error.get("details", {}) time.sleep(details.get("retry_after", 60)) saved["job_id"] = job["id"] save() print(number, job["id"], job["status"]) quota = api("GET", "/quota/")Keep calendar-state.json for safe retries; a crash before saving a draft id can leave an unused draft. The script waits for retry_after when the hourly publishing limit is hit.
List the scheduled jobs
Follow the cursor to inspect every scheduled X job, including its timestamp and thread id.
params = {"status": "scheduled", "limit": 200} while True: page = api("GET", "/x/posts/", params=params) for job in page["data"]: print(job["id"], job["thread_id"], job["scheduled_for"]) if not page["has_more"]: break params["cursor"] = page["next_cursor"]Move or cancel a scheduled job
Use a job id from the listing. Choose either reschedule or cancel; edit the publishing job rather than reimporting a changed CSV row.
job_id = int(input("Scheduled job id: ")) action = input("reschedule or cancel: ") if action == "reschedule": when = input("New ISO-8601 timestamp with offset (at least 2 minutes ahead): ") print(api("POST", f"/x/posts/{job_id}/reschedule/", json={"scheduled_for": when})) elif action == "cancel": print(api("POST", f"/x/posts/{job_id}/cancel/"))
Watch out for
- Always send ISO-8601 timestamps with an offset and allow at least two minutes before publishing.
- Check GET /quota/ for your monthly allowance; the script waits for retry_after when the hourly publishing limit is hit. A month-long calendar does not bypass quotas.
- Reuse the Idempotency-Key per row and retain calendar-state.json. Do not reorder rows or move the CSV when retrying this example.
Check the API rate limits and quotas before running your workflow at scale.
Write your next Twitter thread or LinkedIn post today
Sign up in under a minute. $9 a month, cancel anytime.