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. May be empty for an Instagram Story-only post because Stories do not display captions.
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": "",
  "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:

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

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