Bulk-Schedule Posts from a CSV
A working script that reads a CSV of posts and schedules each row through PostGate’s API with a per-row idempotency key — no native import feature or bulk endpoint required.
PostGate has no CSV import or bulk endpoint — you build it in about thirty lines: read the file with a CSV library, loop over the rows, and call POST /api/posts once per row with an Idempotency-Key derived from the row (like {batchTag}:{rowId}), so a crashed run can be re-run without re-publishing rows that already succeeded.
PostGate has no native "import a CSV" feature and no dedicated bulk-posting endpoint. Bulk scheduling from a spreadsheet is a pattern you build yourself, in about thirty lines: read the file with your language's CSV library, loop over the rows, and call POST /api/posts once per row with a stable idempotency key. This page has a complete, runnable script that does exactly that.
A CSV shape that maps directly onto a create-post request
row_id (used to build the idempotency key), profile_ids (semicolon-separated, since a row can target more than one connected profile), body, an optional media_url, and an optional scheduled_at in ISO 8601 — leave it blank to publish immediately, or set it to the literal string queue to take the next open slot on a recurring schedule.row_id,profile_ids,body,media_url,scheduled_at
101,pro_instagram123,"Launch day. New feature is live.",https://cdn.example.com/launch.jpg,2026-09-01T13:00:00Z
102,pro_linkedin456;pro_instagram123,"We wrote up how it works.",,2026-09-01T15:00:00Z
103,pro_x789,"Shipping today.",,A working script: read the CSV, loop, post each row
csv module plus requests. It derives an Idempotency-Key from each row's row_id so re-running the script after a crash or a network error never re-publishes a row that already succeeded — see the note below on why that key has to come from your data, not from the moment you happen to run the script.import csv
import os
import sys
import time
import requests
API_URL = "https://postgate.studio"
API_KEY = os.environ["POSTGATE_API_KEY"]
PROFILE_GROUP_ID = os.environ["POSTGATE_PROFILE_GROUP_ID"]
CSV_PATH = sys.argv[1] if len(sys.argv) > 1 else "schedule.csv"
BATCH_TAG = os.environ.get("BATCH_TAG", "bulk-import") # keep stable across reruns
def build_payload(row: dict) -> dict:
body = {
"post": {"body": row["body"]},
"profiles": [p.strip() for p in row["profile_ids"].split(";") if p.strip()],
}
if row.get("media_url"):
body["media"] = [row["media_url"]]
if row.get("scheduled_at"):
body["scheduled_at"] = row["scheduled_at"]
return body
def create_post(row: dict, max_attempts: int = 4) -> dict:
idempotency_key = f"{BATCH_TAG}:{row['row_id']}"
payload = build_payload(row)
for attempt in range(1, max_attempts + 1):
response = requests.post(
f"{API_URL}/api/posts",
params={"profile_group_id": PROFILE_GROUP_ID},
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
json=payload,
timeout=30,
)
if response.ok:
return response.json()
error = response.json().get("error", {})
if not error.get("retryable") or attempt == max_attempts:
raise RuntimeError(f"row {row['row_id']} failed: {error.get('code')} {error.get('message')}")
time.sleep((2 ** attempt) * 0.5) # exponential backoff between retries
raise RuntimeError(f"row {row['row_id']}: exhausted retries")
def main() -> None:
with open(CSV_PATH, newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
print(f"Scheduling {len(rows)} posts from {CSV_PATH}...")
for row in rows:
try:
result = create_post(row)
print(f" row {row['row_id']}: created post {result['id']} ({result['status']})")
except RuntimeError as exc:
print(f" row {row['row_id']}: FAILED — {exc}")
if __name__ == "__main__":
main()Why does the key come from the row, not the run?
Idempotency-Key as {BATCH_TAG}:{row_id} — a value that stays identical every time you run the file against the same CSV, as long as BATCH_TAG doesn't change. That's deliberate: PostGate honors a key for 72 hours, so if the script dies at row 140 of 300 and you rerun it, rows 1–139 return their original, already-created post instead of being published a second time. See Idempotency & safe retries for the full contract, including what happens if a key gets reused with different content — a 409 conflict, not a silent overwrite.How do you add concurrency once one-row-at-a-time is too slow?
Frequently asked questions
Does PostGate have a bulk posting or CSV import endpoint?
No — there is no native "import a CSV" feature and no dedicated bulk-posting endpoint. Bulk scheduling from a spreadsheet is a pattern you build yourself in about thirty lines: read the file with your language’s CSV library, loop over the rows, and call POST /api/posts once per row with a stable idempotency key.
What columns should a bulk scheduling CSV have?
One row per post, with columns named after the fields the API already expects: a stable row_id (used to build the idempotency key), profile_ids (semicolon-separated, since a row can target more than one profile), body, an optional media_url, and an optional scheduled_at in ISO 8601 — leave it blank to publish immediately, or set the literal string queue to take the next open slot on a recurring schedule.
What happens if the bulk script crashes halfway through?
Nothing gets double-published on the rerun. The script builds its Idempotency-Key as {BATCH_TAG}:{row_id}, identical every run against the same CSV, and PostGate honors a key for 72 hours — so if the script dies at row 140 of 300, rows 1–139 return their original, already-created post instead of publishing again. Reusing a key with different content returns a 409 conflict, not a silent overwrite.
Can I run the bulk create requests concurrently?
Yes. Sequential is the safest start, but for a CSV in the hundreds of rows a worker pool of 5–10 concurrent requests cuts real wall-clock time without needing anything extra from PostGate: every row still carries its own idempotency key, and a create response is acceptance, not proof of delivery, so the client doesn’t need to wait for a post to reach the platform before moving to the next row.
