Twitter (X) Templates — Bulk Post via API
Tweet templates are how marketing teams, content shops, and dev workflows scale posting. A single template like 'New blog post: {{ title }} — {{ url }}' rendered against 50 blog posts produces 50 personalized tweets without writing each one by hand. The dev workflow wraps this with a templating engine, a row source, and an API write call.
This guide walks the implementation with Python and Jinja2: template rendering pattern, posting with X official's POST /2/tweets, rate-limit pacing for batch workflows, and per-call cost. Pricing references are URL-cited.
The pattern — three steps under any template workflow
1. Template — define your tweet shape with placeholders. Examples: New post: {{ title }} → {{ url }} for blog announcements; 🎬 {{ movie }} now showing at {{ cinema }} — book: {{ link }} for cinema scheduling; 📊 {{ metric }} hit {{ value }} this week ({{ delta }}) for KPI updates.
2. Render — for each row in your source data (CSV, database, sheet), substitute placeholders with row values. Engine of choice: Jinja2 (most full-featured), Python f-strings (built-in, simple), Handlebars / Mustache (cross-language).
3. Post — loop each rendered tweet through POST /2/tweets (or twitterapi.io's POST /twitter/create_tweet_v2) with rate-limit pacing. Log per-attempt success / failure to JSONL for audit.
Two write routes — X official or twitterapi.io
Write actions on twitterapi.io run through an authenticated session: you log the account in once with POST /twitter/user_login_v2 ($0.10 per login), passing your X username, email, password and a proxy you supply (a residential or ISP proxy works best), and it returns login_cookies. Store the returned login_cookies and reuse them until the session ends; do not log in on every run. Each post, delete, like, retweet, follow or unfollow is then one call at $0.0006 that takes login_cookies and the same proxy. The call acts as that X account, so X's rules apply to it and X can rate-limit, lock or suspend the account; keep a human pace. The session can stop working and require a new login. The proxy is required on every write call. Billing is per call that returns HTTP 200, and that includes some refusals from X, which come back as HTTP 200 with status: error. Reads are different: they need only an API key and no X account at all.
Official route auth flow: OAuth 1.0a user-context via the X Developer Console (developer.x.com). You'll need an X account in good standing, register a dev app, generate user access tokens. The official path is documented at docs.x.com/x-api/posts/manage-tweets/quickstart.
Path 1 — X official + Jinja2 template
Pricing per docs.x.com/x-api/getting-started/pricing: $0.015 per post created, $0.200 if the post contains a URL.
# pip install tweepy jinja2 pandas
import tweepy, time, random, json
from jinja2 import Template
from datetime import datetime, timezone
import pandas as pd
client = tweepy.Client(
consumer_key="YOUR_KEY",
consumer_secret="YOUR_SECRET",
access_token="USER_TOKEN",
access_token_secret="USER_TOKEN_SECRET",
)
TEMPLATE = Template("📝 New post: {{ title }} — {{ url }} #{{ tag }}")
def bulk_post(rows_csv: str, dry_run: bool = True):
df = pd.read_csv(rows_csv)
log_path = f"post_log_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.jsonl"
success, fail = 0, 0
with open(log_path, "a") as log:
for i, row in df.iterrows():
text = TEMPLATE.render(
title=row["title"],
url=row["url"],
tag=row["tag"],
)
entry = {
"row_index": int(i),
"rendered": text,
"at": datetime.now(timezone.utc).isoformat(),
}
if len(text) > 280:
entry["action"] = "skip: over 280 chars"
log.write(json.dumps(entry) + "\n")
continue
if dry_run:
entry["action"] = "dry_run"
else:
try:
resp = client.create_tweet(text=text)
entry["action"] = "posted"
entry["tweet_id"] = resp.data["id"]
success += 1
except tweepy.TooManyRequests:
entry["action"] = "rate_limited"
log.write(json.dumps(entry) + "\n")
time.sleep(60 + random.uniform(0, 5))
continue
except Exception as e:
entry["action"] = f"failed: {e}"
fail += 1
log.write(json.dumps(entry) + "\n")
time.sleep(1.5 + random.uniform(0.1, 0.5))
return {"success": success, "fail": fail, "log": log_path}
# Always dry_run first to inspect rendered output
# result = bulk_post("new_posts.csv", dry_run=False)
Path 2 — twitterapi.io /twitter/create_tweet_v2 + the same template
POST /twitter/create_tweet_v2 takes a JSON body with three required fields: login_cookies, tweet_text and proxy, plus your key in the X-API-Key header. The response has a status field (success or error); log it for every row. The rendering step is the same as Path 1, only the post call changes.
Price, side by side: $0.015 per post on the official API (Post: Create) vs $0.0006 here (0.015 / 0.0006 = 25x), plus the $0.10 login, which is one per session and not one per post. For 1,000 posts that is 1,000 × $0.015 = $15.00 official vs 1,000 × $0.0006 + $0.10 = $0.70 here. A post that contains a URL is $0.200 on the official rate card. For a handful of posts on a fresh login the official side is cheaper in total: 5 posts cost 5 × $0.015 = $0.075 there and 5 × $0.0006 + $0.10 = $0.103 here. The two totals cross at 7 posts per login ($0.105 there, $0.1042 here).
# pip install jinja2 pandas requests
import os, json, time, random, requests
from datetime import datetime, timezone
from jinja2 import Template
import pandas as pd
BASE = "https://api.twitterapi.io"
HEADERS = {"X-API-Key": os.environ["TWITTERAPI_IO_KEY"]}
PROXY = os.environ["X_PROXY"] # a proxy you supply: http://username:password@ip:port
def login() -> str:
"""Log the account in once ($0.10) and return login_cookies. Store and reuse it."""
body = {
"user_name": os.environ["X_USERNAME"],
"email": os.environ["X_EMAIL"],
"password": os.environ["X_PASSWORD"],
"proxy": PROXY,
}
if os.environ.get("X_TOTP_SECRET"):
body["totp_secret"] = os.environ["X_TOTP_SECRET"]
r = requests.post(f"{BASE}/twitter/user_login_v2",
headers=HEADERS, json=body, timeout=120)
r.raise_for_status()
data = r.json()
if data.get("status") != "success":
raise RuntimeError(f"login failed: {data.get('message')}")
return data["login_cookies"]
def create_post(login_cookies: str, text: str) -> dict:
"""One post through the logged-in session. Returns the parsed response."""
r = requests.post(
f"{BASE}/twitter/create_tweet_v2",
headers=HEADERS,
json={"login_cookies": login_cookies, "tweet_text": text, "proxy": PROXY},
timeout=60,
)
r.raise_for_status()
return r.json() # carries "status": "success" or "error"
TEMPLATE = Template("New post: {{ title }} #{{ tag }}")
def bulk_post_v2(rows_csv: str, login_cookies: str, dry_run: bool = True) -> dict:
df = pd.read_csv(rows_csv)
log_path = f"post_v2_log_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.jsonl"
success = fail = 0
with open(log_path, "a", encoding="utf-8") as log:
for i, row in df.iterrows():
text = TEMPLATE.render(title=row["title"], tag=row["tag"])
entry = {"row_index": int(i), "rendered": text,
"at": datetime.now(timezone.utc).isoformat()}
if len(text) > 280:
entry["action"] = "skip: over 280 chars"
elif dry_run:
entry["action"] = "dry_run"
else:
try:
result = create_post(login_cookies, text)
entry["action"] = result.get("status")
entry["message"] = result.get("message")
if result.get("status") == "success":
success += 1
else:
fail += 1
except requests.RequestException as exc:
entry["action"] = f"failed: {exc}"
fail += 1
time.sleep(1.5 + random.uniform(0.1, 0.5))
log.write(json.dumps(entry, ensure_ascii=False) + "\n")
return {"success": success, "fail": fail, "log": log_path}
# Always dry-run first to inspect the rendered output
print(bulk_post_v2("new_posts.csv", login_cookies="", dry_run=True))
# cookies = os.environ.get("X_LOGIN_COOKIES") or login()
# print(bulk_post_v2("new_posts.csv", cookies, dry_run=False))
Template engine choice
Jinja2 — Python ecosystem standard, supports conditionals ({% if %}), loops, filters ({{ name | upper }}). Best for non-trivial template logic.
Python f-strings — built-in, simplest. f"New post: {row['title']} — {row['url']}" works for straightforward substitution.
Handlebars / Mustache — cross-language; useful if template authoring lives outside Python (e.g., a marketing tool stores templates that a Node.js worker also renders).
Markdown / plain text — for simple {{ variable }} substitution without engine overhead, regex.sub does the job in <10 lines.
Side-by-side — 3 common template-bulk patterns
Two practical observations: (a) Spreadsheet-driven is the easiest to start with — the marketing team owns the row source and dev owns the renderer; (b) Database + scheduler is the path for any product that resells scheduling-as-a-service.
Rate-limit + ToS pacing
X publishes per-app and per-user rate limits at docs.x.com/x-api/fundamentals/rate-limits. Per POST /2/tweets, the practical baseline for batch workflows is 1-2 seconds between posts.
Spammy-looking templates risk account suspension — X enforces against duplicate / near-duplicate content. Vary the rendered output meaningfully (different titles, different links, different metric values) rather than blasting identical templates with one tiny change.
Cost framing + scaling
Math from docs.x.com/x-api/getting-started/pricing at $0.015 per post without a URL (a post that contains a URL is $0.200, so 100 of those cost $20.00):
100 templated posts = $1.50
1,000 templated posts = $15.00
10,000 templated posts = $150.00
The same volumes through twitterapi.io at $0.0006 per post plus one $0.10 login: 100 posts = 100 × $0.0006 + $0.10 = $0.16, 1,000 = $0.70, 10,000 = $6.10.
Cheap relative to the human-time saved (a 100-tweet bulk run takes <5 min of dev time + <3 min wall-clock at paced execution).
Picking a path
Templates with simple substitution? → Python f-strings + tweepy. Smallest dependency footprint.
Templates with conditional logic / loops? → Jinja2 + tweepy. Standard Python pattern.
Templates authored by non-devs (marketing team)? → Handlebars + a shared template file in your repo. Cross-language portability.
All three render plain text; the post call underneath is POST /2/tweets or POST /twitter/create_tweet_v2.
# Practical example: spreadsheet-driven announcement campaign with audit log.
import tweepy, time, random, json
from jinja2 import Template
from datetime import datetime, timezone
import pandas as pd
client = tweepy.Client(
consumer_key=os.environ["X_CONSUMER_KEY"],
consumer_secret=os.environ["X_CONSUMER_SECRET"],
access_token=os.environ["X_USER_TOKEN"],
access_token_secret=os.environ["X_USER_SECRET"],
)
# Multiple templates with conditional choice — keeps output varied
TEMPLATES = [
Template("📝 New on the blog: {{ title }}\n\n{{ url }}"),
Template("Just published — {{ title }}. Read it: {{ url }}"),
Template("📖 {{ title }}\n\nFull read: {{ url }} #{{ tag }}"),
]
def campaign_post(rows_csv: str, dry_run: bool = True):
df = pd.read_csv(rows_csv)
log_path = f"campaign_log_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.jsonl"
posted_ids = []
with open(log_path, "a") as log:
for i, row in df.iterrows():
template = random.choice(TEMPLATES)
text = template.render(**row.to_dict())
entry = {
"row_index": int(i),
"rendered": text,
"length": len(text),
"at": datetime.now(timezone.utc).isoformat(),
}
if len(text) > 280:
entry["action"] = "skipped — over 280"
log.write(json.dumps(entry) + "\n")
continue
if dry_run:
entry["action"] = "dry_run"
else:
try:
resp = client.create_tweet(text=text)
entry["action"] = "posted"
entry["tweet_id"] = resp.data["id"]
posted_ids.append(resp.data["id"])
except tweepy.TooManyRequests:
entry["action"] = "rate_limited"
log.write(json.dumps(entry) + "\n")
time.sleep(60 + random.uniform(0, 5))
continue
except Exception as e:
entry["action"] = f"failed: {e}"
log.write(json.dumps(entry) + "\n")
time.sleep(1.5 + random.uniform(0.2, 0.5))
return {"posted": len(posted_ids), "log": log_path}
# Always dry_run first
# result = campaign_post("campaign_rows.csv", dry_run=False)
# Cost math (docs.x.com pricing):
# 100 rows × $0.015 = $1.50 per 100-row campaign ($0.200 per post that contains a URL)
# Wall-clock: 100 × 1.5s pace = 2.5 minQuestions readers ask
Can I post from twitterapi.io?
Yes. POST /twitter/create_tweet_v2 creates a post for $0.0006 per call, against $0.015 on the official API (Post: Create). It needs login_cookies from one POST /twitter/user_login_v2 login ($0.10, one per session) and a proxy you supply. The post is made as your own X account, so the conditions in the two-routes section above apply. The official alternative is user-context OAuth from the X Developer Console (developer.x.com).
Do I need an X account to bulk-post?
Yes — on both routes. The OAuth user-context auth required for POST /2/tweets ties to a specific X account that owns the posts, and create_tweet_v2 posts as the account whose session you logged in. You can use a brand account, a campaign account, or a personal account; each post is attributed to that account.
How do I avoid 'duplicate content' suspensions?
Vary the rendered text meaningfully — different titles, different links, different metric values per row. Random selection from multiple templates (like the example above) also helps. X's spam detection flags identical or near-identical content posted rapidly; meaningful variation passes.
Can I schedule templated posts for the future?
X's native POST /2/tweets is immediate-fire. For scheduled posting, run your own scheduler (cron / Celery / serverless cron) that triggers the post call at the target time. Or use a managed scheduling tool that handles X auth + scheduling for you.
What's a safe rate limit pacing?
1.5-2 seconds per post is comfortable for batch workflows. X allows higher burst rates but pacing helps avoid 429s mid-run + helps with spam detection. Logs every attempt so you can resume mid-batch if needed.
Can I read engagement metrics on posts I created via template?
Yes — once posted, you can pull engagement back via twitterapi.io's read endpoints (the post is public). The read endpoints need only an API key and no X account, whichever route created the posts.
Continue
- X API — pricing (docs.x.com, 2026 verified)
- X — Post and delete posts quickstart
- Tweepy documentation
- twitterapi.io docs — user_login_v2
- twitterapi.io docs — create_tweet_v2
- twitterapi.io — pricing
- Twitter (X) API — cluster hub
- Twitter (X) scheduling tool API integration
- How to make a Twitter (X) thread via API
- Rate Limit Exceeded on Twitter (X) — Fixes
- twitterapi.io pricing
- 01How to Build an MCP Server in Python for Twitter (X) Data
- 02OpenClaw vs Claude Code: which agent should run your Twitter (X) jobs?
- 03Hermes Agent vs OpenClaw: Which Agent Fits Twitter (X) Work?
- 04Claude Code Channels: Telegram, Discord and Twitter (X) Alerts in Your Session
- 05Codex MCP: How to Add Any MCP Server to OpenAI Codex (CLI, IDE and App)
- 06Upload-Post Alternative for Twitter (X): Post, Read and Search From One API
- 07X Post Templates That Actually Work
- 08Twitter (X) Post Tracker via API — A Developer Guide
Stop reading. Start building.
Starter credits cover real testing on real data. Google sign-in, no card, no application queue.
Get an API keyAlready building with TwitterAPI.io? Publish an honest tutorial or build log about it and earn up to $200 a month in API credits.