Owl3D Cloud API
The API converts a 2D video to side-by-side 3D. Your code gets a price, uploads the file, waits for the job and downloads the result. It runs the same conversion as Owl3D Cloud.
An AI coding agent can read this page as plain text at /llms.txt.
Before you start
- Sign in at app.owl3d.com and verify your email address.
- Get a plan or buy credits. A job spends your plan's monthly credits first, then purchased credits. Trial credits also apply.
- Open API keys and create a key. The page shows the key one time only.
Keep the key in the environment variable OWL3D_API_KEY. All examples on this page read it from there.
Use of the API follows the Owl3D Cloud API Terms.
export OWL3D_API_KEY="o3d_live_..."Authentication
| Item | Value |
|---|---|
| Base URL | https://api.owl3d.com |
| Key format | o3d_live_<keyId>_<secret> |
| Sign-in header | Authorization: Bearer <key> |
Write Bearer with a capital B and one space. Other forms get 401.
Each route needs a scope: jobs:read or jobs:write. The route table names the scope of each route.
Keep a key secret. It can spend your credits. If a key leaks, revoke it on the API keys page. A revoked key stops within 60 seconds.
GET /v1/jobs/{id}
curl https://api.owl3d.com/v1/jobs/job_... \
-H "Authorization: Bearer $OWL3D_API_KEY"{"id": "job_...", "status": "converting"}Convert one video
Download one script and run it on your video. Each script does all the steps below.
| Script | Needs | Run |
|---|---|---|
| convert.sh | bash, curl, jq, ffprobe | bash convert.sh my-video.mp4 |
| convert.py | Python 3.9+, requests, ffprobe |
python3 convert.py my-video.mp4 |
| convert.mjs | Node.js 20+, ffprobe | node convert.mjs my-video.mp4 |
convert.sh · convert.py · convert.mjs
curl -O https://www.owl3d.com/docs/api/convert.sh
bash convert.sh my-video.mp4The scripts are optional. Your own code can use any language. It needs HTTPS calls, SHA-256 and the file facts. With upload first, it needs HTTPS calls only.
The flow has six steps:
- Read the file facts and make the fingerprint.
POST /v1/estimates: get the price. Nothing is charged.POST /v1/jobs: approve the price. This reserves the credits.PUTeach part to its upload URL, thenPOST /v1/jobs/{id}/upload-complete.- Poll
GET /v1/jobs/{id}every 15 seconds. GET /v1/jobs/{id}/result: get a download link. The link works for 15 minutes and forGETonly.
Upload first skips steps 1 and 2. Your code sends the file name and size to POST /v1/jobs. The server reads the file after the upload and sets the price.
Errors
Most error answers have a JSON body with error, a short code.
| Status | Meaning |
|---|---|
400 |
Bad request, for example bad_fingerprint |
401 |
No token, a wrong token, or a revoked key |
402 |
short_of_credits: plan credits, trial credits and purchased credits together do not cover the price |
403 |
insufficient_scope, or the route is not public |
404 |
no_job. Jobs made in the Cloud app are not visible to the API |
409 |
State conflict, for example not_uploading or endpoint_exists |
410 |
The output expired, or insufficient_credits_expired: the job waited 24 hours for credits |
429 |
A limit is full. See Limits |
Retry rules:
- On
429, waitRetry-Afterseconds and send the same request. - On
5xxor a network error, back off from 1 second to 60 seconds. - On any other
4xx, fix the request.
curl -i https://api.owl3d.com/v1/jobs/job_unknown \
-H "Authorization: Bearer $OWL3D_API_KEY"{"error": "no_job"}Limits
| Limit | Value | Answer |
|---|---|---|
| Jobs in progress | 10 for all API keys of an account together. 15 for the whole account, all channels together. Optional limit per key | 429 concurrency_limit, Retry-After: 15 |
| Requests | 120 a minute per key | 429, Retry-After: 60 |
| Burst | Each route 10 a second, burst 20, for all callers together | 429 throttled, Retry-After: 1 |
| Daily credit cap | Optional per key | 429 daily_credit_cap, until 00:00 UTC |
| Active keys | 10 per account | Create refused |
| Input | 100 GB, 3 hours, 4096 x 2160, 60 fps | Estimate refused |
| Containers | MP4, MOV, MKV, M4V | Estimate refused |
A 429 concurrency_limit body names the limit that is full. scope is api (all API keys of the account), account (all jobs of the account) or key (this key). Start the next job when one ends.
{
"error": "concurrency_limit",
"scope": "api",
"limit": 10,
"inProgress": 10
}Pricing
POST /v1/estimates gives the exact price of a file before you pay.
On both paths, the server reads the uploaded file and sets the final price from it. With upload first, you see the price only after the upload. When the balance does not cover the price, the job waits. See Not enough credits.
Routes
| Method and path | Scope | Does |
|---|---|---|
POST /v1/estimates |
jobs:write |
Price and time. Nothing is charged. Valid 1 hour, for one job |
POST /v1/jobs |
jobs:write |
Reserves credits and returns the upload plan. With estimateId, or with filename and sizeBytes (upload first) |
GET /v1/jobs/{id}/upload |
jobs:read |
Upload state: done, missing and fresh parts URLs |
POST /v1/jobs/{id}/upload-complete |
jobs:write |
Sends the ETags and queues the job |
GET /v1/jobs/{id} |
jobs:read |
Status, progress, time left |
POST /v1/jobs/{id}/cancel |
jobs:write |
Cancels the job |
POST /v1/jobs/{id}/resume |
jobs:write |
Continues an insufficient_credits job after you add credits. No new upload |
GET /v1/jobs/{id}/result |
jobs:read |
Download link, 15 minutes |
Estimates
POST /v1/estimates gives the price and the time for one file. Nothing is charged. An estimate is valid for 1 hour, for one job.
| Field | Value |
|---|---|
name, size |
The file name and its size in bytes |
durationSeconds, width, height, fps |
Read from the file with any tool, such as ffprobe or MediaInfo |
codec |
One of H.264, HEVC, AV1, VP9 |
bitDepth, chroma |
Read from the file. chroma is 4:2:0 |
fingerprint |
{size, headSha256, tailSha256, edgeBytes} |
edgeBytesis 4194304, or the file size for a smaller file.headSha256andtailSha256hash the first and the lastedgeBytesof the file.
In the answer, "ok": false means the estimate is refused. Read reason. credits.shortBy above 0 means the account needs more credits. credits.fromPlan, credits.fromTrial and credits.fromPurchased show which credits pay the price.
POST /v1/estimates
curl https://api.owl3d.com/v1/estimates \
-H "Authorization: Bearer $OWL3D_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "my-video.mp4",
"size": 104857600,
"durationSeconds": 120,
"width": 1920, "height": 1080, "fps": 24,
"codec": "H.264", "bitDepth": 8, "chroma": "4:2:0",
"fingerprint": {
"size": 104857600,
"headSha256": "<SHA-256 of the first 4194304 bytes>",
"tailSha256": "<SHA-256 of the last 4194304 bytes>",
"edgeBytes": 4194304
}
}'{
"ok": true,
"id": "est_...",
"credits": {
"total": 60,
"fromPlan": 60,
"fromTrial": 0,
"fromPurchased": 0,
"shortBy": 0
},
"etaMinutes": 6,
"hdr": {"detected": false},
"output": {"format": "FULL_SBS", "width": 3840, "height": 1080}
}Jobs
POST /v1/jobs with {"estimateId": "est_..."} approves the price. This reserves the credits. The answer holds the job id and the upload plan: upload.partSize and the upload.parts URLs.
Output options
POST /v1/jobs also takes output: {layout, max_height}.
layout:full_sbs(default).max_height:"source"(default),2160or1080.
POST /v1/jobs
curl https://api.owl3d.com/v1/jobs \
-H "Authorization: Bearer $OWL3D_API_KEY" \
-H "Content-Type: application/json" \
-d '{"estimateId": "est_...", "output": {"layout": "full_sbs", "max_height": "source"}}'{
"id": "job_...",
"status": "uploading",
"upload": {
"partSize": 67108864,
"parts": ["https://...", "https://..."],
"completeUrl": "/v1/jobs/job_.../upload-complete"
}
}Upload first
Send POST /v1/jobs without estimateId. Your code does not read the file and does not make a fingerprint.
| Field | Value |
|---|---|
filename |
The file name, at most 255 characters. It ends in .mp4, .mov, .mkv or .m4v |
sizeBytes |
The file size in bytes |
output |
Optional. See Output options |
- The balance must cover the minimum price. Else the answer is
402 short_of_creditsand no job is made. - The
201answer also hasreservedCreditsand"priceAfterUpload": true. - After the upload, the server reads the file and sets the price.
- If the server cannot convert the file, the job goes to
failed. Nothing is charged and the file is deleted.
HDR10 video needs an estimate. Without one, the answer is 400 hdr_needs_estimate.
POST /v1/jobs (upload first)
curl https://api.owl3d.com/v1/jobs \
-H "Authorization: Bearer $OWL3D_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"filename\": \"my-video.mp4\", \"sizeBytes\": $(wc -c < my-video.mp4)}"{
"id": "job_...",
"status": "uploading",
"upload": {
"partSize": 67108864,
"parts": ["https://...", "https://..."],
"completeUrl": "/v1/jobs/job_.../upload-complete"
},
"reservedCredits": 15,
"priceAfterUpload": true
}Not enough credits
When the price is above the balance, the job goes to insufficient_credits. Nothing is charged. This applies to both paths, with or without an estimate.
GET /v1/jobs/{id}showsshortBy(the credits to add),neededCredits,deleteAtandresumeUrl.- Add credits, then send
POST /v1/jobs/{id}/resume. Do not upload the file again. - While the balance is still too low, resume answers
402 short_of_creditswithshortBy. - With no resume before
deleteAt(24 hours), the job goes tofailedwithinsufficient_credits_expired. The file is deleted.
POST /v1/jobs/{id}/resume
curl -X POST https://api.owl3d.com/v1/jobs/job_.../resume \
-H "Authorization: Bearer $OWL3D_API_KEY"{
"id": "job_...",
"status": "queued"
}{
"error": "short_of_credits",
"shortBy": 120,
"neededCredits": 450,
"deleteAt": "2026-11-04T09:00:00.000Z"
}Uploads
- Cut the file into parts of
upload.partSizebytes. Part 1 starts at byte 0. PUTeach part to its URL inupload.parts. Keep theETagheader of each answer, with its quotes.POST /v1/jobs/{id}/upload-completewith{"etags": [...]}in part order.
Each URL is signed for one exact length. A part must be partSize bytes, and the last part holds the rest. Storage answers 403 for any other length and for a chunked body. Send Content-Length.
If an upload stops, do not start again:
- Call
GET /v1/jobs/{id}/upload. - Read
done. Each item haspartNumber,etagandsize. - Read
missing. It lists part numbers. - Upload each missing part to
parts[partNumber - 1]. These URLs are fresh for 1 hour. - Complete with all ETags in part order. Use the same file.
Complete an upload within 24 hours. After that, storage removes the parts of an upload that is not complete.
PUT upload.parts[n] · POST /v1/jobs/{id}/upload-complete
split -b 67108864 my-video.mp4 part_
curl -sS -D - -o /dev/null -X PUT --upload-file part_aa "<upload.parts[0]>" | grep -i '^etag'
curl -sS -D - -o /dev/null -X PUT --upload-file part_ab "<upload.parts[1]>" | grep -i '^etag'
curl https://api.owl3d.com/v1/jobs/job_.../upload-complete \
-H "Authorization: Bearer $OWL3D_API_KEY" \
-H "Content-Type: application/json" \
-d '{"etags": ["\"<etag of part 1>\"", "\"<etag of part 2>\""]}'{"id": "job_...", "status": "queued"}GET /v1/jobs/{id}/upload
curl https://api.owl3d.com/v1/jobs/job_.../upload \
-H "Authorization: Bearer $OWL3D_API_KEY"{
"id": "job_...",
"partSize": 67108864,
"parts": ["https://...", "https://..."],
"completeUrl": "/v1/jobs/job_.../upload-complete",
"done": [{"partNumber": 1, "etag": "\"...\"", "size": 67108864}],
"missing": [2]
}Job status
GET /v1/jobs/{id} gives the status, the progress and the time left. Poll it every 15 seconds.
status |
Meaning |
|---|---|
uploading |
The job waits for the file |
queued |
The job waits for a GPU. A job can go back from converting to queued for a short time |
converting |
progress goes from 0 to 1 |
delayed |
Held on our side. The credits stay reserved |
insufficient_credits |
The price is above the balance. Add shortBy credits, then resume. See Not enough credits |
done |
Ready. result has the name, size and link path |
failed |
error has a code. The credits came back |
cancelled |
Cancelled by the caller |
GET /v1/jobs/{id}
curl https://api.owl3d.com/v1/jobs/job_... \
-H "Authorization: Bearer $OWL3D_API_KEY"{
"id": "job_...",
"status": "converting",
"progress": 0.42,
"etaSeconds": 310
}Results
GET /v1/jobs/{id}/result gives a download link for a done job. The link works for 15 minutes and for GET only. Call the route again for a new link. When the output has expired, the answer is 410.
GET /v1/jobs/{id}/result
curl https://api.owl3d.com/v1/jobs/job_.../result \
-H "Authorization: Bearer $OWL3D_API_KEY"
curl -o output.mp4 "<url>"{
"url": "https://...",
"filename": "...",
"sizeBytes": 209715200,
"expiresInS": 900
}Cancel
POST /v1/jobs/{id}/cancel cancels a job. A job cancelled before the conversion starts costs nothing. After that, it costs the price x progress, rounded up, at least 1 credit.
POST /v1/jobs/{id}/cancel
curl -X POST https://api.owl3d.com/v1/jobs/job_.../cancel \
-H "Authorization: Bearer $OWL3D_API_KEY"{"id": "job_...", "status": "cancelled"}