---
title: "Bulk upload: check, then schedule many posts at once (CSV or rows)"
method: POST
path: "/api/bulk"
---

# Bulk upload: check, then schedule many posts at once (CSV or rows)

`POST /api/bulk`

## Request

- Base URL: `https://postwire.io`
- URL: `https://postwire.io/api/bulk`
- Auth: HTTP bearer

A CSV (field csv, or the request body itself with Content-Type: text/csv) or a list of rows. Columns: date ("2026-11-02 09:00" in the row's timezone or the upload's, an ISO time with its offset, or next_slot), time (optional), timezone, networks, text, text_<network> (each network's own text), title, title_<network>, media_urls (https; prefix video: or document:), media_urls_<network>, image_urls, video_url, alt_text (| between images), first_comment (LinkedIn, Bluesky, Mastodon), x_reply, x_thread (||| between posts), brand (name or id), label, privacy. Comma, semicolon or tab separated; UTF-8 with or without a BOM; quoted fields may hold commas and line breaks.

dry_run is true by default: nothing is stored, and every row comes back with its time in its timezone, its networks, each network's length as that network counts it (X: a link 23, an emoji 2), the X credits it would use, whether it waits for approval (the brand's rules, a key's limits, a Contributor) or is held by the plan, and its errors and warnings. With dry_run false, ALL rows are scheduled or NONE: any row with an error answers 422 bulk_rows_invalid and nothing is stored; the rows that go to the queue are inserted in one statement; if the rows that wait for approval cannot be stored, the inserted ones are canceled again (503). The same upload twice answers 409 bulk_already_scheduled unless allow_duplicate is true. Rows per upload: Free 20, Starter 500, Pro 1,000, Agency 2,000, Scale 5,000 (403 bulk_rows_limit past it). On Free, rows past the 3 scheduled posts at a time are saved as held (never published on Free).

## Request body

- object
  - `csv` string: The CSV text, first line = column names. Up to 3 MB.
  - `rows` object[]: Instead of csv: objects with the same column names.
  - `dry_run` boolean
  - `timezone` string: IANA timezone for dates without one.
  - `brand_id` string: Default brand (id or name) for rows without a brand column.
  - `fit_long_text` boolean: A text over a network's limit is cut at a sentence or word when published (a warning), instead of an error.
  - `date_format` 'ymd' | 'dmy' | 'mdy': How to read 12/10/2026.
  - `allow_duplicate` boolean
  - `check_media` boolean: Check that each media link answers (each link once, up to 20 seconds in all).
  - `batch_id` string: Your own id for the upload (6-64 letters, digits, - or _). Default: a hash of the file.

## Response `200`

{ ok, dry_run, batch_id, summary: { rows, ok, with_errors, with_warnings, posts, x_credits, wait_for_approval, held, will_queue, plan }, rows: [{ line, ok, run_at, local, timezone, networks, brand, preview, lengths, x_credits?, approval?, held_for?, errors: [{ field, network?, message, code }], warnings, schedule_id?, status? }], x_credits?, ignored_columns?, undo?, message }

## Other responses

- `400`: bad_csv (with line), bad_timezone, bad_request
- `403`: bulk_rows_limit (with the plan that takes the upload), email not verified
- `409`: bulk_already_scheduled
- `422`: bulk_rows_invalid: nothing was scheduled; each row says what to fix
- `503`: bulk_store_failed / governance_unavailable: nothing was scheduled

---

[API](https://skmtc.dev/postwire/apis/postwire-api.md) · [All operations](https://skmtc.dev/postwire/apis/postwire-api/llms.txt) · [OpenAPI document](https://skmtc.dev/postwire/apis/postwire-api/revisions/513a409ab8f7?raw)
