Finish a video upload (step 3 of 3)
Registers a finished TUS upload as a vault item and kicks moderation.
/api/external/vault/video-upload/completeRegisters a finished TUS upload as a vault item and kicks moderation.
Idempotent: a retried completion for an already-registered video returns the existing item instead of creating a duplicate — retry freely. 409 means "not finished yet, retry": the CDN can lag a few seconds after the TUS client reports done, so on a 409 back off (2s, 5s, 10s, 30s) and call again with the same body.
The item starts PENDING and finalizes asynchronously (the NSFW pipeline plus transcoding). Poll video-status with the bunnyStreamId to know when the video is playable, and the vault list for moderation.
Authentication
Send the creator's API key as a bearer token: Authorization: Bearer dpfn_…. See Authentication & API keys.
Request body
Content type: application/json
| Name | Type | Required | Description |
|---|---|---|---|
videoId | string | Required | The videoId from step 1. |
originalName | string | Required | Same file name you sent in step 1. |
completionToken | string | Required | The token from step 1 (valid 8h). Proves this key started the upload. |
folderId | string | null | Optional | Optional folder — silently ignored if you don’t own it. |
{
"videoId": "c2f7f9e2-1111-4222-b333-4d55e6f7a8b9",
"originalName": "teaser.mp4",
"completionToken": "eyJ…"
}Responses
| Name | Type | Description | |||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
success | boolean | Always true. | |||||||||||||||||||||||||||||||||
item | VideoVaultItem | The new video vault item, starting PENDING.Show child attributes
|
{
"success": true,
"item": {
"id": "clxv9z8y70002item",
"fileName": "teaser.mp4",
"filePath": "https://vz-example.b-cdn.net/c2f7f9e2-…/playlist.m3u8?token=…",
"thumbnailPath": "https://vz-example.b-cdn.net/c2f7f9e2-…/thumbnail.jpg?token=…",
"fileType": "video",
"fileSize": 52428800,
"bunnyStreamId": "c2f7f9e2-1111-4222-b333-4d55e6f7a8b9",
"moderationStatus": "PENDING",
"moderationTags": [],
"createdAt": "2026-08-01T10:20:00.000Z",
"aiEnhanced": false
}
}Errors
| Status | Body | When |
|---|---|---|
| 400 | {"error":"Missing required fields: videoId and originalName"} | Either field absent or blank. |
| 401 | {"error":"Unauthorized","code":"unauthorized"} | Missing or invalid API key. |
| 403 | {"error":"Invalid or expired upload token — please retry the upload"} | The completionToken is wrong, for another key, or older than 8h — start over from step 1. |
| 409 | {"error":"The video upload has not finished — please retry."} | The CDN has not finished receiving the bytes — back off and retry the same call. |
| 413 | {"error":"Video too large. Max 500MB."} | The stored byte count exceeds the cap — the upload is discarded. |
| 500 | {"error":"Failed to register the uploaded video"} | Registration failed — retry later. |
Rate limiting
Every response carries the X-RateLimit-Tier header and, on limited tiers, the per-minute and per-day trios — read X-RateLimit-Remaining and X-RateLimit-Reset instead of hardcoding limits. Details in Rate limits.
Code samples
curl -X POST "https://www.dropfans.io/api/external/vault/video-upload/complete" \
-H "Authorization: Bearer $DROPFANS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"videoId": "c2f7f9e2-1111-4222-b333-4d55e6f7a8b9",
"originalName": "teaser.mp4",
"completionToken": "eyJ…"
}'// Step 2 happens in your client with a TUS library (npm i tus-js-client),
// then step 3 registers the finished upload.
import * as tus from 'tus-js-client';
import { readFile } from 'node:fs/promises';
const creds = await ( // step 1
await fetch('https://www.dropfans.io/api/external/vault/video-upload', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.DROPFANS_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ originalName: 'teaser.mp4' }),
})
).json();
const file = await readFile('teaser.mp4');
await new Promise((resolve, reject) => { // step 2 — raw bytes straight to the CDN
new tus.Upload(file, {
endpoint: creds.tusEndpoint,
headers: {
AuthorizationSignature: creds.signature,
AuthorizationExpire: String(creds.expires),
VideoId: creds.videoId,
LibraryId: creds.libraryId,
},
metadata: { filetype: 'video/mp4', title: 'teaser.mp4' },
onError: reject,
onSuccess: resolve,
}).start();
});
// step 3 — 409 means "not finished yet": back off and retry the same call
for (const waitMs of [0, 2000, 5000, 10000, 30000]) {
if (waitMs) await new Promise((r) => setTimeout(r, waitMs));
const res = await fetch(
'https://www.dropfans.io/api/external/vault/video-upload/complete',
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.DROPFANS_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
videoId: creds.videoId,
originalName: 'teaser.mp4',
completionToken: creds.completionToken,
}),
},
);
if (res.status !== 409) {
console.log(await res.json());
break;
}
}import os
import requests
res = requests.post(
"https://www.dropfans.io/api/external/vault/video-upload/complete",
headers={"Authorization": f"Bearer {os.environ['DROPFANS_API_KEY']}"},
json={
"videoId": "c2f7f9e2-1111-4222-b333-4d55e6f7a8b9",
"originalName": "teaser.mp4",
"completionToken": "eyJ…",
},
)
print(res.json())Notes
This item shape adds moderationStatus, moderationTags and aiEnhanced (and omits durationSeconds) compared to the plain upload endpoint. filePath here is signed for ~1 hour only.
Questions? [email protected]
