Media processing & limits
Every image, video, GIF, audio file, and document you publish is first uploaded as a media asset — directly from your client to object storage via a presigned URL, so large files never pass through the API. This page covers the asset lifecycle (thestatus an asset moves through from upload to publish-ready) and the per-platform media requirements we validate against before a post is accepted. For the upload/confirm endpoints themselves, see the Media API.
The asset lifecycle
An asset is created in pending_upload when you request an upload URL, and reaches ready once the bytes are in storage and any processing (video cover-frame extraction) has finished. Only a ready asset — or a still-processing one that is otherwise valid — can be attached to a post; videos must be fully ready before they publish.
ready on confirm; videos pass through processing while a cover frame is extracted. uploading/uploaded are transient states a resumable, chunked upload reports while bytes are in flight. failed and deleted are terminal.status | Meaning |
|---|---|
pending_upload | The record exists and an upload URL has been issued, but no bytes have landed yet. The default state right after POST /media/upload. |
uploading | A resumable (chunked) upload session is in progress — bytes are streaming to storage but the object is not yet complete. |
uploaded | The object bytes are fully in storage but the asset has not yet been confirmed/processed. It is usable as a custom thumbnail source at this stage. |
processing | Confirmed and being prepared: a video’s cover frame is extracted, or a thumbnail is being (re)generated. Set on confirm for videos and after POST /media/{id}/thumbnail. |
ready | The asset is publish-ready: present in storage, within its size cap, and (for video) with a generated cover. Its url and thumbnail_url are populated. |
failed | Processing failed terminally (e.g. an unreadable/corrupt video). Re-upload to recover. |
deleted | Soft-deleted via DELETE /media/{id}; excluded from list results and not attachable to posts. |
How an asset reaches ready
After POST /media/upload the asset is pending_upload. You PUT the bytes to the presigned URL, then call POST /media/{id}/confirm. Confirm verifies the object exists, records its size, and re-checks the size cap. From there the path branches by kind:
- Images & GIFs become
readyimmediately on confirm — an image is its own thumbnail. - Videos return
processingon confirm while a cover frame is extracted asynchronously; pollGET /media/{id}until it flips toready, at which pointthumbnail_urlis populated.
A resumable upload (see below) additionally surfaces uploading while chunks stream and uploaded once they all land; you still call confirm to advance it into processing/ready. See Media for the full request/response shapes and thumbnail controls.
Upload size caps
Each media kind has a maximum upload size. The cap is enforced twice: it is bound into the presigned PUT signature (as an x-goog-content-length-range constraint, so storage rejects an over-cap upload with a 400), and re-checked against the stored object’s real size at confirm — so a backend that doesn’t honor the range still can’t let an oversized object through. An over-cap object is rejected at confirm with 413 Payload Too Large (carrying max_bytes and size) and deleted from storage.
| Kind | Max upload size |
|---|---|
image | 30 MB |
gif | 50 MB |
video | 5 GB |
document | 100 MB |
audio | 200 MB |
Resumable uploads for large media
single — one presigned PUT with the cap bound into the signature. For large files, request mode: "resumable" on POST /media/upload to get a chunked upload session instead; the byte ceiling is not bound into the resumable session, so the confirm HEAD check is the authoritative size gate. These per-kind caps are the upper bound PostFuze accepts at upload; an individual platform may accept far less for the same kind — see the matrix below.Per-platform media requirements
Before a post is accepted, attached media is validated against the target platform’s published constraints. The conservative public limits per platform are below — where a platform tiers limits (e.g. verified vs. standard accounts), PostFuze encodes the standard tier so a passing asset is safe for every account class. Counts are the maximum attachable to one post; aspect bounds are width-to-height ratios.
Images
| Platform | Max images | Max size each | Formats |
|---|---|---|---|
| X (Twitter) | 4 | 5 MB | JPEG, PNG, GIF, WebP |
| 9 | 10 MB | JPEG, PNG, GIF | |
| 10 | 10 MB | JPEG, PNG, GIF, WebP | |
| 10 | 8 MB | JPEG, PNG | |
| TikTok (photo mode) | 35 | 20 MB | JPEG, WebP |
| YouTube | — | — | Video only (no image post type) |
| 1 | 20 MB | JPEG, PNG, WebP | |
| Threads | 20 | 8 MB | JPEG, PNG |
| Bluesky | 4 | ~1 MB | JPEG, PNG, GIF, WebP |
| Google Business | 1 | 10 MB | JPEG, PNG |
Video
| Platform | Max size | Duration | Formats | Aspect (w:h) |
|---|---|---|---|---|
| X (Twitter) | 512 MB | 0.5 s – 140 s | MP4, MOV | 1:3 – 3:1 |
| 5 GB | 3 s – 30 min | MP4 | ~1:2.4 – 2.4:1 | |
| 4 GB | 1 s – 240 min | MP4, MOV | 9:16 – 16:9 | |
| 1 GB | 3 s – 15 min | MP4, MOV | 9:16 – 1.91:1 | |
| TikTok | 4 GB | 3 s – 10 min | MP4, MOV, WebM | 9:16 – 16:9 |
| YouTube | 128 GB | 1 s – 12 h* | MP4, MOV, WebM, MKV | 9:16 – 16:9 |
| 2 GB | 4 s – 15 min | MP4, MOV, M4V | 1:2 – 2:1 | |
| Threads | 1 GB | 1 s – 5 min | MP4, MOV | 9:16 – 16:9 |
| Bluesky | 50 MB | 1 s – 60 s | MP4, MOV | 1:3 – 3:1 |
| Google Business | 75 MB | 1 s – 30 s | MP4, MOV | 9:16 – 16:9 |
* YouTube accepts up to 12 h / 128 GB at the API, but unverified accounts are capped at 15 minutes; verify the channel to lift it. Across all platforms, video is accepted with H.264 (and, where supported, HEVC) codecs.
One asset, many platforms
Example: poll a video to ready
async function waitForReady(mediaId: string): Promise<void> {
for (;;) {
const res = await fetch(`https://api.postfuze.com/api/v1/media/${mediaId}`, {
headers: { Authorization: `Bearer ${process.env.POSTFUZE_API_KEY}` },
});
const media = (await res.json()) as { status: string };
if (media.status === 'ready') return;
if (media.status === 'failed' || media.status === 'deleted') {
throw new Error(`media ${mediaId} ended in ${media.status}`);
}
// pending_upload / uploading / uploaded / processing — keep polling.
await new Promise((r) => setTimeout(r, 2000));
}
}