PureFrame
Get an API key

PureFrame API

Remove mosaic censorship from videos from your own code. The API uses the same AI, prices and free previews as the website, and is paid with your credits.

It is a JSON REST API. Every request is authenticated with an API key. The full description is also available as an OpenAPI spec for code generators and AI agents.

Base URL

https://pureframe.io/api/v1

Authentication

Create a key in Settings → API and send it as a bearer token. Keys start with pf_live_. They are shown once: store them like passwords, and revoke a key you leaked.

Missing or revoked keys get 401 unauthorized.

Authenticated request

curl https://pureframe.io/api/v1/me \
  -H "Authorization: Bearer $PUREFRAME_API_KEY"
const res = await fetch(`https://pureframe.io/api/v1/me`, {
  headers: {
    Authorization: `Bearer ${process.env.PUREFRAME_API_KEY}`,
  },
});
const me = await res.json();
import os, requests

me = requests.get(
    "https://pureframe.io/api/v1/me",
    headers={"Authorization": f"Bearer {os.environ['PUREFRAME_API_KEY']}"},
).json()
require "net/http"
require "json"

uri = URI("https://pureframe.io/api/v1/me")
req = Net::HTTP::Get.new(uri,
  "Authorization" => "Bearer #{ENV["PUREFRAME_API_KEY"]}")
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
me = JSON.parse(res.body)

Workflow

  1. 1 Create a video with its name and size. You get an upload URL.
  2. 2 Upload the file to that URL, then complete the upload. The video gets its price.
  3. 3 Optional: check a free 10-second preview.
  4. 4 Process it. The price is taken from your credits.
  5. 5 Wait for state: "completed" with polling or a webhook, then download.

Video states

awaiting_upload
Created. Upload the file, then complete the upload.
ready
Uploaded. Request previews or process it.
pending_payment
A payment was started on the website and not finished yet.
queued
Paid, waiting for a GPU.
processing
Removing mosaics. See job.stage and job.progress_percent.
completed
Done. download_url is set for 7 days.
failed
Processing failed. Contact support.
download_expired
Processed, but the 7-day download window ended.
expired
The source file was removed after inactivity.

Full example

# 1. Create the video
curl https://pureframe.io/api/v1/videos \
  -H "Authorization: Bearer $PUREFRAME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "movie.mp4", "size_bytes": 734003200, "attest": true}'

# 2. Upload the file (url and header from step 1)
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Range: bytes 0-734003199/734003200" \
  --upload-file movie.mp4

# 3. Complete, 4. process with credits
curl -X POST https://pureframe.io/api/v1/videos/$VIDEO_ID/complete \
  -H "Authorization: Bearer $PUREFRAME_API_KEY"
curl -X POST https://pureframe.io/api/v1/videos/$VIDEO_ID/process \
  -H "Authorization: Bearer $PUREFRAME_API_KEY"

# 5. When state is "completed", download
curl -L -o movie_pureframe.mp4 https://pureframe.io/api/v1/videos/$VIDEO_ID/download \
  -H "Authorization: Bearer $PUREFRAME_API_KEY"
import { readFile, writeFile } from "node:fs/promises";

const BASE = "https://pureframe.io/api/v1";
const auth = { Authorization: `Bearer ${process.env.PUREFRAME_API_KEY}` };

const api = async (method, path, body) => {
  const res = await fetch(BASE + path, {
    method,
    headers: { ...auth, "Content-Type": "application/json" },
    body: body && JSON.stringify(body),
  });
  if (!res.ok) throw new Error(JSON.stringify(await res.json()));
  return res.json();
};

const file = await readFile("movie.mp4");
const { video, upload } = await api("POST", "/videos", {
  name: "movie.mp4", size_bytes: file.length, attest: true,
});
await fetch(upload.url, { method: "PUT", headers: upload.headers, body: file });
await api("POST", `/videos/${video.id}/complete`);
await api("POST", `/videos/${video.id}/process`);

