---
title: "Bulk-create posts (≤100)"
method: POST
path: "/posts/bulk"
---

# Bulk-create posts (≤100)

`POST /posts/bulk`

## Request

- Base URL: `https://xpost.to/api/v1`
- URL: `https://xpost.to/api/v1/posts/bulk`
- Auth: HTTP bearer

Up to 100 posts in one call, as JSON `{rows: [...]}`, raw `text/csv` (header `caption,accounts[,scheduled_at,media_ids,media_urls,idempotency_key]`, `|` separates multiple values inside a cell), or multipart CSV in a `file` field. Account refs may be ids, `platform:username`, or a bare username when unambiguous. Every row independently runs the FULL single-post path — plan limits, copilot approval. Always read the per-row report: some rows can fail while others succeed (a row that crashed reports `code: "internal_error"` and nothing was created for it); resend only the rows that need it. **The response is 200 even when the key's daily post cap fills mid-batch** — the rows past the cap report it individually, so the rows before it are not lost. Give every row its own `idempotency_key`: a resend after a timeout answers rows already written with `repeated: true` instead of posting them twice.

## Request body

- object
  - `rows` object[], required
    - `caption` string, required
    - `accounts` string[], required: Account id, platform:username, or unique username
    - `scheduled_at` string: ISO 8601; omit to post now; `next_slot` takes the project's next free queue slot — rows are created in order, so each takes the slot after the one before
    - `media_ids` string[]: Media already in the library (the id POST /media answered with), first in the post, in this order — the same field POST /posts takes
    - `media_urls` string[]: Public https URLs, ingested per row; they follow media_ids
    - `idempotency_key` string: Your own id for THIS row, unique per row. A row whose key was already used comes back `ok: true, repeated: true` with the earlier post's id, and nothing new is written.

## Response `200`

Per-row report — check every row. Also the answer when the daily post cap fills mid-batch: the capped rows say so individually.

- object
  - `created` integer: Rows with `ok: true`, repeated ones included
  - `failed` integer
  - `results` object[]
    - `row` integer, required: 1-based data row (a CSV header is not counted)
    - `ok` boolean, required
    - `post_id` string, uuid
    - `status` string
    - `approval_url` string: On a held row: its one-click approve page
    - `repeated` boolean: This row's `idempotency_key` was already used — `post_id` is that earlier post, nothing new was written
    - `error` string
    - `code` 'internal_error': `internal_error`: the row THREW rather than being refused — nothing was created for it; resend it as-is

## Other responses

- `400`: Unparseable body
- `401`: Missing or invalid API key
- `403`: Missing posts:write scope
- `429`: Rate limited (the daily post cap is reported per row, in a 200)

---

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