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.

Updated

Before you start

  1. Sign in at app.owl3d.com and verify your email address.
  2. Get a plan or buy credits. A job spends your plan's monthly credits first, then purchased credits. Trial credits also apply.
  3. 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.

Shell
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
curl https://api.owl3d.com/v1/jobs/job_... \
  -H "Authorization: Bearer $OWL3D_API_KEY"
Response200
{"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
curl -O https://www.owl3d.com/docs/api/convert.sh
bash convert.sh my-video.mp4

The 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:

  1. Read the file facts and make the fingerprint.
  2. POST /v1/estimates: get the price. Nothing is charged.
  3. POST /v1/jobs: approve the price. This reserves the credits.
  4. PUT each part to its upload URL, then POST /v1/jobs/{id}/upload-complete.
  5. Poll GET /v1/jobs/{id} every 15 seconds.
  6. GET /v1/jobs/{id}/result: get a download link. The link works for 15 minutes and for GET only.

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:

  1. On 429, wait Retry-After seconds and send the same request.
  2. On 5xx or a network error, back off from 1 second to 60 seconds.
  3. On any other 4xx, fix the request.
curl
curl -i https://api.owl3d.com/v1/jobs/job_unknown \
  -H "Authorization: Bearer $OWL3D_API_KEY"
Response404
{"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.

Response429
{
  "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}
  • edgeBytes is 4194304, or the file size for a smaller file.
  • headSha256 and tailSha256 hash the first and the last edgeBytes of 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
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
    }
  }'
Response200
{
  "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), 2160 or 1080.

POST /v1/jobs

curl
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"}}'
Response201
{
  "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
  1. The balance must cover the minimum price. Else the answer is 402 short_of_credits and no job is made.
  2. The 201 answer also has reservedCredits and "priceAfterUpload": true.
  3. After the upload, the server reads the file and sets the price.
  4. 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
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)}"
Response201
{
  "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.

  1. GET /v1/jobs/{id} shows shortBy (the credits to add), neededCredits, deleteAt and resumeUrl.
  2. Add credits, then send POST /v1/jobs/{id}/resume. Do not upload the file again.
  3. While the balance is still too low, resume answers 402 short_of_credits with shortBy.
  4. With no resume before deleteAt (24 hours), the job goes to failed with insufficient_credits_expired. The file is deleted.

POST /v1/jobs/{id}/resume

curl
curl -X POST https://api.owl3d.com/v1/jobs/job_.../resume \
  -H "Authorization: Bearer $OWL3D_API_KEY"
Response200
{
  "id": "job_...",
  "status": "queued"
}
Response402
{
  "error": "short_of_credits",
  "shortBy": 120,
  "neededCredits": 450,
  "deleteAt": "2026-11-04T09:00:00.000Z"
}

Uploads

  1. Cut the file into parts of upload.partSize bytes. Part 1 starts at byte 0.
  2. PUT each part to its URL in upload.parts. Keep the ETag header of each answer, with its quotes.
  3. POST /v1/jobs/{id}/upload-complete with {"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:

  1. Call GET /v1/jobs/{id}/upload.
  2. Read done. Each item has partNumber, etag and size.
  3. Read missing. It lists part numbers.
  4. Upload each missing part to parts[partNumber - 1]. These URLs are fresh for 1 hour.
  5. 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

curl
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>\""]}'
Response200
{"id": "job_...", "status": "queued"}

GET /v1/jobs/{id}/upload

curl
curl https://api.owl3d.com/v1/jobs/job_.../upload \
  -H "Authorization: Bearer $OWL3D_API_KEY"
Response200
{
  "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
curl https://api.owl3d.com/v1/jobs/job_... \
  -H "Authorization: Bearer $OWL3D_API_KEY"
Response200
{
  "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
curl https://api.owl3d.com/v1/jobs/job_.../result \
  -H "Authorization: Bearer $OWL3D_API_KEY"
curl -o output.mp4 "<url>"
Response200
{
  "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
curl -X POST https://api.owl3d.com/v1/jobs/job_.../cancel \
  -H "Authorization: Bearer $OWL3D_API_KEY"
Response200
{"id": "job_...", "status": "cancelled"}

Stay Updated on Exciting News!

Owl3D
© 2026 Owl3D. All rights reserved.