Skip to content
postpeg

Publishing

One request publishes or schedules a post to up to 50 accounts on any mix of networks. postpeg checks it against each network first, then publishes each account separately and reports back per account.

Create a post

POST/v1/posts

Create post fields
FieldDescription
account_idsstring[], 1–50 · requiredAccount ids from GET /v1/accounts. Each must be active.
contentstringThe text. Counted in characters as people see them (an emoji is one). Each network’s limit applies; see Platforms.
mediaobject[], up to 20{ url, type, alt_text? }. url must be a public https URL the network can fetch; type is image or video; alt_text is up to 2,000 characters.
scheduled_atISO 8601 with offsetWhen to publish, up to a year ahead. Leave out to publish now.
platform_optionsobjectExtra settings some networks need. See Platform options.

A post needs text, media, or both.

curl https://api.postpeg.com/v1/posts \
  -H "Authorization: Bearer $POSTPEG_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: changelog-2026-10-01" \
  -d '{
    "account_ids": [
      "acc_mfz1x0k27d4q8vbn3c5s0a1p9e",
      "acc_mfz1y4r9h2c6t0wqk8e3m7ld5f",
      "acc_mfz20b5n1s8f4j7xv2p9r6hu1c"
    ],
    "content": "Scheduled posts now retry on their own when a network has a bad minute. Changelog: https://example.com/changelog #buildinpublic",
    "media": [
      {
        "url": "https://example.com/changelog.png",
        "type": "image"
      }
    ],
    "scheduled_at": "2026-10-01T09:00:00Z"
  }'
Response · 201 Created
{
  "id": "post_mfz2k3v8q1w5e9r4t7y0u2i6o3",
  "content": "Scheduled posts now retry on their own when a network has a bad minute. Changelog: https://example.com/changelog #buildinpublic",
  "media": [
    {
      "url": "https://example.com/changelog.png",
      "type": "image"
    }
  ],
  "platform_options": {},
  "status": "scheduled",
  "scheduled_at": "2026-10-01T09:00:00.000Z",
  "targets": [
    {
      "account_id": "acc_mfz1x0k27d4q8vbn3c5s0a1p9e",
      "platform": "x",
      "status": "pending",
      "platform_post_id": null,
      "url": null,
      "error": null,
      "attempts": 0,
      "published_at": null
    },
    {
      "account_id": "acc_mfz1y4r9h2c6t0wqk8e3m7ld5f",
      "platform": "linkedin",
      "status": "pending",
      "platform_post_id": null,
      "url": null,
      "error": null,
      "attempts": 0,
      "published_at": null
    },
    {
      "account_id": "acc_mfz20b5n1s8f4j7xv2p9r6hu1c",
      "platform": "instagram",
      "status": "pending",
      "platform_post_id": null,
      "url": null,
      "error": null,
      "attempts": 0,
      "published_at": null
    }
  ],
  "created_at": "2026-09-24T14:02:11.418Z"
}

Now or later

Without scheduled_at, or with a time in the past, the post is queued straight away: the response has status: "publishing" and targets queued. With a future time the post is scheduled and its targets wait as pending until then. Scheduled posts go out within a minute of their time.

Validation

Before anything is stored or sent, the post is checked against every network it targets: text length, required media, video-only networks, image and video counts, and required options. Every problem comes back at once in issues, each with the path of the field to fix. Nothing is published, on any network, until the request is valid.

curl https://api.postpeg.com/v1/posts \
  -H "Authorization: Bearer $POSTPEG_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "account_ids": [
      "acc_mfz1x0k27d4q8vbn3c5s0a1p9e",
      "acc_mfz22d1q5w8e2r6t0y4u7i3o9s",
      "acc_mfz23e4a7s1d5f9g3h6j0k2l8z"
    ],
    "content": "Big week. Scheduled posts now retry on their own, the dashboard shows every target’s status, and cancelling a post is one call. We also rewrote the connect flow so reconnecting an expired login keeps the same account id. Full changelog and upgrade notes: https://example.com/changelog #buildinpublic #api",
    "media": [
      {
        "url": "https://example.com/changelog.png",
        "type": "image"
      }
    ],
    "platform_options": {}
  }'
