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:

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

FieldTypeDescription
textstringThe post text. Used for every account unless overridden. Required unless every account is overridden.
profilestringA profile name: posts to every account it owns. Optional when you have exactly one profile.
platformsarray<string>Optional filter: keep only these networks from the resolved set (e.g. ["bluesky"]).
accountsarray<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.
textOverridesobjectPer-platform text, keyed by platform (e.g. { "x": "shorter" }). Falls back to text.
mediaarray<string>Media ids from POST /v1/media to attach to every account.
mediaOverridesobjectPer-platform media sets, keyed by platform.
scheduledAtstring<date-time>UTC with a trailing Z, or a naive local time together with timezone. Omit to publish now. See Scheduling.
timezonestringIANA name (e.g. Europe/London). Interprets a naive scheduledAt as wall time in that zone. Stored fire time is always UTC.
platformOptionsobjectPlatform-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:

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

StateMeaning
draftSaved, never sent, costing nothing until you publish it.
queuedAccepted, not yet sent.
processingBeing published (some platforms publish asynchronously).
publishedEvery target succeeded.
partialSome targets published, some failed: check each target.
failedNo target published.
scheduledQueued 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:

ToCall
See what is waitingGET /v1/posts?state=draft
Change itPATCH /v1/posts/{id}
Send itPOST /v1/posts/{id}/publish
Throw it awayDELETE /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"] }'