Skip to content

Developers

API reference

Schedule posts, upload media and read your channels from your own code. Every request is authenticated with a workspace API key and answers in plain JSON.

Base URL

Base URL
https://whispering-celene-ousudhinfo-80996519.koyeb.app/api/v1

Authentication

Create a key in Settings → Developers (workspace owners and admins only). It starts with bk_ and is shown once, so store it somewhere safe, like an environment variable. Never ship it in browser or mobile code.

Send it on every request as a bearer token. An X-API-Key header works too.

Header
Authorization: Bearer bk_your_api_key

A key acts for the workspace it was created in. It stops working when it is revoked or expires, and also when the person who created it leaves the workspace or stops being an owner or admin.

Scopes

Each key gets only the scopes you tick when you create it. Calling an endpoint without its scope answers 403.

channels:read
List the workspace's connected channels.
posts:read
List and read posts.
posts:write
Create, schedule and delete posts.
media:write
Upload images, videos and documents.

Rate limits

Each key can make 60 requests a minute. Past that the API answers 429 until the minute is up; the Retry-After header says how many seconds to wait. Posts also count against your plan’s monthly post limit, exactly as they do in the app.

Errors

Successful responses are the resource itself. Errors share one shape; branch on errorCode, not on message, which is written for people.

400VALIDATION_FAILED
A field is missing or invalid; field names it.
401UNAUTHENTICATED
The key is missing, wrong, revoked or expired.
402PLAN_LIMIT
The workspace's plan has no room left, e.g. monthly posts.
403FORBIDDEN
The key lacks the scope this endpoint needs.
404NOT_FOUND
No such post (or it belongs to another workspace).
413PAYLOAD_TOO_LARGE
The upload is over the size limit.
429RATE_LIMITED
More than 60 requests in a minute with this key.
400 · Error response
{
  "success": false,
  "statusCode": 400,
  "message": "channels must be an array",
  "errorCode": "VALIDATION_FAILED",
  "field": "channels",
  "errors": {
    "channels": [
      "channels must be an array"
    ]
  }
}

Check your key

GET/v1/me

Returns the workspace the key belongs to and the scopes it was granted. Needs no scope, so it is the quickest way to test a new key.

Scope: none

Request · cURL
curl "https://whispering-celene-ousudhinfo-80996519.koyeb.app/api/v1/me" \
  -H "Authorization: Bearer $API_KEY"
Response · 200
{
  "workspace": {
    "id": "6703f1a2c4e5b7a9d1e2f301",
    "name": "Acme Marketing"
  },
  "scopes": [
    "posts:read",
    "posts:write",
    "channels:read",
    "media:write"
  ]
}

List channels

GET/v1/channels

The channels you can post to: connected, fully set up and not deleted, oldest first. Use a channel's id when creating a post. refreshNeeded means the network wants the account reconnected in the app before posts will go out.

Scope: channels:read

Request · cURL
curl "https://whispering-celene-ousudhinfo-80996519.koyeb.app/api/v1/channels" \
  -H "Authorization: Bearer $API_KEY"
Response · 200
[
  {
    "id": "6703f1a2c4e5b7a9d1e2f3a1",
    "identifier": "linkedin-page",
    "type": "page",
    "name": "acme",
    "displayName": "Acme Inc.",
    "avatar": "https://cdn.example.com/avatars/acme.png",
    "timezone": "America/New_York",
    "refreshNeeded": false,
    "isQueuePaused": false,
    "groupId": "6703f1a2c4e5b7a9d1e2f3b0",
    "groupName": "Brand accounts"
  }
]

List posts

GET/v1/posts

Posts in the workspace, paginated. By default pinned posts come first, then the newest by scheduled (or created) date.

Scope: posts:read

Query parameters

pageinteger
Page number, from 1. Default 1.
limitinteger
Posts per page, 1–100. Default 20.
statusstring
Comma-separated: draft, scheduled, publishing, published, partially-published, failed, canceled.
channelIdstring
Comma-separated channel ids.
tagIdstring
Only posts with this tag.
groupIdstring
Posts for any channel in this group; "none" for ungrouped channels.
fromISO 8601
Scheduled (or created) on or after.
toISO 8601
Scheduled (or created) on or before.
sortasc | desc
"asc" lists the soonest first and ignores pinning. Default "desc".
searchstring
Matches the post text. Max 200 characters.
Request · cURL
curl "https://whispering-celene-ousudhinfo-80996519.koyeb.app/api/v1/posts?status=scheduled&limit=10" \
  -H "Authorization: Bearer $API_KEY"
