# Schedule For You API reference: posts, media and webhooks

> Reference for the Schedule For You REST API: authenticate with an API key, create and schedule posts, upload media, manage RSS feeds and verify webhooks.

Canonical: https://scheduleforyou.com/docs

## Introduction

Base URL: `https://scheduleforyou.com/api/v1`. Solo and Studio include the API; requests from a workspace on the Free plan get a 402. Requests and responses are JSON. Times are ISO 8601 or epoch milliseconds.

## Authentication

Create an API key in the dashboard under Settings. Send it on every request as `Authorization: Bearer <key>`. A key belongs to one workspace.

## Errors

| Status | Meaning | When |
| --- | --- | --- |
| 400 | Bad request | The body is not a JSON object, a field is missing or has the wrong type, or a value is not allowed. |
| 401 | Unauthorized | The `Authorization` header is missing or the API key is not valid. |
| 402 | Payment required | Publishing needs an active plan or trial on the workspace. |
| 403 | Forbidden | The action is only available from the dashboard. |
| 404 | Not found | Nothing with that ID in the key’s workspace. |
| 409 | Conflict | The resource is in a state that does not allow this, such as a post that is publishing right now. |
| 413 | Payload too large | A media file is over the size limit for its type. |
| 415 | Unsupported media type | A media file type is not accepted. |
| 422 | Unprocessable | A post breaks a platform rule (`errors` lists each one) or is scheduled in the past. |
| 500 | Server error | Something failed on our side. The message never includes internal details; try again. |
| 504 | Timeout | An outside service took too long. Try again. |

## Limits

| What | Limit |
| --- | --- |
| Posts per list request | 500 by default, up to 2,000 with `limit` |
| Targets per post | 100 |
| Media files per post | 35 |
| Media files per list request | 500, newest first |
| Webhooks per workspace | 10 |
| RSS feeds per workspace | 20 |

## Accounts

Accounts are the social profiles connected to the workspace. Connecting an account goes through each platform’s sign-in, so it happens in the dashboard under **Accounts**. The API lists accounts and can disconnect them.

### List accounts

`GET https://scheduleforyou.com/api/v1/accounts`

