# Posting API

Publish one post to many social networks with POST /v1/posts: shared text and media, per-platform overrides, and a result for every platform.

import { Tab, Tabs } from 'fumadocs-ui/components/tabs';

The Posting API publishes content to the social accounts your users connect. One `POST /v1/posts` call carries the text, the media and the target accounts, and Zernio publishes to each network through its own API.

## What you can build

- **Cross-posting in your app.** A user writes once and picks accounts. `POST /v1/posts` publishes to all of them and returns each platform's status and live URL.
- **Video distribution.** Send one video to TikTok, YouTube and Instagram Reels, with a title, privacy setting or caption per network in `platforms[].platformSpecificData`.
- **Content calendars and approvals.** Save drafts, schedule them, or hold them for review with `requiresApproval`. The [Scheduler API](/products/scheduler) covers the timing side.
- **Automated publishing.** A CMS, a store or an AI agent creates posts from its own events, with an `Idempotency-Key` so retries never post twice.
- **Fixing failed posts.** Read each platform's `errorMessage`, then publish again only the failed platforms with `POST /v1/posts/{postId}/retry`.

## Platforms

<ProductPlatforms product="posting" />

Every platform takes the same request. What each one can publish differs, and two are not publishing today:

| Platform | What you can publish |
|---|---|
| <PlatformName slug="twitter" page="posts" /> | Text, images, videos, threads, Articles |
| <PlatformName slug="instagram" page="posts" /> | Feed posts, carousels, Stories, Reels |
| <PlatformName slug="facebook" page="posts" /> | Text, images, videos, Stories, Reels |
| <PlatformName slug="linkedin" page="posts" /> | Text, images, videos, documents, polls |
| <PlatformName slug="tiktok" page="posts" /> | Videos, photo carousels |
| <PlatformName slug="youtube" page="posts" /> | Videos and Shorts |
| <PlatformName slug="pinterest" page="posts" /> | Image and video Pins |
| <PlatformName slug="threads" page="posts" /> | Text, images, videos, carousels, thread sequences |
| <PlatformName slug="bluesky" page="posts" /> | Text, images, videos, threads |
| <PlatformName slug="google-business" page="posts" /> | Updates with a photo and a button |
| <PlatformName slug="telegram" page="posts" /> | Messages, photos, videos, documents, albums |
| <PlatformName slug="discord" page="posts" /> | Messages, embeds, polls, forum posts, threads |
| <PlatformName slug="slack" page="posts" /> | Channel messages, thread replies, files |
| <PlatformName slug="reddit" page="posts" /> | Paused while Reddit blocks third-party API access |
| <PlatformName slug="snapchat" page="posts" /> | Not open yet: Snap has not approved Zernio's app |

Side by side, feature by feature: [compare platforms](/platforms/compare).

## How it works

A post is one object with one `_id`. Its `platforms` array holds one entry per target account, and each entry has its own `status`, `platformPostId` and `platformPostUrl` once it publishes.

- **Shared content.** `content` and `mediaItems` go to every entry.
- **Per-platform overrides.** An entry can carry `customContent`, `customMedia`, its own `scheduledFor`, and `platformSpecificData` with options only that network has.
- **Media.** Each item in `mediaItems` is a public HTTPS `url`. Upload a local file first with `POST /v1/media/presign` ([media uploads](/guides/media-uploads)).
- **When it publishes.** `publishNow: true` publishes during the request. `scheduledFor` or `queuedFromProfile` schedules it. With none of them, the post is saved as a draft.

The post moves through `draft`, `scheduled`, `publishing`, then `published`, `partial` or `failed`. Every status is on [post lifecycle](/guides/post-lifecycle).

## Your first request

You need an API key and the `accountId` of each connected account ([connecting accounts](/guides/connecting-accounts)). This publishes one post to LinkedIn and X right now.

<Tabs items={['Node.js', 'Python', 'curl']}>
<Tab value="Node.js">
```typescript
import Zernio from '@zernio/node';

const zernio = new Zernio();

const { data } = await zernio.posts.createPost({
  body: {
    content: 'We just launched!',
    publishNow: true,
    platforms: [
      { platform: 'linkedin', accountId: '66b2e19d8c3f5a7e9d0b1c2d' },
      { platform: 'twitter', accountId: '66b2e19d8c3f5a7e9d0b1c2e' }
    ]
  }
});

console.log(data.post.status);
```
</Tab>
<Tab value="Python">
```python
from zernio import Zernio

client = Zernio()

created = client.posts.create_post(
    content="We just launched!",
    publish_now=True,
    platforms=[
        {"platform": "linkedin", "accountId": "66b2e19d8c3f5a7e9d0b1c2d"},
        {"platform": "twitter", "accountId": "66b2e19d8c3f5a7e9d0b1c2e"},
    ],
)

print(created["post"]["status"])
```
</Tab>
<Tab value="curl">
```bash
curl -X POST "https://zernio.com/api/v1/posts" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "We just launched!",
    "publishNow": true,
    "platforms": [
      { "platform": "linkedin", "accountId": "66b2e19d8c3f5a7e9d0b1c2d" },
      { "platform": "twitter", "accountId": "66b2e19d8c3f5a7e9d0b1c2e" }
    ]
  }'
```
</Tab>
</Tabs>

