Skip to content

API

REST API reference

The complete /v1 surface. 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

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)
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
quoteTweetIdstringQuote 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.

LinkedIn — variant overrides#

FieldTypeValues / defaultNotes
visibilityenumPUBLIC \| CONNECTIONSDefaults to PUBLIC.

Instagram — variant overrides#

FieldTypeValues / defaultNotes
contentTypeenumfeed \| reel \| storyDefaults to feed; a single video with no contentType publishes as a reel.
firstCommentstringPosted right after publish. Best-effort — needs the Instagram comment-management permission (pending Meta App Review); a failed comment never fails the post.
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.
locationIdstringInstagram location page id.
paidPartnershipbooleanfalseAdds Instagram's "Paid partnership" disclosure label.

Facebook — variant overrides#

FieldTypeValues / defaultNotes
contentTypeenumfeed \| reel \| storyDefaults to feed.
firstCommentstringPosted right after publish. Best-effort — needs the Page engagement-management permission; a failed comment never fails the post.

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) — RequiredFalls 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 stringsnippet.tags.
licenseenumyoutube \| creativeCommonDefaults to the Standard YouTube License.
descriptionstringOverrides the caption as the YouTube description.

Threads — variant overrides#

FieldTypeValues / defaultNotes
quotePostIdstringQuote a Threads post id.
replyControlenumeveryone \| accounts_you_follow \| mentioned_onlyOmit for Threads' default (everyone can reply).

Pinterest — variant overrides#

FieldTypeValues / defaultNotes
boardIdstring — RequiredA 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.

Telegram — variant overrides#

FieldTypeValues / defaultNotes
chatIdstring — RequiredThe 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.
parseModeenumMarkdownV2 \| HTML \| MarkdownHow the message text is parsed.
disableNotificationbooleanfalsePost without a notification sound.
protectContentbooleanfalsePrevent forwarding and saving.
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.

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, including per-platform rendition status (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.

Be first in line when SkedCast opens

Join the waitlist — agencies on it get early access and launch-day onboarding.