Publishing

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:

posts.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:

curl -X POST "https://api.pozy.ai/v1/posts/bulk-upload?dryRun=true" \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -F "file=@posts.csv"
Response
{
  "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.
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:

import.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: 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 Bulk upload from CSV
POST /v1/posts Create post
GET /v1/posts List posts