DocsProduct

    Public API

    Create an API key and use /api/v1 to list brands, manage inbox items, generate post media, create and schedule posts, and add plan slots from another system.

    PostlessSeptember 12, 202614 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 can partially update a brand’s voice profile (brands:write). They cannot create a brand, 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 & delete inbox items, and Create & update posts. Update brand profile, 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

    PermissionWhat it allows
    brands:readList brands, get a brand (voice profile), and list connections
    brands:writePartially update a brand’s voice profile
    inbox:readList inbox items and get one item by id
    inbox:writeAdd or delete inbox items, including image/screenshot/video upload
    posts:writeCreate & update posts, attach inbox media, generate image/video/carousel, schedule
    posts:readList posts, get a post, and poll media generation status
    posts:publishPublish a draft (POST .../posts/:id/publish) or create with publishNow: true
    plans:readGet the current content calendar
    plans:writeAdd 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

    StatusMeaning
    400Invalid JSON body
    401Missing or invalid key
    402No active subscription (subscription_required)
    403missing_permission, brand_not_allowed, or email_not_verified
    404Unknown brand, post, inbox item, or plan. Also used for unfinished or archived brands
    409Attach to a non-draft, edit a published post (post_not_editable), media generation in progress, or upload object missing
    503AI media provider is not configured (media_provider_not_configured)
    422Invalid body, or the post is not ready to schedule/publish
    429Key rate limit (rate_limit_exceeded) or usage cap (limit_exceeded with limit, used, cap)
    502Upstream publish failed

    API keys are limited to 60 requests per 60 seconds. When that limit is hit:

    { "error": "Rate limit exceeded", "code": "rate_limit_exceeded", "retryAfterSec": 12 }
    

    The response also includes a Retry-After header (seconds). Wait that long (or use retryAfterSec) before retrying. Bulk uploads should stay under the limit or back off on 429.

    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",
      "goal": "Become the default API toolkit for indie founders",
      "voice": ["clear", "direct"],
      "audience": "SaaS founders",
      "pillars": ["product", "engineering", "docs"],
      "about": "We help small teams ship public APIs without a platform team.",
      "beliefs": ["Ship small, ship often"],
      "avoidTopics": ["competitor bashing"],
      "samplePosts": ["Most API docs fail because they explain endpoints, not jobs."],
      "styleGuide": "Short sentences. No hype. Prefer concrete examples."
    }
    

    Update a brand profile

    PATCH /api/v1/brands/:brandId — permission brands:write

    Partial update. Send only fields to change. At least one field is required.

    Allowed fields: goal, audience, voice, pillars, about, beliefs, avoidTopics, samplePosts, styleGuide.

    Product minimums apply to fields you send (for example goal/about ≥ 40 characters, audience ≥ 3, at least 3 pillars, at least 1 voice style).

    {
      "voice": ["clear", "practical"],
      "avoidTopics": ["pricing complaints"]
    }
    

    200: same shape as Get a brand.

    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.

    Delete an inbox item

    DELETE /api/v1/brands/:brandId/inbox/:inboxItemId — permission inbox:write

    Permanently deletes one brand-scoped inbox item owned by the key. Storage objects are removed best-effort; content chunks cascade. Returns 404 when the id is missing or not in this brand.

    200:

    { "deleted": true }
    

    Bulk delete inbox items

    POST /api/v1/brands/:brandId/inbox/bulk-delete — permission inbox:write

    {
      "ids": ["…", "…"]
    }
    

    ids is 1–100 UUIDs. Only items in this brand owned by the key are deleted; unknown or out-of-scope ids are listed in skipped with reason: "not_found" (not an error).

    200:

    {
      "deleted": 2,
      "deletedIds": ["…", "…"],
      "skipped": [{ "id": "…", "reason": "not_found" }]
    }
    

    List connections

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

    Returns ACTIVE social accounts for the brand owner. Use providerAccountId as publishConnectionIds / accountIds.

    Pinterest rows also include pinterest (boards catalog, defaultBoardIds, defaultLink) from Settings. Set those defaults in the dashboard; override per post with platformVariants.boardIds / link. There is no public endpoint to edit connection board defaults. Creating or updating a post with boardIds also adds unknown board ids to that connection’s catalog (name falls back to the id) so they show up on later reads — it does not change defaultBoardIds.

    {
      "connections": [
        {
          "providerAccountId": "acc_123",
          "platform": "linkedin",
          "username": "acme",
          "status": "ACTIVE"
        },
        {
          "providerAccountId": "acc_pin",
          "platform": "pinterest",
          "username": "acme",
          "status": "ACTIVE",
          "pinterest": {
            "boards": [{ "id": "123", "name": "Launch" }],
            "defaultBoardIds": ["123"],
            "defaultLink": "https://example.com"
          }
        }
      ]
    }
    

    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
    {
      "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 }.

    1. PUT the file bytes to uploadUrl with the same Content-Type. The body must be exactly contentLength bytes.
    2. POST /api/v1/brands/:brandId/inbox/:inboxItemId/confirm-upload — inbox:write

    200 is the same inbox item shape as JSON create. Images then compress in the background. Videos skip compression. Public API uploads do not run vision / auto-description (dashboard uploads still do). Attach and publish only need the file — they do not wait on compression.

    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"]
    }
    
    FieldNotes
    typeimage, video, or carousel
    prompt10–3000 characters
    aspectRatioOptional: 1:1, 4:5, 9:16, 16:9. Image/carousel default 4:5. Video default 9:16.
    resolutionOptional, video: 720p or 1080p
    targetAccountIdsOptional. Omit to attach to the post’s publish set
    targetPlatformsOptional, max 5
    videoDefaultsOptional video overlays
    storyPackOptional carousel plan
    requestIdOptional 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
    }
    
    FieldNotes
    topicOptional
    bodyShared fallback caption. Instagram/Pinterest may schedule/publish without caption when media is present; other platforms still need body (same gates as the dashboard)

    | platforms / publishConnectionIds | Optional. Defaults to the brand’s publishing defaults | | platformVariants | Optional. Per-connection copy keyed by providerAccountId. See below | | 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, Pinterest needs exactly one image or video per pin account and at least one board). Platform character limits apply on schedule / publishNow / publish even when caption is optional (Instagram/Pinterest with media): a non-empty over-limit body returns 422. Drafts without a schedule may still store over-limit copy until then.

    201:

    {
      "id": "…",
      "brandId": "…",
      "topic": "Search is live",
      "angle": null,
      "body": "We shipped search today. Here is what changed.",
      "status": "queued",
      "approved": true,
      "scheduledAt": "2026-09-12T10:00:00.000Z",
      "publishConnectionIds": ["…"],
      "platformVariants": {
        "acc_linkedin": {
          "body": "We shipped search today. Here is what changed.",
          "platform": "linkedin"
        }
      },
      "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). Ordered by scheduledAt, then publishedAt, then createdAt (each newest first).

    {
      "posts": [{ "id": "…", "status": "draft", "body": "…" }],
      "nextCursor": null
    }
    

    Update a post

    PATCH /api/v1/brands/:brandId/posts/:postId — permission posts:write

    Partial update by post id. Send only fields to change. At least one field is required.

    Allowed fields: topic, body, angle, scheduledAt (ISO future time, or null to unschedule), publishConnectionIds, platformVariants.

    platformVariants is a patch keyed by providerAccountId (from list connections). Each value may include:

    FieldNotes
    bodyCaption / description (required on each variant object you send)
    platformOptional platform key
    titleDiscovery title for Pinterest and YouTube (not TikTok)
    linkPinterest destination URL (optional)
    boardIdsPinterest boards for this pin (optional override of connection defaults)
    mediaPer-account media. Pinterest must have exactly one image or video

    Responses include the same Pin/YouTube fields when set. Use different bodies per connection (for example longer Threads, shorter X). Unknown account ids return 422. Sending boardIds merges new ids into the connection boards catalog (see List connections).

    Cannot edit a published post (409 post_not_editable). Draft, queued, failed, and pending-approval posts are editable. Setting a future scheduledAt requires the post to be ready to schedule (targets, platform media rules, character limits, caption when the platform requires it).

    {
      "topic": "Search is live",
      "body": "We shipped search today. Here is what changed.",
      "platformVariants": {
        "acc_threads": {
          "body": "Longer Threads version with more detail about what we shipped and why it matters.",
          "platform": "threads"
        },
        "acc_x": { "body": "We shipped search. Here’s what changed.", "platform": "x" },
        "acc_pin": {
          "body": "Keyword-rich pin description",
          "platform": "pinterest",
          "title": "Search is live",
          "boardIds": ["123"],
          "link": "https://example.com"
        }
      }
    }
    

    Schedule only:

    {
      "scheduledAt": "2026-09-15T10:00:00.000Z"
    }
    

    200 is the post object (includes angle and platformVariants).

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

    Ready to try Postless?

    Paste your brand link, connect your platforms, and let AI draft the week's posts.

    Free trial included · No charge until your trial ends

    Related docs