Automations

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.

Quick answer

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.

01

A CSV shape that maps directly onto a create-post request

Keep one row per post and name columns after the fields the API already expects, so the mapping is a straight read instead of a translation layer. A practical minimum is a stable 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.
schedule.csvPostGate API
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.",,
02

A working script: read the CSV, loop, post each row

This is a complete Python script using only the standard library's 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.
bulk_schedule.pyPostGate API
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()
03

Why does the key come from the row, not the run?

The script above builds its 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.
04

How do you add concurrency once one-row-at-a-time is too slow?

The script above runs sequentially, which is the safest place to start — it's easy to reason about and easy to resume. For a CSV in the hundreds of rows, running several requests concurrently (a worker pool of 5–10, for example) cuts real wall-clock time without needing anything PostGate doesn't already support: every row still carries its own idempotency key, and a create response is acceptance, not proof of delivery, so your client doesn't need to wait for a post to actually reach the platform before moving to the next row. See how many posts can I schedule at once for a bounded-concurrency version of this same loop and the retry logic behind it.

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.

Build it with PostGate

One API for every social platform your customers care about — sandboxed, documented, and ready today.

Start building Read the docs