# Uploading media & video covers
URL: https://skedcast.com/developers/media-uploads

Media never transits the API — you upload straight to storage with a presigned URL, then register the object so the pipeline picks it up. This is the exact four-step sequence, plus how a video's cover/thumbnail works and how to narrow transcoding to the platforms you actually need.

## The flow at a glance
Four calls, all authenticated with your usual API key or OAuth token. The bytes go straight from your machine to storage — SkedCast's API only ever sees a URL.
- POST /media/presign — mint an assetId + upload URL.
- PUT <url> — upload the raw bytes.
- POST /media — register the upload; this is what starts scanning + transcoding.
- POST /posts — compose, referencing the assetId in mediaIds.

## Idempotency-Key is required
Like every POST on /v1, presign, register, and import-url each require a unique Idempotency-Key header — omitting it is a 400. Replaying the same key returns the original result instead of creating a duplicate asset, so a network retry after step 1 or step 3 is always safe.

## 1. Presign an upload
clientId is optional — an asset can be agency-wide. Declare the real filename, MIME type, and byte size; they're re-verified server-side (magic bytes, not the declared contentType) once the file lands.

## Presign a direct upload.
```bash
curl -X POST https://api.skedcast.com/v1/media/presign \
  -H "Authorization: Bearer sked_live_YOUR_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "clientId": "<clientId>",
    "filename": "launch-teaser.mp4",
    "contentType": "video/mp4",
    "sizeBytes": 24831992
  }'
```

## Response — keep assetId and key for step 3.
```json
{
  "data": {
    "assetId": "b3f1a2c4-5d6e-4f70-8a9b-1c2d3e4f5a6b",
    "key": "acme-agency/…/b3f1a2c4-…/launch-teaser.mp4",
    "url": "https://<presigned-upload-host>/…",
    "expiresAt": "2026-08-06T15:10:00Z"
  },
  "meta": { "correlationId": "c-…" }
}
```

## 2. Upload the bytes
PUT the file straight to the presigned url from step 1 — no Authorization header, no SkedCast involvement. Match the Content-Type you declared at presign.

## Upload directly to storage.
```bash
curl -X PUT "<url from step 1>" \
  -H "Content-Type: video/mp4" \
  --data-binary @launch-teaser.mp4
```

## 3. Register the asset
Registering is what starts the pipeline — a virus scan, then a single safe master, then only the per-platform renditions a platform genuinely needs. The response carries readyEvent: "media.ready" — subscribe an endpoint to that event (POST /webhooks) and you are told the instant the asset is publishable. Poll GET /media/:id only if you cannot receive inbound HTTP, and when you do, wait on publishable: true — NOT on a platform appearing in variants[]. A platform the safe master already satisfies (YouTube for a 1080p H.264 MP4, for example) never gets a variants row, so a loop waiting for one waits forever; readiness gives you one verdict per platform (ready / preparing / failed) without that ambiguity.
platforms is optional. Omit it and SkedCast prepares a rendition for every platform you have a live connected account on (scoped to clientId when you send one) — the truthful upper bound on where this asset could actually be published. Pass an explicit list, e.g. ["youtube", "tiktok"], to narrow further and skip renditions you know you won't need. If a post later targets a platform with no rendition yet, the publish pipeline fills the gap on demand and parks that target until it's ready — it doesn't fail.

## Register the uploaded object.
```bash
curl -X POST https://api.skedcast.com/v1/media \
  -H "Authorization: Bearer sked_live_YOUR_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "assetId": "b3f1a2c4-5d6e-4f70-8a9b-1c2d3e4f5a6b",
    "key": "acme-agency/…/b3f1a2c4-…/launch-teaser.mp4",
    "clientId": "<clientId>",
    "contentType": "video/mp4",
    "sizeBytes": 24831992,
    "filename": "launch-teaser.mp4",
    "platforms": ["youtube", "tiktok"]
  }'
```

## Response — the asset is queued, and names the event that says it is done.
```json
{
  "data": {
    "assetId": "b3f1a2c4-5d6e-4f70-8a9b-1c2d3e4f5a6b",
    "status": "pending",
    "readyEvent": "media.ready"
  },
  "meta": { "correlationId": "c-…" }
}
```