let current;
do {
  await new Promise((r) => setTimeout(r, 15000));
  current = await api("GET", `/videos/${video.id}`);
} while (["queued", "processing"].includes(current.state));

const out = await fetch(BASE + `/videos/${video.id}/download`, { headers: auth });
await writeFile("movie_pureframe.mp4", Buffer.from(await out.arrayBuffer()));
import os, time, requests

BASE = "https://pureframe.io/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['PUREFRAME_API_KEY']}"

size = os.path.getsize("movie.mp4")
created = session.post(f"{BASE}/videos", json={
    "name": "movie.mp4", "size_bytes": size, "attest": True,
}).json()
video, upload = created["video"], created["upload"]

with open("movie.mp4", "rb") as f:
    requests.put(upload["url"], data=f, headers=upload["headers"]).raise_for_status()

session.post(f"{BASE}/videos/{video['id']}/complete").raise_for_status()
session.post(f"{BASE}/videos/{video['id']}/process").raise_for_status()

while True:
    time.sleep(15)
    video = session.get(f"{BASE}/videos/{video['id']}").json()
    if video["state"] not in ("queued", "processing"):
        break

with session.get(f"{BASE}/videos/{video['id']}/download", stream=True) as r:
    with open("movie_pureframe.mp4", "wb") as out:
        for chunk in r.iter_content(1 << 20):
            out.write(chunk)
require "net/http"
require "json"

BASE = "https://pureframe.io/api/v1"
KEY = ENV.fetch("PUREFRAME_API_KEY")

def api(method, path, body = nil)
  uri = URI(BASE + path)
  req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri,
    "Authorization" => "Bearer #{KEY}", "Content-Type" => "application/json")
  req.body = body.to_json if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
  raise res.body unless res.is_a?(Net::HTTPSuccess) || res.is_a?(Net::HTTPRedirection)
  res
end

size = File.size("movie.mp4")
created = JSON.parse(api("POST", "/videos",
  { name: "movie.mp4", size_bytes: size, attest: true }).body)
video, upload = created["video"], created["upload"]

uri = URI(upload["url"])
put = Net::HTTP::Put.new(uri, upload["headers"])
put.body_stream = File.open("movie.mp4", "rb")
put["Content-Length"] = size.to_s
Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(put) }

api("POST", "/videos/#{video["id"]}/complete")
api("POST", "/videos/#{video["id"]}/process")

loop do
  sleep 15
  video = JSON.parse(api("GET", "/videos/#{video["id"]}").body)
  break unless %w[queued processing].include?(video["state"])
end

file_url = URI(api("GET", "/videos/#{video["id"]}/download")["location"])
File.binwrite("movie_pureframe.mp4", Net::HTTP.get(file_url))\

Errors

Errors use standard HTTP status codes and a JSON body with a stable code you can branch on, and a readable message.

401 unauthorized

Missing, unknown or revoked API key.

403 account_disabled

This account is disabled.

402 insufficient_credits

Not enough credits to process this video. Top up at /credits.

404 not_found

No such resource for this account.

422 invalid_request

Some parameters are missing or invalid.

422 not_attested

Set "attest": true to confirm you are 18+, the video depicts no one under 18 (real, drawn or animated) and you have the right to process it.

422 prohibited_file_name

This file can't be uploaded: content depicting minors is prohibited.

422 unsupported_file_type

Unsupported file type. Use .mp4, .mkv, .avi, .mov or .webm.

422 file_too_large

Files are limited to 10 GB.

422 resolution_unsupported

Videos are supported up to 4K (3840×2160). The upload was deleted.

409 upload_missing

The file was not found in storage. Upload it before calling complete.

409 not_uploading

This video's upload is already complete.

409 video_not_ready

Upload the video and call complete first.

409 video_busy

This video is being paid for or processed.

409 already_processed

This video was already processed.

403 preview_limit

Free preview limit reached.

410 download_unavailable

