Browse docs
Posts#
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /posts | posts.read | List posts (keyset; status/client/platform filters) |
| GET | /posts/:id | posts.read | Get a post + variants + targets |
| POST | /posts | posts.compose | Compose + fan out (or save a draft) |
| PATCH | /posts/:id | posts.compose | Replace a draft post — content/media/targets/schedule (409 if not a draft) |
{
"clientId": "6d58a0f2-1c3e-4b7a-9f21-0a1b2c3d4e5f",
"content": "Launch day is here 🚀",
"mediaIds": [],
"targets": { "mode": "selection", "accountIds": ["a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d"] },
"schedule": { "type": "at", "scheduledAt": "2026-07-01T15:00:00Z" }
}The full compose body#
The minimal body above is enough to publish, but POST /posts accepts a lot more: per-platform overrides, a video cover, thread mode, AI-content disclosure, draft/approval routing, and campaign grouping. Every field below is part of the real contract — send only what you need, everything else defaults.
Post body — every top-level field#
| Field | Type | Default | Notes |
|---|---|---|---|
clientId | uuid | required | The client group this post belongs to. |
content | string (≤50,000) | "" | Base caption. Per-platform captions live in variants[].contentOverride. |
mediaIds | uuid[] (≤20) | [] | Ordered media asset ids attached to the base post. |
targets | object | required | { mode, clientId?, accountIds?, platforms? } — mode is group (needs clientId), selection, or accounts (the latter two need accountIds); an optional platforms filter narrows a group selection. |
variants | array (≤10) | [] | Per-platform { platform, contentOverride?, mediaIdsOverride?, overrides? } — see the platform options below. |
schedule | object | required | { type, scheduledAt?, scheduledLocal?, from?, to?, spreadMinutes? } — type is now, at (needs scheduledAt or scheduledLocal), or range (needs from/to). scheduledLocal (YYYY-MM-DDTHH:mm, no zone) states a wall-clock intent resolved per target's own timezone and wins over scheduledAt when both are sent. spreadMinutes (0–720) opts into anti-ban jitter — a deterministic delay inside the window, never earlier. |
scheduleMode | "managed" \| "native" | "managed" | native hands Facebook/YouTube targets to the platform's own scheduler; every other target still falls back to managed. |
timezone | IANA string | "UTC" | Used to resolve schedule.scheduledLocal. |
aiDisclosure | boolean | false | Mandatory AI-content disclosure. Also copied onto every variant's aiGenerated override automatically at publish time — don't set aiGenerated yourself unless you want a per-variant exception. |
requestApproval | boolean | false | Submit into the approval workflow if the role/client requires it. |
saveDraft | boolean | false | Persist as a draft instead of scheduling — no anti-ban jitter, no quota check, no cross-platform validation gate. |
campaignId | uuid | optional | Tag the post into a named campaign. |
thread | object | optional | { segments: [{ content, mediaIds }, …] }, 2–50 entries. Publishes a connected reply-chain on thread-capable platforms (X, Threads) — segment 0 is the head, each later segment replies to the one before it. content above is ignored for thread-capable targets in favor of segments. |
mediaAltTexts | object | optional | Map of media_assets.id → alt text (≤2,000 chars each) for carousel/per-slide alt text. |
coverMediaId | uuid | optional | A separately uploaded IMAGE asset used as the video's cover/thumbnail (YouTube, Instagram, Facebook, Telegram). Omit to use the auto-extracted poster frame. See Uploading media & video covers. |
coverFrameMs | integer (0–86,400,000) | optional | Cover frame offset in milliseconds, sent to TikTok as its native video_cover_timestamp_ms. Independent of coverMediaId — TikTok takes a timestamp, not an image. |
Per-platform variant overrides (`variants[].overrides`)#
overrides is a free-form object — Record<string, unknown> on the wire. Each platform adapter reads only the keys it declares and ignores everything else, so overrides is safe to keep flat: send youtube keys on a youtube variant, tiktok keys on a tiktok variant, and so on.
The tables below list, per platform, the override keys SkedCast's adapters actually read today — verified against the adapter code, not just what a UI happens to render. A field marked Required means the platform rejects a publish without it (the target fails rather than silently degrading); everything else is optional with the stated default.
X (Twitter) — variant overrides#
| Field | Type | Values / default | Notes |
|---|---|---|---|
quoteTweetId | string | — | Quote a tweet id. |
replySettings | enum | following \| mentionedUsers \| subscribers \| verified | Omit for X's default (everyone can reply). |
madeWithAi | boolean | false | Discloses AI-generated/altered media — independent of the post-level aiDisclosure field (X has no aiGenerated reader). |
paidPartnership | boolean | false | Discloses a paid partnership / branded content. |
LinkedIn — variant overrides#
| Field | Type | Values / default | Notes |
|---|---|---|---|
visibility | enum | PUBLIC \| CONNECTIONS | Defaults to PUBLIC. |
Instagram — variant overrides#
| Field | Type | Values / default | Notes |
|---|---|---|---|
contentType | enum | feed \| reel \| story | Defaults to feed; a single video with no contentType publishes as a reel. |
firstComment | string | — | Posted right after publish. Best-effort — needs the Instagram comment-management permission (pending Meta App Review); a failed comment never fails the post. |
userTags | string[] or [{ username, x?, y? }] | ≤20 | People/photo tags. A comma-separated string of @usernames also works. |
collaborators | string[] | ≤3 | Collaborator usernames. |
shareToFeed | boolean | platform default | Reels only: also show the Reel in the main feed grid. |
coverUrl | string (url) | — | Reels only: a public URL for the Reel's cover frame. This is separate from the post-level coverMediaId mechanism. |
locationId | string | — | Instagram location page id. |
paidPartnership | boolean | false | Adds Instagram's "Paid partnership" disclosure label. |
Facebook — variant overrides#
| Field | Type | Values / default | Notes |
|---|---|---|---|
contentType | enum | feed \| reel \| story | Defaults to feed. |
firstComment | string | — | Posted right after publish. Best-effort — needs the Page engagement-management permission; a failed comment never fails the post. |
TikTok — variant overrides#
| Field | Type | Values / default | Notes |
|---|---|---|---|
privacyLevel | enum — Required | PUBLIC_TO_EVERYONE \| MUTUAL_FOLLOW_FRIENDS \| FOLLOWER_OF_CREATOR \| SELF_ONLY | No default — TikTok's Content Sharing Guidelines require an explicit choice. Also constrained to whatever visibility set the connected creator account allows. |
yourBrand | boolean | false | Creator promoting their OWN brand/business (brand_organic_toggle). |
brandedContent | boolean | false | A paid partnership / third-party promotion (brand_content_toggle). Cannot be combined with privacyLevel: "SELF_ONLY". |
disableComment | boolean | false | — |
disableDuet | boolean | false | — |
disableStitch | boolean | false | — |
YouTube — variant overrides#
| Field | Type | Values / default | Notes |
|---|---|---|---|
title | string (≤100) — Required | — | Falls back to the first line of content when unset, but send it explicitly — video titles matter and the fallback is a plain first line. |
privacyStatus | enum | public \| unlisted \| private | Defaults to the channel default when unset. |
categoryId | enum | 1 Film & Animation, 2 Autos & Vehicles, 10 Music, 15 Pets & Animals, 17 Sports, 19 Travel & Events, 20 Gaming, 22 People & Blogs, 23 Comedy, 24 Entertainment, 25 News & Politics, 26 Howto & Style, 27 Education, 28 Science & Technology, 29 Nonprofits & Activism | Defaults to "22" (People & Blogs). |
madeForKids | boolean | false | COPPA self-declaration (status.selfDeclaredMadeForKids) — always sent, defaults to "not made for kids". |
tags | string[] or comma-separated string | — | snippet.tags. |
license | enum | youtube \| creativeCommon | Defaults to the Standard YouTube License. |
description | string | — | Overrides the caption as the YouTube description. |
Threads — variant overrides#
| Field | Type | Values / default | Notes |
|---|---|---|---|
quotePostId | string | — | Quote a Threads post id. |
replyControl | enum | everyone \| accounts_you_follow \| mentioned_only | Omit for Threads' default (everyone can reply). |
Pinterest — variant overrides#
| Field | Type | Values / default | Notes |
|---|---|---|---|
boardId | string — Required | — | A pin publishes onto a board — the target fails at publish time without one. |
title | string (≤100) | — | Optional headline, distinct from the description (content). |
link | string (url) | — | Destination URL. |
Bluesky — variant overrides#
| Field | Type | Values / default | Notes |
|---|---|---|---|
link | string (url) | — | External link for an embed preview card. |
labelSexual | boolean | false | Self-labels the post as sexually suggestive. |
labelNudity | boolean | false | Self-labels the post as containing non-sexual nudity. |
labelPorn | boolean | false | Self-labels the post as adult/pornographic content. |
labelGraphicMedia | boolean | false | Self-labels the post as violent or otherwise graphic media. |
Telegram — variant overrides#
| Field | Type | Values / default | Notes |
|---|---|---|---|
chatId | string — Required | — | The connected account is the bot, not the channel — a destination chat/channel id (or @channel) is required per post. The bot must be an admin with "Post Messages" rights. |
parseMode | enum | MarkdownV2 \| HTML \| Markdown | How the message text is parsed. |
disableNotification | boolean | false | Post without a notification sound. |
protectContent | boolean | false | Prevent forwarding and saving. |
{
"clientId": "6d58a0f2-1c3e-4b7a-9f21-0a1b2c3d4e5f",
"content": "Behind the scenes of launch day 🎬",
"mediaIds": ["9b1e2c3d-4f5a-4b6c-8d9e-0f1a2b3c4d5e"],
"targets": { "mode": "selection", "accountIds": ["a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d"] },
"variants": [
{
"platform": "tiktok",
"overrides": {
"privacyLevel": "PUBLIC_TO_EVERYONE",
"disableComment": false,
"disableDuet": true
}
}
],
"schedule": { "type": "at", "scheduledAt": "2026-07-01T15:00:00Z" }
}Post targets (per-destination lifecycle)#
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /post-targets | posts.read | List fan-out rows (status/client/platform filters) |
| GET | /post-targets/:id | posts.read | Get one fan-out target |
| POST | /post-targets/:id/reschedule | posts.compose | Move a pending target to a new time (scheduledAt or shiftMinutes) |
| POST | /post-targets/:id/retry | posts.compose | Re-queue a failed / rate-limited target |
| POST | /post-targets/:id/cancel | posts.compose | Cancel a still-pending target |
| POST | /post-targets/bulk | posts.compose | Apply one action across many target ids |
Clients & accounts#
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /clients | clients.read | List client groups (type/search filters) |
| GET | /clients/:id | clients.read | Get one client |
| POST | /clients | clients.manage | Create a client group (Owner/Manager; Idempotency-Key) |
| PATCH | /clients/:id | clients.manage | Update name / type / post mode / rules / brand voice (Owner/Manager) |
| DELETE | /clients/:id | clients.manage | Archive a client (Owner only) |
| POST | /clients/:id/connect-invites | accounts.connect | Mint a self-serve account connect link (Owner/Manager; Idempotency-Key) |
| GET | /accounts | accounts.read | List connected social accounts |
| GET | /accounts/:id | accounts.read | Get one connected account |
| GET | /agency | agency.read | The current workspace profile |
Managing clients#
The two write scopes on this surface are clients.manage (create / update / archive a client) and accounts.connect (mint connect links). Both are Owner/Manager actions — a token's effective power is still scopes ∩ role, so a Manager token can create and update clients but the archive (DELETE) is Owner-only.
POST /clients is idempotent on (agency, name); replaying the same Idempotency-Key returns the original client instead of a duplicate. PATCH /clients/:id is a partial update — only the fields you send change.
{
"name": "Acme Studios",
"type": "brand",
"defaultPostMode": "now"
}Connect links#
A connect link lets a client connect their OWN social accounts without a SkedCast login. You mint one on demand with POST /clients/:id/connect-invites, share the returned url, and the recipient authorizes each requested platform straight from that page.
Each link is bound to a SINGLE client and carries the exact platforms you requested (platforms, ≥1). It is valid until expiresAt (expiresInHours, 1–168, default 48) and is multi-use within that window — one link lets the recipient connect every requested platform, returning as many times as they need. Optionally record a recipient email for your own tracking.
The url has the shape ${CONSOLE_URL}/invite/<token> (the token is also returned raw). Send the whole url; the token IS the authorization for that page — treat it like a secret.
curl -X POST https://api.skedcast.com/v1/clients/<clientId>/connect-invites \
-H "Authorization: Bearer sked_live_YOUR_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "platforms": ["instagram", "tiktok"], "expiresInHours": 72 }'{
"data": {
"inviteId": "3f9a…",
"url": "https://app.skedcast.com/invite/inv_9f3a2b…",
"token": "inv_9f3a2b…",
"platformsRequested": ["instagram", "tiktok"],
"expiresAt": "2026-07-06T15:00:00Z"
},
"meta": { "correlationId": "c-…" }
}Analytics#
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /analytics/overview | analytics.read | Tenant rollup + per-platform + daily series |
| GET | /analytics/posts | analytics.read | Per-post analytics list (keyset) |
| GET | /analytics/posts/:id | analytics.read | One post, broken down by platform target |
| GET | /analytics/accounts/:id | analytics.read | One account's rollup + series |
| GET | /analytics/clients/:id | analytics.read | One client's rollup + byPlatform + series |
Analytics endpoints accept an optional date window (from/to as YYYY-MM-DD, default last 30 days) plus optional platform, clientId, and socialAccountId filters. Metrics a platform doesn't expose come back as null, never a fabricated 0.
Reports & best times#
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /reports | reports.read | List saved report definitions |
| GET | /reports/:id | reports.read | Get a report + its runs |
| GET | /reports/:id/exports/:runId | reports.read | Presigned download for a completed run |
| GET | /best-times | besttimes.read | Strongest-first posting slots (client/platform filter) |
Media (content library)#
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /media | media.read | List library assets (kind/folder/client/search) |
| GET | /media/:id | media.read | Get one asset, including per-platform rendition status (variants) |
| POST | /media/presign | media.manage | Presign a direct upload — returns { assetId, key, url, expiresAt } |
| POST | /media | media.manage | Register an uploaded asset — starts transcoding |
| POST | /media/import-url | media.manage | Import an asset from a URL — starts transcoding |
Uploading is a presign → PUT → register sequence, and registering (or importing) is what starts transcoding — the asset is pending until then. All three POSTs require an Idempotency-Key header, and clientId is optional on all three (an asset can be agency-wide).
The optional platforms array on POST /media and POST /media/import-url narrows which platform-specific renditions get produced. Omit it and SkedCast prepares a rendition for every platform you have a live connected account on (scoped to clientId when you send one); pass e.g. platforms: ["youtube"] to narrow further. If a post later targets a platform with no rendition yet, the publish pipeline fills the gap on demand rather than failing. See Uploading media & video covers for the full worked flow, including how to attach a custom video cover.
Webhooks (event-driven)#
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /webhooks | webhooks.read | List webhook endpoints |
| POST | /webhooks | webhooks.manage | Register an endpoint (signing secret returned once) |
| GET | /webhooks/:id | webhooks.read | Get one endpoint |
| PATCH | /webhooks/:id | webhooks.manage | Update url / events / active flag |
| DELETE | /webhooks/:id | webhooks.delete | Delete an endpoint (deliveries cascade) |
| POST | /webhooks/:id/test | webhooks.manage | Send a signed test delivery |
| GET | /webhooks/:id/deliveries | webhooks.read | List delivery attempts + status |
FAQ
- Is there an OpenAPI spec?
- Yes — https://skedcast.com/openapi.json is a full OpenAPI 3.1 document covering every /v1 operation, the security schemes, and the error model. Use it for Postman, code generation, or AI tooling.
- What happens if I send an unknown key inside variants[].overrides?
- It's silently ignored — overrides has no per-platform schema validation, so a typo or a key from the wrong platform never errors, it just does nothing. Check the field name against the per-platform tables above.
- How do I disclose AI-generated content?
- Set the top-level aiDisclosure boolean on POST /posts. It's copied onto every variant's aiGenerated override automatically at publish time — YouTube sets containsSyntheticMedia and TikTok sets its AIGC flag from it. You don't need to set aiGenerated yourself in overrides.