Introduction
I have been automating Pinterest pin creation for this blog. The script reads a post from _posts/, sends the content to Claude, receives back a pin title, description, hashtags, and a short image excerpt, then renders a 1000×1500 JPEG and uploads it to Pinterest via the v5 API.
Before running it at any scale I wanted a precise cost estimate. Not a rough guess — an exact breakdown of what the script sends, how many tokens that produces, and what the API charges for it.
This post walks through that calculation. If you are building something similar, the methodology is reusable for any prompt-based workflow.
What the script sends
The script calls claude-sonnet-4-6 with a single user message. There is no system prompt. The message is built from four parts:
- A fixed instruction block describing the task
- The post’s existing title (from YAML front matter)
- The post’s existing tags (from YAML front matter)
- The post body, stripped of markdown syntax and truncated to 4,000 characters
After Claude responds, the script parses the JSON and enforces character limits: title to 100 characters, description to 500, hashtags to 5, excerpt to 200.
Here is the exact prompt template the script uses:
prompt = f"""You are an expert Pinterest SEO strategist.
Analyse the blog post below and return a JSON object with these exact keys:
"title" – Pinterest pin title, max 100 characters, compelling and keyword-rich
"description" – Pin description, max 500 characters, natural tone, rich with keywords
"hashtags" – Array of up to 5 relevant hashtags WITHOUT the # symbol
"excerpt" – 1–2 short punchy sentences (max 200 chars) to overlay on the pin image
Existing post title : {existing_title}
Existing tags : {', '.join(existing_tags)}
POST CONTENT:
{content_plain}
Return ONLY the JSON object, no markdown, no extra text."""
Prompt anatomy — where the characters come from
For the post 2026-05-20-claude-pro-vs-free.md, the character counts break down as follows:
| Part | Characters |
|---|---|
| Instruction template (fixed overhead) | 579 |
| Existing post title | 60 |
| Existing tags | 43 |
| Post content (capped at 4,000) | 4,000 |
| Total prompt | 4,682 |
The instruction template is 579 characters. That is the fixed cost of the prompt design — you pay it on every call regardless of post length. The bulk of the input is the post content, which the script hard-caps at 4,000 characters.
One thing worth noting: this post is around 14,000 words. The 4,000-character cap means Claude only sees the opening 600 words or so — roughly the introduction and the first section. That is enough context for reasonable SEO metadata, but if you want Claude to reflect the full content of a long post, the cap would need to be raised. I will come back to this.
Token estimation
The Claude API charges by token, not by character. The standard approximation for English text is 1 token per 4 characters.
# Token estimation for one Pinterest pin generation call
# Verified against claude-sonnet-4-6 pricing as of May 2026
existing_title = "Claude Pro vs Free: Everything That Changes When You Upgrade"
existing_tags = ["genAI", "LLM", "Claude", "Anthropic", "productivity"]
prompt_template = """You are an expert Pinterest SEO strategist.
Analyse the blog post below and return a JSON object with these exact keys:
"title" – Pinterest pin title, max 100 characters, compelling and keyword-rich
"description" – Pin description, max 500 characters, natural tone, rich with keywords
"hashtags" – Array of up to 5 relevant hashtags WITHOUT the # symbol
"excerpt" – 1–2 short punchy sentences (max 200 chars) to overlay on the pin image
Existing post title : {title}
Existing tags : {tags}
POST CONTENT:
{content}
Return ONLY the JSON object, no markdown, no extra text."""
template_overhead = len(prompt_template.format(title="", tags="", content=""))
title_chars = len(existing_title)
tags_chars = len(", ".join(existing_tags))
content_chars = 4000 # hard cap in script
total_input_chars = template_overhead + title_chars + tags_chars + content_chars
input_tokens = total_input_chars / 4 # ~1 token per 4 chars
# Expected output: all fields at their maximum lengths
output_chars = 100 + 500 + 60 + 200 + 80 # title + description + hashtags + excerpt + JSON structure
output_tokens = output_chars / 4
print(f"Input tokens : ~{int(input_tokens)}")
print(f"Output tokens : ~{int(output_tokens)}")
print(f"Total tokens : ~{int(input_tokens + output_tokens)}")
Input tokens : ~1170
Output tokens : ~235
Total tokens : ~1405
The 80 characters of output overhead accounts for JSON braces, key names, colons, quotes, and newlines. The output estimate assumes all fields reach their maximum length, which makes it a conservative upper bound.
Cost
Claude Sonnet 4.6 is priced at $3 per million input tokens and $15 per million output tokens.
# Cost calculation — continuing from the token estimation above
INPUT_PRICE_PER_MTOK = 3.00 # USD per million input tokens
OUTPUT_PRICE_PER_MTOK = 15.00 # USD per million output tokens
input_cost = (input_tokens / 1_000_000) * INPUT_PRICE_PER_MTOK
output_cost = (output_tokens / 1_000_000) * OUTPUT_PRICE_PER_MTOK
total_cost = input_cost + output_cost
print(f"Input cost : ${input_cost:.6f}")
print(f"Output cost : ${output_cost:.6f}")
print(f"Total : ${total_cost:.6f} (~{total_cost * 100:.3f} cents)")
Input cost : $0.003512
Output cost : $0.003525
Total : $0.007037 (~0.704 cents)
Both halves of the cost are nearly equal — about 0.35 cents each. That is unusual. Normally input tokens are cheaper than output tokens, but here the output is small enough and the input large enough that the asymmetric pricing happens to produce almost identical dollar amounts.
The per-pin cost breakdown:
| Tokens | Cost | |
|---|---|---|
| Input @ $3/MTok | ~1,170 | $0.003512 |
| Output @ $15/MTok | ~235 | $0.003525 |
| Total per pin | ~1,405 | $0.007037 |
What this looks like at scale
| Pins | API cost |
|---|---|
| 1 | $0.007 |
| 10 | $0.07 |
| 100 | $0.70 |
| 1,000 | $7.04 |
| 10,000 | $70.37 |
At under one cent per pin, the API cost is negligible for a personal blog. Even if you have 500 posts and generate a new pin for each one every month, you are looking at around $3.50 per month in Claude API charges. The Pinterest trial token, your time, and the quality of the images are far more constraining than the API cost.
The 4,000-character truncation
The script caps post content at 4,000 characters. For a short post of 800 words, this captures the entire body. For a long post like 2026-05-20-claude-pro-vs-free.md — which is around 14,000 words — it captures only the opening 600 words: the introduction and the first major section.
That is a meaningful limitation. A post about Claude Pro vs free has most of its specific content — the Projects section, Claude Code, deep research, the token-saving strategies — in sections 3 through 10. A pin generated from only the introduction will produce accurate but generic metadata, missing the more specific angles that might perform better on Pinterest.
The fix is simple: raise the cap. Here is what each cap level costs:
| Content cap | Input chars | Input tokens | Cost per pin |
|---|---|---|---|
| 4,000 chars (current) | 4,682 | ~1,170 | $0.0070 |
| 8,000 chars | 8,682 | ~2,170 | $0.0098 |
| 16,000 chars | 16,682 | ~4,170 | $0.0150 |
| 32,000 chars | 32,682 | ~8,170 | $0.0270 |
Even at 32,000 characters — which would capture the full text of almost any blog post — the cost per pin rises from 0.7 cents to 2.7 cents. For a personal blog, that is an easy trade-off for meaningfully better metadata.
To raise the cap, change one line in pinterest_pin_generator.py:
# Current — in read_post()
return {"front_matter": fm, "content": content_plain[:4000]}
# Raised to 16,000 characters
return {"front_matter": fm, "content": content_plain[:16000]}
I am keeping the 4,000-character default for now because the sandbox is for testing and the metadata quality is good enough to verify the pipeline. When moving to production, I will raise it to 16,000.
The algorithmic approach to content capture
Simply extending the slice to [:16000] works, but it still only captures the opening of the post. A post about Claude Pro vs free that runs to 14,000 words has its most concrete, pin-worthy angles in sections 3–10 — the parts that a 16,000-character prefix still misses. And for a genuinely massive post, grabbing an ever-larger prefix linearly explodes your token count without necessarily delivering better signal.
The better approach is algorithmic: extract the introduction (first 2,000 characters) and the conclusion (last 2,000 characters), then join them. These two regions carry the post’s promise and its payoff — the parts a reader (and an SEO strategy) cares most about — while keeping the input to a fixed, predictable size regardless of post length.
def extract_post_window(
content: str,
intro_chars: int = 2_000,
outro_chars: int = 2_000,
) -> str:
"""
Capture the introduction and conclusion of a post without linearly
expanding the token count for long posts.
For a 14,000-word post this returns ~4,000 characters instead of
14,000+, while preserving the two most semantically dense regions.
For short posts that fit entirely within the window, the full text
is returned unchanged.
"""
if len(content) <= intro_chars + outro_chars:
return content
intro = content[:intro_chars]
conclusion = content[-outro_chars:]
return intro + "\n\n[... middle of post omitted ...]\n\n" + conclusion
Using this function in read_post():
content_window = extract_post_window(content_plain)
return {"front_matter": fm, "content": content_window}
The window is always 4,000 characters (plus the short separator string), so the token count and cost stay identical to the current 4,000-character hard cap — but the metadata now reflects both the opening framing and the closing takeaway rather than only the first 600 words.
Asynchronous batch pattern
The synchronous script works fine for a handful of posts. For a backlog of hundreds, there is a better architecture: Anthropic’s Message Batches API, which processes requests asynchronously and charges 50% less than the standard API — $1.50 per million input tokens and $7.50 per million output tokens for claude-sonnet-4-6.
The pipeline splits into two independent phases: a Compiler that builds the request file and a Retriever that harvests the results. Neither phase makes a synchronous call to the Claude API for completions, and neither touches the Pinterest v5 API. That separation keeps each script fast, testable, and easy to retry independently.
Phase 1 — Compiler
The Compiler reads every published Jekyll post from _posts/, builds a Message Batches request for each one using the extract_post_window() helper, and writes a .jsonl file. Each line is one self-contained request with a custom_id set to the post slug so the Retriever can match results back to filenames.
#!/usr/bin/env python3
"""
compiler.py — Phase 1 of the async Pinterest SEO pipeline.
Reads every published Jekyll post from _posts/, builds a Claude Message
Batches request for each one, and writes batch_requests.jsonl.
No calls to the Claude API or the Pinterest v5 API are made here.
"""
import json
import pathlib
import re
import yaml
MODEL = "claude-sonnet-4-6"
INTRO_CHARS = 2_000
OUTRO_CHARS = 2_000
OUTPUT_FILE = pathlib.Path("batch_requests.jsonl")
def load_post(path: pathlib.Path) -> dict | None:
"""Parse YAML front matter and plain-text body from a Jekyll markdown post."""
raw = path.read_text(encoding="utf-8")
if not raw.startswith("---"):
return None
_, fm_raw, body = raw.split("---", 2)
fm = yaml.safe_load(fm_raw)
if fm.get("published") is False or fm.get("draft"):
return None
content_plain = re.sub(r"[#*_`\[\]()>|]", " ", body).strip()
return {"slug": path.stem, "fm": fm, "body": content_plain}
def extract_post_window(
text: str,
intro: int = INTRO_CHARS,
outro: int = OUTRO_CHARS,
) -> str:
"""Return intro + conclusion without linearly expanding token count."""
if len(text) <= intro + outro:
return text
return (
text[:intro]
+ "\n\n[... middle of post omitted ...]\n\n"
+ text[-outro:]
)
def build_prompt(post: dict) -> str:
title = post["fm"].get("title", "")
tags = post["fm"].get("tags", [])
tags_str = ", ".join(tags) if isinstance(tags, list) else str(tags)
window = extract_post_window(post["body"])
return f"""You are an expert Pinterest SEO strategist.
Analyse the blog post below and return a JSON object with these exact keys:
"title" – Pinterest pin title, max 100 characters, compelling and keyword-rich
"description" – Pin description, max 500 characters, natural tone, rich with keywords
"hashtags" – Array of up to 5 relevant hashtags WITHOUT the # symbol
"excerpt" – 1–2 short punchy sentences (max 200 chars) to overlay on the pin image
Existing post title : {title}
Existing tags : {tags_str}
POST CONTENT:
{window}
Return ONLY the JSON object, no markdown, no extra text."""
def compile_batch(posts_dir: str = "_posts") -> None:
requests = []
for md_file in sorted(pathlib.Path(posts_dir).glob("*.md")):
post = load_post(md_file)
if post is None:
continue
request = {
"custom_id": post["slug"],
"params": {
"model": MODEL,
"max_tokens": 512,
"messages": [{"role": "user", "content": build_prompt(post)}],
},
}
requests.append(request)
OUTPUT_FILE.write_text(
"\n".join(json.dumps(r) for r in requests),
encoding="utf-8",
)
print(f"Wrote {len(requests)} requests → {OUTPUT_FILE}")
if __name__ == "__main__":
compile_batch()
Run it once. It produces batch_requests.jsonl with one JSON object per line and exits — no API calls, no waiting.
To submit the batch you call the Batches API directly (one HTTP request):
import anthropic
import json
import pathlib
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from env
requests = [
json.loads(line)
for line in pathlib.Path("batch_requests.jsonl").read_text().splitlines()
if line.strip()
]
batch = client.messages.batches.create(requests=requests)
print(f"Batch submitted: {batch.id}") # save this ID for the Retriever
Phase 2 — Retriever
The Retriever accepts a batch ID, polls the Batches API until the job reaches the ended state, then streams the .jsonl result file line by line. Each succeeded result is parsed from JSON and written to a staging CSV. Posts whose requests failed or returned malformed JSON are logged to stderr and skipped — they can be resubmitted individually.
#!/usr/bin/env python3
"""
retriever.py — Phase 2 of the async Pinterest SEO pipeline.
Downloads the completed batch from Anthropic, parses each assistant
response, and writes pinterest_staging.csv ready for Pinterest upload.
No synchronous Claude completions or Pinterest v5 API calls are made here.
Usage:
python retriever.py <batch_id>
"""
import csv
import json
import pathlib
import sys
import time
import anthropic
STAGING_CSV = pathlib.Path("pinterest_staging.csv")
POLL_INTERVAL = 30 # seconds between status checks
def wait_for_batch(client: anthropic.Anthropic, batch_id: str) -> None:
"""Poll until the batch reaches a terminal state."""
while True:
batch = client.messages.batches.retrieve(batch_id)
status = batch.processing_status
counts = batch.request_counts
print(
f" [{batch_id}] status={status} "
f"succeeded={counts.succeeded} "
f"errored={counts.errored} "
f"processing={counts.processing}"
)
if status == "ended":
return
time.sleep(POLL_INTERVAL)
def retrieve_batch(batch_id: str) -> None:
client = anthropic.Anthropic()
wait_for_batch(client, batch_id)
rows: list[dict] = []
for result in client.messages.batches.results(batch_id):
if result.result.type != "succeeded":
print(
f" SKIP {result.custom_id}: {result.result.type}",
file=sys.stderr,
)
continue
raw_text = result.result.message.content[0].text
try:
data = json.loads(raw_text)
except json.JSONDecodeError as exc:
print(
f" SKIP {result.custom_id}: JSON parse error — {exc}",
file=sys.stderr,
)
continue
rows.append({
"slug": result.custom_id,
"title": data.get("title", "")[:100],
"description": data.get("description", "")[:500],
"hashtags": " ".join(f"#{h}" for h in data.get("hashtags", [])[:5]),
"excerpt": data.get("excerpt", "")[:200],
})
fieldnames = ["slug", "title", "description", "hashtags", "excerpt"]
with STAGING_CSV.open("w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
print(f"Wrote {len(rows)} rows → {STAGING_CSV}")
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python retriever.py <batch_id>")
sys.exit(1)
retrieve_batch(sys.argv[1])
The staging CSV is the handoff point. A separate upload script reads it and calls the Pinterest v5 API at whatever rate the Pinterest sandbox allows — completely decoupled from the metadata generation.
Cost comparison: synchronous vs batch
The Message Batches API is priced at half the standard rate. At 1,170 input tokens and 235 output tokens per pin:
- Synchronous: (1,170 / 1M × $3.00) + (235 / 1M × $15.00) = $0.007037 per pin
- Batch: (1,170 / 1M × $1.50) + (235 / 1M × $7.50) = $0.003519 per pin
# Cost comparison — synchronous vs Message Batches API
INPUT_TOKENS = 1_170
OUTPUT_TOKENS = 235
for label, input_rate, output_rate in [
("Synchronous", 3.00, 15.00),
("Batch (50% off)", 1.50, 7.50),
]:
cost = (INPUT_TOKENS / 1_000_000) * input_rate \
+ (OUTPUT_TOKENS / 1_000_000) * output_rate
print(f"{label:20s} ${cost:.6f} per pin")
Synchronous $0.007037 per pin
Batch (50% off) $0.003519 per pin
At scale the savings are meaningful:
| Pins processed | Sync API cost | Batch API cost | Savings |
|---|---|---|---|
| 1 | $0.0070 | $0.0035 | $0.0035 |
| 10 | $0.0704 | $0.0352 | $0.0352 |
| 100 | $0.70 | $0.35 | $0.35 |
| 1,000 | $7.04 | $3.52 | $3.52 |
| 10,000 | $70.37 | $35.19 | $35.18 |
The batch approach also removes the need to manage rate limits, retries, and timeouts in real time. The Compiler runs in seconds, the batch processes overnight if needed, and the Retriever streams results at whatever pace the network allows — all without a long-running process holding a connection open.
Conclusion
Generating a Pinterest pin with claude-sonnet-4-6 costs about 0.7 cents per call at the default 4,000-character content cap. The input and output costs are almost identical at roughly 0.35 cents each. At scale, 1,000 pins costs around $7 in API charges.
The 4,000-character content cap is the only meaningful design decision affecting cost. Raising it to 16,000 characters — enough to cover almost any full post — costs 2.7 cents per pin instead of 0.7, which is still negligible. For long posts, the algorithmic window approach (first 2,000 + last 2,000 characters) achieves the same fixed cost while capturing both the introduction and the conclusion rather than only the opening.
If you are processing a backlog of hundreds of posts, switching from the synchronous script to the two-phase batch pipeline cuts the API bill in half — $3.52 per thousand pins instead of $7.04 — and removes the operational overhead of managing live connections, rate limits, and retries.
For a personal blog with a few hundred posts, the entire API cost of generating pins once for every post is under $5 synchronously, and under $2.50 with the batch pipeline. The bottleneck is not the cost. It is getting a production Pinterest API token.