Response · 400 Bad Request
{
  "type": "https://postpeg.com/errors/invalid_request",
  "title": "Invalid request",
  "status": 400,
  "detail": "X: text is 304 characters, the limit is 280; TikTok: accepts video only; YouTube: accepts video only; YouTube: `platform_options.youtube.title` is required",
  "code": "invalid_request",
  "request_id": "8f14e45f-ceea-467a-9575-1f2a5c3e6b0d",
  "issues": [
    {
      "path": "content",
      "message": "X: text is 304 characters, the limit is 280"
    },
    {
      "path": "media",
      "message": "TikTok: accepts video only"
    },
    {
      "path": "media",
      "message": "YouTube: accepts video only"
    },
    {
      "path": "platform_options.youtube.title",
      "message": "YouTube: `platform_options.youtube.title` is required"
    }
  ]
}

Accounts are checked too: an unknown or disconnected id gives “No connected account …”, and an account whose login expired gives “Account … must be reconnected first”. Malformed fields (a non-https media URL, an unknown key) are reported the same way, with the field’s path.

Platform options

platform_options holds what a network needs beyond text and media, keyed by network. Unknown keys are rejected, so a typo fails loudly instead of doing nothing.

Platform options
FieldDescription
youtube.titlestring, 1–100 · requiredRequired for YouTube: the video’s title.
youtube.privacypublic | unlisted | privateDefault public.
pinterest.board_idstring · requiredRequired for Pinterest. The board to pin to.
pinterest.titlestring, up to 100Pin title.
pinterest.linkURLWhere the pin links to.
tiktok.privacypublic | friends | privateDefault public.
google_business.call_to_actionlearn_more | book | order | shop | sign_up | callButton shown on the post.
google_business.linkURLWhere the button goes.
curl https://api.postpeg.com/v1/posts \
  -H "Authorization: Bearer $POSTPEG_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "account_ids": [
      "acc_mfz23e4a7s1d5f9g3h6j0k2l8z"
    ],
    "content": "What changed in the scheduler this week, in four minutes.",
    "media": [
      {
        "url": "https://example.com/scheduler-update.mp4",
        "type": "video"
      }
    ],
    "platform_options": {
      "youtube": {
        "title": "Scheduler update",
        "privacy": "unlisted"
      }
    }
  }'

Statuses

Each account a post goes to is a target with its own status. The post’s status is worked out from its targets.

Target statuses
FieldDescription
pendingtargetWaiting for its time, or for a retry.
queuedtargetDue, waiting for a publisher.
publishingtargetBeing sent. Videos can stay here while the network processes them.
publishedtargetLive. url and platform_post_id are set.
failedtargetNot published. error says why.
canceledtargetYou cancelled it before it went out.
Post statuses
FieldDescription
scheduledpostTargets are waiting for a future time.
publishingpostAt least one target is on its way.
publishedpostEvery target published.
partially_publishedpostSome targets published, others failed.
failedpostNo target published.
canceledpostEvery target was cancelled.
Response · 200 OK
{
  "id": "post_mfz2k3v8q1w5e9r4t7y0u2i6o3",
  "content": "Scheduled posts now retry on their own when a network has a bad minute. Changelog: https://example.com/changelog #buildinpublic",
  "media": [
    {
      "url": "https://example.com/changelog.png",
      "type": "image"
    }
  ],
  "platform_options": {},
  "status": "partially_published",
  "scheduled_at": "2026-10-01T09:00:00.000Z",
  "targets": [
    {
      "account_id": "acc_mfz1x0k27d4q8vbn3c5s0a1p9e",
      "platform": "x",
      "status": "published",
      "platform_post_id": "1971234567890123456",
      "url": "https://x.com/acme/status/1971234567890123456",
      "error": null,
      "attempts": 1,
      "published_at": "2026-10-01T09:00:04.112Z"
    },
    {
      "account_id": "acc_mfz1y4r9h2c6t0wqk8e3m7ld5f",
      "platform": "linkedin",
      "status": "published",
      "platform_post_id": "urn:li:share:7378123456789012345",
      "url": "https://www.linkedin.com/feed/update/urn:li:share:7378123456789012345",
      "error": null,
      "attempts": 1,
      "published_at": "2026-10-01T09:00:05.870Z"
    },
    {
      "account_id": "acc_mfz20b5n1s8f4j7xv2p9r6hu1c",
      "platform": "instagram",
      "status": "failed",
      "platform_post_id": null,
      "url": null,
      "error": "The account must be reconnected",
      "attempts": 1,
      "published_at": null
    }
  ],
  "created_at": "2026-09-24T14:02:11.418Z"
}

Retries

When a network fails in a way that may pass (a timeout, an outage, a rate limit), the target goes back to pending and is retried, up to 5 attempts in all, waiting 2, 4, 8 and 16 minutes between them, or longer if the network asks. While it waits, error shows the last failure and attempts counts the tries. A refusal that won’t change on retry fails straight away.