Response (`201`):

```json
{
  "message": "Post published successfully",
  "post": {
    "_id": "65f1c0a9e2b5af0012ab34cd",
    "content": "We just launched!",
    "status": "published",
    "publishedAt": "2026-11-10T10:00:05Z",
    "platforms": [
      {
        "platform": "linkedin",
        "status": "published",
        "platformPostId": "urn:li:share:7123456789012345678",
        "platformPostUrl": "https://www.linkedin.com/feed/update/urn:li:share:7123456789012345678"
      },
      {
        "platform": "twitter",
        "status": "published",
        "platformPostId": "1852634789012345678",
        "platformPostUrl": "https://twitter.com/acmecorp/status/1852634789012345678"
      }
    ]
  }
}
```

A `207` means at least one platform did not publish. Read `post.status` (`partial` or `failed`) and each entry's `errorMessage`.

## Examples

### A video to TikTok and YouTube

One video, two networks. `tiktokSettings` holds the consent and privacy fields TikTok requires, and the YouTube entry sets its own title and visibility ([TikTok posts](/platforms/tiktok/posts), [YouTube videos](/platforms/youtube/posts)).

<Tabs items={['Node.js', 'Python', 'curl']}>
<Tab value="Node.js">
```typescript
const { data } = await zernio.posts.createPost({
  body: {
    content: 'How we brew cold coffee in 60 seconds #coffee',
    mediaItems: [{ type: 'video', url: 'https://cdn.example.com/cold-brew.mp4' }],
    scheduledFor: '2027-03-01T17:00:00Z',
    platforms: [
      { platform: 'tiktok', accountId: '66b2e19d8c3f5a7e9d0b1c2f' },
      {
        platform: 'youtube',
        accountId: '66b2e19d8c3f5a7e9d0b1c30',
        platformSpecificData: { title: 'Cold brew in 60 seconds', visibility: 'public' }
      }
    ],
    tiktokSettings: {
      privacy_level: 'PUBLIC_TO_EVERYONE',
      allow_comment: true,
      allow_duet: true,
      allow_stitch: true,
      content_preview_confirmed: true,
      express_consent_given: true
    }
  }
});
```
</Tab>
<Tab value="Python">
```python
created = client.posts.create_post(
    content="How we brew cold coffee in 60 seconds #coffee",
    media_items=[{"type": "video", "url": "https://cdn.example.com/cold-brew.mp4"}],
    scheduled_for="2027-03-01T17:00:00Z",
    platforms=[
        {"platform": "tiktok", "accountId": "66b2e19d8c3f5a7e9d0b1c2f"},
        {
            "platform": "youtube",
            "accountId": "66b2e19d8c3f5a7e9d0b1c30",
            "platformSpecificData": {"title": "Cold brew in 60 seconds", "visibility": "public"},
        },
    ],
    tiktok_settings={
        "privacy_level": "PUBLIC_TO_EVERYONE",
        "allow_comment": True,
        "allow_duet": True,
        "allow_stitch": True,
        "content_preview_confirmed": True,
        "express_consent_given": True,
    },
)
```
</Tab>
<Tab value="curl">
```bash
curl -X POST "https://zernio.com/api/v1/posts" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "How we brew cold coffee in 60 seconds #coffee",
    "mediaItems": [{ "type": "video", "url": "https://cdn.example.com/cold-brew.mp4" }],
    "scheduledFor": "2027-03-01T17:00:00Z",
    "platforms": [
      { "platform": "tiktok", "accountId": "66b2e19d8c3f5a7e9d0b1c2f" },
      {
        "platform": "youtube",
        "accountId": "66b2e19d8c3f5a7e9d0b1c30",
        "platformSpecificData": { "title": "Cold brew in 60 seconds", "visibility": "public" }
      }
    ],
    "tiktokSettings": {
      "privacy_level": "PUBLIC_TO_EVERYONE",
      "allow_comment": true,
      "allow_duet": true,
      "allow_stitch": true,
      "content_preview_confirmed": true,
      "express_consent_given": true
    }
  }'
```
</Tab>
</Tabs>

