Skip to main content
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

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": "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:
202.json
{
  "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 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 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.
  • v1 scheduled posts accept text only. Media and firstComment on scheduled posts surface validation_failed with rule: "scheduledAt.text_only". The remediation is to publish synchronously — the scheduled-media path needs persistent media storage and ships in a follow-up slice.

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.
scheduled-fan-out.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": "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:

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.
reschedule.sh
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.
cancel.sh
curl -X DELETE https://api.letmepost.dev/v1/posts/<postId> \
  -H "Authorization: Bearer $LMP_KEY"
200.json
{ "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