PostZen

Quickstart

Get started with the PostZen API — authenticate, connect accounts, and schedule your first post in minutes.

PostZen is a social media scheduling API — you create profiles, connect social accounts through OAuth, and publish or schedule posts across platforms from a single request. This guide takes you from an API key to your first scheduled post in a few minutes.

Every request goes to the base URL https://api.postzen.dev, and every path is prefixed with /v1 — for example, https://api.postzen.dev/v1/posts.

Install the SDK

PostZen publishes official SDKs for Node.js and Python. You can also call the API directly with curl or any HTTP client — the examples below show all three.

npm install @postzen/node
pip install postzen-sdk

Authentication

The PostZen API authenticates with a bearer token. Every request must include an Authorization: Bearer <API key> header, and requests with a body (POST, PUT) must also set Content-Type: application/json.

Get your API key

  1. Sign in to the dashboard at app.postzen.dev.
  2. Open the API Keys page at app.postzen.dev/api-keys.
  3. Click Create key.
  4. Choose the Read & Write permission — creating profiles, connecting accounts, and publishing posts all require it. Use Read Only for keys that only fetch data.
  5. Optionally scope the key to selected profiles instead of all profiles.
  6. Copy the key. It is shown only once — store it somewhere safe.

An API key looks like pzn_live_ followed by 64 hexadecimal characters (73 characters total). PostZen stores only a SHA-256 hash of the key, so it can never be recovered after creation — if you lose it, create a new one. Treat the key like a password: keep it in a secret manager or environment variable, never commit it to source control, and never expose it in client-side code.

Device authorization (for CLIs and agents) — if you're building a CLI or an AI agent that needs a key without opening the dashboard, use the device authorization flow instead. POST https://api.postzen.dev/auth/cli/initiate to start a session, open the returned browserUrl in a browser and click Authorize, then poll GET https://api.postzen.dev/auth/cli/poll with the deviceCode as a bearer token (Authorization: Bearer <deviceCode>) every 5 seconds until it returns the key. The key is returned exactly once, on the first authorized poll — persist it immediately. Sessions expire after 15 minutes. See the step-by-step device authorization guide written for coding agents.

Store your key in an environment variable so the examples below can read it:

export POSTZEN_API_KEY="pzn_live_..."

Set up the client

Both SDKs are a single unified client: create one PostZen instance and reach every API area through a namespace — profiles, accounts, connect, media, and posts. Each targets the https://api.postzen.dev base URL by default. Pass your key to the constructor, or leave it out and the client reads POSTZEN_API_KEY from the environment.

import PostZen from '@postzen/node';

const postzen = new PostZen({
  apiKey: process.env.POSTZEN_API_KEY,
});

// Every API area is a namespace on the one client:
// postzen.profiles, postzen.accounts, postzen.connect, postzen.media, postzen.posts
from postzen import PostZen

client = PostZen()  # reads POSTZEN_API_KEY, or pass PostZen(api_key="...")

# Every API area is a namespace on the one client:
# client.profiles, client.accounts, client.connect, client.media, client.posts
curl https://api.postzen.dev/v1/profiles \
  -H "Authorization: Bearer $POSTZEN_API_KEY"

Each SDK method returns the parsed response. In Node.js, methods take a single options object and resolve to { data } — destructure data to read the response. In Python, methods return a Pydantic model you read by attribute. Response attributes keep the API's field names verbatim (profile.isDefault, account.username), except the _id field, which surfaces as field_id (profile.field_id).

Key concepts

Three resources make up the PostZen model:

  • Profiles — a profile is a workspace that groups connected accounts. You attach every social account and every post to a profile. Most integrations start by creating one profile per brand or client.
  • Accounts — an account is a social account (an X handle, an Instagram account, a LinkedIn page) connected to a profile through OAuth. You reference accounts when choosing where a post publishes.
  • Posts — a post is content published to one or more accounts. A post can be published immediately, scheduled for a future time, or saved as a draft.

Profile and account IDs are returned as _id — a 32-character lowercase alphanumeric string like jh72r5nqk9wx3v8m1t4cz6bs0fy5dg3e. Use these IDs to reference resources in later requests.

Step 1: Create a profile

Create a profile to hold your connected accounts and posts. Only name is required (1–80 characters); description (≤240 characters) and color (a hex value) are optional.

const { data } = await postzen.profiles.createProfile({
  body: {
    name: 'Acme Marketing',
    description: 'Posts for the Acme brand',
    color: '#4f46e5',
  },
});

console.log(data.message);       // "Profile created successfully"
const profileId = data.profile._id; // e.g. "jh72r5nqk9wx3v8m1t4cz6bs0fy5dg3e"
response = client.profiles.create_profile(
    name="Acme Marketing",
    description="Posts for the Acme brand",
    color="#4f46e5",
)

print(response.message)                    # "Profile created successfully"
profile_id = response.profile.field_id      # e.g. "jh72r5nqk9wx3v8m1t4cz6bs0fy5dg3e"
curl -X POST https://api.postzen.dev/v1/profiles \
  -H "Authorization: Bearer $POSTZEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Marketing",
    "description": "Posts for the Acme brand",
    "color": "#4f46e5"
  }'