The processed file is no longer available (7-day download window).

409 not_completed

The video is not processed yet.

429 rate_limited

Too many requests. Retry after the Retry-After header.

Error response

{
  "error": {
    "code": "not_attested",
    "message": "Set \"attest\": true to confirm you are 18+, …"
  }
}

Rate limits

60 requests per minute per key. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. Past the limit you get 429 with a Retry-After header.

  • Files up to 10 GB and 4K (.mp4, .mkv, .avi, .mov, .webm).
  • Free previews follow the website limits.
  • Processed files are kept 7 days.
  • The Terms of Service apply to the API.

Response headers

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1791736440

Account

Retrieve your account

GET /me

Your account and your credit balance. Handy to check that a key works.

GET /me

curl https://pureframe.io/api/v1/me \
  -H "Authorization: Bearer $PUREFRAME_API_KEY"
const res = await fetch(`https://pureframe.io/api/v1/me`, {
  headers: {
    Authorization: `Bearer ${process.env.PUREFRAME_API_KEY}`,
  },
});
import os, requests

requests.get(
    "https://pureframe.io/api/v1/me",
    headers={"Authorization": f"Bearer {os.environ['PUREFRAME_API_KEY']}"},
).raise_for_status()
require "net/http"
require "json"

uri = URI("https://pureframe.io/api/v1/me")
req = Net::HTTP::Get.new(uri,
  "Authorization" => "Bearer #{ENV["PUREFRAME_API_KEY"]}")
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }

Response

{
  "id": 42,
  "email": "you@example.com",
  "credits": {
    "cents": 2350,
    "formatted": "$23.50"
  }
}

Videos

Create a video

POST /videos

Registers a video and returns a one-time upload URL. Upload the file there, then call complete.

Body parameters

name string required

File name with its extension: .mp4, .mkv, .avi, .mov or .webm.

size_bytes integer required

Exact file size in bytes. Up to 10 GB.

attest boolean required

Must be true. You confirm you are 18 or older, that the video depicts no one under 18 (real, drawn or animated) and that you have the right to process it.

POST /videos

curl -X POST https://pureframe.io/api/v1/videos \
  -H "Authorization: Bearer $PUREFRAME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "movie.mp4", "size_bytes": 734003200, "attest": true}'
const res = await fetch(`https://pureframe.io/api/v1/videos`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PUREFRAME_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "movie.mp4",
    size_bytes: 734003200,
    attest: true,
  }),
});
const video = await res.json();
import os, requests

video = requests.post(
    "https://pureframe.io/api/v1/videos",
    headers={"Authorization": f"Bearer {os.environ['PUREFRAME_API_KEY']}"},
    json={
      "name": "movie.mp4",
      "size_bytes": 734003200,
      "attest": True,
    },
).json()
require "net/http"
require "json"

uri = URI("https://pureframe.io/api/v1/videos")
req = Net::HTTP::Post.new(uri,
  "Authorization" => "Bearer #{ENV["PUREFRAME_API_KEY"]}",
  "Content-Type" => "application/json")
req.body = {
  name: "movie.mp4",
  size_bytes: 734003200,
  attest: true,
}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
video = JSON.parse(res.body)

Response

{
  "video": {
    "id": "a3cad7d9-899e-4dae-a764-9cb083792818",
    "name": "movie.mp4",
    "state": "awaiting_upload",
    "size_bytes": null,
    "duration_seconds": null,
    "width": null,
    "height": null,
    "price": null,
    "job": null,
    "download_url": null,
    "created_at": "2026-10-11T16:33:38Z"
  },
  "upload": {
    "method": "PUT",
    "url": "https://storage.googleapis.com/upload/…",
    "headers": {
      "Content-Range": "bytes 0-734003199/734003200"
    }
  }
}

Upload the file

PUT {upload.url}

Send the whole file in one PUT request to the upload URL, with the Content-Range header returned by create. No API key on this request: the URL is signed.

