DocsIntegrations
Python with FastAPI
A FastAPI app with async httpx calls that creates jobs from JSON, takes uploads straight from the browser, verifies webhooks and hands out downloads.
What you build#
A FastAPI app that turns an uploaded file into an MP4, or into an MP4, a poster and a GIF preview
in one job. The browser sends the file straight to Videxo with a one-time URL, so it never
passes through your server. Your server keeps the API key, creates the job, verifies the webhook
and sends each download to a fresh signed link. Every call to Videxo sits in one module,
app/videxo.py, with plain HTTP (httpx).
The whole project: videxo-python-fastapi.zip, or browse it in the examples repository (opens in a new tab).
Before you start#
- An API key from the dashboard's API keys page, in
VIDEXO_API_KEY. - The webhook signing secret from the dashboard's Webhooks page, in
VIDEXO_WEBHOOK_SECRET. - Python 3.10 or newer.
Both values stay on the server, in environment variables. Never put them in code that runs in a browser.
Install#
python -m venv .venv
.venv/bin/pip install -r requirements.txtCopy failed. The text is selected; copy it with your keyboard.
requirements.txt holds fastapi, uvicorn, httpx and
standardwebhooks (opens in a new tab), which verifies the webhook's
signature.
Create a job#
The JSON bodies sit in requests/, next to the code: one MP4 through POST /v1/transcode, and an
MP4, a poster and a GIF preview in one job through POST /v1/jobs. The functions put the uploaded
file in as the input and send an Idempotency-Key:
async def transcode(input_url: str, idempotency_key: str) -> dict[str, Any]:
"""One MP4 from one input: POST /v1/transcode with the body in requests/transcode.json."""
body = {**TRANSCODE_BODY, "input": input_url, **_webhook()}
return await _json("POST", "/v1/transcode", body, idempotency_key)
async def create_job(input_url: str, idempotency_key: str) -> dict[str, Any]:
"""Several outputs from one input in one job: POST /v1/jobs with the body in requests/jobs.json."""
body = {**JOBS_BODY, "inputs": {"talk": input_url}, **_webhook()}
return await _json("POST", "/v1/jobs", body, idempotency_key)Copy failed. The text is selected; copy it with your keyboard.
Every function goes through one _request helper. An error response is
problem details; it becomes a VidexoProblem with the code, the detail and the
field errors:
class VidexoProblem(Exception):
"""An error answer of the API: RFC 9457 problem details. Branch on `code`; show `detail` to people."""
def __init__(self, problem: dict[str, Any]) -> None:
super().__init__(f"{problem.get('code')}: {problem.get('detail')}")
self.problem = problem
self.status: int = problem.get("status", 500)
_client = httpx.AsyncClient(base_url=API_URL, timeout=60)
async def _request(method: str, path: str, body: Any = None, idempotency_key: str | None = None) -> httpx.Response:
api_key = os.environ.get("VIDEXO_API_KEY")
if not api_key:
raise RuntimeError("Set VIDEXO_API_KEY in the server environment.")
headers = {"Authorization": f"Bearer {api_key}"}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
# httpx does not follow redirects unless asked: the download route reads the Location itself.
response = await _client.request(method, path, json=body, headers=headers)
if response.status_code >= 400:
try:
problem = response.json()
except ValueError:
problem = None
if not isinstance(problem, dict) or "code" not in problem:
# Not problem details: a proxy or the network answered, not the API.
problem = {
"type": "about:blank",
"title": "The API answered with an error.",
"status": response.status_code,
"detail": f"HTTP {response.status_code}",
"code": "http_error",
}
raise VidexoProblem(problem)
return response
async def _json(method: str, path: str, body: Any = None, idempotency_key: str | None = None) -> dict[str, Any]:
data: dict[str, Any] = (await _request(method, path, body, idempotency_key)).json()
return dataCopy failed. The text is selected; copy it with your keyboard.
The route takes the upload's input_url from the page and derives the idempotency key from the
upload, so a retried request returns the same job:
UPLOAD = re.compile(r"^videxo://uploads/(up_[a-f0-9]{24})$")
@app.post("/api/jobs", status_code=202)
async def create_job(request: Request) -> dict[str, Any]:
body = await request.json()
input_url = body.get("input_url")
# Accept only uploads, so nobody can make this server fetch URLs of their choice.
match = UPLOAD.match(input_url) if isinstance(input_url, str) else None
if not match:
raise bad_request("input_url must be the input_url of an upload.")
upload = match.group(1)
# The same key for the same upload and kind: a retried request answers the same job, never a second one.
if body.get("kind") == "jobs":
return await videxo.create_job(input_url, f"jobs-{upload}")
return await videxo.transcode(input_url, f"transcode-{upload}")Copy failed. The text is selected; copy it with your keyboard.
An exception handler passes Videxo's problem details to the page as they came, so the page can show what was wrong:
@app.exception_handler(videxo.VidexoProblem)
async def videxo_problem(_request: Request, error: videxo.VidexoProblem) -> JSONResponse:
# Videxo's problem details go to the page as they came, so it can show `detail` and the field errors.
return JSONResponse(error.problem, status_code=error.status, media_type="application/problem+json")Copy failed. The text is selected; copy it with your keyboard.
Receive the webhook#
When the job ends, Videxo sends a signed POST (Webhooks). The route reads
the raw body exactly as it arrived, refusing one over 2 MB with 413 (Content-Length first, then
it stops reading past the limit), and checks it:
def verify_webhook(body: bytes, headers: dict[str, str]) -> dict[str, Any]:
"""The event when the request is a webhook Videxo signed, in the last five minutes; it raises
WebhookVerificationError otherwise. `body` is the raw body exactly as received: parsed and
serialized again, it no longer matches the signature."""
secret = os.environ.get("VIDEXO_WEBHOOK_SECRET")
if not secret:
raise RuntimeError("Set VIDEXO_WEBHOOK_SECRET in the server environment.")
event: dict[str, Any] = Webhook(secret).verify(body, headers)
return eventCopy failed. The text is selected; copy it with your keyboard.
# A webhook body is the whole job: up to several hundred KB, so 2 MB leaves room.
MAX_WEBHOOK_BYTES = 2 * 1024 * 1024
async def read_limited(request: Request, limit: int) -> bytes | None:
"""The raw body, or None when it is longer than `limit` bytes; never reads more than `limit` + 1."""
length = request.headers.get("content-length")
if length is not None and length.isdigit() and int(length) > limit:
return None
body = bytearray()
async for chunk in request.stream():
body += chunk
if len(body) > limit:
return None
return bytes(body)
@app.post("/webhooks/videxo")
async def webhook(request: Request) -> Response:
body = await read_limited(request, MAX_WEBHOOK_BYTES)
if body is None:
return Response(status_code=413)
try:
event = videxo.verify_webhook(body, dict(request.headers))
# The library raises ValueError, not its own error, for a webhook-signature that is not "v1,<base64>".
except (WebhookVerificationError, ValueError):
return Response(status_code=400)
webhook_id = request.headers["webhook-id"]
if webhook_id not in handled:
handled.add(webhook_id)
on_job_ended(event)
# Answer 2xx within seconds; do slow work in a queue of your own (FastAPI's BackgroundTasks, a worker).
return Response(status_code=204)Copy failed. The text is selected; copy it with your keyboard.
verify rejects a wrong signature and a timestamp more than five minutes away. For a
webhook-signature header that is not v1, and base64 it raises ValueError instead of its own
error, so the route catches both. A delivery can
arrive more than once with the same webhook-id; keep the ids you have handled in a unique column
and skip one you have seen.
Upload a file#
The server creates the upload with the API key and gives the browser only its one-time URL:
async def create_upload(filename: str, size_bytes: int, content_type: str) -> dict[str, Any]:
"""A one-time upload URL for a file the browser sends straight to Videxo; the API key stays here."""
body = {"filename": filename, "size_bytes": size_bytes, "content_type": content_type}
return await _json("POST", "/v1/uploads", body)Copy failed. The text is selected; copy it with your keyboard.
@app.post("/api/uploads", status_code=201)
async def create_upload(request: Request) -> dict[str, str]:
body = await request.json()
upload = await videxo.create_upload(body.get("filename"), body.get("size_bytes"), body.get("content_type"))
# Only the one-time URL goes to the browser; it holds no API key.
return {"input_url": upload["input_url"], "upload_url": upload["upload_url"]}Copy failed. The text is selected; copy it with your keyboard.
The page then sends the file with PUT to upload_url (no API key; the URL takes one PUT) and
asks the server for the job with the upload's input_url, videxo://uploads/<id>. See
Files for the size limit and how long an upload lasts.
Download the outputs#
Signed links expire within an hour, so the download route asks for a fresh one each time:
GET /v1/jobs/{id}/outputs/{alias} returns a redirect to it, which httpx does not follow, and the
route sends the browser on.
async def output_url(job_id: str, alias: str) -> str:
"""A fresh signed link to one output, from the redirect of GET /v1/jobs/{id}/outputs/{alias}."""
response = await _request("GET", f"/v1/jobs/{job_id}/outputs/{alias}")
return response.headers["location"]Copy failed. The text is selected; copy it with your keyboard.
@app.get("/api/jobs/{job_id}/outputs/{alias}")
async def download(job_id: str, alias: str) -> RedirectResponse:
# A fresh link each time: signed links expire within an hour.
return RedirectResponse(await videxo.output_url(job_id, alias), status_code=302)Copy failed. The text is selected; copy it with your keyboard.
A job's outputs[].download_url is also a signed link, valid until download_url_expires_at.
Run it#
cp .env.example .env # fill in VIDEXO_API_KEY and VIDEXO_WEBHOOK_SECRET
.venv/bin/uvicorn app.main:app --env-file .env --port 3000Copy failed. The text is selected; copy it with your keyboard.
Open http://localhost:3000, pick a file and choose the job. The page follows the job by reading it
every 2 seconds, then lists the outputs as download links.
Webhooks need a public https URL. In development run a tunnel (for example
cloudflared tunnel --url http://localhost:3000) and set VIDEXO_WEBHOOK_URL to its address plus
/webhooks/videxo; each job then carries it as its webhook_url. Or set the address once as your
account's endpoint on the Webhooks page.
Next steps#
- Operations and Presets: what else a JSON job can make.
- Webhooks: events, retries and the deliveries log.
- Error codes: every
codea problem can carry.