The response is { message, profile }, where profile._id is the ID you use to attach accounts and posts.

Step 2: Connect a social account

Connecting an account is an OAuth flow. Request a connect URL for a profile and platform, redirect the user to the returned authUrl, and PostZen handles the platform authorization and stores the account. Pass an optional redirectUrl to control where PostZen sends the user after the connection completes.

The returned state value expires after 10 minutes, so start the redirect promptly. This endpoint requires a Read & Write key.

// platform is a path parameter; profileId and redirectUrl are query parameters.
const { data } = await postzen.connect.createConnectUrl({
  path: { platform: 'twitter' },
  query: {
    profileId,
    redirectUrl: 'https://yourapp.com/connected', // optional
  },
});

console.log(data.authUrl); // redirect the user here
console.log(data.state);   // expires after 10 minutes
# platform is a positional path parameter; profile_id and redirect_url are keyword args.
response = client.connect.create_connect_url(
    "twitter",
    profile_id=profile_id,
    redirect_url="https://yourapp.com/connected",  # optional
)

print(response.authUrl)  # redirect the user here
print(response.state)    # expires after 10 minutes
curl "https://api.postzen.dev/v1/connect/twitter?profileId=$PROFILE_ID&redirectUrl=https://yourapp.com/connected" \
  -H "Authorization: Bearer $POSTZEN_API_KEY"

The response is { authUrl, state }. Send the user to authUrl to complete the OAuth grant.

Available platforms

PlatformValue
Xx (or twitter)
Instagraminstagram
TikToktiktok
LinkedInlinkedin
Facebookfacebook
YouTubeyoutube
Threadsthreads
Pinterestpinterest
Blueskybluesky
Telegramtelegram

twitter is accepted as an alias for x. In responses, the platform is normalized to twitter.

The Free plan includes 2 connected accounts and does not allow extras. Connecting a third account returns a 402. Connecting an X (Twitter) account requires a payment method on file.

Step 3: Get your connected accounts

List the accounts available to your API key. You can filter by profileId, platform, or status, and paginate with page and limit.

const { data } = await postzen.accounts.listAccounts({
  query: { profileId },
});

for (const account of data.accounts) {
  console.log(account._id, account.platform, account.username, account.status);
}
response = client.accounts.list_accounts(profile_id=profile_id)

for account in response.accounts:
    print(account.field_id, account.platform, account.username, account.status)
curl "https://api.postzen.dev/v1/accounts?profileId=$PROFILE_ID" \
  -H "Authorization: Bearer $POSTZEN_API_KEY"

Each account in the response includes its _id, platform, username, displayName, and status. Note the _id (or the platform's providerAccountId) of each account you want to post to — you pass it as accountId when creating a post.

Step 4: Schedule your first post

Create a post that publishes at a future time. Set scheduledFor to an ISO-8601 timestamp at least 60 seconds in the future, and list the target accounts in platforms. Each target is { platform, accountId }, where accountId is the account's PostZen _id or the platform's providerAccountId.

Use a Z-suffixed (UTC) timestamp so the scheduled time is interpreted unambiguously. PostZen also stores a timezone field (default "UTC") as metadata on the post.

const { data } = await postzen.posts.createPost({
  body: {
    content: 'Scheduled with the PostZen API 🚀',
    scheduledFor: '2026-07-15T09:00:00Z',
    platforms: [
      { platform: 'twitter', accountId: 'jh72r5nqk9wx3v8m1t4cz6bs0fy5dg3e' },
    ],
  },
});

console.log(data.message);
console.log(data.post._id); // the new post's _id
response = client.posts.create_post(
    content="Scheduled with the PostZen API 🚀",
    scheduled_for="2026-07-15T09:00:00Z",
    platforms=[
        {"platform": "twitter", "account_id": "jh72r5nqk9wx3v8m1t4cz6bs0fy5dg3e"},
    ],
)

print(response.message)
print(response.post.field_id)  # the new post's _id
curl -X POST https://api.postzen.dev/v1/posts \
  -H "Authorization: Bearer $POSTZEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Scheduled with the PostZen API 🚀",
    "scheduledFor": "2026-07-15T09:00:00Z",
    "platforms": [
      { "platform": "twitter", "accountId": "jh72r5nqk9wx3v8m1t4cz6bs0fy5dg3e" }
    ]
  }'

A successful request returns 201 with { post, message }. Exactly one creation mode must be set on each post — publishNow, scheduledFor, or isDraft. Setting none returns a 400 ("Set publishNow, scheduledFor, or isDraft"), and combining publishNow with either of the others is also a 400. To make a request idempotent, send an x-request-id header — repeating the same value returns the original post instead of creating a duplicate. In Node.js, add it to the call's headers option (postzen.posts.createPost({ body, headers: { 'x-request-id': '...' } })); in Python, pass x_request_id="..." directly to create_post; with curl, pass -H "x-request-id: ...".

Posting to multiple platforms

