Upload videos to your storage and optionally prepare them for streaming.
Upload an original video directly to storage, then complete the Upload to create its Source. You can also prepare a video asset automatically for streaming.
Your server creates an Upload session with the SDK. The file is transferred to the selected storage using the session's signed URLs. Completion verifies the uploaded file and creates the Source that references it.
Run SDK calls on your server with a Project access token. Keep that token out of browser code. Replace the example paths and metadata with your own values.
Use the SDK procedures below for server uploads. For uploads from your application's frontend, use Chunkify Uploader.
The Upload request's storage field selects the original file's destination. Omit storage.id to use the Project default, or set another storage configuration's ID from the same Project.
Destination for the original
What to pass
Your storage
Set storage.path to a unique object key including the filename, such as incoming/video-123.mp4.
Chunkify temporary storage
Omit storage.path. Chunkify chooses the key.
This is the original video's upload destination. If you request a video asset, its generated files use the Project's default storage saved when the Upload session is created.
For your storage, storage.path is required even when it is the Project default. Chunkify uses this exact key without adding the output base_prefix. Uploading to an existing key can overwrite that file.
With an initialized SDK client, this creates a single-PUT session that uploads the original to incoming/video-123.mp4 in your Project's default storage:
The storage credentials must allow reads and writes of the original. Multipart uploads also need part listing, completion, and abort permissions. Follow your provider's storage setup guide.
Originals uploaded to Chunkify temporary storage are eligible for deletion after 24 hours. With your storage, you control retention. Keep the original available for processing and later outputs that need it.
Choose what to create
Completion always creates a Source. Set create_asset: true to also prepare a video asset automatically. Omit it or set it to false when you only need the Source, such as for a separate transcoding Job.
Chunkify copies Upload metadata to the Source and any automatically created video asset. Include every metadata value required by the Project's video asset path template. Missing or empty values fail session creation.
Add create_asset: true to the same session creation request:
The video asset uses the Project's output storage, video settings, and default Workflow saved when the Upload session is created. The Upload's storage field selects only where the original file goes.
With create_asset: true, set workflow_id to another Workflow's ID to override the default, or set it to null to disable automatic actions. See Workflows.
Both upload examples below use create_asset: true and include metadata.VideoID. They upload the original to your storage configured as the Project default. For Chunkify temporary storage, omit storage.
Choose the upload mode
Mode
How the file is transferred
file_size_bytes
single
The whole file in one signed PUT request.
Optional
multipart
File slices uploaded to separate signed part URLs.
Required
auto or omitted
Single PUT at or below 100 MiB; multipart above 100 MiB.
Required
file_size_bytes must be the file's exact size in bytes. Chunkify checks the team's Source size limit. Single PUT supports up to 5 GiB, subject to the provider's limit; multipart supports up to 5 TiB.
AWS S3 and Cloudflare R2 support multipart uploads. Verify other providers' support or choose single. A multipart setup error does not trigger an automatic switch to single PUT.
The following procedures select each mode explicitly. If you use auto, follow the flow indicated by the returned upload_mode. For browser uploads, Chunkify Uploader handles both modes.
Upload with a single PUT
The server example performs the whole flow:
Create a session with upload_mode: "single".
Send the whole file to upload_url with one PUT request.
Complete the Upload through the SDK using the token from completion_url.
The storage.path below is where the original file will be uploaded. The generated video asset files use the Project's saved output storage.
The TypeScript example runs in Node.js. The PHP transfer uses Symfony HTTP Client; install it with composer require symfony/http-client if needed.
import{openAsBlob}from'node:fs';importChunkifyfrom'@chunkify/chunkify';constclient=newChunkify({projectAccessToken:'My Project Access Token',});constfile=awaitopenAsBlob('video.mp4',{type:'video/mp4'});constupload=awaitclient.uploads.create({upload_mode:'single',create_asset: true,storage:{path:'incoming/video-123.mp4',},metadata:{VideoID:'video-123',},});if(!upload.upload_url||!upload.completion_url){thrownewError('Missing upload session URLs');}// Transfer the file directly to storage.
constresponse=awaitfetch(upload.upload_url,{method:'PUT',body: file,headers:{'Content-Type':'video/mp4',},});if(!response.ok){thrownewError(`File transfer failed: ${response.status}`);}consttoken=newURL(upload.completion_url).pathname.split('/').pop();if(!token)thrownewError('Missing completion token');awaitclient.uploads.complete(token);console.log('Upload completed');
fromurllib.parseimporturlparseimporthttpxfromchunkifyimportChunkifyclient=Chunkify(project_access_token="My Project Access Token",)upload=client.uploads.create(upload_mode="single",create_asset=True,storage={"path":"incoming/video-123.mp4",},metadata={"VideoID":"video-123",},)ifnotupload.upload_urlornotupload.completion_url:raiseRuntimeError("Missing upload session URLs")# Transfer the file directly to storage.withopen("video.mp4","rb")asfile:withhttpx.stream("PUT",upload.upload_url,content=file,headers={"Content-Type":"video/mp4"},)asresponse:response.raise_for_status()token=urlparse(upload.completion_url).path.rsplit("/",1)[-1]client.uploads.complete(token)print("Upload completed")
<?phpuseChunkify\Client;useSymfony\Component\HttpClient\HttpClient;require_once__DIR__.'/vendor/autoload.php';$client=newClient(projectAccessToken:'My Project Access Token',);$upload=$client->uploads->create(uploadMode:'single',createAsset:true,storage:['path'=>'incoming/video-123.mp4',],metadata:['VideoID'=>'video-123',],);if(!$upload->uploadURL||!$upload->completionURL){thrownew\RuntimeException('Missing upload session URLs');}$httpClient=HttpClient::create();// Transfer the file directly to storage.
$fileStream=fopen('video.mp4','rb');if($fileStream===false){thrownew\RuntimeException('Could not open video.mp4');}$response=$httpClient->request('PUT',$upload->uploadURL,['body'=>$fileStream,'headers'=>['Content-Type'=>'video/mp4',],]);// Wait for the transfer response before closing the stream.
$status=$response->getStatusCode();fclose($fileStream);if($status<200||$status>=300){thrownew\RuntimeException("File transfer failed: $status");}$token=basename(parse_url($upload->completionURL,PHP_URL_PATH));$client->uploads->complete($token);print_r("Upload completed\n");
packagemainimport("context""io""log""net/http""net/url""os""path""github.com/chunkifydev/chunkify-go""github.com/chunkifydev/chunkify-go/option")funcmain(){client:=chunkify.NewClient(option.WithProjectAccessToken("My Project Access Token"),)upload,err:=client.Uploads.New(context.Background(),chunkify.UploadNewParams{UploadMode:chunkify.UploadNewParamsUploadModeSingle,CreateAsset:chunkify.Bool(true),Storage:chunkify.UploadNewParamsStorage{Path:chunkify.String("incoming/video-123.mp4"),},Metadata:map[string]string{"VideoID":"video-123",},},)iferr!=nil{log.Fatal(err)}ifupload.UploadURL==""||upload.CompletionURL==""{log.Fatal("Missing upload session URLs")}// Open the file to upload
file,err:=os.Open("video.mp4")iferr!=nil{log.Fatal(err)}deferfile.Close()info,err:=file.Stat()iferr!=nil{log.Fatal(err)}// Transfer the file directly to storage.
req,err:=http.NewRequest("PUT",upload.UploadURL,file)iferr!=nil{log.Fatal(err)}req.ContentLength=info.Size()req.Header.Set("Content-Type","video/mp4")httpClient:=&http.Client{}resp,err:=httpClient.Do(req)iferr!=nil{log.Fatal(err)}io.Copy(io.Discard,resp.Body)resp.Body.Close()ifresp.StatusCode<200||resp.StatusCode>=300{log.Fatalf("File transfer failed: %d",resp.StatusCode)}completionURL,err:=url.Parse(upload.CompletionURL)iferr!=nil{log.Fatal(err)}token:=path.Base(completionURL.Path)iferr:=client.Uploads.Complete(context.Background(),token,chunkify.UploadCompleteParams{},option.WithRequestBody("application/json",[]byte{}),);err!=nil{log.Fatal(err)}log.Println("Upload completed")}
Completion returns 204 No Content, with no JSON body. Chunkify creates the Source and starts preparing the requested video asset. The video asset becomes playable separately; see After the upload.
Upload in multiple parts
Multipart uploads have a different transfer and completion sequence:
Create a session with upload_mode: "multipart" and the exact file size.
Upload each file slice to its signed URL and save the returned ETag.
Complete the Upload with all part numbers and ETags in ascending order.
Create the multipart session
Create the multipart Upload session on your server. Provide the file's exact size, a unique destination key for the original file, and your application's video ID.
Save the returned Upload ID to correlate later events with your video. The response contains completion_url and multipart. Its signed URLs are returned only at creation and are absent from later reads, lists, and webhook payloads.
For a 100 MB file, or 100,000,000 bytes, the Upload object returned by the SDK contains these transfer fields. Other fields are omitted, and the signed URLs are abbreviated:
Use the returned multipart fields to divide the file:
Field
Purpose
part_size_bytes
The size of each slice except the final slice.
parts
Every signed upload_url and its one-based part_number, in ascending order.
expires_at
The signed part URLs' expiration time.
Each slice starts at (part_number - 1) * part_size_bytes. The final slice contains the remaining bytes. Send each slice with PUT to its matching URL, check for success, and save the response's exact ETag, including quotation marks.
In this example, parts 1 and 2 each contain 33,554,432 bytes (32 MiB). Part 3 contains the remaining 32,891,136 bytes. Each part goes to its own upload_url.
Repeat for every part. A failed part can be retried with the same URL and bytes before expiry. Successful parts do not need to be sent again. A multipart session has no top-level upload_url.
The following server helpers upload one part and return its ETag. For the first part, pass the first entry's upload_url and part_number from multipart.parts, along with multipart.part_size_bytes.
import{openAsBlob}from'node:fs';exportasyncfunctionuploadPart(filePath: string,uploadUrl: string,partNumber: number,partSizeBytes: number,):Promise<string>{constfile=awaitopenAsBlob(filePath);conststart=(partNumber-1)*partSizeBytes;constend=Math.min(start+partSizeBytes,file.size);if(start>=file.size)thrownewError('Part starts beyond the file');constresponse=awaitfetch(uploadUrl,{method:'PUT',body: file.slice(start,end),headers:{'Content-Type':'application/octet-stream',},});if(!response.ok){thrownewError(`Part upload failed: ${response.status}`);}constetag=response.headers.get('ETag');if(!etag)thrownewError('Storage did not return an ETag');returnetag;}
frompathlibimportPathimporthttpxdefupload_part(file_path,upload_url,part_number,part_size_bytes):start=(part_number-1)*part_size_byteslength=min(part_size_bytes,Path(file_path).stat().st_size-start)iflength<=0:raiseValueError("Part starts beyond the file")withopen(file_path,"rb")asfile:file.seek(start)defread_part():remaining=lengthwhileremaining:chunk=file.read(min(1024*1024,remaining))ifnotchunk:raiseValueError("File ended before the part was complete")remaining-=len(chunk)yieldchunkresponse=httpx.put(upload_url,content=read_part(),headers={"Content-Type":"application/octet-stream","Content-Length":str(length),},)response.raise_for_status()etag=response.headers.get("ETag")ifnotetag:raiseRuntimeError("Storage did not return an ETag")returnetag
<?phpuseSymfony\Component\HttpClient\HttpClient;require_once__DIR__.'/vendor/autoload.php';functionuploadPart(string$filePath,string$uploadURL,int$partNumber,int$partSizeBytes,):string{$file=fopen($filePath,'rb');if($file===false){thrownewRuntimeException('Could not open the file');}try{$start=($partNumber-1)*$partSizeBytes;$length=min($partSizeBytes,fstat($file)['size']-$start);if($length<=0||fseek($file,$start)!==0){thrownewRuntimeException('Could not read the requested part');}$body=(function()use($file,$length):\Generator{while($length>0){$chunk=fread($file,min(1024*1024,$length));if($chunk===false||$chunk===''){thrownewRuntimeException('File ended before the part was complete');}$length-=strlen($chunk);yield$chunk;}})();$response=HttpClient::create()->request('PUT',$uploadURL,['body'=>$body,'headers'=>['Content-Type'=>'application/octet-stream','Content-Length'=>(string)$length,],]);$status=$response->getStatusCode();if($status<200||$status>=300){thrownewRuntimeException("Part upload failed: $status");}$etag=$response->getHeaders(false)['etag'][0]??null;if(!$etag){thrownewRuntimeException('Storage did not return an ETag');}return$etag;}finally{fclose($file);}}
packagemainimport("fmt""io""net/http""os")funcuploadPart(filePath,uploadURLstring,partNumber,partSizeBytesint64)(string,error){file,err:=os.Open(filePath)iferr!=nil{return"",err}deferfile.Close()info,err:=file.Stat()iferr!=nil{return"",err}start:=(partNumber-1)*partSizeByteslength:=min(partSizeBytes,info.Size()-start)iflength<=0{return"",fmt.Errorf("part starts beyond the file")}body:=io.NewSectionReader(file,start,length)request,err:=http.NewRequest(http.MethodPut,uploadURL,body)iferr!=nil{return"",err}request.ContentLength=lengthrequest.Header.Set("Content-Type","application/octet-stream")response,err:=http.DefaultClient.Do(request)iferr!=nil{return"",err}deferresponse.Body.Close()ifresponse.StatusCode<200||response.StatusCode>=300{return"",fmt.Errorf("part upload failed: %d",response.StatusCode)}etag:=response.Header.Get("ETag")ifetag==""{return"",fmt.Errorf("storage did not return an ETag")}returnetag,nil}
Pair the returned ETag with that part's part_number. Repeat for all parts and pass the complete list to the completion step below. Preserve the ETag's quotation marks.
Complete the multipart session
After every part succeeds, pass the full part list to the SDK. Each entry has part_number and the exact storage etag. For the 100 MB example above, include all three parts:
The ETags below are placeholders. Replace each with the ETag response header returned by storage for that part's successful PUT. The uploadPart helper above returns this value. Preserve its quotation marks.
These server helpers accept completion_url and that part list. They extract the token from the URL's last path segment and call the SDK's completion method.
Chunkify checks the uploaded parts and assembled file, creates the Source, and starts preparing the requested video asset. Successful completion returns 204 No Content.
Upload from a browser
Use Chunkify Uploader to handle file selection, single PUT or multipart transfer, progress, retries, and completion.
Your server authorizes the upload and creates its session with file_size_bytes from the selected file's size. Use auto or omit upload_mode to let Chunkify choose the transfer mode.
Return only the session fields the component needs. Keep the Project token and storage credentials on your server:
Configure bucket CORS to allow your website's origin, PUT, and the Content-Type request header. Include https://chunkify.dev for uploads through the Chunkify dashboard.
Expose the storage response's ETag header for multipart uploads. Expose Retry-After too if you want the uploader to honor storage retry delays when that header is returned.
If your site uses a Content Security Policy, allow https://api.chunkify.dev and the storage endpoint in connect-src. See Uploader setup for browser integration examples.
After the upload
The upload.completed event confirms Source creation. Its payload contains data.source and data.upload. If you requested a video asset, data.upload.asset_id identifies it.
Upload completion confirms that the original file is available. The video asset is processed afterward. Handle asset.playback_ready to receive its playback URLs when streaming becomes available.
Also handle upload.failed, upload.expired, and asset.failed as appropriate for your application. See Webhooks for setup and payload details.
Retries and expiration
Retry a network failure, 429, or 5xx completion response with bounded attempts and increasing delays before expires_at. Reuse the same completion URL. For multipart, resend the same complete part list.
A valid completion retry does not create duplicate Sources or video assets. For other errors, inspect the response. A failed or expired Upload needs a new session.
A video asset output folder conflict returns 409. Correct the Project's path settings or the Upload metadata before creating another session. See Output paths.
Both transfer and completion must finish before expiry. Single PUT defaults to 2 hours; multipart uses a file-size-based default between 2 and 48 hours. Use validity_timeout to adjust the deadline within the API limits.
Storage credentials and bucket policies can shorten signed URL validity. The Upload API cannot refresh or reissue part URLs. Keep the URLs and returned ETags until completion, and create a new session after expiry or if its URLs are lost.
Deleting or expiring an Upload does not abort its incomplete multipart parts. Configure your bucket's lifecycle rules to remove abandoned multipart uploads.
Use a file that is already online
If the original video is already accessible, you can register it as a Source using its URL or storage path. These methods reference the existing file without uploading or copying it.
Use a hosted URL
Create a Source that references an existing file at a direct HTTP or HTTPS URL. Chunkify must be able to read the file:
Keep the original accessible while Chunkify processes it and for later outputs that need it. Creating the Source registers the file without copying it. See Source videos.
Use a file in your storage
Add your storage configuration to Chunkify, then create a Source using the existing file's exact object key. The file must already be present at that path.
These examples read sources/video.mp4 from your Project's default storage configuration:
Set storage.id to another storage configuration's ID to read the original from there. It must belong to the same Project and allow reads of the file. This input method requires your storage; Chunkify temporary storage cannot be used.
Here, storage.path identifies the existing original. It includes the filename, and the storage configuration's output base_prefix is not added. Provide either url or storage when creating a Source.
The file stays in its bucket. This request creates a Source record, and no Upload session is needed. Changing the Project default later does not change the Source's saved file location.
# Video upload
> Upload videos to your storage and optionally prepare them for streaming.
Upload an original video directly to storage, then complete the Upload to create its Source. You can also prepare a video asset automatically for streaming.
Your server creates an Upload session with the SDK. The file is transferred to the selected storage using the session's signed URLs. Completion verifies the uploaded file and creates the Source that references it.
Run SDK calls on your server with a [Project access token](/docs/integration/sdk#create-a-project-access-token). Keep that token out of browser code. Replace the example paths and metadata with your own values.
Use the SDK procedures below for server uploads. For uploads from your application's frontend, use [Chunkify Uploader](#upload-from-a-browser).
If your video is already hosted at a URL or in your storage, you can also [create a Source from the existing file](#use-a-file-that-is-already-online).
<a id="upload-from-disk"></a>
<a id="choose-where-the-file-goes"></a>
<a id="choose-where-to-upload-the-original"></a>
## Choose where to upload
The Upload request's `storage` field selects the original file's destination. Omit `storage.id` to use the Project default, or set another storage configuration's ID from the same Project.
| Destination for the original | What to pass |
| --- | --- |
| Your storage | Set `storage.path` to a unique object key including the filename, such as `incoming/video-123.mp4`. |
| Chunkify temporary storage | Omit `storage.path`. Chunkify chooses the key. |
<note>
This is the original video's upload destination. If you request a video asset, its generated files use the Project's default storage saved when the Upload session is created.
</note>
For your storage, `storage.path` is required even when it is the Project default. Chunkify uses this exact key without adding the output `base_prefix`. Uploading to an existing key can overwrite that file.
With an initialized SDK client, this creates a single-PUT session that uploads the original to `incoming/video-123.mp4` in your Project's default storage:
<!-- SDK publication check: verify the Upload parameters and Go enum names in these short configuration examples after SDK generation. The complete SDK setup and transfer flows appear below. -->
<codegroup>
<codeblock lang="Typescript">
```typescript
const upload = await client.uploads.create({
upload_mode: 'single',
storage: {
path: 'incoming/video-123.mp4',
},
});
```
</codeblock>
<codeblock lang="Python">
```python
upload = client.uploads.create(
upload_mode="single",
storage={
"path": "incoming/video-123.mp4",
},
)
```
</codeblock>
<codeblock lang="PHP">
```php
<?php
$upload = $client->uploads->create(
uploadMode: 'single',
storage: [
'path' => 'incoming/video-123.mp4',
],
);
```
</codeblock>
<codeblock lang="Go">
```go
upload, err := client.Uploads.New(
context.Background(),
chunkify.UploadNewParams{
UploadMode: chunkify.UploadNewParamsUploadModeSingle,
Storage: chunkify.UploadNewParamsStorage{
Path: chunkify.String("incoming/video-123.mp4"),
},
},
)
if err != nil {
panic(err)
}
```
</codeblock>
</codegroup>
The storage credentials must allow reads and writes of the original. Multipart uploads also need part listing, completion, and abort permissions. Follow your provider's [storage setup guide](/docs/video-streaming/storage).
Originals uploaded to Chunkify temporary storage are eligible for deletion after 24 hours. With your storage, you control retention. Keep the original available for processing and later outputs that need it.
<a id="automatic-asset-output-folders"></a>
<a id="automatic-video-asset-creation"></a>
<a id="choose-what-to-create-after-the-upload"></a>
## Choose what to create
Completion always creates a Source. Set `create_asset: true` to also prepare a video asset automatically. Omit it or set it to `false` when you only need the Source, such as for a separate transcoding Job.
Chunkify copies Upload metadata to the Source and any automatically created video asset. Include every metadata value required by the Project's video asset path template. Missing or empty values fail session creation.
Add `create_asset: true` to the same session creation request:
<codegroup>
<codeblock lang="Typescript">
```typescript
const upload = await client.uploads.create({
upload_mode: 'single',
create_asset: true,
storage: {
path: 'incoming/video-123.mp4',
},
});
```
</codeblock>
<codeblock lang="Python">
```python
upload = client.uploads.create(
upload_mode="single",
create_asset=True,
storage={
"path": "incoming/video-123.mp4",
},
)
```
</codeblock>
<codeblock lang="PHP">
```php
<?php
$upload = $client->uploads->create(
uploadMode: 'single',
createAsset: true,
storage: [
'path' => 'incoming/video-123.mp4',
],
);
```
</codeblock>
<codeblock lang="Go">
```go
upload, err := client.Uploads.New(
context.Background(),
chunkify.UploadNewParams{
UploadMode: chunkify.UploadNewParamsUploadModeSingle,
CreateAsset: chunkify.Bool(true),
Storage: chunkify.UploadNewParamsStorage{
Path: chunkify.String("incoming/video-123.mp4"),
},
},
)
if err != nil {
panic(err)
}
```
</codeblock>
</codegroup>
The video asset uses the Project's output storage, video settings, and default Workflow saved when the Upload session is created. The Upload's `storage` field selects only where the original file goes.
With `create_asset: true`, set `workflow_id` to another Workflow's ID to override the default, or set it to `null` to disable automatic actions. See [Workflows](/docs/video-streaming/asset-workflow).
Both upload examples below use `create_asset: true` and include `metadata.VideoID`. They upload the original to your storage configured as the Project default. For Chunkify temporary storage, omit `storage`.
## Choose the upload mode
| Mode | How the file is transferred | `file_size_bytes` |
| --- | --- | --- |
| `single` | The whole file in one signed PUT request. | Optional |
| `multipart` | File slices uploaded to separate signed part URLs. | Required |
| `auto` or omitted | Single PUT at or below 100 MiB; multipart above 100 MiB. | Required |
`file_size_bytes` must be the file's exact size in bytes. Chunkify checks the team's Source size limit. Single PUT supports up to 5 GiB, subject to the provider's limit; multipart supports up to 5 TiB.
AWS S3 and Cloudflare R2 support multipart uploads. Verify other providers' support or choose `single`. A multipart setup error does not trigger an automatic switch to single PUT.
The following procedures select each mode explicitly. If you use `auto`, follow the flow indicated by the returned `upload_mode`. For browser uploads, [Chunkify Uploader](#upload-from-a-browser) handles both modes.
<a id="transfer-and-complete-the-upload"></a>
## Upload with a single PUT
The server example performs the whole flow:
1. Create a session with `upload_mode: "single"`.
2. Send the whole file to `upload_url` with one PUT request.
3. Complete the Upload through the SDK using the token from `completion_url`.
The `storage.path` below is where the original file will be uploaded. The generated video asset files use the Project's saved output storage.
The TypeScript example runs in Node.js. The PHP transfer uses [Symfony HTTP Client](https://symfony.com/doc/current/http_client.html#uploading-data); install it with `composer require symfony/http-client` if needed.
<!-- SDK publication check: verify single-PUT creation fields and completion calls in all four languages after SDK generation. Go is expected to accept UploadCompleteParams once the multipart request body is generated; the empty request-body option preserves single-PUT completion without JSON. -->
<codegroup>
<codeblock lang="Typescript">
```typescript
import { openAsBlob } from 'node:fs';
import Chunkify from '@chunkify/chunkify';
const client = new Chunkify({
projectAccessToken: 'My Project Access Token',
});
const file = await openAsBlob('video.mp4', { type: 'video/mp4' });
const upload = await client.uploads.create({
upload_mode: 'single',
create_asset: true,
storage: {
path: 'incoming/video-123.mp4',
},
metadata: {
VideoID: 'video-123',
},
});
if (!upload.upload_url || !upload.completion_url) {
throw new Error('Missing upload session URLs');
}
// Transfer the file directly to storage.
const response = await fetch(upload.upload_url, {
method: 'PUT',
body: file,
headers: {
'Content-Type': 'video/mp4',
},
});
if (!response.ok) {
throw new Error(`File transfer failed: ${response.status}`);
}
const token = new URL(upload.completion_url).pathname.split('/').pop();
if (!token) throw new Error('Missing completion token');
await client.uploads.complete(token);
console.log('Upload completed');
```
</codeblock>
<codeblock lang="Python">
```python
from urllib.parse import urlparse
import httpx
from chunkify import Chunkify
client = Chunkify(
project_access_token="My Project Access Token",
)
upload = client.uploads.create(
upload_mode="single",
create_asset=True,
storage={
"path": "incoming/video-123.mp4",
},
metadata={
"VideoID": "video-123",
},
)
if not upload.upload_url or not upload.completion_url:
raise RuntimeError("Missing upload session URLs")
# Transfer the file directly to storage.
with open("video.mp4", "rb") as file:
with httpx.stream(
"PUT",
upload.upload_url,
content=file,
headers={"Content-Type": "video/mp4"},
) as response:
response.raise_for_status()
token = urlparse(upload.completion_url).path.rsplit("/", 1)[-1]
client.uploads.complete(token)
print("Upload completed")
```
</codeblock>
<codeblock lang="PHP">
```php
<?php
use Chunkify\Client;
use Symfony\Component\HttpClient\HttpClient;
require_once __DIR__ . '/vendor/autoload.php';
$client = new Client(
projectAccessToken: 'My Project Access Token',
);
$upload = $client->uploads->create(
uploadMode: 'single',
createAsset: true,
storage: [
'path' => 'incoming/video-123.mp4',
],
metadata: [
'VideoID' => 'video-123',
],
);
if (!$upload->uploadURL || !$upload->completionURL) {
throw new \RuntimeException('Missing upload session URLs');
}
$httpClient = HttpClient::create();
// Transfer the file directly to storage.
$fileStream = fopen('video.mp4', 'rb');
if ($fileStream === false) {
throw new \RuntimeException('Could not open video.mp4');
}
$response = $httpClient->request('PUT', $upload->uploadURL, [
'body' => $fileStream,
'headers' => [
'Content-Type' => 'video/mp4',
],
]);
// Wait for the transfer response before closing the stream.
$status = $response->getStatusCode();
fclose($fileStream);
if ($status < 200 || $status >= 300) {
throw new \RuntimeException("File transfer failed: $status");
}
$token = basename(parse_url($upload->completionURL, PHP_URL_PATH));
$client->uploads->complete($token);
print_r("Upload completed\n");
```
</codeblock>
<codeblock lang="Go">
```go
package main
import (
"context"
"io"
"log"
"net/http"
"net/url"
"os"
"path"
"github.com/chunkifydev/chunkify-go"
"github.com/chunkifydev/chunkify-go/option"
)
func main() {
client := chunkify.NewClient(
option.WithProjectAccessToken("My Project Access Token"),
)
upload, err := client.Uploads.New(
context.Background(),
chunkify.UploadNewParams{
UploadMode: chunkify.UploadNewParamsUploadModeSingle,
CreateAsset: chunkify.Bool(true),
Storage: chunkify.UploadNewParamsStorage{
Path: chunkify.String("incoming/video-123.mp4"),
},
Metadata: map[string]string{
"VideoID": "video-123",
},
},
)
if err != nil {
log.Fatal(err)
}
if upload.UploadURL == "" || upload.CompletionURL == "" {
log.Fatal("Missing upload session URLs")
}
// Open the file to upload
file, err := os.Open("video.mp4")
if err != nil {
log.Fatal(err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
log.Fatal(err)
}
// Transfer the file directly to storage.
req, err := http.NewRequest("PUT", upload.UploadURL, file)
if err != nil {
log.Fatal(err)
}
req.ContentLength = info.Size()
req.Header.Set("Content-Type", "video/mp4")
httpClient := &http.Client{}
resp, err := httpClient.Do(req)
if err != nil {
log.Fatal(err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
log.Fatalf("File transfer failed: %d", resp.StatusCode)
}
completionURL, err := url.Parse(upload.CompletionURL)
if err != nil {
log.Fatal(err)
}
token := path.Base(completionURL.Path)
if err := client.Uploads.Complete(
context.Background(),
token,
chunkify.UploadCompleteParams{},
option.WithRequestBody("application/json", []byte{}),
); err != nil {
log.Fatal(err)
}
log.Println("Upload completed")
}
```
</codeblock>
</codegroup>
Completion returns `204 No Content`, with no JSON body. Chunkify creates the Source and starts preparing the requested video asset. The video asset becomes playable separately; see [After the upload](#after-the-upload).
<a id="multipart-sessions-for-large-files"></a>
## Upload in multiple parts
Multipart uploads have a different transfer and completion sequence:
1. Create a session with `upload_mode: "multipart"` and the exact file size.
2. Upload each file slice to its signed URL and save the returned ETag.
3. Complete the Upload with all part numbers and ETags in ascending order.
<a id="create-an-upload-session"></a>
### Create the multipart session
Create the multipart Upload session on your server. Provide the file's exact size, a unique destination key for the original file, and your application's video ID.
<!-- SDK publication check: Upload creation fields upload_mode/uploadMode, file_size_bytes/fileSizeBytes, create_asset/createAsset and the Go UploadNewParamsUploadModeMultipart enum are inferred from api/openapi.json and existing SDK conventions. Verify every supported language after SDK generation. -->
<codegroup>
<codeblock lang="Typescript">
```typescript
import Chunkify from '@chunkify/chunkify';
export async function createMultipartUploadSession(
fileSizeBytes: number,
objectKey: string,
videoId: string,
) {
const client = new Chunkify({
projectAccessToken: 'My Project Access Token',
});
return client.uploads.create({
upload_mode: 'multipart',
file_size_bytes: fileSizeBytes,
create_asset: true,
storage: {
path: objectKey,
},
metadata: {
VideoID: videoId,
},
});
}
```
</codeblock>
<codeblock lang="Python">
```python
from chunkify import Chunkify
def create_multipart_upload_session(file_size_bytes, object_key, video_id):
client = Chunkify(
project_access_token="My Project Access Token",
)
return client.uploads.create(
upload_mode="multipart",
file_size_bytes=file_size_bytes,
create_asset=True,
storage={
"path": object_key,
},
metadata={
"VideoID": video_id,
},
)
```
</codeblock>
<codeblock lang="PHP">
```php
<?php
use Chunkify\Client;
use Chunkify\Uploads\Upload;
require_once __DIR__ . '/vendor/autoload.php';
function createMultipartUploadSession(
int $fileSizeBytes,
string $objectKey,
string $videoID,
): Upload {
$client = new Client(
projectAccessToken: 'My Project Access Token',
);
return $client->uploads->create(
uploadMode: 'multipart',
fileSizeBytes: $fileSizeBytes,
createAsset: true,
storage: [
'path' => $objectKey,
],
metadata: [
'VideoID' => $videoID,
],
);
}
```
</codeblock>
<codeblock lang="Go">
```go
package main
import (
"context"
"github.com/chunkifydev/chunkify-go"
"github.com/chunkifydev/chunkify-go/option"
)
func createMultipartUploadSession(fileSizeBytes int64, objectKey, videoID string) (*chunkify.Upload, error) {
client := chunkify.NewClient(
option.WithProjectAccessToken("My Project Access Token"),
)
return client.Uploads.New(
context.Background(),
chunkify.UploadNewParams{
UploadMode: chunkify.UploadNewParamsUploadModeMultipart,
FileSizeBytes: chunkify.Int(fileSizeBytes),
CreateAsset: chunkify.Bool(true),
Storage: chunkify.UploadNewParamsStorage{
Path: chunkify.String(objectKey),
},
Metadata: map[string]string{
"VideoID": videoID,
},
},
)
}
```
</codeblock>
</codegroup>
Save the returned Upload ID to correlate later events with your video. The response contains `completion_url` and `multipart`. Its signed URLs are returned only at creation and are absent from later reads, lists, and webhook payloads.
For a 100 MB file, or 100,000,000 bytes, the Upload object returned by the SDK contains these transfer fields. Other fields are omitted, and the signed URLs are abbreviated:
```json
{
"id": "upl_2G6MJiNz71bHQGNzGwKx5cJwPFS",
"upload_mode": "multipart",
"file_size_bytes": 100000000,
"expires_at": "2026-01-01T14:00:00Z",
"completion_url": "https://api.chunkify.dev/v1/uploads/completion/EXAMPLE_TOKEN",
"multipart": {
"part_size_bytes": 33554432,
"parts": [
{
"part_number": 1,
"upload_url": "https://storage.example.com/incoming/video-123.mp4?partNumber=1&uploadId=EXAMPLE&SIGNED_QUERY"
},
{
"part_number": 2,
"upload_url": "https://storage.example.com/incoming/video-123.mp4?partNumber=2&uploadId=EXAMPLE&SIGNED_QUERY"
},
{
"part_number": 3,
"upload_url": "https://storage.example.com/incoming/video-123.mp4?partNumber=3&uploadId=EXAMPLE&SIGNED_QUERY"
}
],
"expires_at": "2026-01-01T14:00:00Z"
}
}
```
### Upload the parts
Use the returned `multipart` fields to divide the file:
| Field | Purpose |
| --- | --- |
| `part_size_bytes` | The size of each slice except the final slice. |
| `parts` | Every signed `upload_url` and its one-based `part_number`, in ascending order. |
| `expires_at` | The signed part URLs' expiration time. |
Each slice starts at `(part_number - 1) * part_size_bytes`. The final slice contains the remaining bytes. Send each slice with PUT to its matching URL, check for success, and save the response's exact `ETag`, including quotation marks.
In this example, parts 1 and 2 each contain 33,554,432 bytes (32 MiB). Part 3 contains the remaining 32,891,136 bytes. Each part goes to its own `upload_url`.
Repeat for every part. A failed part can be retried with the same URL and bytes before expiry. Successful parts do not need to be sent again. A multipart session has no top-level `upload_url`.
The following server helpers upload one part and return its ETag. For the first part, pass the first entry's `upload_url` and `part_number` from `multipart.parts`, along with `multipart.part_size_bytes`.
<codegroup>
<codeblock lang="Typescript">
```typescript
import { openAsBlob } from 'node:fs';
export async function uploadPart(
filePath: string,
uploadUrl: string,
partNumber: number,
partSizeBytes: number,
): Promise<string> {
const file = await openAsBlob(filePath);
const start = (partNumber - 1) * partSizeBytes;
const end = Math.min(start + partSizeBytes, file.size);
if (start >= file.size) throw new Error('Part starts beyond the file');
const response = await fetch(uploadUrl, {
method: 'PUT',
body: file.slice(start, end),
headers: {
'Content-Type': 'application/octet-stream',
},
});
if (!response.ok) {
throw new Error(`Part upload failed: ${response.status}`);
}
const etag = response.headers.get('ETag');
if (!etag) throw new Error('Storage did not return an ETag');
return etag;
}
```
</codeblock>
<codeblock lang="Python">
```python
from pathlib import Path
import httpx
def upload_part(file_path, upload_url, part_number, part_size_bytes):
start = (part_number - 1) * part_size_bytes
length = min(part_size_bytes, Path(file_path).stat().st_size - start)
if length <= 0:
raise ValueError("Part starts beyond the file")
with open(file_path, "rb") as file:
file.seek(start)
def read_part():
remaining = length
while remaining:
chunk = file.read(min(1024 * 1024, remaining))
if not chunk:
raise ValueError("File ended before the part was complete")
remaining -= len(chunk)
yield chunk
response = httpx.put(
upload_url,
content=read_part(),
headers={
"Content-Type": "application/octet-stream",
"Content-Length": str(length),
},
)
response.raise_for_status()
etag = response.headers.get("ETag")
if not etag:
raise RuntimeError("Storage did not return an ETag")
return etag
```
</codeblock>
<codeblock lang="PHP">
```php
<?php
use Symfony\Component\HttpClient\HttpClient;
require_once __DIR__ . '/vendor/autoload.php';
function uploadPart(
string $filePath,
string $uploadURL,
int $partNumber,
int $partSizeBytes,
): string {
$file = fopen($filePath, 'rb');
if ($file === false) {
throw new RuntimeException('Could not open the file');
}
try {
$start = ($partNumber - 1) * $partSizeBytes;
$length = min($partSizeBytes, fstat($file)['size'] - $start);
if ($length <= 0 || fseek($file, $start) !== 0) {
throw new RuntimeException('Could not read the requested part');
}
$body = (function () use ($file, $length): \Generator {
while ($length > 0) {
$chunk = fread($file, min(1024 * 1024, $length));
if ($chunk === false || $chunk === '') {
throw new RuntimeException('File ended before the part was complete');
}
$length -= strlen($chunk);
yield $chunk;
}
})();
$response = HttpClient::create()->request('PUT', $uploadURL, [
'body' => $body,
'headers' => [
'Content-Type' => 'application/octet-stream',
'Content-Length' => (string) $length,
],
]);
$status = $response->getStatusCode();
if ($status < 200 || $status >= 300) {
throw new RuntimeException("Part upload failed: $status");
}
$etag = $response->getHeaders(false)['etag'][0] ?? null;
if (!$etag) {
throw new RuntimeException('Storage did not return an ETag');
}
return $etag;
} finally {
fclose($file);
}
}
```
</codeblock>
<codeblock lang="Go">
```go
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func uploadPart(filePath, uploadURL string, partNumber, partSizeBytes int64) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return "", err
}
start := (partNumber - 1) * partSizeBytes
length := min(partSizeBytes, info.Size()-start)
if length <= 0 {
return "", fmt.Errorf("part starts beyond the file")
}
body := io.NewSectionReader(file, start, length)
request, err := http.NewRequest(http.MethodPut, uploadURL, body)
if err != nil {
return "", err
}
request.ContentLength = length
request.Header.Set("Content-Type", "application/octet-stream")
response, err := http.DefaultClient.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return "", fmt.Errorf("part upload failed: %d", response.StatusCode)
}
etag := response.Header.Get("ETag")
if etag == "" {
return "", fmt.Errorf("storage did not return an ETag")
}
return etag, nil
}
```
</codeblock>
</codegroup>
Pair the returned ETag with that part's `part_number`. Repeat for all parts and pass the complete list to the completion step below. Preserve the ETag's quotation marks.
<a id="complete-a-multipart-upload"></a>
### Complete the multipart session
After every part succeeds, pass the full part list to the SDK. Each entry has `part_number` and the exact storage `etag`. For the 100 MB example above, include all three parts:
The ETags below are placeholders. Replace each with the `ETag` response header returned by storage for that part's successful PUT. The `uploadPart` helper above returns this value. Preserve its quotation marks.
```json
{
"parts": [
{
"part_number": 1,
"etag": "\"etag-for-part-1\""
},
{
"part_number": 2,
"etag": "\"etag-for-part-2\""
},
{
"part_number": 3,
"etag": "\"etag-for-part-3\""
}
]
}
```
These server helpers accept `completion_url` and that part list. They extract the token from the URL's last path segment and call the SDK's completion method.
<!-- SDK publication check: multipart completion is expected to map to uploads.complete(token, {parts}) in TypeScript, uploads.complete(token, parts=...) in Python, uploads->complete(token, parts: ...) in PHP, and Uploads.Complete(ctx, token, UploadCompleteParams{Parts: []UploadCompleteParamsPart{...}}) in Go. Its body parameter and generated Go part type need verification after SDK generation. -->
<codegroup>
<codeblock lang="Typescript">
```typescript
import Chunkify from '@chunkify/chunkify';
type CompletedPart = {
part_number: number;
etag: string;
};
export async function completeMultipartUpload(
completionUrl: string,
parts: CompletedPart[],
) {
const client = new Chunkify({
projectAccessToken: 'My Project Access Token',
});
const token = new URL(completionUrl).pathname.split('/').pop();
if (!token) throw new Error('Missing completion token');
await client.uploads.complete(token, {
parts,
});
}
```
</codeblock>
<codeblock lang="Python">
```python
from urllib.parse import urlparse
from chunkify import Chunkify
def complete_multipart_upload(completion_url, parts):
client = Chunkify(
project_access_token="My Project Access Token",
)
token = urlparse(completion_url).path.rsplit("/", 1)[-1]
if not token:
raise ValueError("Missing completion token")
client.uploads.complete(
token,
parts=parts,
)
```
</codeblock>
<codeblock lang="PHP">
```php
<?php
use Chunkify\Client;
require_once __DIR__ . '/vendor/autoload.php';
function completeMultipartUpload(string $completionURL, array $parts): void
{
$client = new Client(
projectAccessToken: 'My Project Access Token',
);
$path = parse_url($completionURL, PHP_URL_PATH);
if (!is_string($path) || $path === '') {
throw new RuntimeException('Missing completion token');
}
$token = basename($path);
$client->uploads->complete(
$token,
parts: $parts,
);
}
```
</codeblock>
<codeblock lang="Go">
```go
package main
import (
"context"
"fmt"
"net/url"
"path"
"github.com/chunkifydev/chunkify-go"
"github.com/chunkifydev/chunkify-go/option"
)
func completeMultipartUpload(completionURL string, parts []chunkify.UploadCompleteParamsPart) error {
client := chunkify.NewClient(
option.WithProjectAccessToken("My Project Access Token"),
)
parsed, err := url.Parse(completionURL)
if err != nil {
return err
}
token := path.Base(parsed.Path)
if token == "." || token == "/" {
return fmt.Errorf("missing completion token")
}
return client.Uploads.Complete(
context.Background(),
token,
chunkify.UploadCompleteParams{
Parts: parts,
},
)
}
```
</codeblock>
</codegroup>
Chunkify checks the uploaded parts and assembled file, creates the Source, and starts preparing the requested video asset. Successful completion returns `204 No Content`.
## Upload from a browser
Use [Chunkify Uploader](/docs/integration/uploader) to handle file selection, single PUT or multipart transfer, progress, retries, and completion.
Your server authorizes the upload and creates its session with `file_size_bytes` from the selected file's `size`. Use `auto` or omit `upload_mode` to let Chunkify choose the transfer mode.
Return only the session fields the component needs. Keep the Project token and storage credentials on your server:
| Returned mode | Session fields |
| --- | --- |
| `single` | `upload_mode`, `upload_url`, `completion_url`, `expires_at` |
| `multipart` | `upload_mode`, `completion_url`, `expires_at`, `multipart` |
Configure [bucket CORS](/docs/video-streaming/storage/cors#allow-browser-uploads) to allow your website's origin, PUT, and the `Content-Type` request header. Include `https://chunkify.dev` for uploads through the Chunkify dashboard.
Expose the storage response's `ETag` header for multipart uploads. Expose `Retry-After` too if you want the uploader to honor storage retry delays when that header is returned.
If your site uses a Content Security Policy, allow `https://api.chunkify.dev` and the storage endpoint in `connect-src`. See [Uploader setup](/docs/integration/uploader/setup) for browser integration examples.
<a id="use-webhooks-to-continue-your-workflow"></a>
## After the upload
The `upload.completed` event confirms Source creation. Its payload contains `data.source` and `data.upload`. If you requested a video asset, `data.upload.asset_id` identifies it.
Upload completion confirms that the original file is available. The video asset is processed afterward. Handle `asset.playback_ready` to receive its playback URLs when streaming becomes available.
Also handle `upload.failed`, `upload.expired`, and `asset.failed` as appropriate for your application. See [Webhooks](/docs/integration/webhooks) for setup and payload details.
<a id="retry-completion-safely"></a>
## Retries and expiration
Retry a network failure, `429`, or `5xx` completion response with bounded attempts and increasing delays before `expires_at`. Reuse the same completion URL. For multipart, resend the same complete part list.
A valid completion retry does not create duplicate Sources or video assets. For other errors, inspect the response. A failed or expired Upload needs a new session.
A video asset output folder conflict returns `409`. Correct the Project's path settings or the Upload metadata before creating another session. See [Output paths](/docs/video-streaming/storage/output-paths).
Both transfer and completion must finish before expiry. Single PUT defaults to 2 hours; multipart uses a file-size-based default between 2 and 48 hours. Use `validity_timeout` to adjust the deadline within the [API limits](/docs/api-reference/uploads/create-a-new-upload).
Storage credentials and bucket policies can shorten signed URL validity. The Upload API cannot refresh or reissue part URLs. Keep the URLs and returned ETags until completion, and create a new session after expiry or if its URLs are lost.
Deleting or expiring an Upload does not abort its incomplete multipart parts. Configure your bucket's lifecycle rules to remove abandoned multipart uploads.
## Use a file that is already online
If the original video is already accessible, you can register it as a Source using its URL or storage path. These methods reference the existing file without uploading or copying it.
<a id="upload-from-an-url"></a>
### Use a hosted URL
Create a Source that references an existing file at a direct HTTP or HTTPS URL. Chunkify must be able to read the file:
<codegroup>
<codeblock lang="Typescript">
```typescript
import Chunkify from '@chunkify/chunkify';
const client = new Chunkify({
projectAccessToken: 'My Project Access Token',
});
const source = await client.sources.create({
url: 'https://media.example.com/video.mp4',
});
console.log(source.id);
```
</codeblock>
<codeblock lang="Python">
```python
from chunkify import Chunkify
client = Chunkify(
project_access_token="My Project Access Token",
)
source = client.sources.create(
url="https://media.example.com/video.mp4",
)
print(source.id)
```
</codeblock>
<codeblock lang="PHP">
```php
<?php
use Chunkify\Client;
require_once __DIR__ . '/vendor/autoload.php';
$client = new Client(
projectAccessToken: 'My Project Access Token',
);
$source = $client->sources->create(
url: 'https://media.example.com/video.mp4',
);
print_r($source->id);
```
</codeblock>
<codeblock lang="Go">
```go
package main
import (
"context"
"fmt"
"github.com/chunkifydev/chunkify-go"
"github.com/chunkifydev/chunkify-go/option"
)
func main() {
client := chunkify.NewClient(
option.WithProjectAccessToken("My Project Access Token"),
)
source, err := client.Sources.New(
context.Background(),
chunkify.SourceNewParams{
URL: chunkify.String("https://media.example.com/video.mp4"),
},
)
if err != nil {
panic(err)
}
fmt.Println(source.ID)
}
```
</codeblock>
</codegroup>
Keep the original accessible while Chunkify processes it and for later outputs that need it. Creating the Source registers the file without copying it. See [Source videos](/docs/video-streaming/storage#source-videos).
<a id="use-connected-storage"></a>
<a id="use-your-storage"></a>
### Use a file in your storage
Add your storage configuration to Chunkify, then create a Source using the existing file's exact object key. The file must already be present at that path.
These examples read `sources/video.mp4` from your Project's default storage configuration:
<codegroup>
<codeblock lang="Typescript">
```typescript
import Chunkify from '@chunkify/chunkify';
const client = new Chunkify({
projectAccessToken: 'My Project Access Token',
});
const source = await client.sources.create({
storage: {
path: 'sources/video.mp4',
},
});
console.log(source.id);
```
</codeblock>
<codeblock lang="Python">
```python
from chunkify import Chunkify
client = Chunkify(
project_access_token="My Project Access Token",
)
source = client.sources.create(
storage={
"path": "sources/video.mp4",
},
)
print(source.id)
```
</codeblock>
<codeblock lang="PHP">
```php
<?php
use Chunkify\Client;
require_once __DIR__ . '/vendor/autoload.php';
$client = new Client(
projectAccessToken: 'My Project Access Token',
);
$source = $client->sources->create(
storage: [
'path' => 'sources/video.mp4',
],
);
print_r($source->id);
```
</codeblock>
<codeblock lang="Go">
```go
package main
import (
"context"
"fmt"
"github.com/chunkifydev/chunkify-go"
"github.com/chunkifydev/chunkify-go/option"
)
func main() {
client := chunkify.NewClient(
option.WithProjectAccessToken("My Project Access Token"),
)
source, err := client.Sources.New(
context.Background(),
chunkify.SourceNewParams{
Storage: chunkify.SourceNewParamsStorage{
Path: "sources/video.mp4",
},
},
)
if err != nil {
panic(err)
}
fmt.Println(source.ID)
}
```
</codeblock>
</codegroup>
Set `storage.id` to another storage configuration's ID to read the original from there. It must belong to the same Project and allow reads of the file. This input method requires your storage; Chunkify temporary storage cannot be used.
Here, `storage.path` identifies the existing original. It includes the filename, and the storage configuration's output `base_prefix` is not added. Provide either `url` or `storage` when creating a Source.
The file stays in its bucket. This request creates a Source record, and no Upload session is needed. Changing the Project default later does not change the Source's saved file location.
Use the Source ID to [create a video asset](/docs/video-streaming/quickstart#create-a-video-asset) or [create a transcoding](/docs/api-reference/transcodings/create-transcoding).