Skip to main content

Uploading With a Pre-Signed URL

When This Option Is Ideal

Use this when you have the video bytes already (on your server, in a browser upload, or on a device) and want the fastest, most reliable upload path.

The upload goes directly to object storage. Your servers never proxy the file.

The Two-Step Process

  1. Request upload URLs: Tell AntCDN how you want the video processed. You get back two upload URLs for the same video, both valid for one week.
  2. Upload bytes: Send the file to one of them.

Processing begins automatically after the upload finishes.

Upload URLHow you send the fileIf the connection dropsUse it for
resumableUploadURLIn chunks, each with a Content-Range headerAsk how many bytes were stored and continue from thereLarge files, slow or unreliable networks, anything that can take more than a few minutes
signedURLOne PUT with the whole fileStart again from byte 0Small files on reliable networks

Step 1: Submit Video Config

POST https://api.antcdn.net/v1/videos/upload-url

Required Parameters

FieldTypeDescription
fileNamestringThe name of the file including its extension (e.g. demo.mp4)
allowPublicAccessbooleanWhether playback can be accessed without signing

Optional Parameters

FieldTypeDefaultDescription
videoNamestring-Display name for the video. Useful for finding the video in the dashboard or via API.
videoQualitystringstandardQuality preset: standard, high, ultra. See Quality Presets & Resolutions for the renditions each one produces.
maxEdgeLengthinteger-1Max short-edge resolution (e.g. 720, 1080, 2160). -1 means “auto”.
maxFrameRateinteger-1Max frame rate. -1 means “auto”.
encodingModestringfastfast or slow
audioCodecstringaacaac, aac-he, aac-he-v2, mp3, ac3, eac3
generateCaptionsbooleanfalseWhether captions should be generated automatically (if enabled for your plan)
encryptSegmentsbooleanfalseAES-128 encrypt every media segment. Requires the Growth plan; playback then requires a signed session. See Encrypted Video
uploadOriginstringRequest’s OriginOrigin of the web page that will upload the file, e.g. https://app.example.com. Set it when a browser sends the bytes to resumableUploadURL but your server requests the upload URLs. Omit it for server-side uploads.

Caution

encryptSegments can only be set at upload time — it cannot be enabled on an existing video.

Response body

{
"body": {
"assetId": "vid_XXX",
"resumableUploadURL": "https://storage.googleapis.com/upload/storage/v1/b/...&upload_id=...",
"signedURL": "https://<storage-provider>/<path>?<signature>",
"contentType": "video/mp4",
"bucketPath": "<internal-bucket-path>",
"expiresAt": 1640995200
}
}
FieldTypeDescription
assetIdstringYour new video (asset) ID
resumableUploadURLstringResumable upload session. Recommended for large files.
signedURLstringAccepts the whole file in a single PUT
contentTypestringThe Content-Type header a PUT to signedURL must send. Not used with resumableUploadURL.
expiresAtintegerUnix timestamp (seconds) after which neither URL accepts data. One week after the request.

Caution

Both URLs authorize uploads to your account without an API key. Do not log them or share them beyond the client that uploads the file.

resumableUploadURL is a Google Cloud Storage resumable upload session. You can use any HTTP client. The protocol has two kinds of request, both PUT to resumableUploadURL.

Send a chunk. Every chunk except the last must be a multiple of 256 KiB. We recommend 8–64 MiB.

PUT {resumableUploadURL}
Content-Range: bytes 0-16777215/52428800
<16 MiB of the file>
  • 308 means the chunk was accepted and more is expected. The Range header, such as bytes=0-16777215, says what is stored. Send the next chunk from the byte after its upper value. That may be earlier than the end of what you sent.
  • 200 or 201 means the file is complete and processing has started.

Ask how much is stored. Do this after a dropped connection, a timeout, a 5xx, or a 429, and when your process restarts.

PUT {resumableUploadURL}
Content-Range: bytes */52428800
Content-Length: 0
  • 308 with a Range header means resume from the byte after its upper value.
  • 308 without a Range header means nothing is stored yet, so start at byte 0.
  • 200 or 201 means the upload already finished.

