# Create a post

Publish now or schedule one post to up to 20 social accounts, with shared media and per-account text and options.


```http
POST https://social-publish-api.onrender.com/v1/posts
```

Creates a post for one or more connected accounts. Each account becomes a **target** that is validated, published and tracked separately, so one platform failing doesn't affect the others.

The request is validated against every target platform's rules before anything is saved. If any target is invalid, nothing is created or charged and you get a `400` listing the problems per account. On success, **1 credit per target** is charged and the targets are queued: immediately with `publishNow: true`, or for `scheduledFor`.

## Headers

| Header | Required | Description |
| --- | --- | --- |
| `x-access-key` | Yes* | Your API key. *Or send `Authorization: Bearer <key>`. |
| `content-type` | Yes | `application/json` |
| `Idempotency-Key` | No | Any unique string (for example a UUID). Retrying with the same key returns the original post instead of creating a duplicate. See [Idempotency](#idempotency). |

## Body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `content` | string | Yes | Post text, 1–10,000 characters. Used for every target without its own `content`. Platform limits are lower; see the [platform guides](/docs/platforms). |
| `targets` | object[] | Yes | 1–20 accounts to post to. |
| `targets[].accountId` | string (UUID) | Yes | Account ID from [List accounts](/docs/api/list-accounts). Must be `active`. |
| `targets[].content` | string | No | Replaces `content` for this account only (1–10,000 characters). |
| `targets[].options` | object | No | Platform-specific options, for example `{ "privacyLevel": "SELF_ONLY" }` for TikTok. See [Platform Options](/docs/platform-options). |
| `media` | object[] | No | Up to 10 items, shared by all targets. Default `[]`. |
| `media[].url` | string | Yes | Public `http(s)` URL, or `media://<asset-id>` for a file in your [media library](/docs/media). Max 2,048 characters. |
| `media[].type` | string | Yes | `image`, `video` or `document`. `document` (PDF) is only accepted by LinkedIn. |
| `media[].altText` | string | No | Alt text, up to 1,000 characters (used where the platform supports it). |
| `media[].title` | string | No | Title shown on a document card, up to 400 characters (documents only). |
| `publishNow` | boolean | No* | `true` publishes right away. Default `false`. |
| `scheduledFor` | string | No* | ISO 8601 date-time with a time zone (`Z` or an offset), in the future. |

\* Send either `publishNow: true` or `scheduledFor`. If both are sent, `publishNow: true` wins. A `scheduledFor` more than one minute in the past is rejected.

> [!NOTE]
> Duplicate account IDs in `targets` are merged: each account is posted to once, using the last entry for that account.

## Request

```bash tab="cURL"
curl -X POST https://social-publish-api.onrender.com/v1/posts \
  -H "x-access-key: $RELAYCAST_API_KEY" \
  -H "content-type: application/json" \
  -H "Idempotency-Key: 4d6e9a8c-launch-2026-11-02" \
  -d '{
    "content": "Our new release is live. Faster uploads, a calendar view and more.",
    "media": [
      { "url": "https://cdn.example.com/release.jpg", "type": "image", "altText": "Release banner" }
    ],
    "targets": [
      { "accountId": "6f1c2a7e-4b8d-4f7a-9a51-2d3c4b5a6e7f" },
      {
        "accountId": "3c8d1e2f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
        "content": "Our new release is live. Here is what our team shipped this quarter and why it matters for your workflow.",
        "options": { "visibility": "PUBLIC" }
      }
    ],
    "scheduledFor": "2026-11-02T15:00:00Z"
  }'
```

```ts tab="Node.js"
const res = await fetch("https://social-publish-api.onrender.com/v1/posts", {
  method: "POST",
  headers: {
    "x-access-key": process.env.RELAYCAST_API_KEY!,
    "content-type": "application/json",
    "Idempotency-Key": "4d6e9a8c-launch-2026-11-02",
  },
  body: JSON.stringify({
    content: "Our new release is live. Faster uploads, a calendar view and more.",
    media: [{ url: "https://cdn.example.com/release.jpg", type: "image", altText: "Release banner" }],
    targets: [
      { accountId: "6f1c2a7e-4b8d-4f7a-9a51-2d3c4b5a6e7f" },
      {
        accountId: "3c8d1e2f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
        content:
          "Our new release is live. Here is what our team shipped this quarter and why it matters for your workflow.",
        options: { visibility: "PUBLIC" },
      },
    ],
    scheduledFor: "2026-11-02T15:00:00Z",
  }),
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
const post = body.data; // res.status is 201 (created) or 200 (idempotent replay)
```

```python tab="Python"
import os, requests

res = requests.post(
    "https://social-publish-api.onrender.com/v1/posts",
    headers={
        "x-access-key": os.environ["RELAYCAST_API_KEY"],
        "Idempotency-Key": "4d6e9a8c-launch-2026-11-02",
    },
    json={
        "content": "Our new release is live. Faster uploads, a calendar view and more.",
        "media": [{"url": "https://cdn.example.com/release.jpg", "type": "image", "altText": "Release banner"}],
        "targets": [
            {"accountId": "6f1c2a7e-4b8d-4f7a-9a51-2d3c4b5a6e7f"},
            {
                "accountId": "3c8d1e2f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
                "content": "Our new release is live. Here is what our team shipped this quarter and why it matters for your workflow.",
                "options": {"visibility": "PUBLIC"},
            },
        ],
        "scheduledFor": "2026-11-02T15:00:00Z",
    },
)
body = res.json()
if not res.ok:
    raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
post = body["data"]
```

## Response

`201 Created` with the new post. With `publishNow: true` the post `status` is `publishing`; with `scheduledFor` it is `scheduled`. Targets start as `queued`.

```json title="Response 201"
{
  "data": {
    "id": "a3f8c2d1-9b7e-4c6a-8f5d-2e1b0c9a8d7f",
    "content": "Our new release is live. Faster uploads, a calendar view and more.",
    "media": [
      { "url": "https://cdn.example.com/release.jpg", "type": "image", "altText": "Release banner" }
    ],
    "status": "scheduled",
    "scheduledFor": "2026-11-02T15:00:00.000Z",
    "request": {
      "content": "Our new release is live. Faster uploads, a calendar view and more.",
      "targets": [
        { "accountId": "6f1c2a7e-4b8d-4f7a-9a51-2d3c4b5a6e7f" },
        {
          "accountId": "3c8d1e2f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
          "content": "Our new release is live. Here is what our team shipped this quarter and why it matters for your workflow.",
          "options": { "visibility": "PUBLIC" }
        }
      ],
      "media": [
        { "url": "https://cdn.example.com/release.jpg", "type": "image", "altText": "Release banner" }
      ],
      "publishNow": false,
      "scheduledFor": "2026-11-02T15:00:00Z"
    },
    "createdAt": "2026-10-05T09:30:12.441Z",
    "updatedAt": "2026-10-05T09:30:12.441Z",
    "targets": [
      {
        "id": "e1d2c3b4-a596-4877-8695-a4b3c2d1e0f9",
        "accountId": "6f1c2a7e-4b8d-4f7a-9a51-2d3c4b5a6e7f",
        "platform": "twitter",
        "status": "queued",
        "content": null,
        "options": {},
        "attempts": 0,
        "externalPostId": null,
        "externalUrl": null,
        "errorCode": null,
        "errorMessage": null,
        "publishedAt": null,
        "createdAt": "2026-10-05T09:30:12.441Z",
        "updatedAt": "2026-10-05T09:30:12.441Z"
      },
      {
        "id": "f0e9d8c7-b6a5-4493-8281-706f5e4d3c2b",
        "accountId": "3c8d1e2f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
        "platform": "linkedin",
        "status": "queued",
        "content": "Our new release is live. Here is what our team shipped this quarter and why it matters for your workflow.",
        "options": { "visibility": "PUBLIC" },
        "attempts": 0,
        "externalPostId": null,
        "externalUrl": null,
        "errorCode": null,
        "errorMessage": null,
        "publishedAt": null,
        "createdAt": "2026-10-05T09:30:12.441Z",
        "updatedAt": "2026-10-05T09:30:12.441Z"
      }
    ]
  }
}
```

The fields are described in [Get a post](/docs/api/get-post#post-fields). To follow progress, poll [`GET /v1/posts/{id}`](/docs/api/get-post) or subscribe to [webhook events](/docs/api/webhook-events).

## Idempotency

Send an `Idempotency-Key` header to make retries safe:

| Situation | Result |
| --- | --- |
| First request with a key | `201`, post created and credits charged |
| Any later request with the same key in the same project | `200` with the **original** post; nothing is created or charged |
| Two concurrent requests with the same key | One creates the post, the other gets `200` with it |

The key is checked before the body is validated, and the body of a replay is not compared with the original, so use a new key for every distinct post. Keys are scoped to the project. See [Idempotency & Retries](/docs/idempotency).

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | `invalid_json` | The body isn't valid JSON. |
| 400 | `validation_error` | Schema errors (`details` is a list of issues with `path` and `message`), content that a platform would reject (`details` is keyed by account ID), or `scheduledFor` in the past. |
| 400 | `unknown_account` | One or more `accountId`s don't exist in this project. `details.missing` lists them. |
| 400 | `account_inactive` | One or more accounts need reconnecting. `details.accountIds` lists them. |
| 400 | `unknown_media` | A `media://` reference (in `media` or in `options`) isn't in this project's media library. `details.missing` lists them. |
| 401 | `unauthorized` | Missing or invalid API key. |
| 402 | `insufficient_credits` | The project has fewer credits than targets. The message says how many are needed. |
| 429 | `rate_limited` | Rate limit exceeded for this key. |

```json title="Response 400"
{
  "error": {
    "code": "validation_error",
    "message": "Content is not valid for some platforms",
    "details": {
      "5e4d3c2b-1a09-4f8e-9d7c-6b5a49382716": [
        { "field": "options.privacyLevel", "message": "Choose who can view this TikTok post" }
      ],
      "8a7b6c5d-4e3f-4a1b-9c8d-7e6f5a4b3c2d": [
        { "field": "media", "message": "YouTube needs exactly one video" }
      ]
    }
  }
}
```

```json title="Response 402"
{
  "error": {
    "code": "insufficient_credits",
    "message": "This post needs 2 credits"
  }
}
```

## After the post is created

- Each target is published independently. Temporary failures are retried automatically with backoff; permanent failures set the target to `failed` with `errorCode` and `errorMessage`, and its credit is refunded.
- The post `status` rolls up from its targets: `published` when all succeeded, `failed` when all failed, `partial` when some failed.
- To cancel a scheduled post, delete it from **[Posts](https://social-publish-kappa.vercel.app/dashboard/posts)** in the dashboard; targets that haven't been published yet are cancelled and refunded. There is no delete endpoint in the API.

See [Posts](/docs/posts) and [Scheduling](/docs/scheduling) for the full lifecycle.
