Skip to content
Reading with an AI assistant?View as MarkdownOpen in ClaudeOpen in ChatGPT

API

The social media posting API reference

This is SkedCast's social media posting API surface, in full. All paths are relative to https://api.skedcast.com/v1, all requests carry Authorization: Bearer, and each operation lists the scope it requires. For the machine-readable contract, see the OpenAPI spec.

Browse docs

12 min read

GET/posts

List posts

operationId
listPosts
Required scope
posts.read
Tag
Posts

The social media posting API, endpoint by endpoint#

Every table below lists the real /v1 paths, the scope each one requires, and what it does — the same registry the API validates requests against.

Posts#

MethodPathScopeDescription
GET/postsposts.readList posts (keyset; status/client/platform filters)
GET/posts/:idposts.readGet a post + variants + targets
POST/postsposts.composeCompose + fan out (or save a draft)
POST/posts/previewposts.composeDRY RUN — validate a compose body and get the per-platform verdict; writes nothing, needs no Idempotency-Key
PATCH/posts/:idposts.composeReplace a draft post — content/media/targets/schedule (409 if not a draft)
json
{
  "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" }
}
POST /posts — minimal body.

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#

FieldTypeDefaultNotes
clientIduuidrequiredThe client group this post belongs to.
contentstring (≤50,000)""Base caption. Per-platform captions live in variants[].contentOverride.
mediaIdsuuid[] (≤20)[]Ordered media asset ids attached to the base post.
targetsobjectrequired{ 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.
variantsarray (≤10)[]Per-platform { platform, contentOverride?, mediaIdsOverride?, overrides? } — see the platform options below.
scheduleobjectrequired{ 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.
timezoneIANA string"UTC"Used to resolve schedule.scheduledLocal.
aiDisclosurebooleanfalseMandatory 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.
requestApprovalbooleanfalseSubmit into the approval workflow if the role/client requires it.
saveDraftbooleanfalsePersist as a draft instead of scheduling — no anti-ban jitter, no quota check, no cross-platform validation gate.
campaignIduuidoptionalTag the post into a named campaign.
threadobjectoptional{ 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.
mediaAltTextsobjectoptionalMap of media_assets.id → alt text (≤2,000 chars each) for carousel/per-slide alt text.
coverMediaIduuidoptionalA 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.
coverFrameMsinteger (0–86,400,000)optionalCover 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#

FieldTypeValues / defaultNotes
quoteTweetIdstring—Quote a tweet id.
replySettingsenumfollowing \| mentionedUsers \| subscribers \| verifiedOmit for X's default (everyone can reply).
madeWithAibooleanfalseDiscloses AI-generated/altered media — independent of the post-level aiDisclosure field (X has no aiGenerated reader).
paidPartnershipbooleanfalseDiscloses a paid partnership / branded content.
firstCommentstring—Posted as a reply tweet from the same account right after publish. Costs one extra API call against the account's budget. Best-effort: a failed comment never fails the post, and the outcome is reported as firstCommentIssue on GET /post-targets/:id.

LinkedIn — variant overrides#

FieldTypeValues / defaultNotes
visibilityenumPUBLIC \| CONNECTIONSDefaults to PUBLIC.
firstCommentstring—Posted as a comment on the published post right after publish. Best-effort: a failed comment never fails the post, and the outcome is reported as firstCommentIssue on GET /post-targets/:id.

Instagram — variant overrides#

FieldTypeValues / defaultNotes
contentTypeenumfeed \| reel \| storyDefaults to feed; a single video with no contentType publishes as a reel.
firstCommentstring—Posted right after publish. Best-effort — needs the Instagram comment-management permission (pending Meta App Review); a failed comment never fails the post, and the outcome is reported as firstCommentIssue on GET /post-targets/:id.
userTagsstring[] or [{ username, x?, y? }]≤20People/photo tags. A comma-separated string of @usernames also works.
collaboratorsstring[]≤3Collaborator usernames.
shareToFeedbooleanplatform defaultReels only: also show the Reel in the main feed grid.
coverUrlstring (url)—Reels only: a public URL for the Reel's cover frame. This is separate from the post-level coverMediaId mechanism.
locationIdstring—Instagram location page id.
paidPartnershipbooleanfalseAdds Instagram's "Paid partnership" disclosure label.

Facebook — variant overrides#

FieldTypeValues / defaultNotes
contentTypeenumfeed \| reel \| storyDefaults to feed.
firstCommentstring—Posted right after publish. Best-effort — needs the Page engagement-management permission; a failed comment never fails the post, and the outcome is reported as firstCommentIssue on GET /post-targets/:id.

TikTok — variant overrides#

FieldTypeValues / defaultNotes
privacyLevelenum — RequiredPUBLIC_TO_EVERYONE \| MUTUAL_FOLLOW_FRIENDS \| FOLLOWER_OF_CREATOR \| SELF_ONLYNo default — TikTok's Content Sharing Guidelines require an explicit choice. Also constrained to whatever visibility set the connected creator account allows.
yourBrandbooleanfalseCreator promoting their OWN brand/business (brand_organic_toggle).
brandedContentbooleanfalseA paid partnership / third-party promotion (brand_content_toggle). Cannot be combined with privacyLevel: "SELF_ONLY".
disableCommentbooleanfalse—
disableDuetbooleanfalse—
disableStitchbooleanfalse—

YouTube — variant overrides#

FieldTypeValues / defaultNotes
titlestring (≤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.
privacyStatusenumpublic \| unlisted \| privateDefaults to the channel default when unset.
categoryIdenum1 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 & ActivismDefaults to "22" (People & Blogs).
madeForKidsbooleanfalseCOPPA self-declaration (status.selfDeclaredMadeForKids) — always sent, defaults to "not made for kids".
tagsstring[] or comma-separated string—snippet.tags.
licenseenumyoutube \| creativeCommonDefaults to the Standard YouTube License.
descriptionstring—Overrides the caption as the YouTube description.
firstCommentstring—Posted as a top-level comment on the video. Needs the youtube.force-ssl scope on the connection; without it Google answers 403 and the video still publishes. Best-effort: a failed comment never fails the post, and the outcome is reported as firstCommentIssue on GET /post-targets/:id.

Threads — variant overrides#

FieldTypeValues / defaultNotes
quotePostIdstring—Quote a Threads post id.
replyControlenumeveryone \| accounts_you_follow \| mentioned_onlyOmit for Threads' default (everyone can reply).
firstCommentstring—Posted as a self-reply to the published thread. Best-effort: a failed comment never fails the post, and the outcome is reported as firstCommentIssue on GET /post-targets/:id.

Pinterest — variant overrides#

FieldTypeValues / defaultNotes
boardIdstring — Required—A pin publishes onto a board — the target fails at publish time without one.
titlestring (≤100)—Optional headline, distinct from the description (content).
linkstring (url)—Destination URL.

Bluesky — variant overrides#

FieldTypeValues / defaultNotes
linkstring (url)—External link for an embed preview card.
labelSexualbooleanfalseSelf-labels the post as sexually suggestive.
labelNuditybooleanfalseSelf-labels the post as containing non-sexual nudity.
labelPornbooleanfalseSelf-labels the post as adult/pornographic content.
labelGraphicMediabooleanfalseSelf-labels the post as violent or otherwise graphic media.
firstCommentstring—Posted as a reply in the same thread, with link facets resolved like any other post. Best-effort: a failed comment never fails the post, and the outcome is reported as firstCommentIssue on GET /post-targets/:id.

Telegram — variant overrides#

FieldTypeValues / defaultNotes
parseModeenumMarkdownV2 \| HTML \| MarkdownHow the message text is parsed.
disableNotificationbooleanfalsePost without a notification sound.
protectContentbooleanfalsePrevent forwarding and saving.
firstCommentstring—Posted as a reply to the published message in the same chat. Best-effort: a failed comment never fails the post, and the outcome is reported as firstCommentIssue on GET /post-targets/:id.
json
{
  "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 /posts — a TikTok variant with required + optional overrides.

Post targets (per-destination lifecycle)#

MethodPathScopeDescription
GET/post-targetsposts.readList fan-out rows (status/client/platform filters)
GET/post-targets/:idposts.readGet one fan-out target
POST/post-targets/:id/rescheduleposts.composeMove a pending target to a new time (scheduledAt or shiftMinutes)
POST/post-targets/:id/retryposts.composeRe-queue a failed / rate-limited target
POST/post-targets/:id/cancelposts.composeCancel a still-pending target
POST/post-targets/bulkposts.composeApply one action across many target ids

Clients & accounts#

MethodPathScopeDescription
GET/clientsclients.readList client groups (type/search filters)
GET/clients/:idclients.readGet one client
POST/clientsclients.manageCreate a client group (Owner/Manager; Idempotency-Key)
PATCH/clients/:idclients.manageUpdate name / type / post mode / rules / brand voice (Owner/Manager)
DELETE/clients/:idclients.manageArchive a client (Owner only)
POST/clients/:id/connect-invitesaccounts.connectMint a self-serve account connect link (Owner/Manager; Idempotency-Key)
GET/accountsaccounts.readList connected social accounts
GET/accounts/:idaccounts.readGet one connected account
GET/agencyagency.readThe 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.

json
{
  "name": "Acme Studios",
  "type": "brand",
  "defaultPostMode": "now"
}
POST /clients — minimal body (mirrors the clients module create schema).

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.

bash
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 }'
Mint a connect link for Instagram + TikTok, good for 72 hours.
json
{
  "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-…" }
}
Response — share data.url with the client.

Analytics#

MethodPathScopeDescription
GET/analytics/overviewanalytics.readTenant rollup + per-platform + daily series
GET/analytics/postsanalytics.readPer-post analytics list (keyset)
GET/analytics/posts/:idanalytics.readOne post, broken down by platform target
GET/analytics/accounts/:idanalytics.readOne account's rollup + series
GET/analytics/clients/:idanalytics.readOne 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.

That distinction is load-bearing and worth reading twice. null means *this platform does not report this number*, 0 means *the platform reported zero*, and an absent key means *we have not fetched it yet*. Charting null as 0 is the single most common way to draw a graph that says something untrue about a customer's account.

Three grains, and which one to ask for#

Analytics exists at three grains, and picking the wrong one is the usual reason a number looks wrong. They do not roll up into one another, because the platforms do not report them that way.

  • **Workspace** — GET /v1/analytics/overview. Everything you published, summed, with a per-platform and per-account breakdown plus a daily series.
  • **Account** — GET /v1/analytics/accounts/{id}. One connected channel. The only place profile-level activity and follower demographics appear.
  • **Post** — GET /v1/analytics/posts and /v1/analytics/posts/{id}. One published post's lifetime counters, broken down per platform target.

Post metrics — every field, and what it counts#

FieldTypeWhat it countsReported by
impressionsintTimes the post was shown, counting repeat views by the same personMost platforms
reachintSeparate people who saw it at least onceFB, IG, LinkedIn, Threads
likesintReactions. On Facebook this is the sum of all six typesAll
commentsintComments; replies on Threads and XAll
sharesintTimes it was shared onwardFB, IG, LinkedIn, X, Threads
savesintTimes someone saved it to come back toIG, Pinterest
clicksintClicks that did not create a story of their ownFB, LinkedIn, Pinterest
engagementRatefloatInteractions ÷ impressions, as a 0–1 fraction (0.0312 = 3.12%)Derived
permalinkstringThe post's canonical URL on the platformFB, IG, Threads
reactionsByTypeobject{ like, love, wow, haha, sad, angry } — the six Facebook reactionsFacebook
clicksByTypeobjectClicks split by what was clicked, e.g. { 'link clicks': 3 }Facebook
profileActivityobjectProfile actions taken after seeing the postInstagram
navigationobjectStory navigation: { forward, tap_back, exit, next_story }IG Stories
followsintPeople who followed the account because of this postInstagram
profileVisitsintPeople who opened the profile after seeing itInstagram
linkClicksintTaps on the story's link stickerIG Stories
totalInteractionsintThe platform's own engagement total — its definition, not oursIG, Threads
repostsintTimes reposted as-isThreads
quotesintReposts that added a comment of their ownThreads
videoViewsintPlays of at least three secondsFB, IG
videoCompletionsintPlays reaching 30s or the end, whichever came firstFacebook
skipRatestringDecimal string, 0–1: viewers who skipped in the first secondsIG Reels
watchTimeMinutesintTotal minutes watched across everyone who watchedYouTube, FB
avgViewDurationSecondsintHow long the average viewer stayedYouTube
avgViewPercentagefloatHow much of the video the average viewer got through (0–100)YouTube
subscribersGained / subscribersLostintSubscriber change attributable to this videoYouTube
audienceRetentionarray[{ position, ratio }] — the retention curveYouTube, FB
trafficSourcesobjectWhere the views came fromYouTube
audienceBreakdownobjectViewer demographics, geography and devicesYouTube
platformRemovedAtstringSet when the post was deleted ON the platform; its figures are then finalFB, IG, Threads

Every field except impressions/reach/likes/comments/shares/saves/clicks/engagementRate is optional and null where the platform does not report it. "Reported by" is where we have observed real values, not where the vendor's documentation claims support.

Account metrics — `GET /v1/analytics/accounts/{id}`#

FieldWhat it countsReported by
audience.profileViewsProfile or Page views over the windowInstagram
audience.accountsEngagedUnique accounts that engaged with the profileInstagram
audience.profileLinkTapsTaps on the bio and contact linksInstagram
audience.videoViewsVideo plays across the whole accountFacebook
audience.videoWatchTimeMinutesTotal video watch time, in minutesFacebook
audience.contentPlaysTimes the account's content was played or displayedFacebook
demographicsFollower demographics as dimension → label → countFB, IG, Threads
totals.followerCountLatest follower snapshot in the windowMost platforms
series[].followerCountThe daily follower snapshot, for a growth curveMost platforms

Follower demographics#

json
{
  "object": "account_analytics",
  "socialAccountId": "acc_01HZ...",
  "platform": "instagram",
  "audience": { "profileViews": 412, "profileLinkTaps": 37 },
  "demographics": {
    "country": { "US": 1204, "GB": 388, "PK": 91 },
    "city": { "London, England": 210 },
    "age": { "18-24": 402, "25-34": 899 },
    "gender": { "F": 720, "M": 640, "U": 41 }
  }
}
Dimensions and labels are the platform's own and are passed through unchanged — do not assume a fixed dimension set. `demographics` is a snapshot of the audience today, not a window aggregate; no platform exposes a dated series for it.

Freshness, and why a number can be older than you expect#

Every analytics payload carries asOf — the freshest underlying snapshot in the window, not the time you made the request. Post rows carry their own fetchedAt. A nightly worker ingests metrics; POST /analytics/refresh (first-party only) triggers an on-demand pull.

Some lag is the platform's, not ours. YouTube Analytics settles roughly 48–72 hours behind, so a video published yesterday legitimately reports nulls on the rich fields. Instagram Stories expire after 24 hours and their insights stop updating at expiry — highlighting a story does not extend that window.

Reports & best times#

MethodPathScopeDescription
GET/reportsreports.readList saved report definitions
GET/reports/:idreports.readGet a report + its runs
GET/reports/:id/exports/:runIdreports.readPresigned download for a completed run
GET/best-timesbesttimes.readStrongest-first posting slots (client/platform filter)

Media (content library)#

MethodPathScopeDescription
GET/mediamedia.readList library assets (kind/folder/client/search)
GET/media/:idmedia.readGet one asset — poll ready, plus per-platform readiness and variants
POST/media/presignmedia.managePresign a direct upload — returns { assetId, key, url, expiresAt }
POST/mediamedia.manageRegister an uploaded asset — starts transcoding
POST/media/import-urlmedia.manageImport 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)#

MethodPathScopeDescription
GET/webhookswebhooks.readList webhook endpoints
POST/webhookswebhooks.manageRegister an endpoint (signing secret returned once)
GET/webhooks/:idwebhooks.readGet one endpoint
PATCH/webhooks/:idwebhooks.manageUpdate url / events / active flag
DELETE/webhooks/:idwebhooks.deleteDelete an endpoint (deliveries cascade)
POST/webhooks/:id/testwebhooks.manageSend a signed test delivery
GET/webhooks/:id/deliverieswebhooks.readList delivery attempts + status
apireferencerest

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.

Ready to broadcast everywhere?

Sign up free — no credit card. You land on the Free plan, and you can start a one-time 7-day Studio trial from your workspace whenever you are ready. Connect your first accounts, import a batch, and watch one post fan out across every platform.