Response · 200
{
  "data": [
    {
      "id": "6707c9e0b1d2a3f4e5c6d7b8",
      "text": "We just shipped scheduled posts from the API 🚀",
      "status": "scheduled",
      "isCustomScheduled": true,
      "isPinned": false,
      "sharedNow": false,
      "schedulingType": "custom",
      "assets": [
        {
          "type": "image",
          "url": "https://cdn.example.com/workspaces/6703f1a2c4e5b7a9d1e2f301/2026/10/6707c9d4b1d2a3f4e5c6d790.png",
          "documentId": "6707c9d4b1d2a3f4e5c6d790"
        }
      ],
      "metrics": null,
      "metadata": {
        "source": "api",
        "apiKeyId": "6706aa01b1d2a3f4e5c6d701"
      },
      "error": null,
      "externalLink": null,
      "dueAt": "2026-10-15T09:00:00.000Z",
      "scheduledAt": "2026-10-15T09:00:00.000Z",
      "sentAt": null,
      "publishedAt": null,
      "metricsUpdatedAt": null,
      "authorId": null,
      "workspaceId": "6703f1a2c4e5b7a9d1e2f301",
      "createdAt": "2026-10-10T08:12:44.120Z",
      "updatedAt": "2026-10-10T08:12:44.120Z",
      "deletedAt": null,
      "author": null,
      "channels": [
        {
          "postId": "6707c9e0b1d2a3f4e5c6d7b8",
          "channelId": "6703f1a2c4e5b7a9d1e2f3a1",
          "status": "scheduled",
          "text": null,
          "assets": null,
          "settings": {},
          "attempts": 0,
          "externalPostId": null,
          "externalUrl": null,
          "scheduledAt": "2026-10-15T09:00:00.000Z",
          "publishedAt": null,
          "error": null,
          "metrics": null,
          "metricsUpdatedAt": null,
          "createdAt": "2026-10-10T08:12:44.120Z",
          "updatedAt": "2026-10-10T08:12:44.120Z",
          "channel": {
            "id": "6703f1a2c4e5b7a9d1e2f3a1",
            "name": "acme",
            "displayName": "Acme Inc.",
            "avatar": "https://cdn.example.com/avatars/acme.png",
            "service": "linkedin",
            "type": "page"
          }
        }
      ],
      "tags": [
        {
          "id": "6703f1a2c4e5b7a9d1e2f3c4",
          "name": "Launch",
          "color": "#7F56D9"
        }
      ]
    }
  ],
  "meta": {
    "page": 1,
    "limit": 10,
    "total": 1,
    "totalPages": 1
  }
}

Get a post

GET/v1/posts/:postId

One post with its per-channel delivery status. Once a channel publishes, its externalUrl links to the live post.

Scope: posts:read

Path parameters

postIdstringrequired
The post's id.
Request · cURL
curl "https://whispering-celene-ousudhinfo-80996519.koyeb.app/api/v1/posts/6707c9e0b1d2a3f4e5c6d7b8" \
  -H "Authorization: Bearer $API_KEY"
Response · 200
{
  "id": "6707c9e0b1d2a3f4e5c6d7b8",
  "text": "We just shipped scheduled posts from the API 🚀",
  "status": "scheduled",
  "isCustomScheduled": true,
  "isPinned": false,
  "sharedNow": false,
  "schedulingType": "custom",
  "assets": [
    {
      "type": "image",
      "url": "https://cdn.example.com/workspaces/6703f1a2c4e5b7a9d1e2f301/2026/10/6707c9d4b1d2a3f4e5c6d790.png",
      "documentId": "6707c9d4b1d2a3f4e5c6d790"
    }
  ],
  "metrics": null,
  "metadata": {
    "source": "api",
    "apiKeyId": "6706aa01b1d2a3f4e5c6d701"
  },
  "error": null,
  "externalLink": null,
  "dueAt": "2026-10-15T09:00:00.000Z",
  "scheduledAt": "2026-10-15T09:00:00.000Z",
  "sentAt": null,
  "publishedAt": null,
  "metricsUpdatedAt": null,
  "authorId": null,
  "workspaceId": "6703f1a2c4e5b7a9d1e2f301",
  "createdAt": "2026-10-10T08:12:44.120Z",
  "updatedAt": "2026-10-10T08:12:44.120Z",
  "deletedAt": null,
  "author": null,
  "channels": [
    {
      "postId": "6707c9e0b1d2a3f4e5c6d7b8",
      "channelId": "6703f1a2c4e5b7a9d1e2f3a1",
      "status": "scheduled",
      "text": null,
      "assets": null,
      "settings": {},
      "attempts": 0,
      "externalPostId": null,
      "externalUrl": null,
      "scheduledAt": "2026-10-15T09:00:00.000Z",
      "publishedAt": null,
      "error": null,
      "metrics": null,
      "metricsUpdatedAt": null,
      "createdAt": "2026-10-10T08:12:44.120Z",
      "updatedAt": "2026-10-10T08:12:44.120Z",
      "channel": {
        "id": "6703f1a2c4e5b7a9d1e2f3a1",
        "name": "acme",
        "displayName": "Acme Inc.",
        "avatar": "https://cdn.example.com/avatars/acme.png",
        "service": "linkedin",
        "type": "page"
      }
    }
  ],
  "tags": [
    {
      "id": "6703f1a2c4e5b7a9d1e2f3c4",
      "name": "Launch",
      "color": "#7F56D9"
    }
  ]
}

