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
https://whispering-celene-ousudhinfo-80996519.koyeb.app/api/v1Authentication
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.
Authorization: Bearer bk_your_api_keyA 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;
fieldnames 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.
{
"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
/v1/meReturns 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
curl "https://whispering-celene-ousudhinfo-80996519.koyeb.app/api/v1/me" \
-H "Authorization: Bearer $API_KEY"{
"workspace": {
"id": "6703f1a2c4e5b7a9d1e2f301",
"name": "Acme Marketing"
},
"scopes": [
"posts:read",
"posts:write",
"channels:read",
"media:write"
]
}List channels
/v1/channelsThe 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
curl "https://whispering-celene-ousudhinfo-80996519.koyeb.app/api/v1/channels" \
-H "Authorization: Bearer $API_KEY"[
{
"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
/v1/postsPosts 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.
curl "https://whispering-celene-ousudhinfo-80996519.koyeb.app/api/v1/posts?status=scheduled&limit=10" \
-H "Authorization: Bearer $API_KEY"{
"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
/v1/posts/:postIdOne 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.
curl "https://whispering-celene-ousudhinfo-80996519.koyeb.app/api/v1/posts/6707c9e0b1d2a3f4e5c6d7b8" \
-H "Authorization: Bearer $API_KEY"{
"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
/v1/postsDrafts, 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? }.textandassetsoverride 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.
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"
]
}'{
"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
/v1/posts/:postIdDeletes 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.
curl -X DELETE "https://whispering-celene-ousudhinfo-80996519.koyeb.app/api/v1/posts/6707c9e0b1d2a3f4e5c6d7b8" \
-H "Authorization: Bearer $API_KEY"(empty body)Upload media
/v1/mediaUploads 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.
curl -X POST "https://whispering-celene-ousudhinfo-80996519.koyeb.app/api/v1/media" \
-H "Authorization: Bearer $API_KEY" \
-F "file=@launch.png"{
"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.
{
"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.createdA post is created, in the app or through the API.
{
"workspaceId": "6703f1a2c4e5b7a9d1e2f301",
"postId": "6707c9e0b1d2a3f4e5c6d7b8"
}post.updatedA post is edited, canceled or retried.
{
"workspaceId": "6703f1a2c4e5b7a9d1e2f301",
"postId": "6707c9e0b1d2a3f4e5c6d7b8"
}post.deletedA post is deleted.
{
"workspaceId": "6703f1a2c4e5b7a9d1e2f301",
"postId": "6707c9e0b1d2a3f4e5c6d7b8"
}post.publishedA post went live on one channel. A post on three channels sends three of these.
{
"workspaceId": "6703f1a2c4e5b7a9d1e2f301",
"postId": "6707c9e0b1d2a3f4e5c6d7b8",
"channelId": "6703f1a2c4e5b7a9d1e2f3a1",
"externalPostId": "urn:li:share:7249812345678901234",
"externalUrl": "https://www.linkedin.com/feed/update/urn:li:share:7249812345678901234"
}post.failedA channel gave up publishing a post, after its own retries.
{
"workspaceId": "6703f1a2c4e5b7a9d1e2f301",
"postId": "6707c9e0b1d2a3f4e5c6d7b8",
"channelId": "6703f1a2c4e5b7a9d1e2f3a1",
"error": "The image is larger than LinkedIn allows"
}channel.connectedA channel is connected, or reconnected.
{
"workspaceId": "6703f1a2c4e5b7a9d1e2f301",
"channelId": "6703f1a2c4e5b7a9d1e2f3a1"
}channel.disconnectedA channel's access expired or was revoked and it needs reconnecting.
{
"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
idin the body. It stays the same across retries. X-Webhook-Timestampstring- Unix time in seconds when this attempt was signed.
X-Webhook-Signaturestringsha256=followed by the hex HMAC-SHA256 of{timestamp}.{raw body}, keyed with your signing secret.Content-Typestringapplication/json
{
"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.
signature = hex(HMAC_SHA256(secret, timestamp + "." + rawBody))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
});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 "", 200Retries
- Answer with any
2xxwithin 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
createdAtor 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.