I didn't set out to build a content API. I set out to stop copy-pasting.
Every week, the same ritual: open a doc, stare at a blank page, write a headline, delete it, write it again. Multiply that by every client, every product page, every email drip campaign. I wasn't doing creative work — I was doing assembly-line work while pretending it was creative.
PostAll started as a script I wrote to stop doing that. The API is what that script became after other developers asked if they could use it too.
This guide walks you through integrating PostAll's API into your own workflow — authentication, the endpoints you'll actually use, real working code in both Python and Node.js, and the specific places things will break before they work. By the end, you'll have a functioning pipeline that generates formatted, CMS-ready content programmatically.
What you'll build
A script that takes a list of content briefs (keywords, tone, target length) and returns publish-ready content — with proper formatting, metadata, and error handling for the rate limits you'll hit in production.
Here's the shape of what you're building:
[ CSV of briefs ] → [ PostAll API ] → [ formatted content objects ] → [ your CMS / database ]
The full working code for both languages is at the end of each section. I'll explain the interesting parts inline.
Prerequisites
- A PostAll account with API access enabled (free tier works for this guide — rate limits noted below)
- Node.js 18+ or Python 3.10+
- Basic familiarity with
async/awaitin either language - An HTTP client:
axiosor nativefetchfor Node,httpxfor Python
Step 1: Authentication
PostAll uses API key authentication. Every request needs your key in the Authorization header.
Get your key: Dashboard → Settings → API Keys → Generate New Key
Store it as an environment variable. Never hardcode it.
export PostAll_API_KEY="postall_live_xxxxxxxxxxxxxxxxxxxx"
Your key has two prefixes: postall_live_ for production, postall_test_ for the sandbox. The sandbox returns real-shaped responses with placeholder content — useful for testing your pipeline without burning request quota.
Verify authentication before building anything else:
// verify-auth.js
const PostAll_API_BASE = "https://api.usepostall.io/v1";
async function verifyAuth() {
const response = await fetch(`${PostAll_API_BASE}/account`, {
headers: {
Authorization: `Bearer ${process.env.PostAll_API_KEY}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
// 401 = bad key, 403 = key exists but no API access on your plan
throw new Error(`Auth failed: ${response.status} ${response.statusText}`);
}
const account = await response.json();
console.log(`Authenticated as: ${account.email}`);
console.log(`Plan: ${account.plan} | Requests remaining: ${account.quota.remaining}`);
return account;
}
verifyAuth().catch(console.error);
Run this first. A 403 means your account plan doesn't include API access — upgrade or contact support before debugging anything else.
Step 2: The three endpoints you'll actually use
PostAll has a full REST API, but 90% of automation use cases come down to three endpoints:
| Endpoint | Method | What it does |
|---|---|---|
/v1/generate |
POST | Generate a single content piece |
/v1/batch |
POST | Queue multiple generations (async) |
/v1/batch/:id |
GET | Poll a batch job for results |
There's also /v1/templates (GET) to list your saved prompt templates, and /v1/content/:id (GET/PATCH/DELETE) for managing existing content — but start with generate and batch.
Step 3: Your first generation
Let's generate one piece of content end-to-end before building the pipeline.
The request shape:
// generate-single.js
const PostAll_API_BASE = "https://api.usepostall.io/v1";
async function generateContent(brief) {
const response = await fetch(`${PostAll_API_BASE}/generate`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PostAll_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
type: brief.type, // "blog_post" | "product_description" | "email" | "social"
topic: brief.topic,
tone: brief.tone, // "professional" | "conversational" | "technical" | "playful"
target_length: brief.target_length, // word count target, not a hard limit
format: "markdown", // "markdown" | "html" | "plain"
metadata: {
seo_optimize: true, // adds meta_description and focus_keyword to response
include_headings: true,
},
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Generation failed: ${error.message}`);
}
return response.json();
}
// Test it
generateContent({
type: "blog_post",
topic: "how to reduce JavaScript bundle size in 2025",
tone: "technical",
target_length: 800,
}).then((result) => {
console.log("Title:", result.content.title);
console.log("Word count:", result.content.word_count);
console.log("Meta description:", result.metadata.meta_description);
// result.content.body contains the full markdown
});
The response shape:
{
"id": "gen_01hx4k2m9f...",
"status": "complete",
"content": {
"title": "7 Ways to Reduce Your JavaScript Bundle Size in 2025",
"body": "## The bundle problem\n\nYour users...",
"word_count": 847,
"reading_time_minutes": 4
},
"metadata": {
"meta_description": "Learn the most effective strategies for reducing JavaScript bundle size...",
"focus_keyword": "reduce JavaScript bundle size",
"tokens_used": 1203
},
"created_at": "2025-01-15T14:32:11Z"
}
The id field matters — you'll use it to retrieve or update content later. Store it.
Step 4: Batch generation for real volume
Single generation works for interactive use. For processing a list of briefs, use the batch endpoint. It's async: you submit the job, get a batch_id back, then poll for results.
Why async matters here: batch jobs can take 30 seconds to several minutes depending on size. Don't try to do this synchronously with a timeout — you'll time out on large batches and have no idea what succeeded.
// batch-pipeline.js
const PostAll_API_BASE = "https://api.usepostall.io/v1";
// Step 1: Submit the batch
async function submitBatch(briefs) {
const response = await fetch(`${PostAll_API_BASE}/batch`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PostAll_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
items: briefs.map((brief) => ({
type: brief.type,
topic: brief.topic,
tone: brief.tone || "professional",
target_length: brief.target_length || 500,
format: "markdown",
// Optional: tag each item for easier result mapping
external_id: brief.id,
})),
// Webhook URL to hit when complete — highly recommended over polling
webhook_url: process.env.PostAll_WEBHOOK_URL || null,
}),
});
const batch = await response.json();
return batch.id; // e.g. "batch_01hx4k2m9f..."
}
// Step 2: Poll until done
async function pollBatch(batchId, intervalMs = 3000) {
const headers = {
Authorization: `Bearer ${process.env.PostAll_API_KEY}`,
"Content-Type": "application/json",
};
while (true) {
const response = await fetch(`${PostAll_API_BASE}/batch/${batchId}`, { headers });
const batch = await response.json();
console.log(`Status: ${batch.status} | Progress: ${batch.completed}/${batch.total}`);
if (batch.status === "complete") {
return batch.results;
}
if (batch.status === "failed") {
throw new Error(`Batch failed: ${batch.error}`);
}
// "processing" or "queued" — wait and try again
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
// Put it together
async function runBatchPipeline(briefs) {
console.log(`Submitting batch of ${briefs.length} items...`);
const batchId = await submitBatch(briefs);
console.log(`Batch ID: ${batchId} — polling for results...`);
const results = await pollBatch(batchId);
console.log(`Done. ${results.length} items generated.`);
return results;
}
Step 5: The same pipeline in Python
For those of you running data pipelines, automations, or integrations in Python:
# PostAll_pipeline.py
import os
import time
import httpx
from typing import Optional
PostAll_API_BASE = "https://api.usepostall.io/v1"
def get_headers():
api_key = os.environ.get("PostAll_API_KEY")
if not api_key:
raise EnvironmentError("PostAll_API_KEY not set")
return {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
def generate_single(brief: dict) -> dict:
"""Generate one content piece synchronously."""
with httpx.Client(timeout=30.0) as client:
response = client.post(
f"{PostAll_API_BASE}/generate",
headers=get_headers(),
json={
"type": brief["type"],
"topic": brief["topic"],
"tone": brief.get("tone", "professional"),
"target_length": brief.get("target_length", 500),
"format": "markdown",
"metadata": {"seo_optimize": True},
},
)
response.raise_for_status()
return response.json()
def submit_batch(briefs: list[dict]) -> str:
"""Submit a batch job and return the batch ID."""
with httpx.Client(timeout=30.0) as client:
response = client.post(
f"{PostAll_API_BASE}/batch",
headers=get_headers(),
json={
"items": [
{
"type": b["type"],
"topic": b["topic"],
"tone": b.get("tone", "professional"),
"target_length": b.get("target_length", 500),
"format": "markdown",
"external_id": b.get("id"),
}
for b in briefs
]
},
)
response.raise_for_status()
return response.json()["id"]
def poll_batch(batch_id: str, interval_seconds: float = 3.0) -> list[dict]:
"""Poll a batch job until complete. Returns list of results."""
with httpx.Client(timeout=30.0) as client:
while True:
response = client.get(
f"{PostAll_API_BASE}/batch/{batch_id}",
headers=get_headers(),
)
response.raise_for_status()
batch = response.json()
print(f"Status: {batch['status']} | Progress: {batch['completed']}/{batch['total']}")
if batch["status"] == "complete":
return batch["results"]
if batch["status"] == "failed":
raise RuntimeError(f"Batch failed: {batch.get('error')}")
time.sleep(interval_seconds)
# Example: run from a list of dicts
if __name__ == "__main__":
briefs = [
{"id": "1", "type": "product_description", "topic": "noise-cancelling headphones", "tone": "conversational"},
{"id": "2", "type": "product_description", "topic": "mechanical keyboard", "tone": "technical"},
{"id": "3", "type": "email", "topic": "product launch announcement", "target_length": 200},
]
batch_id = submit_batch(briefs)
print(f"Submitted batch: {batch_id}")
results = poll_batch(batch_id)
for item in results:
print(f"\n--- {item['external_id']} ---")
print(f"Title: {item['content']['title']}")
print(f"Words: {item['content']['word_count']}")
What can go wrong (and will)
Rate limits
Free tier: 10 requests/minute, 100 requests/day. Pro tier: 60 requests/minute, no daily cap.
The API returns 429 Too Many Requests when you hit the limit, with a Retry-After header telling you how many seconds to wait. Don't ignore that header. I learned this the hard way — a tight retry loop without backoff will keep 429-ing you indefinitely.
// Respect the Retry-After header
async function generateWithRetry(brief, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(`${POSTALL_API_BASE}/generate`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.POSTALL_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(brief),
});
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get("Retry-After") || "5", 10);
console.log(`Rate limited. Waiting ${retryAfter}s...`);
await new Promise((r) => setTimeout(r, retryAfter * 1000));
continue;
}
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
throw new Error("Max retries exceeded");
}
Batch jobs don't fail loudly
If one item in a batch fails, the rest still complete. The failed item shows up in results with "status": "failed" and an error field. Check each item's status — don't assume the whole batch succeeded just because batch.status === "complete".
results.forEach((item) => {
if (item.status === "failed") {
console.error(`Item ${item.external_id} failed:`, item.error);
// Log it, re-queue it, or flag for manual review
}
});
Webhooks are more reliable than polling
If you're running batches in a server environment, set webhook_url when submitting and handle the POST PostAll sends when the job is done. Polling works for scripts, but it's wasteful and fails silently if your process exits. The webhook payload is the same shape as the poll response — swap the logic, not the data handling.
Content length is a target, not a guarantee
target_length: 800 gets you a content piece in the 700–950 word range in my testing. If you need strict character counts for database constraints, validate and truncate after the fact — don't rely on the API hitting an exact number.
A note on the test vs production key
Run your integration against postall_test_ keys until you're confident the pipeline handles errors correctly. Test mode returns structurally valid responses with placeholder content — great for schema validation and error path testing without burning API quota.
Switch to postall_live_ when:
- Your retry logic handles 429s correctly
- You're checking per-item status in batch results
- You're storing the
idfield from responses (you'll want it for content management later)
Where to go from here
This covers the generation layer. The next pieces you'll likely want to build:
-
Template management —
/v1/templateslets you save and version your prompt configurations, so you're not repeating the full brief object every request. -
Content CRUD — Once you're generating programmatically, you'll want to retrieve, update, and delete via
/v1/content/:id. Especially useful if you're feeding Postall output into a CMS with a review step. - Webhook handling — If you're building a production automation rather than a script, set up a webhook receiver and drop the polling loop entirely.
The full working code from this guide is on GitHub: github.com/usepostall/api-quickstart. Both the Node and Python examples are there with a sample CSV loader and a basic webhook receiver.
What are you automating first? Drop it in the comments — I'm particularly curious what use cases people hit that the /generate endpoint doesn't cover out of the box.
Already using the PostAll API? I'd love to know what your retry/backoff strategy looks like at scale — the Retry-After approach above works at low volume but I'm still iterating on the right pattern for 1000+ item batches.
Top comments (0)