YouTube makes a vertical or square video of 3 minutes or less a Short on its own. There is no flag to set.

### An Instagram carousel

Several items in `mediaItems` make a carousel, up to 20 images and videos ([Instagram posts](/platforms/instagram/posts)).

<Tabs items={['Node.js', 'curl']}>
<Tab value="Node.js">
```typescript
const { data } = await zernio.posts.createPost({
  body: {
    content: 'Three looks for spring. Which one is yours?',
    mediaItems: [
      { type: 'image', url: 'https://cdn.example.com/look-1.jpg' },
      { type: 'image', url: 'https://cdn.example.com/look-2.jpg' },
      { type: 'image', url: 'https://cdn.example.com/look-3.jpg' }
    ],
    publishNow: true,
    platforms: [{ platform: 'instagram', accountId: '66b2e19d8c3f5a7e9d0b1c31' }]
  }
});
```
</Tab>
<Tab value="curl">
```bash
curl -X POST "https://zernio.com/api/v1/posts" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Three looks for spring. Which one is yours?",
    "mediaItems": [
      { "type": "image", "url": "https://cdn.example.com/look-1.jpg" },
      { "type": "image", "url": "https://cdn.example.com/look-2.jpg" },
      { "type": "image", "url": "https://cdn.example.com/look-3.jpg" }
    ],
    "publishNow": true,
    "platforms": [{ "platform": "instagram", "accountId": "66b2e19d8c3f5a7e9d0b1c31" }]
  }'
```
</Tab>
</Tabs>

### A thread on X

Put every post of the thread in `platformSpecificData.threadItems`. The first item is the root, and top-level `content` is not published ([X posts](/platforms/twitter/posts)).

<Tabs items={['Node.js', 'Python']}>
<Tab value="Node.js">
```typescript
const { data } = await zernio.posts.createPost({
  body: {
    content: 'How we cut our build time in half',
    publishNow: true,
    platforms: [
      {
        platform: 'twitter',
        accountId: '66b2e19d8c3f5a7e9d0b1c2e',
        platformSpecificData: {
          threadItems: [
            { content: '1/ How we cut our build time in half' },
            { content: '2/ We cached dependencies between runs.' },
            { content: '3/ Then we split the test suite across 4 machines.' }
          ]
        }
      }
    ]
  }
});
```
</Tab>
<Tab value="Python">
```python
created = client.posts.create_post(
    content="How we cut our build time in half",
    publish_now=True,
    platforms=[
        {
            "platform": "twitter",
            "accountId": "66b2e19d8c3f5a7e9d0b1c2e",
            "platformSpecificData": {
                "threadItems": [
                    {"content": "1/ How we cut our build time in half"},
                    {"content": "2/ We cached dependencies between runs."},
                    {"content": "3/ Then we split the test suite across 4 machines."},
                ]
            },
        }
    ],
)
```
</Tab>
</Tabs>

## How it behaves

- **Platforms publish independently.** One that fails leaves the others published, and the post becomes `partial`.
- **A due time publishes now.** A `scheduledFor` already in the past publishes during the request, like `publishNow`.
- **Duplicates are rejected.** The same text and media to the same account within 24 hours returns `409`. Send an `Idempotency-Key` to retry safely ([idempotency](/guides/idempotency)).
- **Over 25 posts an hour per account are held, not rejected.** They publish as the hourly cap frees up ([rate limits](/guides/rate-limits)).
- **Check before you publish.** `POST /v1/tools/validate/post` takes the same body and returns the errors each platform would raise ([validate post](/validate/validate-post)).

## If it fails

| Error | Cause | Fix |
|---|---|---|
| `207` | At least one platform did not publish | Read each entry's `errorMessage`, then call `POST /v1/posts/{postId}/retry` |
| `400` | A field breaks a platform's rule, or `platforms` is empty | Fix the field the `error` names |
| `403` | A target account is disconnected, ads-only or not yours | Reconnect it, then read ids from `GET /v1/accounts` |
| `409` | The same content went to this account in the last 24 hours | Change the content, or use `existingPostId` from the response |
| `429` | A daily cap or an account cooldown | Wait for the time in the message ([rate limits](/guides/rate-limits)) |

## Related

- [Create post reference](/posts/create-post)
- [Post lifecycle](/guides/post-lifecycle)
- [Media uploads](/guides/media-uploads)
- [Post webhooks](/webhooks/social)
- [Scheduler API](/products/scheduler)
- [Analytics API](/products/analytics)