Media Uploads
Upload images, videos, audio, and PDFs to include in your posts.
Overview
RelayAPI accepts canonical media objects up to 200 MiB (209,715,200
bytes). For new clients, use the upload-session API: it sends bytes directly to
object storage, declares the expected size up front, and switches to resumable
multipart upload above 64 MiB. The Worker-proxy endpoint remains capped at
50 MiB.
An upload is usable only after completion succeeds. Completion verifies the
stored size and MIME type, marks the media ready, and starts asynchronous
normalization when that optional service is available. Attach the ready
media's reference_url to a post; post writes accept media objects, not a
med_ ID in place of the object.
Recommended upload-session flow
- Send
POST /v1/media/uploadswithfilename,content_type, exactsize_bytes, and optionalworkspace_id. - Inspect
mode:single: PUT the file once toupload.url, using every returned header.multipart: request signed URLs fromPOST /v1/media/uploads/{id}/parts(at most 32 part numbers per request), upload every 16 MiB part, and retain each responseETag.
- Send
POST /v1/media/uploads/{id}/complete. For multipart sessions, pass every contiguous{part_number, etag}pair in order; single-part sessions pass an emptypartsarray. - Use the returned ready media's
reference_urlinPOST /v1/posts.
reference_url is the stable attachment URL while Relay retains the original.
url is a read URL and can be signed and short-lived. Do not persist url for
a scheduled post. reference_url becomes null after the original is no
longer available; url can then point to the durable preview instead.
Sessions expose created, uploading, completing, completed, aborting,
aborted, failed, and expired. Retrieve a session with
GET /v1/media/uploads/{id} after an interrupted client request. Abort an
incomplete session with DELETE /v1/media/uploads/{id}; a completed object is
deleted through DELETE /v1/media/{media_id} instead.
import Relay from '@relayapi/sdk';
const client = new Relay();
const file = new Blob([bytes], { type: 'video/mp4' });
const session = await client.media.createUploadSession({
filename: 'launch.mp4',
content_type: file.type,
size_bytes: file.size,
});
if (session.mode === 'single') {
if (!session.upload) throw new Error('Missing single-part upload URL');
const response = await fetch(session.upload.url, {
method: 'PUT',
headers: session.upload.headers,
body: file,
});
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
const completed = await client.media.completeUploadSession(session.id, {
parts: [],
});
if (!completed.reference_url) {
throw new Error('Completed upload has no attachment URL');
}
} else {
const partSize = session.part_size;
const partCount = session.part_count;
if (!partSize || !partCount) throw new Error('Missing multipart plan');
const uploaded: Array<{ part_number: number; etag: string }> = [];
for (let first = 1; first <= partCount; first += 32) {
const partNumbers = Array.from(
{ length: Math.min(32, partCount - first + 1) },
(_, index) => first + index,
);
const signed = await client.media.createUploadPartURLs(session.id, {
part_numbers: partNumbers,
});
for (const part of signed.parts) {
const start = (part.part_number - 1) * partSize;
const response = await fetch(part.upload_url, {
method: 'PUT',
headers: part.upload_headers,
body: file.slice(start, Math.min(start + partSize, file.size)),
});
if (!response.ok) throw new Error(`Part ${part.part_number} failed`);
const etag = response.headers.get('etag');
if (!etag) throw new Error('Storage response did not expose ETag');
uploaded.push({ part_number: part.part_number, etag });
}
}
const completed = await client.media.completeUploadSession(session.id, {
parts: uploaded,
});
if (!completed.reference_url) {
throw new Error('Completed upload has no attachment URL');
}
}Hosted RelayAPI pins media-bucket CORS to the RelayAPI dashboard origin;
self-host provisioning pins it to the configured dashboard origin. Both permit
PUT and expose ETag, which browser multipart completion requires. A custom
browser app on another origin should proxy the signed PUT through its own
backend; server-to-server clients are not subject to browser CORS. Do not
persist signed upload URLs: request new part URLs from the session when a
signature expires.
Legacy pre-signed flow
POST /v1/media/presign remains available for single-PUT clients. It does not
provide multipart retry/resume, so the upload-session flow above is preferred.
A legacy direct upload is complete only after all three steps succeed: create
an intent, PUT the bytes, and confirm the object.
- Send
POST /v1/media/presignwithfilename,content_type, and optionalworkspace_id. - PUT the raw file bytes to
upload_urlwith every entry fromupload_headers.Content-Typemust exactly match the normalized type signed in step 1, andIf-None-Match: *makes the object key create-only. - Send
POST /v1/media/confirmwith the storage key from the returned canonicalurl. Confirmation is mandatory: it verifies the stored MIME type and size and marks the media ready. - Create a post with the confirmed media record's
reference_url, such as{ "url": confirmed.reference_url, "type": "image" }. Posts accept media objects containingurland an optionaltype; they do not accept a media ID in place of the object.
The presign response has this shape:
{
"id": "med_abc123",
"upload_url": "https://<account>.r2.cloudflarestorage.com/relayapi-media/...?X-Amz-Signature=...",
"upload_headers": {
"Content-Type": "image/jpeg",
"If-None-Match": "*"
},
"url": "https://media.relayapi.dev/org_123/file_abc/photo.jpg",
"expires_in": 3600
}id identifies the pending upload intent. The presign response's url is the
canonical attachment URL and is returned as reference_url after confirmation;
upload_url is only for the PUT and expires after expires_in seconds.
Presigned URLs remain usable until they expire, so RelayAPI signs
If-None-Match: * and requires clients to send it to prevent a later PUT from
replacing confirmed bytes.
# Step 1: Create the upload intent
presign="$(curl --fail-with-body -sS -X POST https://api.relayapi.dev/v1/media/presign \
-H "Authorization: Bearer $RELAYAPI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"filename":"photo.jpg","content_type":"image/jpeg"}')"
upload_url="$(printf '%s' "$presign" | jq -r '.upload_url')"
upload_content_type="$(printf '%s' "$presign" | jq -r '.upload_headers["Content-Type"]')"
upload_precondition="$(printf '%s' "$presign" | jq -r '.upload_headers["If-None-Match"]')"
canonical_url="$(printf '%s' "$presign" | jq -r '.url')"
storage_key="${canonical_url#https://media.relayapi.dev/}"
# Step 2: PUT once with every exact header signed in step 1
curl --fail-with-body -sS -X PUT "$upload_url" \
-H "Content-Type: $upload_content_type" \
-H "If-None-Match: $upload_precondition" \
--data-binary @photo.jpg
# Step 3: Confirm the object (mandatory)
confirmed="$(jq -n --arg storage_key "$storage_key" '{storage_key: $storage_key}' | \
curl --fail-with-body -sS -X POST https://api.relayapi.dev/v1/media/confirm \
-H "Authorization: Bearer $RELAYAPI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @-)"
# Step 4: Attach the stable reference URL, not the expiring read URL
reference_url="$(printf '%s' "$confirmed" | jq -r '.reference_url')"
jq -n --arg media_url "$reference_url" '{
content: "Check out this photo!",
targets: ["instagram"],
scheduled_at: "now",
media: [{url: $media_url, type: "image"}]
}' | curl --fail-with-body -sS -X POST https://api.relayapi.dev/v1/posts \
-H "Authorization: Bearer $RELAYAPI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @-The confirmation response is the ready media record, including id, url,
reference_url, original_available, filename, mime_type, size, and
created_at. Use reference_url in future or scheduled posts. The url field
is for reading now and may be short-lived.
Request envelope limits
File-size limits and HTTP request-envelope limits are separate:
| Flow | Envelope limit | File limit |
|---|---|---|
POST /v1/media/uploads | File bytes bypass the Worker | 200 MiB; multipart when the declared size is greater than 64 MiB |
Legacy POST /v1/media/presign | File bytes bypass the Worker | 200 MiB, verified at confirmation; single PUT only |
POST /v1/media/upload | Streamed by the media route | 50 MiB |
POST /v1/ideas/{id}/media | 3 MiB multipart envelope | 2 MiB file |
POST /v1/posts/bulk-csv | 2 MiB multipart envelope | 1 MiB CSV file |
The general 4 MiB authenticated JSON limit does not replace these route-specific file limits. Conversely, embedding file bytes or a data URL in JSON is not an upload mechanism; upload media first and reference its canonical URL.
RelayAPI enforces streamed limits even when Content-Length is absent, malformed, or understated. Buffered JSON and the two multipart endpoints reject non-identity Content-Encoding; do not gzip those request bodies.
Supported Formats and Size
The canonical object limit is 200 MiB. Only the Worker-proxy
POST /v1/media/upload flow retains the smaller 50 MiB limit. The same
MIME allowlist applies to all media-library upload paths.
| Type | Allowed MIME types |
|---|---|
| Image | image/jpeg, image/png, image/gif, image/webp, image/heic, image/heif, image/avif |
| Video | video/mp4, video/webm, video/quicktime, video/mpeg |
| Audio | audio/mpeg, audio/mp4, audio/webm, audio/wav, audio/ogg |
| Document | application/pdf |
Generic application/octet-stream, SVG, HTML, and MIME types not listed above are rejected. The pre-signed PUT is bound to the declared content_type; changing the header on the PUT invalidates the signed request.
Automatic normalization
After an image, video, or audio object becomes ready, RelayAPI best-effort
queues a publish-standard-v1 normalization job. It can normalize images,
transcode video/audio, and convert GIFs into provider-friendly MP4 variants.
Processing is deliberately fail-open:
- the verified original becomes ready immediately and remains publishable;
- a pending or failed derivative never makes the original upload fail;
- publishing selects a ready, unexpired, compatible normalized derivative only when it is smaller than the original, and otherwise uses the original;
- the media response exposes
processing_status,processing_error, and boundedvariantsmetadata. It never exposes a permanent signed derivative URL.
Use GET /v1/media/{id} to poll processing state. You can also explicitly
request a normalization or provider variant:
await client.media.process('med_source', {
operation: 'normalize',
profile: 'publish-standard-v1',
options: {
compression_mode: 'balanced',
fail_open: true,
},
});The processing request is a strict discriminated contract:
| Operation | Accepted options |
|---|---|
normalize / provider_variant | compression_mode: balanced, high_quality, or smaller; fail_open: boolean |
cover | timestamp_seconds: number from 0 through 86,400 |
profile must be 1–128 characters, begin with a letter or number, and contain
only letters, numbers, dots, underscores, colons, or hyphens. Unknown option
fields and options intended for another operation fail schema validation rather
than being silently ignored. Automatic publish-standard-v1 normalization
uses the fail-open path described above.
POST /v1/media/{id}/process returns 503 MEDIA_PROCESSING_UNAVAILABLE when a
deployment has not enabled the processing service. That does not affect the
original upload.
Custom Reel covers
Generate a still image from an uploaded video by requesting operation: 'cover'.
timestamp_seconds must be between 0 and 86400.
await client.media.process('med_video', {
operation: 'cover',
profile: 'instagram-reel-cover-v1',
options: { timestamp_seconds: 3.5 },
});
// After GET /v1/media/med_video reports a ready `cover` variant:
await client.posts.create({
content: 'Launch day',
targets: ['instagram'],
scheduled_at: 'now',
media: [{ url: mediaURL, type: 'video' }],
target_options: {
instagram: {
content_type: 'reels',
cover_variant_id: 'mder_ready_cover',
},
},
});Instagram Reel covers accept exactly one of cover_url, cover_media_id,
cover_variant_id, or thumb_offset. Stable Relay IDs are tenant- and
workspace-checked and resolved into a fresh signed URL only at the final
provider boundary.
Self-hosted processing
The media library and 200 MiB direct-to-storage upload sessions are part of the
base self-hosted deployment. Automatic normalization and explicit custom-cover
jobs are optional because Cloudflare Containers and Workflows can add cost.
Initialize with --media-processing, or set features.mediaProcessing: true,
to provision the private, internet-disabled ffmpeg Container, Workflow, Queue,
and dead-letter Queue. Without that flag, originals still upload and publish,
while explicit processing returns MEDIA_PROCESSING_UNAVAILABLE.
Platform-Specific Limits
Each platform can impose stricter dimensions, duration, codec, aspect-ratio, or file-size requirements. The 200 MiB library limit does not override a provider's smaller limit. Automatic normalization improves common inputs but does not guarantee that every source can be converted for every provider; review the relevant platform page before publishing.
Important current publisher boundaries:
audiois a valid shared media type, but WhatsApp is currently the only publisher that accepts it. Every other adapter rejects audio during central validation before provider I/O.- Bluesky image blobs are capped at exactly 2,000,000 decimal bytes (about 1.91 MiB); RelayAPI does not silently recompress an oversized source.
- Reddit accepts at most one media URL through RelayAPI, and it is submitted as a link post rather than a native upload or gallery.
- YouTube accepts exactly one video and at most one thumbnail for that video.
- Telegram media groups contain 2–10 items and can include up to ten videos; RelayAPI journals every returned album message ID for later unpublish.
- Discord uploads at most ten non-video attachments at the defensive 10 MiB-per-file limit. Up to ten video items are appended as public URLs for Discord to unfurl, not uploaded as files.
- Beehiiv accepts image and GIF media as inline HTML when no custom
content_htmlis supplied. Kit, Mailchimp, and Listmonk accept text/HTML only and reject every Relay media attachment before provider I/O.
TikTok source modes
TikTok video posts require duration_ms on the media item. RelayAPI re-queries
fresh creator info and rejects a video that exceeds either TikTok's global
duration limit or that creator's current max_video_post_duration_sec before
publish initialization.
target_options.tiktok.source_mode controls how RelayAPI transfers a video:
file_uploadmakes RelayAPI fetch the media through its guarded, bounded public-media client and stream sequential chunks to TikTok. The binary is not embedded in the post JSON.pull_from_urllets TikTok fetch the media and is allowed only when the URL is beneath an HTTPS URL prefix verified by the operator and pinned into the connected account.- If omitted, a video under a pinned prefix uses pull mode; any other video falls back to bounded file upload.
Photo posts always use PULL_FROM_URL; every photo URL must match a pinned
prefix. Pull-mode URLs must remain directly reachable over HTTPS and must not
redirect. The operator prefix list is snapshotted when TikTok OAuth completes,
so reconnect the account after changing it. See the TikTok platform
guide for the complete consent and source contract.
Found something wrong? Help us improve this page.