# Scheduling (/docs/guides/scheduling)



Timing modes [#timing-modes]

`POST /posts` decides what to do from the timing field you send. Send at most one:

| Field              | Result                                   |
| ------------------ | ---------------------------------------- |
| none               | The post is saved as a draft             |
| `scheduledFor`     | Published at that time                   |
| `addToQueue: true` | Takes the profile's next free queue slot |
| `publishNow: true` | Published right away                     |

Schedule a post [#schedule-a-post]

```bash title="cURL"
curl -X POST https://feedhorizon.com/api/v1/posts \
  -H "Authorization: Bearer fh_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: launch-post-2026-11-10" \
  -d '{
    "profileId": "YOUR_PROFILE_ID",
    "content": "Launching tomorrow at noon",
    "platforms": ["linkedin"],
    "scheduledFor": "2026-11-10T12:00:00-05:00",
    "timezone": "America/New_York"
  }'
```

`platforms` posts to every active account of those platforms in the profile. To pick accounts yourself, send `accountIds` instead.

Own text per account [#own-text-per-account]

X allows 280 characters, LinkedIn 3,000. Send `targets` to pick the accounts and give any of them its own text. A target without `content` uses the post's `content`.

```json
{
  "profileId": "YOUR_PROFILE_ID",
  "content": "We just shipped scheduled exports. Every report can now land in your inbox on a fixed day, as CSV or PDF, without anyone pressing a button. Here is how it works and why we built it.",
  "targets": [
    { "accountId": "LINKEDIN_ACCOUNT_ID" },
    { "accountId": "X_ACCOUNT_ID", "content": "Scheduled exports are live: reports as CSV or PDF, on a fixed day." }
  ],
  "addToQueue": true
}
```

Check before you create [#check-before-you-create]

Every post is checked against each platform before it is stored: text length, number and type of media, whether the platform is available, and whether a scheduled time lies in the past. If something fails, the request answers `400` and `error.details` lists every problem at once, each with its `code` and the field in `param` (for example `targets.1.content`).

`POST /api/v1/posts/validate` takes the same body and runs the same checks without creating anything. It answers `200` with `valid`, `errors`, `warnings`, the accounts the post would go to and when it would go out:

```json
{
  "data": {
    "valid": false,
    "errors": [
      { "code": "content_too_long", "message": "X (Twitter) (Acme) allows 280 characters, the text has 312", "param": "content", "accountId": "…", "platform": "twitter" }
    ],
    "warnings": [],
    "scheduledAt": "2026-11-10T17:00:00.000Z",
    "targets": [{ "accountId": "…", "platform": "twitter" }]
  }
}
```

Warnings do not block the post. The most common one is `account_not_active`: the account has to be reconnected before the post goes out.

Time zones [#time-zones]

* `scheduledFor` is an absolute ISO 8601 timestamp and must carry `Z` or an offset such as `+01:00`. A time without a zone is rejected, so there is never a guess about which noon you meant.
* `timezone` is an IANA name such as `Europe/Berlin`. It is stored with the post and used for display. It defaults to the profile's time zone.
* All timestamps in responses are UTC.

Status lifecycle [#status-lifecycle]

A post has one target per social account, and each target has its own status. The post status sums them up:

| Post status        | Meaning                               |
| ------------------ | ------------------------------------- |
| `draft`            | Not scheduled                         |
| `scheduled`        | Waiting for its time                  |
| `publishing`       | Being sent to the platforms           |
| `published`        | All targets published                 |
| `partially_failed` | Some targets published, others failed |
| `failed`           | All targets failed                    |

A background job checks for due posts every minute.

When a platform is rate limiting or does not answer, FeedHorizon tries again: 3 attempts in total, after 2 and 10 minutes. Between attempts the target shows `failed` with its `error`, while the post stays `publishing`. Errors that would fail the same way again, such as a refused post or an expired connection, are not retried. Each account sends `post.target.published` or `post.target.failed` as soon as it is done; the post sends `post.published`, `post.partial` or `post.failed` once all accounts are done. See [Publishing errors](/docs/api/errors#publishing-errors).

Retry failed posts [#retry-failed-posts]

```bash title="cURL"
curl -X POST https://feedhorizon.com/api/v1/posts/POST_ID/retry \
  -H "Authorization: Bearer fh_YOUR_API_KEY"
```

Only the failed targets are published again. Targets that already went out stay as they are.

Publish, cancel, duplicate [#publish-cancel-duplicate]

| Call                         | What it does                                                         |
| ---------------------------- | -------------------------------------------------------------------- |
| `POST /posts/{id}/publish`   | Publishes a draft or scheduled post right away                       |
| `POST /posts/{id}/cancel`    | Takes a scheduled post off the schedule; it stays as a draft         |
| `POST /posts/{id}/duplicate` | Copies a post as a new draft, with its accounts, own texts and media |

The queue [#the-queue]

Each profile has weekly posting times. `addToQueue: true` puts a post into the next free one.

```bash title="cURL"
curl -X PUT https://feedhorizon.com/api/v1/profiles/PROFILE_ID/queue \
  -H "Authorization: Bearer fh_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "timezone": "Europe/Berlin",
    "slots": [
      { "dayOfWeek": 1, "time": "09:00" },
      { "dayOfWeek": 3, "time": "09:00" },
      { "dayOfWeek": 5, "time": "16:30" }
    ]
  }'
```

`dayOfWeek` runs from 0 (Sunday) to 6 (Saturday), `time` is local time in the queue's time zone. `PUT` replaces all slots; send `"reshuffle": true` to move posts already in the queue to the new times.

`GET /profiles/{id}/queue/slots?count=10` lists the next slots with the post that holds each one. Add `free=true` to see only the times `addToQueue` would pick.

Keep in sync [#keep-in-sync]

`GET /posts?updatedSince=2026-11-04T12:00:00Z` returns only posts changed since then. Store the time before each sync and pass it next time. Deleted posts do not show up there; listen for the `post.deleted` webhook.

Best practices [#best-practices]

* Send an [`Idempotency-Key`](/docs/api/idempotency) with every `POST /posts`, so a retry after a timeout never posts twice
* Subscribe to [webhooks](/docs/guides/webhooks) for `post.published`, `post.partial` and `post.failed` instead of polling
* Keep [rate limits](/docs/api/rate-limits) in mind when scheduling in bulk