Returns every connected account, oldest first, and the number of accounts the workspace’s plan allows. Use the `id` values as `account_ids` or `targets[].account_id` when you create a post.
An account whose `status` is `error` needs to be reconnected in the dashboard; `last_error` says why. For Pinterest accounts, `meta.boards` lists the boards you can use as `board_id`.
`has_refresh` is `true` when the account keeps its sign-in fresh by itself (a refresh token, a self-renewing token, or one that doesn’t expire). `token_expires_at` is only set when it doesn’t: it is when the account will need reconnecting.
`default_first_comment` is posted under every post to the account that has no `first_comment` of its own (see [Update an account](#update-an-account)).

Response (200):

```json
{
  "accounts": [
    {
      "id": "acc_7d21e0c94b3a6f15",
      "platform": "x",
      "username": "harborbakery",
      "display_name": "Harbor Bakery",
      "avatar_url": "https://pbs.twimg.com/profile_images/1780000000000000000/a1b2c3d4_normal.jpg",
      "status": "active",
      "last_error": null,
      "has_refresh": true,
      "token_expires_at": null,
      "created_at": 1788453600000,
      "default_first_comment": null,
      "meta": {}
    },
    {
      "id": "acc_19e4b7d2a05c3f81",
      "platform": "pinterest",
      "username": "harborbakery",
      "display_name": "Harbor Bakery",
      "avatar_url": null,
      "status": "active",
      "last_error": null,
      "has_refresh": true,
      "token_expires_at": null,
      "created_at": 1788453600000,
      "default_first_comment": null,
      "meta": {
        "boards": [
          {
            "id": "1080863410238761234",
            "name": "Seasonal bakes"
          }
        ]
      }
    }
  ],
  "limit": 15
}
```

### Update an account

`PATCH https://scheduleforyou.com/api/v1/accounts/{id}`

Sets the account’s default first comment: text posted by the account under every post to it that has no `first_comment` of its own, such as a hashtag set or a standing link. Send `null` or an empty string to remove it. A post can skip the default with `first_comment: false` on its target.
Only X, Bluesky, Threads, Instagram, Facebook and LinkedIn accounts can post comments. The limit is the platform’s comment limit (X 280, Bluesky 300, Threads 500, LinkedIn 1,250, Instagram 2,200, Facebook 8,000 characters).

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| path | `id` | string | yes | The account ID. |
| body | `default_first_comment` | string \| null | yes | The comment, or `null` to remove it. |

Example request body:

```json
{
  "default_first_comment": "#harborbakery #sourdough #bristolfood"
}
```

Response (200):

```json
{
  "account": {
    "id": "acc_7d21e0c94b3a6f15",
    "platform": "x",
    "username": "harborbakery",
    "display_name": "Harbor Bakery",
    "avatar_url": "https://pbs.twimg.com/profile_images/1780000000000000000/a1b2c3d4_normal.jpg",
    "status": "active",
    "last_error": null,
    "has_refresh": true,
    "token_expires_at": null,
    "created_at": 1788453600000,
    "default_first_comment": "#harborbakery #sourdough #bristolfood",
    "meta": {}
  }
}
```

- 404: No account with that ID in this workspace.
- 422: The platform cannot post comments, or the text is over its comment limit.

### Disconnect an account

`DELETE https://scheduleforyou.com/api/v1/accounts/{id}`

Removes the account from the workspace. Posts already published on the platform stay there. Targets on unpublished posts that pointed at this account are removed; a post left with no targets goes back to `draft`.

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| path | `id` | string | yes | The account ID. |

Response (200):

```json
{
  "ok": true
}
```

- 404: No account with that ID in this workspace.

## Posts

A post is one caption and set of media sent to one or more accounts. Each account it goes to is a **target** with its own status, so a post can publish on LinkedIn and fail on X.
Post `status` is one of `draft`, `scheduled`, `publishing`, `published`, `partial` (some targets published, at least one failed) or `failed`. Target `status` is `pending`, `publishing`, `published` or `failed`. A target is tried up to three times: after a temporary error it is retried a couple of minutes later, and an error that retrying cannot fix (such as a missing video) fails it straight away. While a platform is still processing a video the target simply waits and is checked again, without using up a try. If the platform never answered after the post was sent, the target fails instead of being sent again, so nothing is posted twice: check the account, then retry it.

### List posts

`GET https://scheduleforyou.com/api/v1/posts`

Returns posts with their targets, newest first by scheduled time (or creation time for posts without one).

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| query | `status` | string |  | Only posts with this status: `draft`, `scheduled`, `publishing`, `published`, `partial` or `failed`. |
| query | `from` | string \| number |  | Only posts scheduled (or created) at or after this time: an ISO 8601 date or epoch milliseconds. |
| query | `to` | string \| number |  | Only posts scheduled (or created) at or before this time: an ISO 8601 date or epoch milliseconds. |
| query | `limit` | number |  | Maximum number of posts. Defaults to 500, at most 2,000. |

Response (200):

```json
{
  "posts": [
    {
      "id": "post_3f9c2a71b8d04e65",
      "caption": "Our autumn menu starts Monday.",
      "media": [
        "med_a41c7e2093bd5f18"
      ],
      "scheduled_at": 1793628000000,
      "status": "scheduled",
      "labels": [
        "Launch"
      ],
      "repeat": "none",
      "evergreen": false,
      "approval": null,
      "remind": true,
      "note": "",
      "batch": null,
      "created_at": 1792487524000,
      "updated_at": 1792487524000,
      "signature": true,
      "targets": [
        {
          "id": "tgt_0f6e2d9a4c71b358",
          "account_id": "acc_7d21e0c94b3a6f15",
          "caption_override": "Autumn menu: Monday.",
          "options": {},
          "status": "pending",
          "external_id": null,
          "external_url": null,
          "error": null,
          "published_at": null,
          "stats": {},
          "failed_at": null
        },
        {
          "id": "tgt_8a3c51f07e2d94b6",
          "account_id": "acc_c0a85f3e17d94b26",
          "caption_override": null,
          "options": {},
          "status": "pending",
          "external_id": null,
          "external_url": null,
          "error": null,
          "published_at": null,
          "stats": {},
          "failed_at": null
        }
      ]
    }
  ]
}
```

### Create a post

`POST https://scheduleforyou.com/api/v1/posts`

Creates a draft, a scheduled post, or a post that publishes straight away with `publish_now`.
Unless you send `status: "draft"`, every target is checked against its platform’s rules first: caption length, whether the platform needs an image or a video, Instagram’s JPEG-only images, and Facebook’s video-or-images rule. If anything fails, nothing is saved and you get a `422` with an `errors` list. Send `force: true` to save anyway.
With `publish_now`, the request waits while the post publishes, which can take a while for video. The response shows each target’s result. The plan is checked first, so nothing is saved if the workspace can’t publish.
To schedule, send `scheduled_at`. `status: "scheduled"` without a time is refused rather than saved as a draft, and so is a `scheduled_at` more than 5 minutes in the past (use `publish_now` instead).

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| body | `caption` | string |  | The post text, up to 65,000 characters. Each target is also checked against its platform’s own limit (see [Platforms](#platforms)). |
| body | `account_ids` | string[] |  | Shortcut for `targets`: one target per account ID, each using `caption`. Up to 100. Ignored when `targets` is sent. |
| body | `targets` | object[] |  | Where to publish, up to 100. Each item has `account_id` (required), an optional `caption` that replaces the post caption for that account (`null` uses the post caption), and optional per-platform `options` (see [Platform options](#platform-options)). Duplicate account IDs are dropped. |
| body | `media` | string[] |  | Up to 35 media IDs from [Upload a file](#upload-a-file), in display order. |
| body | `scheduled_at` | string \| number \| null |  | When to publish: an ISO 8601 date (`2026-11-02T14:00:00Z`) or epoch milliseconds. `null` saves a draft. |
| body | `status` | string |  | `draft` or `scheduled`. Send `draft` to save without a time and to skip platform checks. A post with a `scheduled_at` is scheduled. |
| body | `publish_now` | boolean |  | Publish during this request instead of waiting for the scheduler. |
| body | `labels` | string[] |  | Up to 50 labels, each up to 40 characters. |
| body | `repeat` | string |  | `none` (default), `weekly` or `monthly`. After the post publishes, a copy is scheduled for the next occurrence: the same weekday, or the same day of the month (the last day in shorter months), at the same local time in the workspace’s timezone. |
| body | `evergreen` | boolean |  | After the post publishes, put a copy back in the next open posting slot. |
| body | `approval` | string \| null |  | `pending`, `approved` or `null`. Posts with `pending` approval are never published until approved. |
| body | `remind` | boolean |  | Send the reminder email before this post publishes, when reminders are turned on for the workspace. Defaults to `true`. |
| body | `note` | string |  | Internal note, up to 2,000 characters. Never published. |
| body | `batch` | string \| null |  | A free-form batch name, up to 80 characters, for grouping posts. |
| body | `signature` | boolean |  | Set to `false` to leave the workspace signature off this post. Defaults to `true`. |
| body | `force` | boolean |  | Save even when a platform check fails. The affected targets will then fail when they publish. |

Example request body:

```json
{
  "caption": "Our autumn menu starts Monday.",
  "targets": [
    {
      "account_id": "acc_7d21e0c94b3a6f15",
      "caption": "Autumn menu: Monday."
    },
    {
      "account_id": "acc_c0a85f3e17d94b26"
    }
  ],
  "media": [
    "med_a41c7e2093bd5f18"
  ],
  "scheduled_at": "2026-11-02T14:00:00Z",
  "labels": [
    "Launch"
  ]
}
```

Response (201):

```json
{
  "post": {
    "id": "post_3f9c2a71b8d04e65",
    "caption": "Our autumn menu starts Monday.",
    "media": [
      "med_a41c7e2093bd5f18"
    ],
    "scheduled_at": 1793628000000,
    "status": "scheduled",
    "labels": [
      "Launch"
    ],
    "repeat": "none",
    "evergreen": false,
    "approval": null,
    "remind": true,
    "note": "",
    "batch": null,
    "created_at": 1792487524000,
    "updated_at": 1792487524000,
    "signature": true,
    "targets": [
      {
        "id": "tgt_0f6e2d9a4c71b358",
        "account_id": "acc_7d21e0c94b3a6f15",
        "caption_override": "Autumn menu: Monday.",
        "options": {},
        "status": "pending",
        "external_id": null,
        "external_url": null,
        "error": null,
        "published_at": null,
        "stats": {},
        "failed_at": null
      },
      {
        "id": "tgt_8a3c51f07e2d94b6",
        "account_id": "acc_c0a85f3e17d94b26",
        "caption_override": null,
        "options": {},
        "status": "pending",
        "external_id": null,
        "external_url": null,
        "error": null,
        "published_at": null,
        "stats": {},
        "failed_at": null
      }
    ]
  }
}
```

- 400: The body is not valid JSON or a field has the wrong type. `issues` lists each problem.
- 402: With `publish_now`: the workspace has no active plan or trial. Nothing is saved.
- 409: With `publish_now`: the post has `approval: "pending"`. Approve it first, or leave out `publish_now`.
- 422: A platform check failed, a media file hasn’t been confirmed as uploaded, `status: "scheduled"` came without `scheduled_at`, or `scheduled_at` is more than 5 minutes in the past. `errors` lists each platform problem, for example `X / Twitter (@harborbakery): 312/280 characters`.

### Retrieve a post

`GET https://scheduleforyou.com/api/v1/posts/{id}`

Returns one post with its targets. Published targets include `external_url`, the link to the post on the platform.

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| path | `id` | string | yes | The post ID. |

Response (200):

```json
{
  "post": {
    "id": "post_3f9c2a71b8d04e65",
    "caption": "Our autumn menu starts Monday.",
    "media": [
      "med_a41c7e2093bd5f18"
    ],
    "scheduled_at": 1793628000000,
    "status": "published",
    "labels": [
      "Launch"
    ],
    "repeat": "none",
    "evergreen": false,
    "approval": null,
    "remind": true,
    "note": "",
    "batch": null,
    "created_at": 1792487524000,
    "updated_at": 1793628009000,
    "signature": true,
    "targets": [
      {
        "id": "tgt_0f6e2d9a4c71b358",
        "account_id": "acc_7d21e0c94b3a6f15",
        "caption_override": "Autumn menu: Monday.",
        "options": {},
        "status": "published",
        "external_id": "1853920716648120321",
        "external_url": "https://x.com/harborbakery/status/1853920716648120321",
        "error": null,
        "published_at": 1793628009000,
        "stats": {},
        "failed_at": null
      },
      {
        "id": "tgt_8a3c51f07e2d94b6",
        "account_id": "acc_c0a85f3e17d94b26",
        "caption_override": null,
        "options": {},
        "status": "published",
        "external_id": "urn:li:share:7259012345678901248",
        "external_url": "https://www.linkedin.com/feed/update/urn:li:share:7259012345678901248",
        "error": null,
        "published_at": 1793628009000,
        "stats": {},
        "failed_at": null
      }
    ]
  }
}
```

- 404: No post with that ID in this workspace.

### Update a post

`PATCH https://scheduleforyou.com/api/v1/posts/{id}`

Takes the same fields as [Create a post](#create-a-post). Fields you leave out keep their values.
Sending `targets` or `account_ids` replaces the post’s targets; targets that already published are kept as they are. Platform checks run when you send targets and the post is not a draft.
Updating a `failed` post puts it back in the queue with fresh attempts for its failed targets; send a new `scheduled_at` to choose when it goes out. A post that was already due keeps its time; otherwise a new `scheduled_at` more than 5 minutes in the past is refused.

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| path | `id` | string | yes | The post ID. |
| body | `caption` | string |  | The post text, up to 65,000 characters. Each target is also checked against its platform’s own limit (see [Platforms](#platforms)). |
| body | `account_ids` | string[] |  | Shortcut for `targets`: one target per account ID, each using `caption`. Up to 100. Ignored when `targets` is sent. |
| body | `targets` | object[] |  | Where to publish, up to 100. Each item has `account_id` (required), an optional `caption` that replaces the post caption for that account (`null` uses the post caption), and optional per-platform `options` (see [Platform options](#platform-options)). Duplicate account IDs are dropped. |
| body | `media` | string[] |  | Up to 35 media IDs from [Upload a file](#upload-a-file), in display order. |
| body | `scheduled_at` | string \| number \| null |  | When to publish: an ISO 8601 date (`2026-11-02T14:00:00Z`) or epoch milliseconds. `null` saves a draft. |
| body | `status` | string |  | `draft` or `scheduled`. Send `draft` to save without a time and to skip platform checks. A post with a `scheduled_at` is scheduled. |
| body | `labels` | string[] |  | Up to 50 labels, each up to 40 characters. |
| body | `repeat` | string |  | `none` (default), `weekly` or `monthly`. After the post publishes, a copy is scheduled for the next occurrence: the same weekday, or the same day of the month (the last day in shorter months), at the same local time in the workspace’s timezone. |
| body | `evergreen` | boolean |  | After the post publishes, put a copy back in the next open posting slot. |
| body | `approval` | string \| null |  | `pending`, `approved` or `null`. Posts with `pending` approval are never published until approved. |
| body | `remind` | boolean |  | Send the reminder email before this post publishes, when reminders are turned on for the workspace. Defaults to `true`. |
| body | `note` | string |  | Internal note, up to 2,000 characters. Never published. |
| body | `batch` | string \| null |  | A free-form batch name, up to 80 characters, for grouping posts. |
| body | `signature` | boolean |  | Set to `false` to leave the workspace signature off this post. Defaults to `true`. |
| body | `force` | boolean |  | Save even when a platform check fails. The affected targets will then fail when they publish. |

Example request body:

```json
{
  "caption": "Our autumn menu starts Tuesday.",
  "scheduled_at": "2026-11-03T14:00:00Z"
}
```

Response (200):

```json
{
  "post": {
    "id": "post_3f9c2a71b8d04e65",
    "caption": "Our autumn menu starts Tuesday.",
    "media": [
      "med_a41c7e2093bd5f18"
    ],
    "scheduled_at": 1793714400000,
    "status": "scheduled",
    "labels": [
      "Launch"
    ],
    "repeat": "none",
    "evergreen": false,
    "approval": null,
    "remind": true,
    "note": "",
    "batch": null,
    "created_at": 1792487524000,
    "updated_at": 1792487524000,
    "signature": true,
    "targets": [
      {
        "id": "tgt_0f6e2d9a4c71b358",
        "account_id": "acc_7d21e0c94b3a6f15",
        "caption_override": "Autumn menu: Monday.",
        "options": {},
        "status": "pending",
        "external_id": null,
        "external_url": null,
        "error": null,
        "published_at": null,
        "stats": {},
        "failed_at": null
      },
      {
        "id": "tgt_8a3c51f07e2d94b6",
        "account_id": "acc_c0a85f3e17d94b26",
        "caption_override": null,
        "options": {},
        "status": "pending",
        "external_id": null,
        "external_url": null,
        "error": null,
        "published_at": null,
        "stats": {},
        "failed_at": null
      }
    ]
  }
}
```

- 400: A field has the wrong type.
- 404: No post with that ID in this workspace.
- 409: The post is publishing right now, or it is published and the update would reschedule it.
- 422: A platform check failed (`errors` lists each one), or the new `scheduled_at` is more than 5 minutes in the past.

### Delete a post

`DELETE https://scheduleforyou.com/api/v1/posts/{id}`

Deletes the post from Schedule For You. Anything already published stays on the platforms.

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| path | `id` | string | yes | The post ID. |

Response (200):

```json
{
  "ok": true
}
```

- 404: No post with that ID in this workspace.

### Publish a post now

`POST https://scheduleforyou.com/api/v1/posts/{id}/publish`

Publishes a draft or scheduled post right away, or retries the targets of a `failed` or `partial` post. Targets that already published are skipped; failed targets get a fresh set of attempts.
The request waits while the post publishes. `status` in the response is the post’s status afterwards: `published`, `partial`, `failed`, or `scheduled` when a target hit a temporary error, is still being processed by the platform, or didn’t fit in this request’s time; the scheduler picks it up within a couple of minutes. `publishing` means another run already had the post.

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| path | `id` | string | yes | The post ID. |

Response (200):

```json
{
  "status": "published",
  "post": {
    "id": "post_3f9c2a71b8d04e65",
    "caption": "Our autumn menu starts Monday.",
    "media": [
      "med_a41c7e2093bd5f18"
    ],
    "scheduled_at": 1793628000000,
    "status": "published",
    "labels": [
      "Launch"
    ],
    "repeat": "none",
    "evergreen": false,
    "approval": null,
    "remind": true,
    "note": "",
    "batch": null,
    "created_at": 1792487524000,
    "updated_at": 1793628009000,
    "signature": true,
    "targets": [
      {
        "id": "tgt_0f6e2d9a4c71b358",
        "account_id": "acc_7d21e0c94b3a6f15",
        "caption_override": "Autumn menu: Monday.",
        "options": {},
        "status": "published",
        "external_id": "1853920716648120321",
        "external_url": "https://x.com/harborbakery/status/1853920716648120321",
        "error": null,
        "published_at": 1793628009000,
        "stats": {},
        "failed_at": null
      },
      {
        "id": "tgt_8a3c51f07e2d94b6",
        "account_id": "acc_c0a85f3e17d94b26",
        "caption_override": null,
        "options": {},
        "status": "published",
        "external_id": "urn:li:share:7259012345678901248",
        "external_url": "https://www.linkedin.com/feed/update/urn:li:share:7259012345678901248",
        "error": null,
        "published_at": 1793628009000,
        "stats": {},
        "failed_at": null
      }
    ]
  }
}
```

- 402: The workspace has no active plan or trial.
- 404: No post with that ID in this workspace.
- 409: The post is waiting for approval, already published, or publishing right now.
- 422: The post has no targets.

### Create a preview link

`POST https://scheduleforyou.com/api/v1/posts/{id}/share`

Returns a link anyone can open, without an account, to see the post, comment on it, approve it or ask for changes. Approving sets the post’s `approval` to `approved`; asking for changes sets it to `pending`.
Links last 30 days. Calling this again returns the same link while it has more than 7 days left.

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| path | `id` | string | yes | The post ID. |

Response (200):

```json
{
  "url": "https://scheduleforyou.com/p/Xq3vK8mN2pR5tW9yB4cFh7Lz",
  "token": "Xq3vK8mN2pR5tW9yB4cFh7Lz",
  "expires_at": 1795080600000
}
```

- 404: No post with that ID in this workspace.

### List preview comments

`GET https://scheduleforyou.com/api/v1/posts/{id}/comments`

Returns the comments, approvals and change requests left on the post’s preview link, oldest first. `kind` is `comment`, `approved` or `changes`.

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| path | `id` | string | yes | The post ID. |

Response (200):

```json
{
  "comments": [
    {
      "id": "cmt_4e1a9c07b25d83f6",
      "author": "Dana (client)",
      "body": "Can we say “from Monday”?",
      "kind": "changes",
      "source": "client",
      "created_at": 1792594920000
    },
    {
      "id": "cmt_b7d30f9e612a4c85",
      "author": "Dana (client)",
      "body": "Approved",
      "kind": "approved",
      "source": "client",
      "created_at": 1792601100000
    }
  ]
}
```

- 404: No post with that ID in this workspace.

## Media

Files go straight from your code to storage, so uploads of any size never pass through the API. Uploading takes three requests:
[object Object]
[object Object]
Accepted types: JPG, PNG and WebP images up to 20 MB, GIF up to 15 MB, and MP4, MOV or WebM video up to 1 GB.

### Upload a file

`POST https://scheduleforyou.com/api/v1/media`

Registers a file and returns a presigned `upload_url` to send it to. The file is not listed or usable until you confirm the upload.

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| body | `filename` | string | yes | The file name, up to 200 characters. |
| body | `mime` | string | yes | `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `video/mp4`, `video/quicktime` or `video/webm`. |
| body | `size` | integer | yes | The file size in bytes. |
| body | `folder` | string \| null |  | A media library folder, up to 80 characters. |
| body | `poster_size` | integer |  | Videos only: the byte size of a JPEG still (up to 2 MB) to show as the video’s thumbnail. The response then also has a `poster_upload_url` to `PUT` it to with `Content-Type: image/jpeg`; confirm with `poster: true`. |

Example request body:

```json
{
  "filename": "autumn-menu.jpg",
  "mime": "image/jpeg",
  "size": 482113
}
```

Response (201):

```json
{
  "id": "med_a41c7e2093bd5f18",
  "upload_url": "https://storage.example.com/sfy-media/ws_5b1e9d02c7a64f38/med_a41c7e2093bd5f18/autumn-menu.jpg?X-Amz-Expires=900&X-Amz-Signature=…",
  "headers": {
    "content-type": "image/jpeg"
  }
}
```

- 413: The file is larger than its type allows.
- 415: The file type is not supported.

### Confirm or move a file

`PATCH https://scheduleforyou.com/api/v1/media/{id}`

Send `uploaded: true` after the `PUT` to confirm the upload; the stored size is read back from storage. Send `folder` to move the file to another library folder, or `null` to take it out of one.

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| path | `id` | string | yes | The media ID. |
| body | `uploaded` | true |  | Confirms the file is in storage. |
| body | `folder` | string \| null |  | The folder name, up to 80 characters, or `null`. |
| body | `poster_size` | integer |  | Videos only: asks for a `poster_upload_url` for a JPEG still of this size (up to 2 MB). |
| body | `poster` | true |  | Confirms the poster still is in storage. The video then gets a `thumb_url`. |

Example request body:

```json
{
  "uploaded": true
}
```

Response (200):

```json
{
  "media": {
    "id": "med_a41c7e2093bd5f18",
    "filename": "autumn-menu.jpg",
    "mime": "image/jpeg",
    "size": 482113,
    "folder": "Product shots",
    "created_at": 1792487524000,
    "url": "https://storage.example.com/sfy-media/ws_5b1e9d02c7a64f38/med_a41c7e2093bd5f18/autumn-menu.jpg?X-Amz-Expires=3600&X-Amz-Signature=…",
    "thumb_url": "https://scheduleforyou.com/_next/image?url=%2Fm%2Fmed_a41c7e2093bd5f18&w=640&q=75",
    "poster": false,
    "usage": {
      "posts": 2,
      "drafts": 0,
      "last_used_at": 1760000000000,
      "platforms": {
        "instagram": {
          "published": 1,
          "scheduled": 1,
          "last": 1760000000000
        },
        "x": {
          "published": 1,
          "scheduled": 0,
          "last": 1757000000000
        }
      }
    }
  }
}
```

- 404: No file with that ID in this workspace.
- 409: The file is not in storage yet. Upload it again.

### List media

`GET https://scheduleforyou.com/api/v1/media`

Returns uploaded files, newest first, up to 500. A `url` can be a signed link that expires after an hour, so fetch the list again rather than storing it.
`thumb_url` is a small cached version for previews: images always have one, videos once they have a poster still (`poster`), GIFs never.
`usage` says where each file has been: `posts` and `drafts` that include it, `last_used_at` (when it last published, or null), and per platform how many times it `published`, how many posts are still `scheduled`, and the `last` time it went out there. A platform the file was never published to is simply absent, so a script can pick files that are fresh for it.

Response (200):

```json
{
  "media": [
    {
      "id": "med_a41c7e2093bd5f18",
      "filename": "autumn-menu.jpg",
      "mime": "image/jpeg",
      "size": 482113,
      "folder": "Product shots",
      "created_at": 1792487524000,
      "url": "https://storage.example.com/sfy-media/ws_5b1e9d02c7a64f38/med_a41c7e2093bd5f18/autumn-menu.jpg?X-Amz-Expires=3600&X-Amz-Signature=…",
      "thumb_url": "https://scheduleforyou.com/_next/image?url=%2Fm%2Fmed_a41c7e2093bd5f18&w=640&q=75",
      "poster": false,
      "usage": {
        "posts": 2,
        "drafts": 0,
        "last_used_at": 1760000000000,
        "platforms": {
          "instagram": {
            "published": 1,
            "scheduled": 1,
            "last": 1760000000000
          },
          "x": {
            "published": 1,
            "scheduled": 0,
            "last": 1757000000000
          }
        }
      }
    }
  ]
}
```

### Delete a file

`DELETE https://scheduleforyou.com/api/v1/media/{id}`

Deletes the file from storage and the media library.

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| path | `id` | string | yes | The media ID. |

Response (200):

```json
{
  "ok": true
}
```

- 404: No file with that ID in this workspace.

## Webhooks

Register a URL and Schedule For You sends it a signed `POST` when a post finishes publishing. See [Webhook events](#webhook-events) for the payload and how to check the signature. A workspace can have up to 10 webhooks.

### List webhooks

`GET https://scheduleforyou.com/api/v1/webhooks`

Returns the workspace’s webhooks with the result of their last delivery. Secrets are shortened; the full secret is only returned when a webhook is created.

Response (200):

```json
{
  "webhooks": [
    {
      "id": "whk_e83b1f6a20d94c57",
      "url": "https://example.com/hooks/scheduleforyou",
      "format": "json",
      "events": [
        "post.published",
        "post.partial",
        "post.failed"
      ],
      "active": true,
      "secret": "whsec_3a9f…",
      "last_status": 200,
      "last_error": null,
      "last_sent_at": 1793628009000,
      "created_at": 1792487524000
    }
  ]
}
```

### Create a webhook

`POST https://scheduleforyou.com/api/v1/webhooks`

Adds a webhook. The response contains the full signing `secret` this one time, so store it now.

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| body | `url` | string | yes | A public `https://` URL, up to 2,000 characters. Private and local addresses are refused. |
| body | `events` | string[] |  | Any of `post.published`, `post.partial` and `post.failed`. Defaults to all three. |
| body | `format` | string |  | `json` (default): the signed JSON described under [Webhook events](#webhook-events). `slack` or `discord`: a readable message in the shape their incoming webhooks take (`{ "text": … }` / `{ "content": … }`), so a Slack or Discord webhook URL works as the endpoint. |

Example request body:

```json
{
  "url": "https://example.com/hooks/scheduleforyou",
  "events": [
    "post.published",
    "post.failed"
  ]
}
```

Response (201):

```json
{
  "webhook": {
    "id": "whk_e83b1f6a20d94c57",
    "url": "https://example.com/hooks/scheduleforyou",
    "format": "json",
    "events": [
      "post.published",
      "post.failed"
    ],
    "active": true,
    "secret": "whsec_3a9f0c27d1e84b6a5f2c9d0e7b13a48c6f5e2d1a9b0c7e34",
    "last_status": null,
    "last_error": null,
    "last_sent_at": null,
    "created_at": 1792487524000
  }
}
```

- 400: The URL is not a public https URL, or the workspace already has 10 webhooks.

### Update a webhook

`PATCH https://scheduleforyou.com/api/v1/webhooks/{id}`

Pauses or resumes a webhook, or changes the events it receives. The URL cannot be changed; create a new webhook instead.

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| path | `id` | string | yes | The webhook ID. |
| body | `active` | boolean |  | `false` stops deliveries until you set it back to `true`. |
| body | `events` | string[] |  | At least one of `post.published`, `post.partial` and `post.failed`. |
| body | `format` | string |  | `json`, `slack` or `discord`. |

Example request body:

```json
{
  "active": false
}
```

Response (200):

```json
{
  "webhook": {
    "id": "whk_e83b1f6a20d94c57",
    "url": "https://example.com/hooks/scheduleforyou",
    "format": "json",
    "events": [
      "post.published",
      "post.partial",
      "post.failed"
    ],
    "active": false,
    "secret": "whsec_3a9f…",
    "last_status": 200,
    "last_error": null,
    "last_sent_at": 1793628009000,
    "created_at": 1792487524000
  }
}
```

- 404: No webhook with that ID in this workspace.

### Delete a webhook

`DELETE https://scheduleforyou.com/api/v1/webhooks/{id}`

Deletes the webhook.

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| path | `id` | string | yes | The webhook ID. |

Response (200):

```json
{
  "ok": true
}
```

- 404: No webhook with that ID in this workspace.

### Send a test event

`POST https://scheduleforyou.com/api/v1/webhooks/{id}/test`

Sends a `ping` event to the webhook right away and returns what your endpoint answered: its HTTP `status`, and an `error` when the delivery failed or the status was not 2xx.

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| path | `id` | string | yes | The webhook ID. |

Response (200):

```json
{
  "status": 200,
  "error": null
}
```

- 404: No webhook with that ID in this workspace.

## RSS feeds

An RSS or Atom feed turns each new item into a post for the feed’s accounts. Feeds are checked every 15 minutes, and up to five new items are picked up per check. Items already in the feed when you add it are never posted. A workspace can have up to 20 feeds.
Posts from a feed get the label `RSS`. In `draft` mode they wait in drafts for review; in `queue` mode they are scheduled into the next open posting slot (if the feed has no accounts, they are saved as drafts).

### List feeds

`GET https://scheduleforyou.com/api/v1/feeds`

Returns the workspace’s feeds with the time and result of their last check.

Response (200):

```json
{
  "feeds": [
    {
      "id": "feed_6c0d4a9b13e2f758",
      "url": "https://harborbakery.com/blog/rss.xml",
      "title": "Harbor Bakery Journal",
      "mode": "queue",
      "template": "{title}\n\n{link}",
      "account_ids": [
        "acc_7d21e0c94b3a6f15",
        "acc_c0a85f3e17d94b26"
      ],
      "active": true,
      "last_checked_at": 1792487700000,
      "last_error": null
    }
  ]
}
```

### Add a feed

`POST https://scheduleforyou.com/api/v1/feeds`

Adds a feed and reads it once right away, so a wrong URL fails here rather than later. That first read only records the items already in the feed.

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| body | `url` | string | yes | The feed URL, up to 2,000 characters. |
| body | `mode` | string |  | `draft` (default) or `queue`. |
| body | `template` | string |  | The caption for each item, up to 2,000 characters. Placeholders: `{title}`, `{link}` and `{description}` (HTML removed, cut to 280 characters). Defaults to `{title}` and `{link}` on separate paragraphs. |
| body | `account_ids` | string[] |  | Accounts to post to, up to 100. IDs that are not in the workspace are dropped. |

Example request body:

```json
{
  "url": "https://harborbakery.com/blog/rss.xml",
  "mode": "queue",
  "account_ids": [
    "acc_7d21e0c94b3a6f15",
    "acc_c0a85f3e17d94b26"
  ]
}
```

Response (201):

```json
{
  "feed": {
    "id": "feed_6c0d4a9b13e2f758",
    "url": "https://harborbakery.com/blog/rss.xml",
    "title": "Harbor Bakery Journal",
    "mode": "queue",
    "template": "{title}\n\n{link}",
    "account_ids": [
      "acc_7d21e0c94b3a6f15",
      "acc_c0a85f3e17d94b26"
    ],
    "active": true,
    "last_checked_at": 1792487700000,
    "last_error": null
  }
}
```

- 400: The URL could not be read as an RSS or Atom feed, or the workspace already has 20 feeds.

### Update a feed

`PATCH https://scheduleforyou.com/api/v1/feeds/{id}`

Changes any of the feed’s settings. Fields you leave out keep their values.

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| path | `id` | string | yes | The feed ID. |
| body | `active` | boolean |  | `false` pauses the feed. |
| body | `mode` | string |  | `draft` or `queue`. |
| body | `template` | string |  | The caption template, 1 to 2,000 characters. |
| body | `account_ids` | string[] |  | Replaces the feed’s accounts. Up to 100. |

Example request body:

```json
{
  "mode": "draft"
}
```

Response (200):

```json
{
  "feed": {
    "id": "feed_6c0d4a9b13e2f758",
    "url": "https://harborbakery.com/blog/rss.xml",
    "title": "Harbor Bakery Journal",
    "mode": "draft",
    "template": "{title}\n\n{link}",
    "account_ids": [
      "acc_7d21e0c94b3a6f15",
      "acc_c0a85f3e17d94b26"
    ],
    "active": true,
    "last_checked_at": 1792487700000,
    "last_error": null
  }
}
```

- 404: No feed with that ID in this workspace.

### Delete a feed

`DELETE https://scheduleforyou.com/api/v1/feeds/{id}`

Deletes the feed. Posts it already created are kept.

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| path | `id` | string | yes | The feed ID. |

Response (200):

```json
{
  "ok": true
}
```

- 404: No feed with that ID in this workspace.

### Check a feed now

`POST https://scheduleforyou.com/api/v1/feeds/{id}/check`

Reads the feed now instead of waiting for the next check, and returns how many posts it `created`. If the feed could not be read, `feed.last_error` says why.

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| path | `id` | string | yes | The feed ID. |

Response (200):

```json
{
  "created": 1,
  "feed": {
    "id": "feed_6c0d4a9b13e2f758",
    "url": "https://harborbakery.com/blog/rss.xml",
    "title": "Harbor Bakery Journal",
    "mode": "queue",
    "template": "{title}\n\n{link}",
    "account_ids": [
      "acc_7d21e0c94b3a6f15",
      "acc_c0a85f3e17d94b26"
    ],
    "active": true,
    "last_checked_at": 1792487700000,
    "last_error": null
  }
}
```

- 404: No feed with that ID in this workspace.

## Workspace

Settings and read-only views of the workspace the API key belongs to.

### Set the signature

`PUT https://scheduleforyou.com/api/v1/signature`

Sets text that is added to the end of every post, on its own paragraph, when it publishes. It is skipped for a post that already contains it, for a post that would go over its platform’s character limit, and for posts created with `signature: false`. An empty string turns the signature off.

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| body | `signature` | string | yes | Up to 500 characters. Leading and trailing spaces are removed. |

Example request body:

```json
{
  "signature": "Harbor Bakery · 12 Quay Street"
}
```

Response (200):

```json
{
  "signature": "Harbor Bakery · 12 Quay Street"
}
```

### Shared calendar link

`GET https://scheduleforyou.com/api/v1/calendar-link`

The shared calendar is a read-only page of the workspace’s upcoming and recent posts (next 90 days, last 14) for clients and teammates, plus an iCal feed (`ics_url`) that Google Calendar, Apple Calendar and Outlook can subscribe to. Anyone with the link can see it; there is no sign-in.

Response (200):

```json
{
  "enabled": true,
  "url": "https://scheduleforyou.com/c/Qm3xT9vLp2aZ8rKd1sWn4yHc7bFe",
  "ics_url": "https://scheduleforyou.com/c/Qm3xT9vLp2aZ8rKd1sWn4yHc7bFe/feed.ics"
}
```

### Create or rotate the calendar link

`POST https://scheduleforyou.com/api/v1/calendar-link`

Turns the shared calendar on, keeping the current link. With `rotate: true` a new link replaces it and every copy of the old one stops working.

| Where | Field | Type | Required | Description |
| --- | --- | --- | --- | --- |
| body | `rotate` | boolean |  | Replace the current link with a new one. |

Example request body:

```json
{
  "rotate": false
}
```

Response (200):

```json
{
  "enabled": true,
  "url": "https://scheduleforyou.com/c/Qm3xT9vLp2aZ8rKd1sWn4yHc7bFe",
  "ics_url": "https://scheduleforyou.com/c/Qm3xT9vLp2aZ8rKd1sWn4yHc7bFe/feed.ics"
}
```

### Turn the calendar link off

`DELETE https://scheduleforyou.com/api/v1/calendar-link`

Turns the shared calendar off. The page and the feed answer 404 from then on.

Response (200):

```json
{
  "enabled": false,
  "url": null,
  "ics_url": null
}
```

### Get automations

`GET https://scheduleforyou.com/api/v1/automations`

Returns the signature, webhooks, feeds and a short list of accounts in one request. With an API key, `workspace.role` is `api`.
`track_links` says whether link click tracking is on for the workspace (turned on in the dashboard under Automations → Link clicks). When it is, links in captions and first comments are swapped for short links on the app’s domain as the post publishes, and every click is counted: `link_clicks` and `tracked_links` are the workspace totals, and each published target carries its own count in `stats.clicks`. With `utm_tags` on, the destination gets `utm_source` (the platform), `utm_medium=social` and `utm_campaign` (the post’s first label) before it is shortened.
`calendar` is the shared calendar link (see [Shared calendar link](#get-the-calendar-link)).
`referral` is the workspace’s referral link and its results: `signups` workspaces were created through it, `rewarded` of them have paid, and each one credited `credit_cents` (a month of the workspace’s plan) to the owner’s bill. `share` carries the share-to-queue bookmarklet; opening `<app_url>?share=1&text=…&url=…` prefills the composer.

Response (200):

```json
{
  "workspace": {
    "id": "ws_5b1e9d02c7a64f38",
    "name": "Harbor Bakery",
    "role": "api"
  },
  "signature": "Harbor Bakery · 12 Quay Street",
  "track_links": true,
  "utm_tags": true,
  "link_clicks": 128,
  "tracked_links": 9,
  "calendar": {
    "enabled": true,
    "url": "https://scheduleforyou.com/c/Qm3xT9vLp2aZ8rKd1sWn4yHc7bFe",
    "ics_url": "https://scheduleforyou.com/c/Qm3xT9vLp2aZ8rKd1sWn4yHc7bFe/feed.ics"
  },
  "webhooks": [
    {
      "id": "whk_e83b1f6a20d94c57",
      "url": "https://example.com/hooks/scheduleforyou",
      "format": "json",
      "events": [
        "post.published",
        "post.partial",
        "post.failed"
      ],
      "active": true,
      "secret": "whsec_3a9f…",
      "last_status": 200,
      "last_error": null,
      "last_sent_at": 1793628009000,
      "created_at": 1792487524000
    }
  ],
  "feeds": [
    {
      "id": "feed_6c0d4a9b13e2f758",
      "url": "https://harborbakery.com/blog/rss.xml",
      "title": "Harbor Bakery Journal",
      "mode": "queue",
      "template": "{title}\n\n{link}",
      "account_ids": [
        "acc_7d21e0c94b3a6f15",
        "acc_c0a85f3e17d94b26"
      ],
      "active": true,
      "last_checked_at": 1792487700000,
      "last_error": null
    }
  ],
  "accounts": [
    {
      "id": "acc_7d21e0c94b3a6f15",
      "platform": "x",
      "username": "harborbakery",
      "display_name": "Harbor Bakery"
    }
  ],
  "referral": {
    "code": "k7mvq2xd",
    "url": "https://scheduleforyou.com/r/k7mvq2xd",
    "signups": 3,
    "rewarded": 1,
    "credit_cents": 1900,
    "trial_days": 14,
    "share_text": "I schedule my posts … This link gets you a 14-day free trial: https://scheduleforyou.com/r/k7mvq2xd"
  },
  "share": {
    "bookmarklet": "javascript:void(open(\"https://scheduleforyou.com/app?share=1\"+…))",
    "app_url": "https://scheduleforyou.com/app"
  }
}
```

### List team members

`GET https://scheduleforyou.com/api/v1/team`

Returns the workspace’s members, invitations that have not been accepted yet, and the workspace plan. Inviting people is done from the dashboard.

Response (200):

```json
{
  "members": [
    {
      "id": "0b7e4c1a-5d2f-4e9b-8a63-1c9d2e7f4a50",
      "email": "owner@harborbakery.com",
      "name": "Sam Rivera",
      "role": "owner"
    }
  ],
  "invites": [
    {
      "id": "inv_93a1d7f0c2b84e65",
      "email": "dana@harborbakery.com",
      "role": "editor",
      "created_at": 1792487524000
    }
  ],
  "plan": "studio"
}
```

### List API keys

`GET https://scheduleforyou.com/api/v1/keys`

Returns the workspace’s API keys with when each was last used. Only the first eight characters of a key (`prefix`) are stored in readable form. Creating and revoking keys is done from the dashboard.

Response (200):

```json
{
  "keys": [
    {
      "id": "key_2b7f90e1c4a35d68",
      "name": "CMS",
      "prefix": "sfy_Qm7x",
      "created_at": 1788454320000,
      "last_used_at": 1792487524000
    }
  ]
}
```

## Platforms

What each platform accepts. The same rules are applied when a post is created.

### List platforms

`GET https://scheduleforyou.com/api/v1/platforms`

Returns every platform with its caption limit (`maxChars`), image limit (`maxImages`), whether it takes video, and which media it requires (`requiresMedia`: `none`, `image`, `video` or `any`). `fields` lists the per-target options the platform takes, and `configured` is `false` for platforms that cannot be connected yet.

Response (200):

```json
{
  "platforms": [
    {
      "id": "x",
      "label": "X / Twitter",
      "color": "#000000",
      "authMode": "oauth",
      "maxChars": 280,
      "maxImages": 4,
      "video": true,
      "requiresMedia": "none",
      "configured": true
    },
    {
      "id": "youtube",
      "label": "YouTube",
      "color": "#ff0000",
      "authMode": "oauth",
      "maxChars": 5000,
      "maxImages": 0,
      "video": true,
      "requiresMedia": "video",
      "fields": [
        {
          "key": "title",
          "label": "Title",
          "type": "text",
          "required": true
        },
        {
          "key": "privacy",
          "label": "Privacy",
          "type": "select",
          "options": [
            "public",
            "unlisted",
            "private"
          ]
        }
      ],
      "configured": true
    }
  ]
}
```

## Dashboard-only actions

- `POST /keys`: Create an API key
- `DELETE /keys/{id}`: Revoke an API key
- `POST /team`: Invite a team member
- `DELETE /team/invites/{id}`: Revoke an invitation that hasn’t been accepted

## Webhook events

| Event | Sent when |
| --- | --- |
| post.published | Every target of the post published. |
| post.partial | Some targets published and at least one failed for good. |
| post.failed | No target published. |

## MCP server

The MCP server lives at https://scheduleforyou.com/api/mcp and takes the same API key. See the HTML reference for the tool list and client setup.

## Related

- [API overview](https://scheduleforyou.com/api)
- [Pricing](https://scheduleforyou.com/pricing)
