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 Create a video with its name and size. You get an upload URL.
- 2 Upload the file to that URL, then complete the upload. The video gets its price.
- 3 Optional: check a free 10-second preview.
- 4 Process it. The price is taken from your credits.
-
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.
unauthorized
Missing, unknown or revoked API key.
account_disabled
This account is disabled.
insufficient_credits
Not enough credits to process this video. Top up at /credits.
not_found
No such resource for this account.
invalid_request
Some parameters are missing or invalid.
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.
prohibited_file_name
This file can't be uploaded: content depicting minors is prohibited.
unsupported_file_type
Unsupported file type. Use .mp4, .mkv, .avi, .mov or .webm.
file_too_large
Files are limited to 10 GB.
resolution_unsupported
Videos are supported up to 4K (3840×2160). The upload was deleted.
upload_missing
The file was not found in storage. Upload it before calling complete.
not_uploading
This video's upload is already complete.
video_not_ready
Upload the video and call complete first.
video_busy
This video is being paid for or processed.
already_processed
This video was already processed.
preview_limit
Free preview limit reached.
download_unavailable
The processed file is no longer available (7-day download window).
not_completed
The video is not processed yet.
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.