If a publish is interrupted after the request may have reached the network, the target is marked failed rather than retried: a duplicate post is worse than a missing one. If a login expired, the account becomes reconnect_required.

Cancelling

DELETE/v1/posts/{id}

Cancels every target that hasn’t started publishing and returns the post. Targets already publishing or published are left as they are. If nothing is left to cancel you get a 409.

curl -X DELETE https://api.postpeg.com/v1/posts/post_mfz2k3v8q1w5e9r4t7y0u2i6o3 \
  -H "Authorization: Bearer $POSTPEG_KEY"
Response · 200 OK
{
  "id": "post_mfz2k3v8q1w5e9r4t7y0u2i6o3",
  "content": "Scheduled posts now retry on their own when a network has a bad minute. Changelog: https://example.com/changelog #buildinpublic",
  "media": [
    {
      "url": "https://example.com/changelog.png",
      "type": "image"
    }
  ],
  "platform_options": {},
  "status": "canceled",
  "scheduled_at": "2026-10-01T09:00:00.000Z",
  "targets": [
    {
      "account_id": "acc_mfz1x0k27d4q8vbn3c5s0a1p9e",
      "platform": "x",
      "status": "canceled",
      "platform_post_id": null,
      "url": null,
      "error": null,
      "attempts": 0,
      "published_at": null
    },
    {
      "account_id": "acc_mfz1y4r9h2c6t0wqk8e3m7ld5f",
      "platform": "linkedin",
      "status": "canceled",
      "platform_post_id": null,
      "url": null,
      "error": null,
      "attempts": 0,
      "published_at": null
    },
    {
      "account_id": "acc_mfz20b5n1s8f4j7xv2p9r6hu1c",
      "platform": "instagram",
      "status": "canceled",
      "platform_post_id": null,
      "url": null,
      "error": null,
      "attempts": 0,
      "published_at": null
    }
  ],
  "created_at": "2026-09-24T14:02:11.418Z"
}

Idempotency keys

Send an Idempotency-Key header (up to 255 characters) with POST /v1/posts. If a request with the same key already created a post, you get that post back with 200 instead of 201, and nothing new is published. Use one key per post you mean to create, such as your own record’s id, so a timeout or a retry on your side never double-posts.

Listing posts

GET/v1/posts

Newest first. limit is 1–100 (default 20); filter with status. When there are more, pass next_cursor back as cursor; it’s null on the last page. With a status filter a page can hold fewer than limit posts, so keep following the cursor until it’s null.

curl https://api.postpeg.com/v1/posts?limit=1 \
  -H "Authorization: Bearer $POSTPEG_KEY"
Response · 200 OK
{
  "data": [
    {
      "id": "post_mfz2k3v8q1w5e9r4t7y0u2i6o3",
      "content": "Scheduled posts now retry on their own when a network has a bad minute. Changelog: https://example.com/changelog #buildinpublic",
      "media": [
        {
          "url": "https://example.com/changelog.png",
          "type": "image"
        }
      ],
      "platform_options": {},
      "status": "published",
      "scheduled_at": "2026-10-01T09:00:00.000Z",
      "targets": [
        {
          "account_id": "acc_mfz1x0k27d4q8vbn3c5s0a1p9e",
          "platform": "x",
          "status": "published",
          "platform_post_id": "1971234567890123456",
          "url": "https://x.com/acme/status/1971234567890123456",
          "error": null,
          "attempts": 1,
          "published_at": "2026-10-01T09:00:04.112Z"
        },
        {
          "account_id": "acc_mfz1y4r9h2c6t0wqk8e3m7ld5f",
          "platform": "linkedin",
          "status": "published",
          "platform_post_id": "urn:li:share:7378123456789012345",
          "url": "https://www.linkedin.com/feed/update/urn:li:share:7378123456789012345",
          "error": null,
          "attempts": 1,
          "published_at": "2026-10-01T09:00:05.870Z"
        },
        {
          "account_id": "acc_mfz20b5n1s8f4j7xv2p9r6hu1c",
          "platform": "instagram",
          "status": "published",
          "platform_post_id": "18061234567890123",
          "url": "https://www.instagram.com/p/DPq7xYzAbCd/",
          "error": null,
          "attempts": 1,
          "published_at": "2026-10-01T09:00:09.304Z"
        }
      ],
      "created_at": "2026-09-24T14:02:11.418Z"
    }
  ],
  "next_cursor": "MjAyNi0wOS0yNFQxNDowMjoxMS40MThafHBvc3RfbWZ6MmszdjhxMXc1ZTlyNHQ3eTB1Mmk2bzM"
}