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

Response

You get 202 Accepted instead of 200 OK:
202.json
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.
  • 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.
scheduled-fan-out.sh

Lifecycle

A scheduled target moves through these statuses:
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
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
200.json
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