Publishing
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:
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/MadridAlways 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"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());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()){
"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 { 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 |