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. 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<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.- Instagram Stories: Set
platformOptions.instagram.placementtostory(storiesis 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 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": "",
"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-Keyon 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 returnedidis canonical andsourceDraftIdis the original. - Read
GET /v1/idempotency/{key}without repeating POST.originalResponseis the original creation response;postis the current resource. A completed receipt may haveresourceAvailable: falseafter 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
expectedSnapshotfrom GET onPOST /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. YouTubeprivacyStatus: privateis preserved. List by exactcampaign, then inspectassetMetadataon each result; arbitrary asset-metadata filtering is not supported. These fields are not unique identifiers or deduplication keys.- Subscribe explicitly to
post.approvedandpost.rejected. Their full Post payload includesid,sourceDraftIdand 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 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"] }'