Skip to main content
Publishing is one HTTP call. The same POST /v1/posts endpoint handles immediate, scheduled, and multi-target publishes; the response status code and the lifecycle differ.

Immediate publish

A request body without scheduledAt runs synchronously. Preflight runs, the upstream platform call fires, and the response is 200 with a batch envelope:
immediate.sh
curl -X POST https://api.letmepost.dev/v1/posts \
  -H "Authorization: Bearer $LMP_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  --data @- <<EOF
{
  "targets": [
    { "accountId": "00000000-0000-0000-0000-000000000000" }
  ],
  "text": "shipped"
}
EOF
Response:
200.json
{
  "id": "batch_01HY6X4AWBJM2K9F2PTQMRD9JQ",
  "status": "published",
  "createdAt": "2026-05-16T12:00:00.000Z",
  "results": [
    {
      "accountId": "00000000-0000-0000-0000-000000000000",
      "platform": "bluesky",
      "postId": "post_01HY6X4...",
      "status": "published",
      "uri": "at://did:plc:abc/app.bsky.feed.post/3kr...",
      "cid": "bafyrei..."
    }
  ]
}
The top-level id is the batch id; each entry in results[] is one target’s outcome. The uri shape is platform-native (AT Proto for Bluesky, Twitter snowflake id, LinkedIn URN, etc.) — save it to deep-link to the post later, or save results[].postId to fetch the record back via GET /v1/posts/:id.

Multi-target fan-out (the headline)

Add more entries to targets[] and one request publishes to N accounts. The cap is 25 targets per request — beyond that you get validation_failed with rule: "targets.max".
multi-target.sh
curl -X POST https://api.letmepost.dev/v1/posts \
  -H "Authorization: Bearer $LMP_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  --data @- <<EOF
{
  "targets": [
    { "accountId": "<twitter_account_id>" },
    { "accountId": "<linkedin_account_id>" },
    { "accountId": "<bluesky_account_id>" }
  ],
  "text": "Hello from three platforms at once"
}
EOF
Each target can override text, media, firstComment, and per-platform options independently — anything you omit on a target inherits from the top-level defaults. Mix per-target overrides to send tailored copy in one call:
per-target-overrides.json
{
  "targets": [
    {
      "accountId": "<twitter_account_id>",
      "text": "shipped: 280-char version with emoji"
    },
    {
      "accountId": "<linkedin_account_id>",
      "text": "Shipped — the long-form version with paragraphs and a CTA."
    },
    { "accountId": "<bluesky_account_id>" }
  ],
  "text": "shipped — default that bluesky picks up"
}

Batch status semantics

The top-level status summarizes the per-target outcomes:
statuswhen
publishedevery target succeeded
partial_failedat least one succeeded, at least one failed
failedevery target failed
queuedscheduled batch accepted (returned with 202)
The batch itself returns 200 even on partial_failed — per-target errors live inside results[].error. Loop the array and branch on each entry’s status; a mixed batch is a normal, expected shape, not an exception path.

Atomic shape preflight

Cheap shape checks (text length, media counts + exclusivity, alt-text length, per-platform option sanity) run atomically across the batch before any persistence: if any target fails a shape rule the whole batch is rejected as 400 preflight_failed and nothing is published. Deep checks (URL reachability, MIME sniffing, byte caps) run inside each publisher and surface per-target — a deep-check failure lands as partial_failed, not a 400.

Auto-resolution by platform

If your org has exactly one connected account for a platform, you can omit accountId and let the API resolve it:
platform-only.json
{
  "targets": [{ "platform": "bluesky" }],
  "text": "shipped"
}
When two or more accounts match, you get validation_failed with rule: "target.account.ambiguous" and the candidate ids in remediation — pass an explicit accountId to disambiguate.

Scheduled publish

Add scheduledAt and every target in the batch goes onto the publish queue. Response is 202 Accepted with status: "queued":
scheduled.sh
curl -X POST https://api.letmepost.dev/v1/posts \
  -H "Authorization: Bearer $LMP_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  --data @- <<EOF
{
  "targets": [
    { "accountId": "<bluesky_account_id>" }
  ],
  "text": "Friday at noon UTC",
  "scheduledAt": "2026-05-08T12:00:00Z"
}
EOF
See schedule a post for the full lifecycle and the v1 text-only constraint.

What to subscribe to

Webhooks fire per target, not per batch. A successful immediate publish on three targets fires three post.published events. A scheduled batch fires post.queued per target on accept, then post.published / post.rejected / post.failed per target when the worker runs. Subscribe via POST /v1/webhook-endpoints and verify signatures per webhooks.

Idempotency

Idempotency-Key applies to the whole batch. Reuse the same key on a retry and you get the original CreatePostResponse (same batch id, same per-target results) replayed verbatim — no double publishing on any target. Change any byte of the body (different targets[], different text, anything) and you get idempotency_conflict. See idempotency for the full contract.

When it fails

The error envelope is the same shape across every failure mode — code, message, rule, platformResponse, remediation, docUrl, ruleUrl, requestId. See errors for the eleven codes.

See also

Schedule posts

Defer publishes via scheduledAt.

Upload media

Three media-source modes; when to use each.

Connect accounts

OAuth flows per platform.

Webhooks

Stop polling; subscribe instead.