---
title: "Publish or schedule a multi-target post"
method: POST
path: "/v1/posts"
tags: ["Posts"]
---

# Publish or schedule a multi-target post

`POST /v1/posts`

## Request

- Base URL: `https://api.letmepost.dev`
- URL: `https://api.letmepost.dev/v1/posts`
- Auth: HTTP bearer

Publish a single body to up to 25 connected accounts in one call, or schedule the whole batch for a future time.

- **Multi-target only:** the v1 wire shape requires `targets[]`. The legacy `account: {}` single-target shape is no longer accepted.
- **Idempotent:** include `Idempotency-Key`. Replays within 24 hours return the original response.
- **Preflight:** every documented platform constraint runs locally before the upstream call. Cheap shape preflight is atomic across the batch — if any target fails the synchronous checks the whole call is rejected as `400 preflight_failed`. Deep checks (URL reachability, MIME sniffing, byte caps) run per target and surface inside `results[i].error`, producing a `partial_failed` batch rather than a 400.
- **Errors:** failures use the [stable error envelope](https://letmepost.dev/docs/errors/) — `code`, `rule`, `platformResponse`, `remediation`, `docUrl`, `ruleUrl`.

For per-platform payload differences, see the [platforms guides](https://letmepost.dev/docs/platforms/bluesky/).

## Headers

- `Idempotency-Key` string

## Request body

- CreatePostRequest: Multi-target publish request — fans a single body out to up to 25 connected accounts in one call. The legacy single-target `account: {}` shape is no longer accepted.
  - `targets` PostTarget[], required
    - `accountId` string, uuid: letmepost platform_account id. When omitted, `platform` must be set and the org must have exactly one connected account for that platform.
    - `platform` 'bluesky' | 'facebook' | 'instagram' | 'linkedin' | 'pinterest' | 'threads' | 'tiktok' | 'twitter': Supported platforms. `tiktok` posts upload to the creator's TikTok inbox as a draft that the creator confirms in the TikTok app; Direct Post is not implemented yet.
    - `text` string: Per-target text override. Falls back to the top-level `text`.
    - `media` MediaInput[]: Per-target media override. Falls back to the top-level `media`.
      - `kind` 'image' | 'video', required
      - `altText` string: Per-platform alt-text caps apply (e.g. Bluesky 2000, Twitter 1000).
      - `mediaId` string: References a previous `POST /v1/media` upload. Preferred for production — bytes already live on our CDN.
      - `url` string, uri: Public URL we fetch from your CDN at publish time. Must respond 200 to anonymous GET.
      - `bytesBase64` string: Inline base64. Convenient for tiny images and tests; use `POST /v1/media` for anything substantial.
    - `firstComment` FirstComment: Auto-posted reply to the published post. Bluesky-only today; ignored on platforms that don't support it.
      - `text` string, required
    - `options` union: Per-target platform options. Discriminated on `platform`; the discriminator must match the resolved account's platform or the request fails with `targets.options.platform_mismatch`. Replaces the v0 top-level `pinterest` / `threads` / `twitter` keys.
      - object
        - `platform` 'twitter', required
        - `replyToTweetId` string: Reply under this tweet id.
        - `quoteTweetId` string: Quote this tweet id. Mutually exclusive with `replyToTweetId` — X rejects tweets that combine the two.
      - object
        - `platform` 'pinterest', required
        - `boardId` string: Pinterest board to pin to. Falls back to the account's default board when omitted.
        - `destinationUrl` string, uri: Outbound link attached to the pin.
        - `title` string
        - `coverImageUrl` string, uri: Cover image URL for video pins.
      - object
        - `platform` 'threads', required
        - `replyToId` string: Threads thread id to reply under.
      - object
        - `platform` 'tiktok', required
        - `privacy` 'public_to_everyone' | 'mutual_follow_friend' | 'self_only': Privacy level requested for the TikTok post. Currently always rewritten to `self_only` — posts route through the upload-inbox endpoint, which carries no privacy level, and the creator chooses one when confirming the draft. Preflight emits a `tiktok.audit.self_only` warning whenever it rewrites the value. Defaults to `self_only`.
        - `disableComment` boolean: Disable comments on the video. Defaults to `false`.
        - `disableDuet` boolean: Disable Duet on the video. Defaults to `false`.
        - `disableStitch` boolean: Disable Stitch on the video. Defaults to `false`.
        - `brandContentToggle` boolean: Paid-partnership branded-content disclosure. Mutually exclusive with `brandOrganicToggle` (enforced by TikTok at publish); requires the account to have agreed to TikTok's branded-content rules. Defaults to `false`.
        - `brandOrganicToggle` boolean: "Your own brand" branded-content disclosure. Requires the account to have agreed to TikTok's branded-content rules. Defaults to `false`.
      - object
        - `platform` 'bluesky', required
        - `replyToUri` string: AT-URI of the post being replied to. Must be sent together with `replyToCid` (a strong ref to the parent).
        - `replyToCid` string: CID of the post being replied to. Must be sent together with `replyToUri`.
        - `replyRootUri` string: AT-URI of the thread root. Must be sent together with `replyRootCid` and requires `replyToUri` + `replyToCid`. Omit to reply to a top-level post — root defaults to the parent.
        - `replyRootCid` string: CID of the thread root. Must be sent together with `replyRootUri`.
      - object
        - `platform` 'facebook', required
      - object
        - `platform` 'instagram', required
      - object
        - `platform` 'linkedin', required
  - `text` string: Default text applied to any target that omits its own `text`.
  - `media` MediaInput[]: Default media applied to any target that omits its own `media`.
    - `kind` 'image' | 'video', required
    - `altText` string: Per-platform alt-text caps apply (e.g. Bluesky 2000, Twitter 1000).
    - `mediaId` string: References a previous `POST /v1/media` upload. Preferred for production — bytes already live on our CDN.
    - `url` string, uri: Public URL we fetch from your CDN at publish time. Must respond 200 to anonymous GET.
    - `bytesBase64` string: Inline base64. Convenient for tiny images and tests; use `POST /v1/media` for anything substantial.
  - `firstComment` FirstComment: Auto-posted reply to the published post. Bluesky-only today; ignored on platforms that don't support it.
    - `text` string, required
  - `publishNow` boolean: Explicit immediate-publish mode. Mutually exclusive with `scheduledAt` (`mode_conflict`). When neither is set the request publishes immediately.
  - `scheduledAt` string, date-time: ISO-8601 timestamp at least 1 second in the future. When set, every target row is queued and the response is 202. Scheduled posts accept text and media (media refs persist and the worker reads them back at fire time); `firstComment` (rule `scheduledAt.no_first_comment`) and Bluesky reply threading (rule `scheduledAt.no_bluesky_reply`) require an immediate publish.
  - `profileId` string, uuid: Profile scope for this batch. Use to target a specific workspace when the API key or OAuth token is org-wide. Forbidden when the key is already scoped to a different profile (rule `profile.scope_mismatch`). Omit to fall back to the key's bound profile.

## Response `200`

Immediate publish completed. The batch envelope's `status` is `published` (all targets succeeded), `partial_failed` (mixed outcomes), or `failed` (every target failed). Per-target detail lives inside `results[]`.

- CreatePostResponse: Batch envelope returned by `POST /v1/posts`. `status` summarizes the per-target outcomes.
  - `id` string, required: Batch id — unique per request, ties the per-target rows together for audit.
  - `status` 'queued' | 'published' | 'partial_failed' | 'failed', required: `queued`: all targets queued for a scheduled publish (202). `published`: every target succeeded. `partial_failed`: mixed outcomes. `failed`: every target failed.
  - `createdAt` string, date-time, required
  - `scheduledAt` string, date-time: Set when the batch was scheduled (202 responses).
  - `results` PostTargetResult[], required
    - `accountId` string, required: Echo of the resolved account id for this target.
    - `platform` 'bluesky' | 'facebook' | 'instagram' | 'linkedin' | 'pinterest' | 'threads' | 'tiktok' | 'twitter', required: Supported platforms. `tiktok` posts upload to the creator's TikTok inbox as a draft that the creator confirms in the TikTok app; Direct Post is not implemented yet.
    - `postId` string: letmepost-side post row id for this target. Use with `GET /v1/posts/{id}`.
    - `status` 'queued' | 'validated' | 'publishing' | 'published' | 'failed' | 'rejected' | 'canceled', required: Lifecycle state of a single per-target post row. `canceled` is the terminal state reached via `DELETE /v1/posts/{id}` on a queued scheduled post.
    - `uri` string: Platform-native URI (e.g. AT Proto `at://…` for Bluesky, post URN for LinkedIn).
    - `cid` string: Content id (Bluesky only).
    - `firstCommentUri` string
    - `firstCommentCid` string
    - `warnings` object[]
      - `code` string, required
      - `message` string, required
    - `error` object: Per-target failure detail. Present only when `status` is `rejected` or `failed`.
      - `code` 'validation_failed' | 'preflight_failed' | 'platform_auth_failed' | 'platform_rejected' | 'platform_unavailable' | 'platform_not_enabled' | 'internal_error' | 'unauthenticated' | 'unauthorized' | 'not_found' | 'idempotency_conflict' | 'rate_limited' | 'quota_exceeded', required: Canonical error codes. See https://letmepost.dev/docs/errors/ for per-code documentation.
      - `message` string, required
      - `rule` string
      - `remediation` string
      - `platformResponse` unknown

## Other responses

- `202`: Scheduled batch accepted; every target row is queued and will publish at `scheduledAt`.
- `400`: `validation_failed` — request body or query parameters didn't match the schema. Rules surfaced include `body.json`, `targets.required`, `targets.max`, `mode_conflict`, `targets.options.platform_mismatch`, `targets.account.platform_mismatch`, `target.account.not_connected`, `target.account.ambiguous`, `scheduledAt.future`, `scheduledAt.no_first_comment`, `scheduledAt.no_bluesky_reply`.
- `401`: `unauthenticated` — missing or invalid Bearer key.
- `404`: `not_found` — the resource does not exist in the caller's organization (or, for profile-scoped keys, in the caller's profile).
- `409`: `idempotency_conflict` — the same `Idempotency-Key` was used with a different request body. Use a new key, or retry with the original body.
- `429`: `rate_limited` — per-key rate limit exhausted; honor `Retry-After`. Or `quota_exceeded` (rule `billing.posts.monthly_cap`) — the request would push the organization over its monthly post quota. `platformResponse` carries `period`, `quota`, and `resetAt`; upgrade the plan or wait for the reset.
- `500`: `internal_error` — something on our end failed unexpectedly. Quote the `requestId` when reporting.
- `502`: `platform_auth_failed`, `platform_rejected`, `platform_unavailable`, or `platform_not_enabled` — the upstream platform refused the call. `platformResponse` carries the raw upstream payload when available.

---

[API](https://skmtc.dev/letmepost/apis/letmepost-dev-api.md) · [All operations](https://skmtc.dev/letmepost/apis/letmepost-dev-api/llms.txt) · [OpenAPI document](https://skmtc.dev/letmepost/apis/letmepost-dev-api/revisions/f6659001f96b?raw)
