# CSV import

Create many posts at once from a CSV file with POST /v1/posts/bulk-upload, check the file with a dry run first, or script the import yourself with idempotency keys.

When someone hands you a spreadsheet of posts, you have two options. Upload the CSV as it is to `POST /v1/posts/bulk-upload` and let Pozy create one post per row, or read the file yourself and call `POST /v1/posts` per row. The first is one request. The second gives you full control over every field.

## Upload a CSV

Each row becomes one post. The smallest useful file:

```csv
post_content,platforms,profiles,schedule_time,tz
"Monday tip: pin your best post to the top of your profile.","linkedin,twitter",Acme Marketing,2026-11-09 09:00,Europe/Madrid
"Wednesday tip: reply to comments within the first hour.","linkedin,twitter",Acme Marketing,2026-11-11 09:00,Europe/Madrid
"Friday tip: reuse your top post as a carousel.",instagram,Acme Marketing,2026-11-13 12:00,Europe/Madrid
```

Always run it with `dryRun=true` first. A dry run checks every row and reports problems without creating anything:

```bash cURL
curl -X POST "https://api.pozy.ai/v1/posts/bulk-upload?dryRun=true" \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -F "file=@posts.csv"
```
```ts Node
import { readFile } from "node:fs/promises";

const form = new FormData();
form.append("file", new Blob([await readFile("posts.csv")], { type: "text/csv" }), "posts.csv");

const res = await fetch("https://api.pozy.ai/v1/posts/bulk-upload?dryRun=true", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` },
  body: form,
});
console.log(await res.json());
```
```python Python
with open("posts.csv", "rb") as f:
    resp = requests.post(
        "https://api.pozy.ai/v1/posts/bulk-upload",
        params={"dryRun": "true"},
        headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
        files={"file": ("posts.csv", f, "text/csv")},
        timeout=120,
    )
print(resp.json())
```

```json
{
  "total": 3,
  "valid": 2,
  "invalid": 1,
  "results": [
    { "rowIndex": 0, "ok": true, "errors": [] },
    { "rowIndex": 1, "ok": true, "errors": [] },
    { "rowIndex": 2, "ok": false, "errors": ["Instagram posts need at least one image or video."] }
  ],
  "warnings": [],
  "rateLimitedAccounts": []
}
```

Fix the failing rows, run the dry run again until `invalid` is `0`, then send the same request without `dryRun`. Each created row then carries its `createdPostId`.

## Columns

Every row needs a target, a schedule and something to post:

| Need | Columns |
| --- | --- |
| Where | `platforms` (one or more platform ids, comma-separated inside quotes) and `profiles` (the profile the row posts from) |
| When | One of: `schedule_time`, a per-platform `schedule_time_<platform>`, `publish_now=true`, `use_queue=true` or `is_draft=true` |
| What | At least one of `post_content`, `title` or `media_urls` |

Optional columns:

| Column | What it does |
| --- | --- |
| `tz` | IANA timezone that `schedule_time` is read in. Without it, the row uses its profile's timezone when every target profile shares one, otherwise UTC. |
| `media_urls` | Public URLs of the media to attach. Upload local files first with [Media uploads](https://pozy.ai/docs/publishing/media). |
| `custom_content_<platform>` | Replaces the text for one platform, for example `custom_content_twitter`. |
| `custom_media_<platform>` | Replaces the media for one platform, for example `custom_media_instagram`. |
| `schedule_time_<platform>` | A different time for one platform. |
| `hashtags`, `mentions` | Stored on the post but not added to the text. Put them in `post_content` if they should be published. |

Some column names from the `POST /v1/posts` body are also accepted, because people often guess them: `content` for `post_content`, `timezone` for `tz`, `scheduledFor` for `schedule_time` and `mediaUrls` for `media_urls`. When a row has both, the CSV name wins unless it is empty.

## Warnings and limits

| Situation | Result |
| --- | --- |
| A column Pozy does not read | Not an error. `warnings` lists it as `unknown_columns:<names>`, so a typo in a header never goes unnoticed. |
| `hashtags` or `mentions` filled in | `warnings` includes `not_added_to_caption:<columns>`. |
| More than 500 rows | Processed, with `rows_exceed_advisory_limit:500` in `warnings`. Large files take longer and are harder to fix. |
| More than 5,000 rows | Rejected with `400`. Split the file. |
| An account the platform is throttling | Listed in `rateLimitedAccounts` with `rateLimitedUntil`. Move those rows to a later time. |

## Script the import yourself

A script gives you per-row control: your own validation, per-row metadata, retries and a log you can resume from. The key is an `Idempotency-Key` that is stable per row, so running the script twice never creates duplicates:

```ts
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
const ACCOUNTS: Record<string, string> = {
  linkedin: "6710c3e2a9f14b0d8e5c7a21",
  twitter: "6710c3e2a9f14b0d8e5c7a23",
  instagram: "6710c3e2a9f14b0d8e5c7a22",
};

// Expects: text,platforms,date,time (no commas inside fields, for brevity)
const rows = (await readFile("posts.csv", "utf8")).trim().split("\n").slice(1).map((l) => l.split(","));

for (const [text, platforms, date, time] of rows) {
  const key = createHash("sha256").update(`${text}|${platforms}|${date}|${time}`).digest("hex");
  try {
    const res = await fetch("https://api.pozy.ai/v1/posts", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.POZY_API_KEY}`,
        "Idempotency-Key": `csv-${key}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        content: text,
        scheduledFor: `${date}T${time}:00`,
        timezone: "Europe/Madrid",
        platforms: platforms.split("|").map((p) => ({ platform: p, accountId: ACCOUNTS[p] })),
        metadata: { source: "csv-import", rowKey: key },
      }),
    });
    const data = await res.json();
    if (!res.ok) throw new Error(data.error);
    console.log("ok", data.post._id);
  } catch (err) {
    console.error("failed", text.slice(0, 40), err);
  }
}
```

Pace the loop to stay inside your [rate limit](https://pozy.ai/docs/getting-started/rate-limits): 30 requests a minute on the free plan and 120 with a payment method. For hundreds of rows, use the bulk endpoint or add a short sleep between calls.

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`POST /v1/posts/bulk-upload`](https://pozy.ai/docs/api/bulkUploadPosts) | Bulk upload from CSV |
| [`POST /v1/posts`](https://pozy.ai/docs/api/createPost) | Create post |
| [`GET /v1/posts`](https://pozy.ai/docs/api/listPosts) | List posts |
