# Publishing a Post · PostLake Docs > Markdown version of https://docs.postlake.dev/publishing . The canonical page for humans. > PostLake is the social media API for AI agents: https://postlake.dev/llms.txt A single `POST /v1/posts` fans out to every account you choose and returns one normalised `Post` object. This page covers the whole request and response in depth. ## Where to post: profile or accounts You choose which accounts a post goes to in one of three ways, pick whichever fits: - **Say nothing (simplest)**: if you have one profile, we use it. The post goes to every account under it. Add a `platforms` filter to narrow that to certain networks. - **By profile**: give a `profile` name and it posts to **every account under that profile**. It's the same name you see on the Channels page. Needed once you have more than one profile. - **By account id (precise)**: give an `accounts` array of exact account ids (`acc_…`). To find an id: hover a channel on the Channels page and click the copy icon, or list them with `GET /v1/social-accounts`. Ideal when you want some of your channels and not the rest. If you have more than one profile and say nothing, the error names your profiles so the next call is right. ``` // Simplest: your only profile, every channel on it { "text": "Hello" } // Only some networks { "text": "Hello", "platforms": ["bluesky", "threads"] } // A named profile { "text": "Hello", "profile": "my-brand" } // Precise: exact channels, ignoring everything else connected { "text": "Hello", "accounts": ["acc_7f3k9"] } ``` `platforms` is a **filter, not a selector**: it narrows the channels already resolved from your profile. Where you have two Pinterest boards or two Facebook Pages connected, `platforms: ["pinterest"]` means both of them; `accounts` is how you pick one. ## The request body | Field | Type | Description | | --- | --- | --- | | `text` | string | The post text. Used for every account unless overridden. May be empty for an Instagram Story-only post because Stories do not display captions. | | `profile` | string | A profile name: posts to every account it owns. Optional when you have exactly one profile. | | `platforms` | array | Optional filter: keep only these networks from the resolved set (e.g. `["bluesky"]`). | | `accounts` | array | Exact social-account ids to publish to (e.g. `acc_123`), copy one from the Channels page, or list them with `GET /v1/social-accounts`. An alternative (or addition) to `profile`. | | `textOverrides` | object | Per-platform text, keyed by platform (e.g. `{ "x": "shorter" }`). Falls back to `text`. | | `media` | array | Media ids from `POST /v1/media` to attach to every account. | | `mediaOverrides` | object | Per-platform media sets, keyed by platform. | | `scheduledAt` | string | UTC with a trailing Z, or a naive local time together with `timezone`. Omit to publish now. See [Scheduling](https://postlake.dev/scheduling). | | `timezone` | string | IANA name (e.g. `Europe/London`). Interprets a naive `scheduledAt` as wall time in that zone. Stored fire time is always UTC. | | `platformOptions` | object | Platform-specific options (Pinterest `boardId`, TikTok `mode`, …). See below. | ## Per-platform text & options The same post rarely reads the same on every network. Two hooks let you tailor per platform without sending separate requests: - **`textOverrides`**: a shorter caption for X, a longer one for LinkedIn, keyed by platform. Anything not overridden uses `text`. - **`platformOptions`**: platform-specific settings, e.g. a Pinterest `boardId` + destination `link`, or a TikTok `mode` (`direct` to publish now, `inbox` to send to drafts). Call `GET /v1/platforms/{platform}` for the full, live option set. - **Instagram Stories**: Set `platformOptions.instagram.placement` to `story` (`stories` is also accepted). Choose exactly one JPEG or PNG image, or MP4/MOV video. Upload PNG normally; PostLake converts Instagram's copy to JPEG automatically and retains the original. Both Instagram Login and Facebook Login support Stories, subject to Meta's account eligibility checks. Instagram Login does not need a Facebook Page; the Facebook route requires a Business account linked to a Page. Without placement, PostLake publishes a feed photo or Reel. Story text is not visible to viewers, so put any words in the media. First comments, interactive stickers, music, links and feed/Reel settings are not sent to Stories. Validate before creating; an incompatible request fails its Instagram target rather than publishing to the feed. - **Video covers**: For a Pinterest video, PostLake creates a JPEG cover from the video at one second before publishing. To choose the cover yourself, upload a JPEG or PNG and pass its media id as `platformOptions.pinterest.thumbnail`. YouTube takes a custom thumbnail through `platformOptions.youtube.thumbnail`: JPEG or PNG, up to 2MB, and the channel must be verified to use one (Shorts ignore it). TikTok accepts only a frame from the video itself through `platformOptions.tiktok.coverTimestampMs`. - **TikTok links**: TikTok can confirm publication before moderation reveals the public post id. In that window the target is `published` with `url: null` and `permalinkPending: true`; PostLake resolves it separately, then fills the exact `/video/{id}` permalink without publishing twice or firing another `post.published` event. Private/friends posts do not have a public permalink, so their `url` remains `null`. ``` { "text": "", "accounts": ["acc_instagram_business"], "media": ["med_story_image"], "platformOptions": { "instagram": { "placement": "story" } } } ``` ``` { "text": "Big update on the blog 👇", "textOverrides": { "x": "Big update, link in thread" }, "accounts": ["acc_x", "acc_pin"], "platformOptions": { "pinterest": { "boardId": "98765", "link": "https://example.com/post" } } } ``` ## The response & its lifecycle You get one `Post` with an overall `state` and a `targets` array. One entry per account, each with its own `state`, `url` and (if it failed) an `error`. ``` { "id": "post_a1b2c3", "state": "partial", "targets": [ { "account": "acc_x", "platform": "x", "state": "published", "url": "https://x.com/…" }, { "account": "acc_pin", "platform": "pinterest", "state": "failed", "error": { "type": "invalid_request", "message": "boardId not found", "retryable": false } } ] } ``` A post's `state` summarises its targets: | State | Meaning | | --- | --- | | `draft` | Saved, never sent, costing nothing until you publish it. | | `queued` | Accepted, not yet sent. | | `processing` | Being published (some platforms publish asynchronously). | | `published` | Every target succeeded. | | `partial` | Some targets published, some failed: check each target. | | `failed` | No target published. | | `scheduled` | Queued for a future `scheduledAt`. | ## Drafts: write now, approve before it goes out Send `draft: true` and the post is saved instead of published. It costs nothing, contacts no network, and is allowed to be incomplete: no text yet, no destinations yet, a time already in the past. That is the point of a draft. This is how you put a person in the loop. An agent writes the post, someone reads it, and only then does it become public. ``` curl -X POST https://api.postlake.dev/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "Ship notes for Friday", "accounts": ["acc_x"], "draft": true }' ``` The reply is a normal post in state `draft`. From there: | To | Call | | --- | --- | | See what is waiting | `GET /v1/posts?state=draft` | | Change it | `PATCH /v1/posts/{id}` | | Send it | `POST /v1/posts/{id}/publish` | | Throw it away | `DELETE /v1/posts/{id}` | Drafts also appear under **Drafts** in the dashboard, so a post an agent wrote can be reviewed and published by a person who never touches the API. Editing a draft is laxer than editing a scheduled post: both accept an exact `accounts` list, but scheduled edits must keep at least one connected destination and pass preflight validation. A draft can also change `thread` and `firstComment`, and nothing is validated until it publishes. Publishing a draft uses the draft's own content, so you never resend the text, the media ids or the per-platform overrides: ``` curl -X POST https://api.postlake.dev/v1/posts/post_abc/publish \ -H "Authorization: Bearer YOUR_API_KEY" ``` Pass `{"scheduledAt": "2026-09-05T09:00:00Z"}` to schedule it instead, or `{"scheduledAt": null}` to clear a time the draft was carrying and send it now. Omitting the field keeps whatever time the draft had. **Approval keeps the original post ID.** A saved draft moves to scheduled, processing, published, partial or failed under the same ID. `sourceDraftId` records its immutable origin. Personal drafts remain editable if every target fails; human-approved submissions retain the failed outcome. Do not recreate a partial or uncertain publication. ## Durable approval and recovery - Send an `Idempotency-Key` on initial creation, including drafts and approval-required submissions. Reusing it with changed content returns 409. Separate keys may create separate posts with identical campaign or asset metadata. - Read `GET /v1/posts/{id}` throughout the lifecycle. New approvals never rekey the resource. Historical approvals resolve old IDs only where an explicit approval audit proves the link; the returned `id` is canonical and `sourceDraftId` is the original. - Read `GET /v1/idempotency/{key}` without repeating POST. `originalResponse` is the original creation response; `post` is the current resource. A completed receipt may have `resourceAvailable: false` after cancellation or deletion. Cancellation does not free the key. - A pending receipt or missing receipt is not proof that a provider operation did not happen. Retain the key and request reference, read the existing resource and contact support for an uncertain outcome. PostLake does not promise exactly-once execution across a provider network failure. - The reviewer can supply `expectedSnapshot` from GET on `POST /v1/posts/{id}/publish`. This checks every stored field, including media, options and metadata. The dashboard sends this fence automatically. Concurrent approval clicks cannot both publish. - `campaign`, `assetMetadata`, captions, media and provider options survive unchanged unless deliberately edited or overridden at approval. YouTube `privacyStatus: private` is preserved. List by exact `campaign`, then inspect `assetMetadata` on each result; arbitrary asset-metadata filtering is not supported. These fields are not unique identifiers or deduplication keys. - Subscribe explicitly to `post.approved` and `post.rejected`. Their full Post payload includes `id`, `sourceDraftId` and the approval decision. Publication events carry the approved decision too. Webhook deliveries may repeat or arrive out of order; deduplicate by event ID and read the post for current state. Receipts are account-scoped and retained without automatic expiry today. They are not a separate archival service and may be removed by account deletion or required security or legal cleanup. Keep your own integration records. ## Partial success is normal. And free where it fails One target failing (over a limit, a bad option) never blocks the others. You're **only charged for targets that actually publish**: failed targets cost nothing. Always read each `targets[].state` rather than assuming the whole post succeeded. Full detail on the [Errors](https://postlake.dev/errors) page. ## Idempotency Send an `Idempotency-Key` header (any unique string per logical post). If the request is retried with the same key, a network blip, an agent re-run, PostLake returns the original result instead of posting again. This is what makes automated and agent-driven posting safe. ``` curl -X POST https://api.postlake.dev/v1/posts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Idempotency-Key: post-2026-08-01-blog" \ -H "Content-Type: application/json" \ -d '{ "text": "Hello", "accounts": ["acc_123"] }' ```