A 404 or 410 means the session expired. Request new upload URLs and upload again.

Tip

Save resumableUploadURL alongside the file path, for example in your database. If your uploader crashes or the machine restarts, read it back, ask how much is stored, and continue. The session stays valid until expiresAt.

Python

import os
import time
import requests
CHUNK_BYTES = 16 * 1024 * 1024 # must be a multiple of 256 KiB
def next_offset(response):
stored = response.headers.get("Range") # e.g. "bytes=0-16777215"
return int(stored.split("-")[1]) + 1 if stored else 0
def stored_offset(session_url, total):
r = requests.put(session_url, headers={"Content-Range": f"bytes */{total}"}, timeout=60)
if r.status_code in (200, 201):
return total
if r.status_code == 308:
return next_offset(r)
r.raise_for_status()
raise RuntimeError(f"unexpected status {r.status_code}")
def upload_resumable(session_url, file_path):
total = os.path.getsize(file_path)
offset = stored_offset(session_url, total)
failures = 0
with open(file_path, "rb") as f:
while offset < total:
f.seek(offset)
chunk = f.read(CHUNK_BYTES)
end = offset + len(chunk) - 1
try:
r = requests.put(
session_url,
data=chunk,
headers={"Content-Range": f"bytes {offset}-{end}/{total}"},
timeout=300,
)
except requests.RequestException:
r = None
if r is not None and r.status_code in (200, 201):
return
if r is not None and r.status_code == 308:
offset = next_offset(r)
failures = 0
continue
if r is not None and r.status_code in (404, 410):
raise RuntimeError("upload session expired; request new upload URLs")
if r is not None and r.status_code < 500 and r.status_code not in (408, 429):
r.raise_for_status()
failures += 1
if failures > 10:
raise RuntimeError("upload keeps failing; run again later to resume")
time.sleep(min(60, 2 ** failures))
offset = stored_offset(session_url, total)

Go

const chunkBytes = 16 * 1024 * 1024 // must be a multiple of 256 KiB
func nextOffset(resp *http.Response) int64 {
stored := resp.Header.Get("Range") // e.g. "bytes=0-16777215"
if stored == "" {
return 0
}
var last int64
fmt.Sscanf(stored, "bytes=0-%d", &last)
return last + 1
}
func storedOffset(client *http.Client, sessionURL string, total int64) (int64, error) {
req, _ := http.NewRequest(http.MethodPut, sessionURL, nil)
req.Header.Set("Content-Range", fmt.Sprintf("bytes */%d", total))
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK, http.StatusCreated:
return total, nil
case http.StatusPermanentRedirect: // 308
return nextOffset(resp), nil
}
return 0, fmt.Errorf("status query failed: %s", resp.Status)
}
func uploadResumable(sessionURL, filePath string) error {
file, err := os.Open(filePath)
if err != nil {
return err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return err
}
total := info.Size()
client := &http.Client{Timeout: 5 * time.Minute}
offset, err := storedOffset(client, sessionURL, total)
if err != nil {
return err
}
failures := 0
for offset < total {
n := min(int64(chunkBytes), total-offset)
req, _ := http.NewRequest(http.MethodPut, sessionURL, io.NewSectionReader(file, offset, n))
req.ContentLength = n
req.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+n-1, total))
resp, err := client.Do(req)
if err == nil {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
switch {
case resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated:
return nil
case resp.StatusCode == http.StatusPermanentRedirect:
offset = nextOffset(resp)
failures = 0
continue
case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone:
return fmt.Errorf("upload session expired; request new upload URLs")
case resp.StatusCode < 500 && resp.StatusCode != http.StatusRequestTimeout && resp.StatusCode != http.StatusTooManyRequests:
return fmt.Errorf("upload failed: %s", resp.Status)
}
}
failures++
if failures > 10 {
return fmt.Errorf("upload keeps failing; run again later to resume")
}
time.Sleep(min(time.Minute, time.Duration(1<<failures)*time.Second))
if offset, err = storedOffset(client, sessionURL, total); err != nil {
return err
}
}
return nil
}