PUT {upload.url}

curl -X PUT "$UPLOAD_URL" \
  -H "Content-Range: bytes 0-734003199/734003200" \
  --upload-file movie.mp4\
import { readFile } from "node:fs/promises";

// `upload` comes from the create response
await fetch(upload.url, {
  method: "PUT",
  headers: upload.headers,
  body: await readFile("movie.mp4"),
});\
import requests

# `upload` comes from the create response
with open("movie.mp4", "rb") as f:
    requests.put(upload["url"], data=f, headers=upload["headers"]).raise_for_status()\
require "net/http"

# `upload` comes from the create response
uri = URI(upload["url"])
req = Net::HTTP::Put.new(uri, upload["headers"])
req.body_stream = File.open("movie.mp4", "rb")
req["Content-Length"] = File.size("movie.mp4").to_s
Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }\

Complete the upload

POST /videos/:video_id/complete

Checks the file arrived, reads its duration and resolution and sets the price. The video becomes ready: you can request previews or process it.

POST /videos/:video_id/complete

curl -X POST https://pureframe.io/api/v1/videos/$VIDEO_ID/complete \
  -H "Authorization: Bearer $PUREFRAME_API_KEY"
const res = await fetch(`https://pureframe.io/api/v1/videos/${videoId}/complete`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PUREFRAME_API_KEY}`,
  },
});
const video = await res.json();
import os, requests

video = requests.post(
    f"https://pureframe.io/api/v1/videos/{video_id}/complete",
    headers={"Authorization": f"Bearer {os.environ['PUREFRAME_API_KEY']}"},
).json()
require "net/http"
require "json"

uri = URI("https://pureframe.io/api/v1/videos/#{video_id}/complete")
req = Net::HTTP::Post.new(uri,
  "Authorization" => "Bearer #{ENV["PUREFRAME_API_KEY"]}")
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
video = JSON.parse(res.body)

Response

{
  "id": "a3cad7d9-899e-4dae-a764-9cb083792818",
  "name": "movie.mp4",
  "state": "ready",
  "size_bytes": 734003200,
  "duration_seconds": 1830.2,
  "width": 1920,
  "height": 1080,
  "price": {
    "cents": 412,
    "formatted": "$4.12",
    "resolution_tier": "1080p"
  },
  "job": null,
  "download_url": null,
  "created_at": "2026-10-11T16:33:38Z"
}

Retrieve a video

GET /videos/:video_id

Current state, price, processing progress and, once processed, the download URL. Poll it, or use webhooks.

GET /videos/:video_id

curl https://pureframe.io/api/v1/videos/$VIDEO_ID \
  -H "Authorization: Bearer $PUREFRAME_API_KEY"
const res = await fetch(`https://pureframe.io/api/v1/videos/${videoId}`, {
  headers: {
    Authorization: `Bearer ${process.env.PUREFRAME_API_KEY}`,
  },
});
const video = await res.json();
import os, requests

video = requests.get(
    f"https://pureframe.io/api/v1/videos/{video_id}",
    headers={"Authorization": f"Bearer {os.environ['PUREFRAME_API_KEY']}"},
).json()
require "net/http"
require "json"

uri = URI("https://pureframe.io/api/v1/videos/#{video_id}")
req = Net::HTTP::Get.new(uri,
  "Authorization" => "Bearer #{ENV["PUREFRAME_API_KEY"]}")
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
video = JSON.parse(res.body)

Response

{
  "id": "a3cad7d9-899e-4dae-a764-9cb083792818",
  "name": "movie.mp4",
  "state": "processing",
  "size_bytes": 734003200,
  "duration_seconds": 1830.2,
  "width": 1920,
  "height": 1080,
  "price": {
    "cents": 412,
    "formatted": "$4.12",
    "resolution_tier": "1080p"
  },
  "job": {
    "id": 4,
    "status": "processing",
    "stage": "processing",
    "progress_percent": 37,
    "started_at": "2026-10-11T16:40:02Z",
    "completed_at": null,
    "error": null,
    "download_expires_at": null
  },
  "download_url": null,
  "created_at": "2026-10-11T16:33:38Z"
}

