Twitter (X) Templates — Bulk Post via API

By Alex Chen•6 min read

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.

01 — Section

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.

02 — Section

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.

03 — Section

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.

python
# 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)
04 — Section

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).

python
# 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))
05 — Section

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.

06 — Section

Side-by-side — 3 common template-bulk patterns

PatternSourceCadenceBest for
RSS-to-TwitterRSS feed of blogevery 30 minblog auto-promotion
Spreadsheet-driven campaignsCSV / Google Sheetone-shot batchpromo campaigns, evergreen rotations
Database-driven schedulingPostgres + schedulerrollingcontent calendar tools, scheduling SaaS

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.

07 — Section

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.

08 — Section

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).

09 — Section

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.

python
# 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 min
10 — Questions

Questions 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.

11 — Further reading

Continue

Sources & further reading
More from this series
Build it

Stop reading. Start building.

Starter credits cover real testing on real data. Google sign-in, no card, no application queue.

Get an API key

Already building with TwitterAPI.io? Publish an honest tutorial or build log about it and earn up to $200 a month in API credits.