Guides
Publishing a post
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
platformsfilter to narrow that to certain networks. - By profile: give a
profilename 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
accountsarray of exact account ids (acc_…). To find an id: hover a channel on the Channels page and click the copy icon, or list them withGET /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. Required unless every account is overridden. |
profile | string | A profile name: posts to every account it owns. Optional when you have exactly one profile. |
platforms | array<string> | Optional filter: keep only these networks from the resolved set (e.g. ["bluesky"]). |
accounts | array<string> | 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<string> | Media ids from POST /v1/media to attach to every account. |
mediaOverrides | object | Per-platform media sets, keyed by platform. |
scheduledAt | string<date-time> | UTC with a trailing Z, or a naive local time together with timezone. Omit to publish now. See 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 usestext.platformOptions: platform-specific settings, e.g. a PinterestboardId+ destinationlink, or a TikTokmode(directto publish now,inboxto send to drafts). CallGET /v1/platforms/{platform}for the full, live option set.- Video covers: Pinterest uses the video's first frame automatically. For a custom Pinterest cover, upload a JPEG or PNG and pass its media id as
platformOptions.pinterest.thumbnail. YouTube takes a custom thumbnail throughplatformOptions.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 throughplatformOptions.tiktok.coverTimestampMs. - TikTok links: TikTok can confirm publication before moderation reveals the public post id. In that window the target is
publishedwithurl: nullandpermalinkPending: true; PostLake resolves it separately, then fills the exact/video/{id}permalink without publishing twice or firing anotherpost.publishedevent. Private/friends posts do not have a public permalink, so theirurlremainsnull.
{
"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: you can change accounts, thread and firstComment too, 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.
A failed publish does not consume the draft. If nothing published (out of credits, a caption over a network's limit, a channel that has since disconnected), the draft stays exactly where it was. Fix the problem and publish it again. If it published to some networks and failed on others, the draft is consumed: part of the post is already public, and publishing again would double-post where it worked.
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 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"] }'