List videos

GET /videos

Your videos, newest first.

Query parameters

limit integer

1 to 100. Default 20.

offset integer

Number of videos to skip. Default 0.

GET /videos

curl https://pureframe.io/api/v1/videos?limit=20 \
  -H "Authorization: Bearer $PUREFRAME_API_KEY"
const res = await fetch(`https://pureframe.io/api/v1/videos?limit=20`, {
  headers: {
    Authorization: `Bearer ${process.env.PUREFRAME_API_KEY}`,
  },
});
const videos = await res.json();
import os, requests

videos = requests.get(
    "https://pureframe.io/api/v1/videos?limit=20",
    headers={"Authorization": f"Bearer {os.environ['PUREFRAME_API_KEY']}"},
).json()
require "net/http"
require "json"

uri = URI("https://pureframe.io/api/v1/videos?limit=20")
req = Net::HTTP::Get.new(uri,
  "Authorization" => "Bearer #{ENV["PUREFRAME_API_KEY"]}")
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
videos = JSON.parse(res.body)

Response

{
  "data": [
    {
      "id": "a3cad7d9-899e-4dae-a764-9cb083792818",
      "name": "movie.mp4",
      "state": "completed",
      "size_bytes": 734003200,
      "duration_seconds": 1830.2,
      "width": 1920,
      "height": 1080,
      "price": {
        "cents": 412,
        "formatted": "$4.12",
        "resolution_tier": "1080p"
      },
      "job": {
        "id": 4,
        "status": "completed",
        "stage": "finalizing",
        "progress_percent": 100,
        "started_at": "2026-10-11T16:40:02Z",
        "completed_at": "2026-10-11T17:02:41Z",
        "error": null,
        "download_expires_at": "2026-10-18T17:02:41Z"
      },
      "download_url": "https://pureframe.io/api/v1/videos/a3cad7d9-899e-4dae-a764-9cb083792818/download",
      "created_at": "2026-10-11T16:33:38Z"
    }
  ],
  "total": 1,
  "limit": 20,
  "offset": 0
}

Process a video

POST /videos/:video_id/process

Pays the video's price from your credits and queues it. If your balance is short you get a 402 with the missing amount: top up at /credits. Calling it again while the video is queued or processing returns it unchanged and charges nothing.

POST /videos/:video_id/process

curl -X POST https://pureframe.io/api/v1/videos/$VIDEO_ID/process \
  -H "Authorization: Bearer $PUREFRAME_API_KEY"