Create a post

POST/v1/posts

Drafts, schedules, queues or publishes a post on one or more channels. It goes through the same validation, network rules and plan limits as the composer in the app.

Scope: posts:write

Body parameters

action"draft" | "schedule" | "queue" | "now"required
draft saves without sending; schedule sends at scheduledAt; queue takes each channel's next free slot; now publishes right away.
channelsarrayrequired
Up to 50 { channelId, text?, assets?, settings? }. text and assets override the shared ones for that channel; settings.comments (string[]) adds follow-up comments.
textstring
The post text, up to 65,000 characters.
assetsarray
Up to 20 { type, url, documentId?, alt?, thumbnail? }, type one of image, video, document, link. Upload files first with Upload media.
scheduledAtISO 8601
Required when action is "schedule".
tagIdsstring[]
Tags to attach. Unknown ids are rejected.
isPinnedboolean
Pin the post to the top of lists.
shortLinkboolean
Replace links in the text with tracked short links.
Request · cURL
curl -X POST "https://whispering-celene-ousudhinfo-80996519.koyeb.app/api/v1/posts" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "action": "schedule",
  "scheduledAt": "2026-10-15T09:00:00.000Z",
  "text": "We just shipped scheduled posts from the API 🚀",
  "assets": [
    {
      "type": "image",
      "url": "https://cdn.example.com/workspaces/6703f1a2c4e5b7a9d1e2f301/2026/10/6707c9d4b1d2a3f4e5c6d790.png",
      "documentId": "6707c9d4b1d2a3f4e5c6d790"
    }
  ],
  "channels": [
    {
      "channelId": "6703f1a2c4e5b7a9d1e2f3a1"
    }
  ],
  "tagIds": [
    "6703f1a2c4e5b7a9d1e2f3c4"
  ]
}'
Response · 201
{
  "id": "6707c9e0b1d2a3f4e5c6d7b8",
  "text": "We just shipped scheduled posts from the API 🚀",
  "status": "scheduled",
  "isCustomScheduled": true,
  "isPinned": false,
  "sharedNow": false,
  "schedulingType": "custom",
  "assets": [
    {
      "type": "image",
      "url": "https://cdn.example.com/workspaces/6703f1a2c4e5b7a9d1e2f301/2026/10/6707c9d4b1d2a3f4e5c6d790.png",
      "documentId": "6707c9d4b1d2a3f4e5c6d790"
    }
  ],
  "metrics": null,
  "metadata": {
    "source": "api",
    "apiKeyId": "6706aa01b1d2a3f4e5c6d701"
  },
  "error": null,
  "externalLink": null,
  "dueAt": "2026-10-15T09:00:00.000Z",
  "scheduledAt": "2026-10-15T09:00:00.000Z",
  "sentAt": null,
  "publishedAt": null,
  "metricsUpdatedAt": null,
  "authorId": null,
  "workspaceId": "6703f1a2c4e5b7a9d1e2f301",
  "createdAt": "2026-10-10T08:12:44.120Z",
  "updatedAt": "2026-10-10T08:12:44.120Z",
  "deletedAt": null,
  "author": null,
  "channels": [
    {
      "postId": "6707c9e0b1d2a3f4e5c6d7b8",
      "channelId": "6703f1a2c4e5b7a9d1e2f3a1",
      "status": "scheduled",
      "text": null,
      "assets": null,
      "settings": {},
      "attempts": 0,
      "externalPostId": null,
      "externalUrl": null,
      "scheduledAt": "2026-10-15T09:00:00.000Z",
      "publishedAt": null,
      "error": null,
      "metrics": null,
      "metricsUpdatedAt": null,
      "createdAt": "2026-10-10T08:12:44.120Z",
      "updatedAt": "2026-10-10T08:12:44.120Z",
      "channel": {
        "id": "6703f1a2c4e5b7a9d1e2f3a1",
        "name": "acme",
        "displayName": "Acme Inc.",
        "avatar": "https://cdn.example.com/avatars/acme.png",
        "service": "linkedin",
        "type": "page"
      }
    }
  ],
  "tags": [
    {
      "id": "6703f1a2c4e5b7a9d1e2f3c4",
      "name": "Launch",
      "color": "#7F56D9"
    }
  ]
}