## Waiting for the asset: poll the ready flag, never a status string
There is no `ready` status. The terminal value of `status` is `transcoded` — so a loop written as "poll until status === 'ready'" never matches, spins until its own retry cap, and gives up long after the asset was usable. Wait on the boolean instead: `ready: true` (identical to `publishable: true`) on GET /media/:id.
Give the loop a failure exit too. A `failed` or `quarantined` asset leaves `ready` false forever, so break when `statusCategory` is non-null — otherwise a dead asset costs you the same full timeout as a healthy one.
Better still, don't poll. The register response above names `readyEvent: "media.ready"` — subscribe to it. And you don't have to wait at all: compose immediately and the publish pipeline parks the target on `target.preparing` until the asset lands, without burning a publish attempt.

## Importing from a URL instead
Already have the file at a public URL? POST /media/import-url skips steps 1–2 — SkedCast fetches it (behind an SSRF guard, HTTPS only), stores it, and starts the same pipeline. Same clientId/platforms options as register.

## Import from a URL.
```bash
curl -X POST https://api.skedcast.com/v1/media/import-url \
  -H "Authorization: Bearer sked_live_YOUR_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "clientId": "<clientId>", "url": "https://example.com/launch-teaser.mp4" }'
```

## 4. Compose, with an optional video cover
Once the asset is transcoded (or even while it's still processing — the publish pipeline waits), reference its assetId in mediaIds on POST /posts as usual.
For a video, you can also set a custom cover/thumbnail. A cover is a separately uploaded IMAGE asset — run it through the exact same presign → PUT → register sequence above — and pass its assetId as coverMediaId on the post body. YouTube, Instagram, Facebook, and Telegram accept a custom cover image this way; omit coverMediaId to use the auto-extracted poster frame instead.
TikTok doesn't take a cover image — it takes a timestamp. Set coverFrameMs (milliseconds into the video) to pick which frame TikTok uses; it's independent of coverMediaId and applies even when you also set an image cover for the other platforms.
Setting a cover is best-effort by contract: it never fails the publish. Check the outcome afterward via coverIssue on GET /post-targets/:id (see the API reference's Post targets section) — null means it applied cleanly (or there was nothing to apply), a short string explains a rejection, a failed fetch, or an unconfirmed result.

## Compose a post with the video plus a custom cover image.
```bash
curl -X POST https://api.skedcast.com/v1/posts \
  -H "Authorization: Bearer sked_live_YOUR_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "clientId": "<clientId>",
    "content": "Launch day is here 🚀",
    "mediaIds": ["<videoAssetId>"],
    "coverMediaId": "<coverImageAssetId>",
    "targets": { "mode": "selection", "accountIds": ["<accountId>"] },
    "schedule": { "type": "at", "scheduledAt": "2026-08-10T15:00:00Z" }
  }'
```

## FAQ
**Q: Do I need to wait for transcoding before composing the post?**
A: No. You can compose against a still-processing asset — the publish pipeline waits for it (you'll see target.preparing, then target.published). If you want to wait first, subscribe to media.ready (the register response names it as readyEvent) or poll GET /media/:id until ready: true. Do not compare the status string against "ready" — there is no such status, the terminal value is transcoded, and a loop waiting for "ready" will spin until its own retry cap. Break on failure too: failed and quarantined leave ready false forever, so exit when statusCategory is non-null. And do not wait for a platform to appear in variants[] — a platform the safe master already satisfies never gets a row, and a rendition another platform needs is produced on demand at publish time.

**Q: How do I get the URL of the post once it's live?**
A: Read the target: GET /posts/:id or GET /post-targets/:id returns externalId (the platform's own post id) and permalink (its public URL). We capture the URL the platform reports when it publishes, so permalink is populated on every platform that gives us one — including TikTok, Instagram and Threads, whose URLs cannot be rebuilt from the id we store. It is null until the target reaches published; for posts published before 2026-09-03 we fall back to deriving the URL from externalId, which works for YouTube, X, Pinterest, LinkedIn and Facebook feed posts and yields null elsewhere. A few cases have no URL to give at all — a SELF_ONLY TikTok post, a Telegram plain group — and we return null rather than a link that 404s. Subscribing to target.published gets you the externalId the moment it happens, without polling.

**Q: What's the difference between coverMediaId and coverFrameMs?**
A: coverMediaId points at a separately uploaded IMAGE asset used as the cover on platforms that accept one (YouTube, Instagram, Facebook, Telegram). coverFrameMs is a millisecond offset into the video itself, sent to TikTok as its native cover-frame timestamp — TikTok has no image-cover mechanism. They're independent and can both be set on the same post.

**Q: Does clientId have to be set when uploading?**
A: No — it's optional on presign, register, and import-url. Omit it for an agency-wide asset available to every client.