const res = await fetch(`https://pureframe.io/api/v1/videos/${videoId}/process`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PUREFRAME_API_KEY}`,
  },
});
const video = await res.json();
import os, requests

video = requests.post(
    f"https://pureframe.io/api/v1/videos/{video_id}/process",
    headers={"Authorization": f"Bearer {os.environ['PUREFRAME_API_KEY']}"},
).json()
require "net/http"
require "json"

uri = URI("https://pureframe.io/api/v1/videos/#{video_id}/process")
req = Net::HTTP::Post.new(uri,
  "Authorization" => "Bearer #{ENV["PUREFRAME_API_KEY"]}")
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
video = JSON.parse(res.body)

Response

{
  "id": "a3cad7d9-899e-4dae-a764-9cb083792818",
  "name": "movie.mp4",
  "state": "queued",
  "size_bytes": 734003200,
  "duration_seconds": 1830.2,
  "width": 1920,
  "height": 1080,
  "price": {
    "cents": 412,
    "formatted": "$4.12",
    "resolution_tier": "1080p"
  },
  "job": {
    "id": 4,
    "status": "paid",
    "stage": null,
    "progress_percent": 0,
    "started_at": null,
    "completed_at": null,
    "error": null,
    "download_expires_at": null
  },
  "download_url": null,
  "created_at": "2026-10-11T16:33:38Z"
}

402 Insufficient credits

{
  "error": {
    "code": "insufficient_credits",
    "message": "Not enough credits to process this video. Top up at /credits.",
    "price": {
      "cents": 412,
      "formatted": "$4.12"
    },
    "credits": {
      "cents": 300,
      "formatted": "$3.00"
    },
    "missing": {
      "cents": 112,
      "formatted": "$1.12"
    },
    "top_up_url": "https://pureframe.io/credits"
  }
}

Download a processed video

GET /videos/:video_id/download

Redirects (302) to a signed file URL valid for one hour. Processed files are kept 7 days.

GET /videos/:video_id/download

curl -L -o movie_pureframe.mp4 https://pureframe.io/api/v1/videos/$VIDEO_ID/download \
  -H "Authorization: Bearer $PUREFRAME_API_KEY"
import { writeFile } from "node:fs/promises";

// fetch follows the redirect to the signed file URL
const res = await fetch(`https://pureframe.io/api/v1/videos/${videoId}/download`, {
  headers: { Authorization: `Bearer ${process.env.PUREFRAME_API_KEY}` },
});
await writeFile("movie_pureframe.mp4", Buffer.from(await res.arrayBuffer()));
import os, requests

with requests.get(
    f"https://pureframe.io/api/v1/videos/{video_id}/download",
    headers={"Authorization": f"Bearer {os.environ['PUREFRAME_API_KEY']}"},
    stream=True,
) as r:
    r.raise_for_status()
    with open("movie_pureframe.mp4", "wb") as out:
        for chunk in r.iter_content(1 << 20):
            out.write(chunk)
require "net/http"

uri = URI("https://pureframe.io/api/v1/videos/#{video_id}/download")
req = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{ENV["PUREFRAME_API_KEY"]}")
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
# 302: the file is at the signed URL in Location
File.binwrite("movie_pureframe.mp4", Net::HTTP.get(URI(res["location"])))

Delete a video

DELETE /videos/:video_id

Deletes the video, its previews and its files. Refused with 409 while the video is queued or processing.

DELETE /videos/:video_id

curl -X DELETE https://pureframe.io/api/v1/videos/$VIDEO_ID \
  -H "Authorization: Bearer $PUREFRAME_API_KEY"
const res = await fetch(`https://pureframe.io/api/v1/videos/${videoId}`, {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.PUREFRAME_API_KEY}`,
  },
});
import os, requests

requests.delete(
    f"https://pureframe.io/api/v1/videos/{video_id}",
    headers={"Authorization": f"Bearer {os.environ['PUREFRAME_API_KEY']}"},
).raise_for_status()
require "net/http"
require "json"

uri = URI("https://pureframe.io/api/v1/videos/#{video_id}")
req = Net::HTTP::Delete.new(uri,
  "Authorization" => "Bearer #{ENV["PUREFRAME_API_KEY"]}")
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }

Response

204 No Content

Previews

Create a preview

POST /videos/:video_id/previews

Processes a free 10-second clip so you can check the result before paying. Free previews are limited per video, per day and per account until a first purchase (403 preview_limit).

Body parameters

start_seconds integer

Where the clip starts, in seconds. Default 0.

POST /videos/:video_id/previews

curl -X POST https://pureframe.io/api/v1/videos/$VIDEO_ID/previews \
  -H "Authorization: Bearer $PUREFRAME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"start_seconds": 600}'
const res = await fetch(`https://pureframe.io/api/v1/videos/${videoId}/previews`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PUREFRAME_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ start_seconds: 600 }),
});
const preview = await res.json();
import os, requests

preview = requests.post(
    f"https://pureframe.io/api/v1/videos/{video_id}/previews",
    headers={"Authorization": f"Bearer {os.environ['PUREFRAME_API_KEY']}"},
    json={"start_seconds": 600},
).json()
require "net/http"
require "json"