Delete a post

DELETE/v1/posts/:postId

Deletes the post and cancels any channel still waiting to publish. Copies already live on a network stay there.

Scope: posts:write

Path parameters

postIdstringrequired
The post's id.
Request · cURL
curl -X DELETE "https://whispering-celene-ousudhinfo-80996519.koyeb.app/api/v1/posts/6707c9e0b1d2a3f4e5c6d7b8" \
  -H "Authorization: Bearer $API_KEY"
Response · 204
(empty body)

Upload media

POST/v1/media

Uploads one file as multipart/form-data in a field named file, and adds it to the workspace's media library. Put the returned url and id (as documentId) in a post's assets.

Scope: media:write

Form fields

filebinaryrequired
An image (up to 10 MB), video or document. The type is checked from the file's contents, not its name.
Request · cURL
curl -X POST "https://whispering-celene-ousudhinfo-80996519.koyeb.app/api/v1/media" \
  -H "Authorization: Bearer $API_KEY" \
  -F "file=@launch.png"
Response · 201
{
  "id": "6707c9d4b1d2a3f4e5c6d790",
  "name": "launch.png",
  "type": "image/png",
  "size": 248311,
  "bucket": "quickpost-media",
  "key": "workspaces/6703f1a2c4e5b7a9d1e2f301/2026/10/6707c9d4b1d2a3f4e5c6d790.png",
  "url": "https://cdn.example.com/workspaces/6703f1a2c4e5b7a9d1e2f301/2026/10/6707c9d4b1d2a3f4e5c6d790.png",
  "uploaderId": "6703f1a2c4e5b7a9d1e2f2ff",
  "workspaceId": "6703f1a2c4e5b7a9d1e2f301",
  "generationId": null,
  "createdAt": "2026-10-10T08:12:30.004Z",
  "updatedAt": "2026-10-10T08:12:30.004Z"
}

Webhooks

Instead of polling the API, let Postreo tell you when something happens: a post goes live, a channel needs reconnecting. Each event is sent as a signed POST with a JSON body to a URL you choose.

Add an endpoint in Settings → Developers: give it a public https:// URL, pick the events it should get and, optionally, the channels. You then see its signing secret (it starts with whsec_) once. Store it the way you would an API key. Send test delivers a ping event so you can check your endpoint, and Rotate secret issues a new one.

Test delivery · ping
{
  "id": "6707d1f3b1d2a3f4e5c6d7c9",
  "event": "ping",
  "createdAt": "2026-10-10T08:20:00.000Z",
  "data": {
    "webhookId": "6706ab02b1d2a3f4e5c6d702",
    "workspaceId": "6703f1a2c4e5b7a9d1e2f301"
  }
}

Events

data holds ids, not whole objects. Fetch the latest state with Get a post or List channels when you need more. If you limit an endpoint to some channels, events about a channel only arrive for those channels, while post.created, post.updated and post.deleted name no channel and always arrive.

post.created

A post is created, in the app or through the API.

data · post.created
{
  "workspaceId": "6703f1a2c4e5b7a9d1e2f301",
  "postId": "6707c9e0b1d2a3f4e5c6d7b8"
}
post.updated

A post is edited, canceled or retried.

data · post.updated
{
  "workspaceId": "6703f1a2c4e5b7a9d1e2f301",
  "postId": "6707c9e0b1d2a3f4e5c6d7b8"
}
post.deleted

A post is deleted.

data · post.deleted
{
  "workspaceId": "6703f1a2c4e5b7a9d1e2f301",
  "postId": "6707c9e0b1d2a3f4e5c6d7b8"
}
post.published

A post went live on one channel. A post on three channels sends three of these.

data · post.published
{
  "workspaceId": "6703f1a2c4e5b7a9d1e2f301",
  "postId": "6707c9e0b1d2a3f4e5c6d7b8",
  "channelId": "6703f1a2c4e5b7a9d1e2f3a1",
  "externalPostId": "urn:li:share:7249812345678901234",
  "externalUrl": "https://www.linkedin.com/feed/update/urn:li:share:7249812345678901234"
}
post.failed