Browser

A resumable upload from a browser needs CORS permission for the page’s origin. That permission is fixed when the upload URLs are created:

  • If the browser calls upload-url itself, its Origin header is used automatically.
  • If your server calls upload-url and hands the URL to a browser, pass the browser page’s origin as uploadOrigin.
const CHUNK_BYTES = 16 * 1024 * 1024; // must be a multiple of 256 KiB
function nextOffset(res) {
const stored = res.headers.get("Range"); // e.g. "bytes=0-16777215"
return stored ? Number(stored.split("-")[1]) + 1 : 0;
}
async function storedOffset(sessionURL, total) {
const res = await fetch(sessionURL, {
method: "PUT",
headers: { "Content-Range": `bytes */${total}` },
});
if (res.status === 200 || res.status === 201) return total;
if (res.status === 308) return nextOffset(res);
throw new Error(`status query failed: ${res.status}`);
}
async function uploadResumable(sessionURL, file, onProgress) {
const total = file.size;
let offset = await storedOffset(sessionURL, total);
let failures = 0;
while (offset < total) {
const end = Math.min(offset + CHUNK_BYTES, total);
let res = null;
try {
res = await fetch(sessionURL, {
method: "PUT",
headers: { "Content-Range": `bytes ${offset}-${end - 1}/${total}` },
body: file.slice(offset, end),
});
} catch {
// Network error: fall through to retry.
}
if (res && (res.status === 200 || res.status === 201)) return;
if (res && res.status === 308) {
offset = nextOffset(res);
failures = 0;
onProgress?.(offset / total);
continue;
}
if (res && (res.status === 404 || res.status === 410)) {
throw new Error("Upload session expired; request new upload URLs");
}
if (res && res.status < 500 && res.status !== 408 && res.status !== 429) {
throw new Error(`Upload failed: ${res.status}`);
}
failures++;
if (failures > 10) throw new Error("Upload keeps failing; try again later to resume");
await new Promise((r) => setTimeout(r, Math.min(60_000, 1000 * 2 ** failures)));
offset = await storedOffset(sessionURL, total);
}
}

To resume after the user reloads the page, store resumableUploadURL in localStorage, keyed by the file’s name, size and lastModified. When the same file is selected again, call uploadResumable with the stored URL instead of requesting new ones.

Step 2b: Single PUT

Send the whole file in one PUT to signedURL. The request must include a Content-Type header equal to contentType from the response, or storage rejects it with 403.

file, _ := os.Open(filePath)
defer file.Close()
putReq, _ := http.NewRequest("PUT", uploadResp.Body.SignedURL, file)
putReq.Header.Set("Content-Type", uploadResp.Body.ContentType)
client.Do(putReq)

The upload must finish in one request. If the connection drops, the next PUT starts again from byte 0.

Code Examples


View:

How To Play The Video You Just Uploaded

After you upload your video, it will begin processing. Once it’s ready, create (or use) an Edge Key and embed it:

<!-- Replace {edgeKey} with an Edge Key -->
<iframe
src="https://player.antcdn.net/v1/{edgeKey}/master.m3u8?autoplay=true&muted=true"
frameborder="0"
allowfullscreen>
</iframe>

Check out our “Video Player” docs for more information.

Bonus: Upload Progress Tracking

The resumable examples above report progress after each chunk. For a single PUT from a browser, XMLHttpRequest reports progress while the file is sent:

const uploadWithProgress = async (file, signedURL, contentType) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
console.log(`Upload progress: ${percentComplete.toFixed(2)}%`);
}
});
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
resolve('Upload complete');
} else {
reject(new Error('Upload failed'));
}
});
xhr.open('PUT', signedURL);
xhr.setRequestHeader('Content-Type', contentType);
xhr.send(file);
});
};