> ## Documentation Index
> Fetch the complete documentation index at: https://docs.letmepost.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Scheduled posts

> Pass scheduledAt to queue a batch for the future. 202 instead of 200, queued status per target, post.queued webhook per target.

Add `scheduledAt` to a `POST /v1/posts` body and every target in the batch is persisted with `status: "queued"`. A delayed publish job is enqueued per target at the requested time.

## Request

```bash scheduled.sh theme={"system"}
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": "Friday at noon UTC",
  "scheduledAt": "2026-05-08T12:00:00Z"
}
EOF
```

## Response

You get `202 Accepted` instead of `200 OK`:

```json 202.json theme={"system"}
{
  "id": "batch_01HY6X4AWBJM2K9F2PTQMRD9JQ",
  "status": "queued",
  "createdAt": "2026-05-04T15:30:00.000Z",
  "scheduledAt": "2026-05-08T12:00:00.000Z",
  "results": [
    {
      "accountId": "00000000-0000-0000-0000-000000000000",
      "platform": "bluesky",
      "postId": "post_01HY6X4AWBJM2K9F2PTQMRD9JQ",
      "status": "queued"
    }
  ]
}
```

Save `results[].postId` per target. To check what happened later, fetch [`GET /v1/posts/:id`](/api-reference/#get-/v1/posts/:id) for any of them — each returns the full attempt history once its job has run.

## Constraints

* `scheduledAt` must be at least 1 second in the future. Closer than that and you get [`validation_failed`](/errors/validation_failed) with `rule: "scheduledAt.future"`. The 1-second floor is a guardrail against races where the worker fires before the persisting transaction commits.
* **`publishNow: true` is mutually exclusive with `scheduledAt`.** Sending both surfaces `validation_failed` with `rule: "mode_conflict"`. Drop one of them.
* **Scheduled posts accept text and media.** Media refs persist on the queued row and the worker reads them back at fire time, so a `scheduledAt` post can include images or video.
* **`firstComment` is not supported on scheduled posts.** It surfaces `validation_failed` with `rule: "scheduledAt.no_first_comment"`. Publish synchronously (drop `scheduledAt`) if you need a first comment.
* **Bluesky reply threading is not supported on scheduled posts.** The `bluesky` `replyTo*` options surface `validation_failed` with `rule: "scheduledAt.no_bluesky_reply"` — a queued reply would publish un-threaded, so publish synchronously instead.

## Multi-target scheduling

`scheduledAt` applies to every target in the batch — same firing time for all of them. The batch is atomic on accept: either every target gets queued, or none do. Each target then runs through its own publish job and surfaces independently in webhooks and the post log.

```bash scheduled-fan-out.sh theme={"system"}
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": "Coordinated drop on Friday at noon UTC",
  "scheduledAt": "2026-05-08T12:00:00Z"
}
EOF
```

## Lifecycle

A scheduled target moves through these statuses:

```
queued    →   publishing   →   published
   ↓                      ↘   rejected
   ↓                      ↘   failed
   ↓
 canceled  (terminal — set by DELETE before the job fires)
```

Subscribe to webhooks for each transition. Events fire **per target**, not per batch:

* [`post.queued`](/webhooks/post-queued) — fired per target immediately on accept.
* [`post.published`](/webhooks/post-published) — successful publish on a target.
* [`post.rejected`](/webhooks/post-rejected) — preflight or platform rejected; not retried.
* [`post.failed`](/webhooks/post-failed) — transient failure; the worker may retry per the queue policy.
* [`post.rescheduled`](/webhooks/post-rescheduled) — operator moved the firing time via `PATCH`.
* [`post.canceled`](/webhooks/post-canceled) — operator canceled the post before it fired.

## Reschedule

Move a queued post to a new time with `PATCH /v1/posts/:id`. The window is `status=queued AND scheduledAt > now` — once the worker picks the row up, the window closes and the API returns `409`.

```bash reschedule.sh theme={"system"}
curl -X PATCH https://api.letmepost.dev/v1/posts/<postId> \
  -H "Authorization: Bearer $LMP_KEY" \
  -H "Content-Type: application/json" \
  -d '{"scheduledAt":"2026-05-09T15:00:00Z"}'
```

The operation is atomic: the API removes the existing BullMQ job, enqueues a new one at the updated delay, then persists the new `scheduledAt`. If the queue swap fails the row stays as-is — no orphaned rows pointing at a job that never fires.

A `post.rescheduled` webhook fires on success with both the old and new timestamps.

## Cancel

Cancel a queued post with `DELETE /v1/posts/:id`. Same window as reschedule.

```bash cancel.sh theme={"system"}
curl -X DELETE https://api.letmepost.dev/v1/posts/<postId> \
  -H "Authorization: Bearer $LMP_KEY"
```

```json 200.json theme={"system"}
{ "id": "post_…", "status": "canceled" }
```

The BullMQ job is removed and the row transitions to `status: "canceled"`. A `post.canceled` webhook fires on success.

**Race-safety:** if a worker has already started running the job when the cancel lands, the worker's conditional transition (`UPDATE … WHERE status IN ('queued','validated')`) sees `canceled` and short-circuits — the publish never happens.

## Idempotency on scheduled posts

`Idempotency-Key` works the same way as on immediate posts and applies to the whole batch — the replay cache returns the original `202` envelope (same batch id, same per-target rows) if you retry within 24 h with the same body. Each queued job is enqueued exactly once.

## Time zones

`scheduledAt` is ISO-8601 with a timezone offset. Always include the `Z` (or an explicit `+HH:MM`); naive datetimes are rejected. We do not interpret a "local" timezone for you.

## See also

* [`POST /v1/posts` reference](/api-reference/#post-/v1/posts)
* [`PATCH /v1/posts/{id}` reference](/api-reference/#patch-/v1/posts/\{id})
* [`DELETE /v1/posts/{id}` reference](/api-reference/#delete-/v1/posts/\{id})
* [Idempotency](/idempotency)
* [`post.queued` webhook](/webhooks/post-queued)
* [`post.rescheduled` webhook](/webhooks/post-rescheduled)
* [`post.canceled` webhook](/webhooks/post-canceled)
