Posts
Create a post
Publish now or schedule one post to up to 20 social accounts, with shared media and per-account text and options.
POST https://social-publish-api.onrender.com/v1/postsCreates 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. |
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. |
targets | object[] | Yes | 1–20 accounts to post to. |
targets[].accountId | string (UUID) | Yes | Account ID from 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. |
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. 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.
Duplicate account IDs in targets are merged: each account is posted to once, using the last entry for that account.
Request
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"
}'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)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.
{
"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. To follow progress, poll GET /v1/posts/{id} or subscribe to 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.
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 accountIds 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. |
{
"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" }
]
}
}
}{
"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
failedwitherrorCodeanderrorMessage, and its credit is refunded. - The post
statusrolls up from its targets:publishedwhen all succeeded,failedwhen all failed,partialwhen some failed. - To cancel a scheduled post, delete it from 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 and Scheduling for the full lifecycle.
This page is also available as Markdown.