API reference
REST over HTTPS, JSON in and out, workspace-scoped bearer keys. Create keys under Settings → API keys; they look like cc_live_… and are shown once.
Authentication
Authorization: Bearer cc_live_xxxxxxxxxxxxxxxx
# missing/invalid/revoked key → 401
{ "error": { "code": "unauthorized", "message": "Missing or invalid API key." } }Revoke keys any time under Settings — revocation is immediate.
Rate limits
Per key: 120 requests/min for reads, 60/min for POST /posts, 30/min for media staging. Per IP: 240/min overall. Exceeding a budget returns 429 with Retry-After.
{ "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry." } }POST/media
Stage a video. Returns a presigned upload URL — PUT the file bytes there, then the asset is ready to attach to posts.
curl -X POST https://crosscast.io/api/v1/media \
-H "Authorization: Bearer cc_live_..." \
-H "Content-Type: application/json" \
-d '{ "filename": "final.mp4", "bytes": 18422011, "content_type": "video/mp4" }'
# content_type must be video/* (default video/mp4); files cap at 1GB (413).
# The PUT's Content-Type header must match content_type or S3 rejects the
# signature.
# → 201
{ "id": "med_01hxkq...", "upload_url": "https://...", "expires_in": 3600 }
curl -X PUT "<upload_url>" --data-binary @final.mp4 -H "Content-Type: video/mp4"POST/media/:id/cover
Stage a custom cover image for a video. PUT the image bytes to the returned URL, then pass cover_key when creating the post. Applies on Instagram and YouTube; TikTok only accepts a frame time (cover_offset_ms).
curl -X POST https://crosscast.io/api/v1/media/med_01hxkq.../cover \
-H "Authorization: Bearer cc_live_..." \
-H "Content-Type: application/json" \
-d '{ "content_type": "image/jpeg" }'
# → 201
{ "cover_key": "ws_.../med_.../cover.jpeg", "upload_url": "https://...", "expires_in": 3600 }GET /channels
List your workspace's channels, sorted by slug. Uses the same bearer authentication and read rate limits as GET /posts.
GET https://crosscast.io/api/v1/channels
# → 200
{ "channels": [{ "id": "ch_01...", "slug": "mapsofwhy", "name": "Maps of Why" }] }POST/posts
Create a post. Targets default to the channel's accounts; captions default to the channel's templates rendered with your title. Both are overridable per call. Send an Idempotency-Key header and retries return the original post instead of double-scheduling — always do this from pipelines. (Idempotency applies to post creation only, not media staging.)
Use schedule: "draft" to hold a post for human approval in Dashboard → Queue → Drafts. Drafts have status draft, scheduled_at: null, and no targets. Captions and cover are saved at creation. Explicit accounts are ignored for drafts: approval resolves the channel's current accounts. Choose a channel when creating a draft; a draft without one cannot be scheduled. Draft creation supports the same Idempotency-Key retries. All post responses include channel_slug (or null).
curl -X POST https://crosscast.io/api/v1/posts \
-H "Authorization: Bearer cc_live_..." \
-H "Idempotency-Key: render-7f3a2c" \
-H "Content-Type: application/json" \
-d '{
"channel": "mapsofwhy",
"media_id": "med_01hxkq...",
"title": "why the mississippi runs backwards",
"schedule": "next_slot", // or "draft", "now", or "2026-09-01T17:00:00Z"
"captions": { // optional per-platform overrides
"tiktok": "why the mississippi runs backwards #maps #geography"
},
"cover_offset_ms": 1200, // optional frame-time cover
"cover_key": "ws_.../cover.jpeg", // optional uploaded cover (see /media/:id/cover)
"accounts": ["acc_01..."] // optional subset of the channel
}'
# → 201
{ "id": "post_01hxkr...", "status": "scheduled", "channel_slug": "mapsofwhy",
"targets": [
{ "id": "tgt_01...", "platform": "tiktok", "account": "@mapsofwhy", "at": "2026-09-01T17:00:00Z" },
{ "id": "tgt_02...", "platform": "instagram", "account": "@mapsofwhy", "at": "2026-09-01T17:00:00Z" },
{ "id": "tgt_03...", "platform": "youtube", "account": "mapsofwhy", "at": "2026-09-01T17:00:00Z" }
] }GET/posts
List posts, newest first. Filter with ?status=draft|scheduled|publishing|published|partial|failed|canceled, cap with ?limit= (max 100), and page by passing the response's next_before back as ?before=.
curl "https://crosscast.io/api/v1/posts?status=failed&limit=10" \
-H "Authorization: Bearer cc_live_..."
# → 200
{ "posts": [ { "id": "post_01...", "title": "...", "status": "failed",
"targets": [ { "platform": "tiktok", "status": "failed", "at": "..." } ] } ] }GET/posts/:id
Status, permalinks, and the latest metrics snapshot per target.
curl https://crosscast.io/api/v1/posts/post_01hxkr... \
-H "Authorization: Bearer cc_live_..."
# → 200
{ "id": "post_01hxkr...", "status": "published",
"targets": [
{ "platform": "instagram", "status": "published",
"permalink": "https://www.instagram.com/reel/...",
"metrics": { "views": 8211, "likes": 402, "avg_watch_time_ms": 11400 } },
{ "platform": "tiktok", "status": "on_phone",
"note": "delivered to TikTok inbox; finish in-app" }
] }PATCH/posts/:id DELETE/posts/:id
PATCH edits title, captions, moves still-scheduled targets with a new schedule, or aborts individual platforms via cancel_targets. DELETE removes a post that hasn't reached any platform yet (409 otherwise — published records are permanent).
For drafts, PATCH with schedule creates targets from the channel's current accounts and changes status to scheduled. Schedule accepts now, next_slot, or a future ISO 8601 timestamp. Next slot requires channel slots. Missing accounts, unavailable media, and YouTube duration limits are checked on approval. Scheduling a post outside draft/scheduled status returns 409; PATCH cannot turn a scheduled post back into a draft.
curl -X PATCH https://crosscast.io/api/v1/posts/post_01hxkr... \
-H "Authorization: Bearer cc_live_..." \
-H "Content-Type: application/json" \
-d '{ "schedule": "2026-09-02T17:00:00Z" }'
curl -X DELETE https://crosscast.io/api/v1/posts/post_01hxkr... \
-H "Authorization: Bearer cc_live_..." # → 204Errors
Standard HTTP status codes with a stable machine-readable shape. Platform-side failures include the platform's own message verbatim — debugging distribution should never require guessing.
{ "error": { "code": "quota_exceeded",
"message": "YouTube upload quota reached for today (6/6). Next reset 08:00 UTC." } }