uri = URI("https://pureframe.io/api/v1/videos/#{video_id}/previews")
req = Net::HTTP::Post.new(uri,
  "Authorization" => "Bearer #{ENV["PUREFRAME_API_KEY"]}",
  "Content-Type" => "application/json")
req.body = { start_seconds: 600 }.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
preview = JSON.parse(res.body)

Response

{
  "id": "c0591685-7e85-4ad5-9b38-e9b104aab422",
  "video_id": "a3cad7d9-899e-4dae-a764-9cb083792818",
  "number": 1,
  "start_seconds": 600,
  "status": "processing",
  "url": null,
  "created_at": "2026-10-11T16:35:10Z"
}

List previews

GET /videos/:video_id/previews

Previews of a video. Finished ones have a url.

GET /videos/:video_id/previews

curl https://pureframe.io/api/v1/videos/$VIDEO_ID/previews \
  -H "Authorization: Bearer $PUREFRAME_API_KEY"
const res = await fetch(`https://pureframe.io/api/v1/videos/${videoId}/previews`, {
  headers: {
    Authorization: `Bearer ${process.env.PUREFRAME_API_KEY}`,
  },
});
const previews = await res.json();
import os, requests

previews = requests.get(
    f"https://pureframe.io/api/v1/videos/{video_id}/previews",
    headers={"Authorization": f"Bearer {os.environ['PUREFRAME_API_KEY']}"},
).json()
require "net/http"
require "json"

uri = URI("https://pureframe.io/api/v1/videos/#{video_id}/previews")
req = Net::HTTP::Get.new(uri,
  "Authorization" => "Bearer #{ENV["PUREFRAME_API_KEY"]}")
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
previews = JSON.parse(res.body)

Response

{
  "data": [
    {
      "id": "c0591685-7e85-4ad5-9b38-e9b104aab422",
      "video_id": "a3cad7d9-899e-4dae-a764-9cb083792818",
      "number": 1,
      "start_seconds": 600,
      "status": "completed",
      "url": "https://pureframe.io/api/v1/previews/c0591685-7e85-4ad5-9b38-e9b104aab422/file",
      "created_at": "2026-10-11T16:35:10Z"
    }
  ]
}

Download a preview

GET /previews/:preview_id/file

Redirects (302) to the preview file.

GET /previews/:preview_id/file

curl -L -o preview.mp4 https://pureframe.io/api/v1/previews/$PREVIEW_ID/file \
  -H "Authorization: Bearer $PUREFRAME_API_KEY"
import { writeFile } from "node:fs/promises";

// fetch follows the redirect to the signed file URL
const res = await fetch(`https://pureframe.io/api/v1/previews/${previewId}/file`, {
  headers: { Authorization: `Bearer ${process.env.PUREFRAME_API_KEY}` },
});
await writeFile("preview.mp4", Buffer.from(await res.arrayBuffer()));
import os, requests

with requests.get(
    f"https://pureframe.io/api/v1/previews/{preview_id}/file",
    headers={"Authorization": f"Bearer {os.environ['PUREFRAME_API_KEY']}"},
    stream=True,
) as r:
    r.raise_for_status()
    with open("preview.mp4", "wb") as out:
        for chunk in r.iter_content(1 << 20):
            out.write(chunk)
require "net/http"

uri = URI("https://pureframe.io/api/v1/previews/#{preview_id}/file")
req = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{ENV["PUREFRAME_API_KEY"]}")
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
# 302: the file is at the signed URL in Location
File.binwrite("preview.mp4", Net::HTTP.get(URI(res["location"])))

Webhooks

Events

Set one URL in Settings → API. We POST an event there when a video or a preview finishes, whether it was started on the website or with the API. The payload is small: fetch the video for details.

video.completed A video finished processing. Download it now.
video.failed Processing failed.
preview.completed A preview is ready to watch.
preview.failed A preview failed.

