# 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.

- HTML: https://postless.app/docs/public-api
- Published: 2026-09-12
- Author: Postless
- Category: Product
- Tags: founders, creators, agencies, teams, api

Use the public API when another system should send content into Postless without opening the
dashboard. AI assistants should use [MCP](https://postless.app/docs/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

1. Open **Settings → API keys**.
2. Name the key.
3. 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.
4. Optionally limit the key to specific brands. Leave them unchecked to allow every finished brand
   you own. Incomplete and archived brands are never reachable.
5. 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`:

```json
{
  "brands": [{ "id": "11111111-1111-4111-8111-111111111111", "name": "Acme" }]
}
```

## Get a brand

`GET /api/v1/brands/:brandId` — permission `brands:read`

```json
{
  "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.

```json
{
  "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**.

```json
{
  "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`:

```json
{
  "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`:

```json
{
  "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`.

```json
{
  "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**. `type` is `image` or
  `screenshot`.
- Video: MP4 (`video/mp4`), **100 MB or smaller**. `type` is `video`. Confirm does not run image
  compression or vision.

The signed PUT is locked to `contentLength`, so a larger file is rejected by storage.

1. `POST /api/v1/brands/:brandId/inbox/upload-url` — `inbox:write`

```json
{
  "type": "image",
  "filename": "launch.png",
  "contentType": "image/png",
  "contentLength": 48210,
  "title": "Launch still"
}
```

Video:

```json
{
  "type": "video",
  "filename": "clip.mp4",
  "contentType": "video/mp4",
  "contentLength": 2482100
}
```

`201`: `{ inboxItemId, uploadUrl, path, token }`.

2. `PUT` the file bytes to `uploadUrl` with the same `Content-Type`. The body must be exactly
   `contentLength` bytes.
3. `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.

```json
{
  "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`:

```json
{
  "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.

```json
{
  "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:

```json
{
  "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.

```json
{
  "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:

```json
{
  "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`:

```json
{
  "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).

```json
{
  "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.

```json
{
  "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.

```json
{
  "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`:

```json
{
  "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:

```bash
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."}'
```

## Related

- [MCP for AI agents](https://postless.app/docs/mcp)
- [Getting started](https://postless.app/docs/getting-started)
- [Content Inbox](https://postless.app/docs/content-inbox)
- [Scheduling](https://postless.app/docs/scheduling)