A channel gave up publishing a post, after its own retries.

data · post.failed
{
  "workspaceId": "6703f1a2c4e5b7a9d1e2f301",
  "postId": "6707c9e0b1d2a3f4e5c6d7b8",
  "channelId": "6703f1a2c4e5b7a9d1e2f3a1",
  "error": "The image is larger than LinkedIn allows"
}
channel.connected

A channel is connected, or reconnected.

data · channel.connected
{
  "workspaceId": "6703f1a2c4e5b7a9d1e2f301",
  "channelId": "6703f1a2c4e5b7a9d1e2f3a1"
}
channel.disconnected

A channel's access expired or was revoked and it needs reconnecting.

data · channel.disconnected
{
  "workspaceId": "6703f1a2c4e5b7a9d1e2f301",
  "channelId": "6703f1a2c4e5b7a9d1e2f3a1"
}

Request format

Every delivery has the same envelope: a delivery id, the event name, when it was sent, and the event’s data. These headers come with it:

Headers

X-Webhook-Eventstring
The event name, e.g. post.published.
X-Webhook-Idstring
The delivery's id, the same as id in the body. It stays the same across retries.
X-Webhook-Timestampstring
Unix time in seconds when this attempt was signed.
X-Webhook-Signaturestring
sha256= followed by the hex HMAC-SHA256 of {timestamp}.{raw body}, keyed with your signing secret.
Content-Typestring
application/json
Request body · post.published
{
  "id": "4821",
  "event": "post.published",
  "createdAt": "2026-10-15T09:00:04.512Z",
  "data": {
    "workspaceId": "6703f1a2c4e5b7a9d1e2f301",
    "postId": "6707c9e0b1d2a3f4e5c6d7b8",
    "channelId": "6703f1a2c4e5b7a9d1e2f3a1",
    "externalPostId": "urn:li:share:7249812345678901234",
    "externalUrl": "https://www.linkedin.com/feed/update/urn:li:share:7249812345678901234"
  }
}

Verifying signatures

Check every request before you trust it. Compute an HMAC-SHA256 of the timestamp header, a dot, and the raw request body, using the whole signing secret as the key. Compare it, in constant time, with the hex value after sha256= in X-Webhook-Signature.

Parse the JSON only after the check passes: parsing and re-serializing changes the bytes, and the signature no longer matches. Rejecting timestamps more than five minutes old stops someone replaying a delivery they captured.

Signed content
signature = hex(HMAC_SHA256(secret, timestamp + "." + rawBody))
Node.js · Express
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.WEBHOOK_SECRET; // the whole whsec_… value

// Sign the raw bytes: re-serialized JSON won't match the signature.
app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  const timestamp = req.get("X-Webhook-Timestamp") ?? "";
  const signature = req.get("X-Webhook-Signature") ?? "";
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", SECRET).update(`${timestamp}.${req.body}`).digest("hex");

  const valid =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
  if (!valid || !fresh) return res.sendStatus(401);

  const { id, event, data } = JSON.parse(req.body);
  // Retries reuse the same id: skip deliveries you've already handled.
  res.sendStatus(200); // answer quickly, then do the slow work
});
Python · Flask
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["WEBHOOK_SECRET"].encode()  # the whole whsec_… value


@app.post("/webhooks")
def webhook():
    timestamp = request.headers.get("X-Webhook-Timestamp", "")
    signature = request.headers.get("X-Webhook-Signature", "")
    body = request.get_data()  # raw bytes, exactly as signed
    expected = "sha256=" + hmac.new(
        SECRET, timestamp.encode() + b"." + body, hashlib.sha256
    ).hexdigest()

    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        abort(401)
    if not hmac.compare_digest(signature, expected):
        abort(401)

    delivery = json.loads(body)
    # Retries reuse delivery["id"]: skip ones you've already handled.
    return "", 200

Retries

  • Answer with any 2xx within 10 seconds. Anything else counts as a failure: other status codes, timeouts and redirects, which are not followed. Reply first, then do slow work in the background.
  • A failed delivery is tried up to 5 times in all, waiting about 30 seconds, then 1, 2 and 4 minutes. Retries keep the same id, so use it to ignore duplicates.
  • Deliveries can arrive out of order. Use createdAt or fetch the current state rather than relying on order.
  • After 50 failed attempts in a row the endpoint is switched off. Flip its switch back on in Settings → Developers once it is fixed. Every attempt, with its status code and error, is in the endpoint’s Delivery log.