Public API
Create an API key and use /api/v1 to list brands, upload inbox media, generate post media, create and schedule posts, and add plan slots from another system.
PostlessSeptember 12, 202611 min read
Use the public API when another system should send content into Postless without opening the dashboard. AI assistants should use MCP instead; it uses the same keys and permissions.
The API can manage content on a brand that already exists. Keys cannot create a brand, change brand settings, connect social accounts, change billing, delete your account, or create or revoke API keys.
Create a key
- Open Settings → API keys.
- Name the key.
- Choose permissions. Defaults are List brands, Add inbox items, and Create posts. Read inbox, Read posts, Read content plan, and Add plan slots are off unless you turn them on. Turn on Publish posts only if the key should publish a draft immediately.
- Optionally limit the key to specific brands. Leave them unchecked to allow every finished brand you own. Incomplete and archived brands are never reachable.
- Click Create key and copy the secret. It is shown once. Disable or revoke later from the same Settings page. The public API cannot mint or revoke keys.
Call the API with:
Authorization: Bearer pl_...
x-api-key: pl_... also works. Call from your server, not from a browser page.
Base URL: https://dashboard.postless.app (or your Postless host).
Permissions
| Permission | What it allows |
|---|---|
brands:read | List brands, get a brand (voice, audience, pillars), and list connections |
inbox:read | List inbox items and get one item by id |
inbox:write | Add an inbox item, including image/screenshot/video upload |
posts:write | Create a post, attach inbox media, generate image/video/carousel, schedule a draft |
posts:read | List posts, get a post, and poll media generation status |
posts:publish | Publish a draft (POST .../posts/:id/publish) or create with publishNow: true |
plans:read | Get the current content calendar |
plans:write | Add a slot to the current plan |
Same subscription and usage limits as the dashboard apply. Keys are also limited to 60 requests per minute.
Bodies are JSON. Most errors look like { "error": "…" }. Some also include code.
Errors
| Status | Meaning |
|---|---|
400 | Invalid JSON body |
401 | Missing or invalid key |
402 | No active subscription (subscription_required) |
403 | missing_permission, brand_not_allowed, or email_not_verified |
404 | Unknown brand, post, inbox item, or plan. Also used for unfinished or archived brands |
409 | Attach or schedule a non-draft, media generation in progress, or upload object missing |
503 | AI media provider is not configured (media_provider_not_configured) |
422 | Invalid body, or the post is not ready to schedule/publish |
429 | Key rate limit (rate_limit_exceeded) or usage cap (limit_exceeded with limit, used, cap) |
502 | Upstream publish failed |
Inbox JSON create also uses 422 with code: use_upload_url (image/screenshot/video) or
generated_not_allowed.
List brands
GET /api/v1/brands — permission brands:read
200:
{
"brands": [{ "id": "11111111-1111-4111-8111-111111111111", "name": "Acme" }]
}
Get a brand
GET /api/v1/brands/:brandId — permission brands:read
{
"id": "…",
"name": "Acme",
"voice": ["clear", "direct"],
"audience": "SaaS founders",
"pillars": ["product", "engineering"]
}
List inbox items
GET /api/v1/brands/:brandId/inbox — permission inbox:read
Query: type (text, link, article, idea, work_update, feedback, screenshot, image,
video, generated), cursor, limit (1–50, default 20). Newest first.
{
"items": [
{
"id": "…",
"brandId": "…",
"type": "idea",
"title": "API idea",
"rawContent": "Ship a public API",
"sourceUrl": null,
"mediaPath": null,
"mediaType": null,
"uploadStatus": "ready",
"createdAt": "2026-09-10T12:00:00.000Z"
}
],
"nextCursor": null
}
GET /api/v1/brands/:brandId/inbox/:inboxItemId — permission inbox:read. One item, same shape as
an items[] entry.
Add an inbox item
POST /api/v1/brands/:brandId/inbox — permission inbox:write
JSON create matches dashboard Add New for text types. Send type plus at least one of title,
rawContent, or sourceUrl (except Work Update and Customer Feedback, which may be empty
like the dashboard). sourceUrl is optional on Quick Note, Content Idea, Article, and
Link.
{
"type": "link",
"title": "We shipped search",
"rawContent": "Notes for later drafts.",
"sourceUrl": "https://example.com/search"
}
type is one of text, link, article, idea, work_update, feedback.
Work update uses the same rawContent string as the dashboard (What happened plus
What I learned:). Feedback uses rawContent plus optional metadata.source:
{
"type": "feedback",
"rawContent": "Checkout felt slow on mobile",
"metadata": { "source": "App Store" }
}
generated is created only by AI in the dashboard, not Add New and not this endpoint. Image,
screenshot, and video use the upload flow below, not this JSON body (422 use_upload_url).
201:
{
"id": "…",
"brandId": "…",
"type": "link",
"title": "We shipped search",
"rawContent": "Notes for later drafts.",
"sourceUrl": "https://example.com/search",
"mediaPath": null,
"mediaType": null,
"uploadStatus": null,
"createdAt": "2026-09-10T12:00:00.000Z"
}
Feedback includes metadata when you send it. Media types set mediaPath, mediaType, and
uploadStatus after the upload flow.
List connections
GET /api/v1/brands/:brandId/connections — permission brands:read
Returns ACTIVE social accounts for the brand owner. Use providerAccountId as
publishConnectionIds / accountIds.
{
"connections": [
{
"providerAccountId": "acc_123",
"platform": "linkedin",
"username": "acme",
"status": "ACTIVE"
}
]
}
Upload inbox media
Same three steps as dashboard Add New: one signed URL, PUT the file, confirm. One file per request.
- Images / screenshots: PNG, JPEG, WEBP, or GIF, 10 MB or smaller.
typeisimageorscreenshot. - Video: MP4 (
video/mp4), 100 MB or smaller.typeisvideo. Confirm does not run image compression or vision.
The signed PUT is locked to contentLength, so a larger file is rejected by storage.
POST /api/v1/brands/:brandId/inbox/upload-url—inbox:write
{
"type": "image",
"filename": "launch.png",
"contentType": "image/png",
"contentLength": 48210,
"title": "Launch still"
}
Video:
{
"type": "video",
"filename": "clip.mp4",
"contentType": "video/mp4",
"contentLength": 2482100
}
201: { inboxItemId, uploadUrl, path, token }.
PUTthe file bytes touploadUrlwith the sameContent-Type. The body must be exactlycontentLengthbytes.POST /api/v1/brands/:brandId/inbox/:inboxItemId/confirm-upload—inbox:write
200 is the same inbox item shape as JSON create. Images then compress and run vision in the
background (vision waits until compression finishes). Videos skip both.
Confirm on a still includes metadata.visionStatus: "pending". Attach and publish do not wait
for vision — they only copy the file. Vision is a background description for dashboard Create
Post / planner generate. Poll GET /api/v1/brands/:brandId/inbox/:inboxItemId (inbox:read) if
you need metadata.visionStatus. A missing description does not fail the upload.
Confirm errors: 404 unknown item, 400 no pending upload, 409 object missing in storage.
Generate post media
POST /api/v1/brands/:brandId/posts/:postId/media/generate — permission posts:write
Same job as dashboard Generate with AI. The post must exist. Send a prompt; do not fold this into create. Image, video, and carousel all run in the background.
{
"type": "image",
"prompt": "A clean product still of a search bar on a laptop, soft daylight, no text.",
"aspectRatio": "4:5",
"targetAccountIds": ["acc_123"]
}
| Field | Notes |
|---|---|
type | image, video, or carousel |
prompt | 10–3000 characters |
aspectRatio | Optional: 1:1, 4:5, 9:16, 16:9. Image/carousel default 4:5. Video default 9:16. |
resolution | Optional, video: 720p or 1080p |
targetAccountIds | Optional. Omit to attach to the post’s publish set |
targetPlatforms | Optional, max 5 |
videoDefaults | Optional video overlays |
storyPack | Optional carousel plan |
requestId | Optional UUID; echoed so you can reconcile polls |
202:
{
"requestId": "…",
"postId": "…",
"type": "image",
"status": "queued",
"pollUrl": "/api/v1/brands/…/posts/…/media/generate"
}
GET the same URL — permission posts:read — until succeeded or failed. Idle posts return
{ "postId": "…", "status": "idle" }. On success the file is already attached to the post; GET
the post for media[].
Brand aiImagesEnabled / aiVideosEnabled, weekly quota, and provider config still apply. 409
media_generation_disabled or media_generation_in_progress. 429 media_quota_exceeded. 503
media_provider_not_configured.
This does not invent a prompt. The caller writes it.
Attach inbox media to a draft
PUT /api/v1/brands/:brandId/posts/:postId/media — permission posts:write
The post must be a draft. This does not upload a file and does not publish. It copies inbox items you already uploaded onto the post.
{
"inboxItemIds": ["…"],
"accountIds": ["acc_123"]
}
Omit accountIds to attach to every compatible account in the post’s publish set (brand
defaults, or publishConnectionIds on the post). Stills skip YouTube. Videos attach to YouTube. If
nothing compatible remains, 422. Cap is 20 inbox items.
200 is the post object plus:
{
"attachedAccountIds": ["acc_123"],
"skippedAccountIds": ["yt_456"]
}
skippedAccountIds are YouTube (or other video-only) accounts skipped for stills. That does not
remove them from publish targets. If YouTube is still a target and you only attached stills, publish
will fail until you attach a real video or drop YouTube from publishConnectionIds.
409 if the post is not a draft. 422 if an inbox id is missing or has no file.
Create a post
POST /api/v1/brands/:brandId/posts — permission posts:write
This is the same as Create Post in the dashboard: you send finished copy. It is not AI generation. Posts are always approved. Brand “require approval” does not apply.
{
"topic": "Search is live",
"body": "We shipped search today. Here is what changed.",
"platforms": ["linkedin", "x"],
"scheduledAt": "2026-09-12T10:00:00.000Z",
"publishNow": false
}
| Field | Notes |
|---|---|
topic | Optional |
body | Required to schedule or publish now. Omit it to create an empty draft |
platforms / publishConnectionIds | Optional. Defaults to the brand’s publishing defaults |
scheduledAt | Future ISO time → queued. Omit → draft |
inboxItemIds | Optional. After the file is in the inbox, attach those items in this same call |
accountIds | Optional with inboxItemIds. Same targeting as PUT attach |
publishNow | true publishes this new post in the same request (after attach, if any). Needs posts:publish. Ignores scheduledAt |
Create → attach → publish in one JSON call after you have already uploaded the file:
{
"body": "We shipped search today.",
"inboxItemIds": ["…"],
"publishNow": true
}
publishNow on create is for a new post. POST .../posts/:postId/publish is for a draft that
already exists (you attached media later, or you created the draft first). Neither attach endpoint
publishes. Same publish gates as the dashboard (Instagram needs media, YouTube needs video, TikTok
rejects mixed image+video).
201:
{
"id": "…",
"brandId": "…",
"topic": "Search is live",
"body": "We shipped search today. Here is what changed.",
"status": "queued",
"approved": true,
"scheduledAt": "2026-09-12T10:00:00.000Z",
"publishConnectionIds": ["…"],
"media": [{ "path": "…/launch.png", "type": "image/png" }],
"createdAt": "2026-09-10T12:00:00.000Z"
}
media[] items are { path, type } and thumbnailPath when a cover exists. When inboxItemIds is
sent, the response also includes attachedAccountIds and skippedAccountIds. Attach failure after
create returns that error plus the created post so you can retry PUT .../media.
List posts
GET /api/v1/brands/:brandId/posts — permission posts:read
Query: status (draft, queued, published, failed), from / to (ISO, filters
scheduledAt), cursor, limit (1–50, default 20).
{
"posts": [{ "id": "…", "status": "draft", "body": "…" }],
"nextCursor": null
}
Schedule a draft
PATCH /api/v1/brands/:brandId/posts/:postId — permission posts:write
The post must be a draft with a body that is ready to schedule.
{
"scheduledAt": "2026-09-15T10:00:00.000Z"
}
200 is the post (status typically queued). 409 post_not_draft if it is not a draft.
Publish a draft
POST /api/v1/brands/:brandId/posts/:postId/publish — permission posts:publish
Publishes an existing draft immediately. Same gates as publishNow on create. 200 is the post
object (status typically published or failed). 409 if it is already published. 422 if
caption or media gates fail. 502 if the publisher rejects the post.
Get a post
GET /api/v1/brands/:brandId/posts/:postId — permission posts:read
Returns the same post object as create.
Get the content calendar
GET /api/v1/brands/:brandId/plan — permission plans:read
Returns the active plan and its slots (id, day, platform, topic, format, scheduledAt,
slotDate, postId). 404 if the brand has no active plan.
Add a plan slot
POST /api/v1/brands/:brandId/plan/slots — permission plans:write
Adds one idea to the plan that already exists. It does not regenerate the week.
{
"topic": "What we learned shipping search",
"day": "monday",
"format": "hot_take",
"scheduledAt": "2026-09-15T10:00:00.000Z"
}
topic is required. day, format, and scheduledAt are optional. Omit day to use today. A
named day is the next occurrence of that weekday (today rolls to next week). Omit format to use
insight. Multiple cards can share the same day. 404 if the brand has no active plan.
201:
{
"id": "…",
"planId": "…",
"day": "monday",
"topic": "What we learned shipping search",
"format": "hot_take",
"scheduledAt": "2026-09-15T10:00:00.000Z",
"slotDate": "2026-09-15"
}
Example
List brands, then create a draft:
curl https://dashboard.postless.app/api/v1/brands \
-H "Authorization: Bearer pl_YOUR_KEY"
curl https://dashboard.postless.app/api/v1/brands/BRAND_ID/posts \
-H "Authorization: Bearer pl_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"topic":"Search is live","body":"We shipped search today."}'