Documentation

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 (Z or +09:00).
  • AI assistants: the same features are available as an MCP server.

Quick start

  1. Sign in to Postzik and connect your social accounts on the Channels page.
  2. Open Developers, create an API key and copy it. It starts with pzk_ and is shown only once.
  3. 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

Price1 credit per post per account (a post to 3 accounts is 3 credits)
When you payWhen Postzik sends the post, not when you schedule it
Failed postsRefunded automatically
Drafts, edits, readsFree
Buying creditsCredit 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:

StatusMeaning
draftSaved, won't publish until you publish or schedule it
scheduledWaiting for scheduled_at (or about to go out, if it's due)
publishingSent to the network; waiting for it to confirm
publishedLive. published_url links to it
failedDidn'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.

FieldTypeNotes
brandstringA brand's name (not case-sensitive) or id: posts to every account in it
accountsarray of idsAccounts to post to. Or use targets
targetsarray[{ "account_id", "title", "caption", "tags", "media_url", "options" }] for per-account content. Each field falls back to the top-level value
media_urlstring, requiredPublic https URL of the image or video. Every post needs media
titlestringUsed by YouTube, TikTok and Pinterest
captionstringCaption or description. On X it's cut to 280 characters, on Threads to 500
tagsarray of stringsHashtags or tags. Added to the caption as #tag; sent as tags on YouTube
scheduled_atISO timeLeave out (or use a past time) to publish now
draftbooleantrue saves without publishing
optionsobjectPlatform 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

PlatformOptions
YouTubeprivacy: public | unlisted | private; category_id; made_for_kids
TikTokprivacy_level: PUBLIC_TO_EVERYONE | MUTUAL_FOLLOW_FRIENDS | FOLLOWER_OF_CREATOR | SELF_ONLY; disable_comment; disable_duet; disable_stitch
Instagramplacement: reels | stories | timeline (default reels); share_to_feed
Facebookplacement: reels | stories | timeline (default timeline)
Threadsplacement: reels | timeline
Pinterestboard_id; link (where the pin goes)
Xreply_settings (default everyone)
LinkedInnone

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.

QueryNotes
statusComma-separated: draft,scheduled,publishing,published,failed
from, toOnly posts scheduled in [from, to)
limit1–100, default 50
cursornext_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": "…" } ] } }
StatusCodeWhat to do
400invalid_json, no_targets, invalid_target, invalid_date, invalid_status, invalid_options, too_many_targets, invalid_idempotency_keyFix the request
401unauthorizedCheck the key; it may be revoked
402insufficient_creditsBuy credits. details has required and balance
404not_foundThe post doesn't exist or isn't yours
409post_busy, already_publishedWait for publishing to finish, or leave published posts alone
422invalid_postdetails lists each problem by target index: account_not_found, platform_mismatch, duplicate_account, media_required
422brand_not_found, brand_emptyNo brand by that name (the message lists the user's brands), or it has no connected accounts
429rate_limitedWait for Retry-After seconds
503accounts_unavailableTemporary; retry with the same Idempotency-Key
500server_errorTemporary; 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.