---
title: "Bulk upload from CSV"
method: POST
path: "/v1/posts/bulk-upload"
tags: ["Posts"]
---

# Bulk upload from CSV

`POST /v1/posts/bulk-upload`

Create multiple posts by uploading a CSV file. Use dryRun=true to validate without creating posts.

CSV columns:
- Required: `platforms`, `profiles`, and a schedule (one of `schedule_time`, a `schedule_time_<platform>` override, `publish_now=true`, `use_queue=true`, or `is_draft=true`).
- Content: at least one of `post_content`, `title`, or `media_urls` is required.
- Aliases: a handful of columns accept the JSON field name from POST /v1/posts, since integrators infer the CSV shape from that endpoint's body. When both are present the real CSV column wins, unless it is blank for that row, in which case the alias value is used.
  - `content` aliases `post_content`
  - `timezone` aliases `tz`
  - `scheduledFor` aliases `schedule_time`
  - `mediaUrls` aliases `media_urls`
- Per-platform overrides use three dynamic column prefixes, one column per platform (e.g. `schedule_time_instagram`, `custom_content_tiktok`, `custom_media_youtube`): `schedule_time_<platform>`, `custom_content_<platform>`, `custom_media_<platform>`.
- Any other column is not read. It does not error, but it is reported in the response's `warnings` array as `unknown_columns:<a,b,c>` (see BulkUploadResult), so a misnamed or unsupported column is never silently dropped.
- Row limits: 5000 rows is a hard cap that returns 400 above it. 500 rows is only an advisory threshold, it adds `rows_exceed_advisory_limit:500` to `warnings` and the request still processes.

Example row (header + one data row):
```
post_content,platforms,profiles,schedule_time,tz
"Hello world",instagram,MyProfile,2026-09-01 10:00,America/New_York
```

## Query parameters

- `dryRun` boolean

## Response `200`

Bulk upload results. Returned when every row succeeded (or every row failed).
A mix of successes and failures returns `207` instead, with the same body shape.

- BulkUploadResult — Result of a CSV bulk upload. The same shape is returned for `200` (all rows succeeded or all failed) and `207` (mixed). Per-row outcomes live in `results`; the row's success is `ok`, and failures carry machine-readable codes in `errors`.
  - `total` integer — Number of data rows processed from the CSV
  - `valid` integer — Count of rows that succeeded (results[].ok === true)
  - `invalid` integer — Count of rows that failed (total - valid)
  - `results` object[] — One entry per CSV data row, in row order.
    - `rowIndex` integer — 1-based index of the CSV data row (header excluded)
    - `ok` boolean — Whether the row was created successfully
    - `createdPostId` string — ID of the created post. Present only when `ok` is true and not a dry run.
    - `errors` string[] — Machine-readable failure codes for this row. Present only when `ok` is false. Examples: `unknown_profile:<id>`, `no_account_for_platform:<platform>`, `schedule_time_missing`, `rate_limited:<platform>:@<username>:<remaining>`.
  - `warnings` string[] — Top-level advisory warnings, e.g. `rows_exceed_advisory_limit:500` or `unknown_columns:<a,b,c>` (comma-separated unrecognized CSV column names). Empty when none.
  - `rateLimitedAccounts` object[] — Present only when one or more rows targeted an account currently in cooldown. Lets callers map `rate_limited:*` row errors back to structured metadata without parsing the error strings.
    - `accountId` string
    - `platform` string
    - `username` string
    - `rateLimitedUntil` string, date-time

## Other responses

- `207` — Partial success: some rows were created and some failed. Body is identical in shape to the `200` response. Inspect each entry in `results` (`ok` plus `errors`) to see which rows failed and why.
- `400` — Invalid CSV or validation errors
- `401` — Unauthorized
- `402` — Payment required: the account owner has a failed payment. Not returned on dry-run.
- `404` — Authenticated user not found
- `429` — Rate limit exceeded. Possible causes: API rate limit (requests per minute) or account cooldown (one or more accounts for platforms specified in the CSV are temporarily rate-limited).

---

[API](https://skmtc.dev/zernio/apis/zernio-api.md) · [All operations](https://skmtc.dev/zernio/apis/zernio-api/llms.txt) · [OpenAPI document](https://skmtc-service-production.skmtc.workers.dev/v1/apis/zernio/zernio-api/revisions/08611567febf/schema)
