Postzik API
Publish and schedule posts to YouTube, TikTok, Instagram, Facebook, X, LinkedIn, Pinterest and Threads from your own app. You pay per post with prepaid credits; there is no subscription.
- Base URL:
https://postzik.com/api/v1(self-hosted:https://your-domain/api/v1) - Format: JSON in, JSON out. Times are ISO 8601; send a timezone (
Zor+09:00). - AI assistants: the same features are available as an MCP server.
Quick start
- Sign in to Postzik and connect your social accounts on the Channels page.
- Open Developers, create an API key and copy it. It starts with
pzk_and is shown only once. - Find your account ids, then post:
curl https://postzik.com/api/v1/accounts \
-H "Authorization: Bearer $POSTZIK_API_KEY"
curl https://postzik.com/api/v1/posts \
-H "Authorization: Bearer $POSTZIK_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: launch-001" \
-d '{
"accounts": [123, 456],
"media_url": "https://example.com/video.mp4",
"title": "Launch day",
"caption": "We are live!",
"tags": ["launch", "startup"],
"scheduled_at": "2026-11-02T09:00:00Z"
}'
Leave out scheduled_at to publish right away.
JavaScript
const postzik = (path, init = {}) =>
fetch(`https://postzik.com/api/v1${path}`, {
...init,
headers: { Authorization: `Bearer ${process.env.POSTZIK_API_KEY}`, "Content-Type": "application/json", ...init.headers },
}).then(async (res) => {
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
return body;
});
const { data: accounts } = await postzik("/accounts");
const { data: posts } = await postzik("/posts", {
method: "POST",
headers: { "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify({ accounts: accounts.map((a) => a.id), media_url: "https://example.com/v.mp4", caption: "Hello" }),
});
Python
import os, uuid, requests
API = "https://postzik.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['POSTZIK_API_KEY']}"}
accounts = requests.get(f"{API}/accounts", headers=HEADERS).json()["data"]
res = requests.post(
f"{API}/posts",
headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())},
json={"accounts": [a["id"] for a in accounts], "media_url": "https://example.com/v.mp4", "caption": "Hello"},
)
res.raise_for_status()
print(res.json()["data"])
Authentication
Send your key as a bearer token on every request:
Authorization: Bearer pzk_...
X-API-Key: pzk_... also works. Keys act as your Postzik account: keep them on your server, never in a browser or
mobile app. Revoke a key on the Developers page and it stops working immediately. You can have up to 25 active keys.
Pricing
| Price | 1 credit per post per account (a post to 3 accounts is 3 credits) |
| When you pay | When Postzik sends the post, not when you schedule it |
| Failed posts | Refunded automatically |
| Drafts, edits, reads | Free |
| Buying credits | Credit packs on the Pricing page; larger packs cost less per credit |
GET /me shows your balance. Publishing now with too few credits returns 402 insufficient_credits. A scheduled post
that comes due when you're out of credits fails with "Not enough credits", so keep your balance topped up.
How publishing works
Each account in a request becomes its own post with its own status:
| Status | Meaning |
|---|---|
draft | Saved, won't publish until you publish or schedule it |
scheduled | Waiting for scheduled_at (or about to go out, if it's due) |
publishing | Sent to the network; waiting for it to confirm |
published | Live. published_url links to it |
failed | Didn't publish. error says why; the credit was refunded |
Temporary problems (the network is busy, a timeout before the request was sent) are retried automatically, up to 5 attempts over about 8 minutes. Postzik never sends a post twice: if a request might have reached the network but the answer was lost, the post fails with a message asking you to check the platform before publishing it again.
Publishing is asynchronous. Poll GET /posts/{id} (every 10–30 seconds is plenty) until the status is published or
failed.
Endpoints
GET /me
{ "id": "clx…", "email": "[email protected]", "name": "You", "credits": 482,
"pricing": { "credits_per_post": 1, "refunded_on_failure": true } }
GET /accounts
The accounts you can post to. Add ?refresh=true right after connecting a new account (the list is cached for a minute).
{ "data": [
{ "id": 123, "platform": "youtube", "name": "My Channel", "connected_at": "2026-10-01T08:00:00Z" },
{ "id": 456, "platform": "instagram", "name": "@mybrand", "connected_at": "2026-10-03T12:30:00Z" }
] }
platform is one of youtube, tiktok, instagram, facebook, x, linkedin, pinterest, threads.
brand is { "id", "name" } when the account is in a brand, otherwise null.
Connect accounts in the Postzik app; the API can't connect accounts.
GET /brands
Brands are optional groups of accounts that users create on the Channels page, usually one per business or client: "Overfit Uncle" might be its YouTube, Instagram, Facebook and TikTok. An account is in at most one brand. Users who don't use brands get an empty list.
{ "data": [
{ "id": "cm9…", "name": "Overfit Uncle", "accounts": [
{ "id": 123, "platform": "youtube", "name": "Overfit Uncle", "connected_at": "2026-10-01T08:00:00Z" },
{ "id": 456, "platform": "instagram", "name": "@overfit.uncle", "connected_at": "2026-10-03T12:30:00Z" }
] }
] }
Accounts that were disconnected are left out. To post to a whole brand, pass brand to POST /posts.
POST /posts
Creates one post per account.
| Field | Type | Notes |
|---|---|---|
brand | string | A brand's name (not case-sensitive) or id: posts to every account in it |
accounts | array of ids | Accounts to post to. Or use targets |
targets | array | [{ "account_id", "title", "caption", "tags", "media_url", "options" }] for per-account content. Each field falls back to the top-level value |
media_url | string, required | Public https URL of the image or video. Every post needs media |
title | string | Used by YouTube, TikTok and Pinterest |
caption | string | Caption or description. On X it's cut to 280 characters, on Threads to 500 |
tags | array of strings | Hashtags or tags. Added to the caption as #tag; sent as tags on YouTube |
scheduled_at | ISO time | Leave out (or use a past time) to publish now |
draft | boolean | true saves without publishing |
options | object | Platform options, below. Per-account targets[].options override these |
You need at least one of brand, accounts or targets; combining them posts to all of them, each account once.
With brand and targets, the listed accounts use their overrides and the brand's other accounts use the top-level
content:
{ "brand": "Overfit Uncle", "media_url": "https://example.com/ep12.mp4", "caption": "Episode 12 is out",
"targets": [{ "account_id": 123, "title": "Episode 12: the full story" }] }
Up to 20 accounts per request. Header Idempotency-Key (any string up to 200 characters) makes retries safe: sending
the same key again returns the posts from the first request with status 200 and Idempotent-Replayed: true instead
of creating new ones. Use one key per logical post.
Platform options
| Platform | Options |
|---|---|
| YouTube | privacy: public | unlisted | private; category_id; made_for_kids |
| TikTok | privacy_level: PUBLIC_TO_EVERYONE | MUTUAL_FOLLOW_FRIENDS | FOLLOWER_OF_CREATOR | SELF_ONLY; disable_comment; disable_duet; disable_stitch |
placement: reels | stories | timeline (default reels); share_to_feed | |
placement: reels | stories | timeline (default timeline) | |
| Threads | placement: reels | timeline |
board_id; link (where the pin goes) | |
| X | reply_settings (default everyone) |
| none |
Options that don't apply to an account's platform are ignored.
Response 201:
{
"data": [
{
"id": "cm3…",
"status": "scheduled",
"platform": "youtube",
"account_id": 123,
"account_name": "My Channel",
"media_url": "https://example.com/video.mp4",
"title": "Launch day",
"caption": "We are live!",
"tags": ["launch", "startup"],
"options": { "privacy": "public", "made_for_kids": false, "placement": null, "board_id": null, "…": "…" },
"scheduled_at": "2026-11-02T09:00:00.000Z",
"published_at": null,
"published_url": null,
"error": null,
"attempts": 0,
"credits_charged": 0,
"source": "api",
"created_at": "2026-10-30T10:00:00.000Z",
"updated_at": "2026-10-30T10:00:00.000Z"
}
],
"pricing": { "credits_per_post": 1, "refunded_on_failure": true }
}
GET /posts
Newest scheduled_at first.
| Query | Notes |
|---|---|
status | Comma-separated: draft,scheduled,publishing,published,failed |
from, to | Only posts scheduled in [from, to) |
limit | 1–100, default 50 |
cursor | next_cursor from the previous page |
{ "data": [ { "id": "cm3…", "status": "published", "published_url": "https://youtu.be/…", "…": "…" } ],
"next_cursor": "cm2…" }
next_cursor is null on the last page.
GET /posts/{id}
One post ({ "data": { … } }).
PATCH /posts/{id}
Edits a draft, scheduled or failed post. Send only what changes: title, caption, tags, media_url,
options, scheduled_at. Setting scheduled_at (re)schedules the post; a past time publishes it now. Returns
409 post_busy while the post is publishing and 409 already_published once it's live.
POST /posts/{id}/publish
Publishes a draft or scheduled post now, or retries a failed one.
DELETE /posts/{id}
Cancels and deletes a post that isn't publishing right now. Deleting a published post only removes it from Postzik; it stays on the platform.
Errors
Every error has the same shape:
{ "error": { "code": "invalid_post", "message": "Account 999 isn't connected to your Postzik account",
"details": [ { "target": 1, "code": "account_not_found", "message": "…" } ] } }
| Status | Code | What to do |
|---|---|---|
| 400 | invalid_json, no_targets, invalid_target, invalid_date, invalid_status, invalid_options, too_many_targets, invalid_idempotency_key | Fix the request |
| 401 | unauthorized | Check the key; it may be revoked |
| 402 | insufficient_credits | Buy credits. details has required and balance |
| 404 | not_found | The post doesn't exist or isn't yours |
| 409 | post_busy, already_published | Wait for publishing to finish, or leave published posts alone |
| 422 | invalid_post | details lists each problem by target index: account_not_found, platform_mismatch, duplicate_account, media_required |
| 422 | brand_not_found, brand_empty | No brand by that name (the message lists the user's brands), or it has no connected accounts |
| 429 | rate_limited | Wait for Retry-After seconds |
| 503 | accounts_unavailable | Temporary; retry with the same Idempotency-Key |
| 500 | server_error | Temporary; retry with the same Idempotency-Key |
Limits
- 120 requests per minute per key (default; self-hosters set
API_RATE_LIMIT_PER_MINUTE). - 20 accounts per post request, 100 posts per list page.
- Media must be publicly reachable over
http(s)when the post is sent, and must meet each network's rules (for example TikTok and YouTube need video).
Self-hosting notes
The API runs inside the Postzik web app; publishing needs the worker (npm run worker, see
scheduler.md). The API rate limit is kept in memory per server instance: put a shared limiter (for
example at your load balancer or edge) in front when you run several instances.