List several targets in platforms to publish the same content to multiple accounts at once. Content still passes each platform's own validation, so keep it within the strictest platform's limits.

const { data } = await postzen.posts.createPost({
  body: {
    content: 'Announcing our latest release! 🎉',
    scheduledFor: '2026-07-15T09:00:00Z',
    platforms: [
      { platform: 'twitter', accountId: 'jh72r5nqk9wx3v8m1t4cz6bs0fy5dg3e' },
      { platform: 'linkedin', accountId: 'kp83s6mrl0xy4w9n2u5da7ct1gz6eh4f' },
      { platform: 'instagram', accountId: 'lq94t7nsm1yz5x0o3v6eb8du2ha7fi5g' },
    ],
  },
});

console.log(data.post._id);
response = client.posts.create_post(
    content="Announcing our latest release! 🎉",
    scheduled_for="2026-07-15T09:00:00Z",
    platforms=[
        {"platform": "twitter", "account_id": "jh72r5nqk9wx3v8m1t4cz6bs0fy5dg3e"},
        {"platform": "linkedin", "account_id": "kp83s6mrl0xy4w9n2u5da7ct1gz6eh4f"},
        {"platform": "instagram", "account_id": "lq94t7nsm1yz5x0o3v6eb8du2ha7fi5g"},
    ],
)

print(response.post.field_id)
curl -X POST https://api.postzen.dev/v1/posts \
  -H "Authorization: Bearer $POSTZEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Announcing our latest release! 🎉",
    "scheduledFor": "2026-07-15T09:00:00Z",
    "platforms": [
      { "platform": "twitter", "accountId": "jh72r5nqk9wx3v8m1t4cz6bs0fy5dg3e" },
      { "platform": "linkedin", "accountId": "kp83s6mrl0xy4w9n2u5da7ct1gz6eh4f" },
      { "platform": "instagram", "accountId": "lq94t7nsm1yz5x0o3v6eb8du2ha7fi5g" }
    ]
  }'

Each target also accepts an optional customContent field that overrides the shared content for that platform.

Publishing immediately

To publish right away instead of scheduling, set publishNow: true and omit scheduledFor.

const { data } = await postzen.posts.createPost({
  body: {
    content: 'Hello from the PostZen API!',
    publishNow: true,
    platforms: [
      { platform: 'twitter', accountId: 'jh72r5nqk9wx3v8m1t4cz6bs0fy5dg3e' },
    ],
  },
});

console.log(data.post._id);
response = client.posts.create_post(
    content="Hello from the PostZen API!",
    publish_now=True,
    platforms=[
        {"platform": "twitter", "account_id": "jh72r5nqk9wx3v8m1t4cz6bs0fy5dg3e"},
    ],
)

print(response.post.field_id)
curl -X POST https://api.postzen.dev/v1/posts \
  -H "Authorization: Bearer $POSTZEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Hello from the PostZen API!",
    "publishNow": true,
    "platforms": [
      { "platform": "twitter", "accountId": "jh72r5nqk9wx3v8m1t4cz6bs0fy5dg3e" }
    ]
  }'

Creating a draft

Set isDraft: true to save a post without publishing or scheduling it. Drafts are the one mode where platforms is optional, so you can capture content first and choose the target accounts later.

const { data } = await postzen.posts.createPost({
  body: {
    content: 'Draft — finalize the wording before publishing.',
    isDraft: true,
  },
});

console.log(data.post._id);
response = client.posts.create_post(
    content="Draft — finalize the wording before publishing.",
    is_draft=True,
)

print(response.post.field_id)
curl -X POST https://api.postzen.dev/v1/posts \
  -H "Authorization: Bearer $POSTZEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Draft — finalize the wording before publishing.",
    "isDraft": true
  }'

Remember that a post must set exactly one of publishNow, scheduledFor, or isDraft — a draft explicitly needs isDraft: true.

Attach media (optional)

To include an image or video, upload it to PostZen-hosted storage first. Request a presigned URL with POST /v1/media/presign, PUT the file to the returned uploadUrl, then reference the returned publicUrl in the post's mediaItems.

# 1. Request a presigned upload URL
curl -X POST https://api.postzen.dev/v1/media/presign \
  -H "Authorization: Bearer $POSTZEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "filename": "launch.png", "contentType": "image/png" }'

# 2. Upload the file to the returned uploadUrl
curl -X PUT "<uploadUrl>" \
  -H "Content-Type: image/png" \
  --data-binary @launch.png

# 3. Reference the returned publicUrl in mediaItems
curl -X POST https://api.postzen.dev/v1/posts \
  -H "Authorization: Bearer $POSTZEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Launch day! 🚀",
    "publishNow": true,
    "mediaItems": [{ "url": "<publicUrl>" }],
    "platforms": [
      { "platform": "twitter", "accountId": "jh72r5nqk9wx3v8m1t4cz6bs0fy5dg3e" }
    ]
  }'

You can attach up to 10 media items per post. You can also pass an external image or video URL directly in mediaItems — PostZen downloads and re-hosts it (up to 100 MB).

Where to go next

On this page