Answer with any 2xx within 10 seconds. Failed deliveries are retried twice, after 10 seconds and after 1 minute. Settings → API shows the last delivery result and can send a test event.

POST to your URL

PureFrame-Event: video.completed
PureFrame-Signature: t=1791736420,v1=5f2b…

{
  "id": "evt_9870b020-2c02-4d37-af00-2f4e89cfcfec",
  "type": "video.completed",
  "created_at": "2026-10-11T16:33:40Z",
  "data": {
    "video_id": "a3cad7d9-899e-4dae-a764-9cb083792818",
    "job_id": 4,
    "status": "completed"
  }
}

Verify signatures

Each request has a PureFrame-Signature header: t=<unix time>,v1=<hex>. v1 is the HMAC-SHA256 of t, a dot and the raw request body, keyed with your signing secret (whsec_…).

Compute it on the raw bytes before parsing the JSON, compare in constant time, and reject timestamps older than 5 minutes.

Verify a webhook

# Check a signature by hand (t and v1 come from the PureFrame-Signature header)
printf '%s.%s' "$T" "$RAW_BODY" \
  | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex
# The hex digest must equal v1\
import crypto from "node:crypto";

// rawBody: the exact bytes received (not re-serialized JSON)
export function verifyPureFrame(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}\
import hashlib, hmac, time

def verify_pureframe(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > 300:
        return False
    expected = hmac.new(secret.encode(), parts["t"].encode() + b"." + raw_body,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])\
require "openssl"

def verify_pureframe(raw_body, header, secret)
  parts = header.split(",").to_h { |p| p.split("=", 2) }
  return false if (Time.now.to_i - parts["t"].to_i).abs > 300
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{parts["t"]}.#{raw_body}")
  OpenSSL.secure_compare(expected, parts["v1"])
end\

MCP server

Connect an AI agent

PureFrame runs a remote MCP server. Add it to Claude Code, Cursor, VS Code or Claude Desktop and ask your agent to clean a video: it uploads, checks a preview, asks you to approve the price and downloads the result.

URL: https://pureframe.io/mcp. It uses the same API key, credits and limits as the REST API.

A remote server can't read your disk: for uploads the agent gets a signed URL and the exact command to send the file, so it needs to be able to run shell commands (Claude Code, Cursor, VS Code agent mode).

Claude Code · Terminal

claude mcp add --transport http pureframe https://pureframe.io/mcp \
  --header "Authorization: Bearer $PUREFRAME_API_KEY"

Cursor · ~/.cursor/mcp.json

{
  "mcpServers": {
    "pureframe": {
      "url": "https://pureframe.io/mcp",
      "headers": { "Authorization": "Bearer pf_live_…" }
    }
  }
}

VS Code · .vscode/mcp.json

{
  "servers": {
    "pureframe": {
      "type": "http",
      "url": "https://pureframe.io/mcp",
      "headers": { "Authorization": "Bearer pf_live_…" }
    }
  }
}

Claude Desktop · claude_desktop_config.json (through mcp-remote)

{
  "mcpServers": {
    "pureframe": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://pureframe.io/mcp",
               "--header", "Authorization:${PUREFRAME_AUTH}"],
      "env": { "PUREFRAME_AUTH": "Bearer pf_live_…" }
    }
  }
}

Tools

The agent asks you before it charges anything: process_video only runs with the price you approved, and create_upload asks you to confirm the content statement.

get_account

Get account and credits

create_upload

Start a video upload

complete_upload

Finish an upload

list_videos

List videos

get_video

Get a video

create_preview

Create a free preview

list_previews

List previews

process_video asks first

Process a video (charges credits)

get_download_link

Get the download link

delete_video asks first

Delete a video

Example prompt

Remove the mosaics from ~/Videos/movie.mp4 with PureFrame.
Show me a preview at 10:00 first, then tell me the price
before processing, and download the result next to the original.
Questions? Contact us.