Text-to-video generation is now reliable enough to be a real building block for production products, not just a proof-of-concept demo. This tutorial walks through building a working Python integration against a text-to-video API from scratch: authentication, sending your first request, checking on the result, downloading the output, and handling the errors that will inevitably show up. By the end, you will have a small FastAPI service that wraps the whole flow. If you want the concepts behind the technology first, our explainer on how text-to-video generation works is a good primer before diving into code.
What You'll Build
A minimal backend service that accepts a text prompt, starts a video generation job, and lets a client check on progress until the video is ready to download.
Prerequisites:
- Python 3.9 or later
- An API key from Apiframe (see the getting started guide) (the examples below use Apiframe's unified video API, but the same pattern applies to most async generation APIs)
- The
requestspackage (pip install requests), andfastapiplusuvicornfor the final example (pip install fastapi uvicorn)
How Text-to-Video APIs Differ from Image-to-Video
If you have already built an image-to-video integration, most of the setup here will look familiar, but a few real differences are worth calling out before you write any code.
Prompt-only input instead of a reference image. Image-to-video generation starts from a still image and animates it. Text-to-video has no visual anchor at all: the model has to invent the scene, the subject, and the motion purely from the words in your prompt. That makes prompt quality matter more. Vague prompts tend to produce vague, drifting motion.
Duration, motion, and camera control. Text-to-video models typically let you set the clip length (often 5, 6, or 10 seconds depending on the model), and some support basic camera direction inside the prompt itself, like a slow zoom or a pan, since there is no reference frame to anchor the camera to.
If you later want to add image-to-video to the same product, for example letting users upload a product photo and animate it, most APIs support both modes through the same endpoint, just with an added image parameter. That means the two features can share almost all of the code in this tutorial.
Step 1: Authentication and Setup
Every request needs your API key passed in an X-API-Key header. Keep it out of source control, load it from an environment variable:
import os
import requests
API_KEY = os.environ["APIFRAME_API_KEY"]
BASE_URL = "https://api.apiframe.ai/v2"
HEADERS = {
"X-API-Key": API_KEY,
"Content-Type": "application/json",
}Store the key in a .env file or your platform's secrets manager, never hardcode it, and never commit it.
Step 2: Sending Your First Text-to-Video Request
Video generation runs as a background process. You submit a prompt, and the API responds right away with a job ID and a QUEUED status, rather than making you wait for the finished video.
Here is a request using Hailuo 02, a text-to-video model that supports 6 or 10 second clips at 768p or 1080p resolution.
def submit_video_job(prompt: str, duration: int = 6, resolution: str = "768p") -> dict:
payload = {
"prompt": prompt,
"model": "hailuo-02",
"hailuoParams": {
"duration": duration,
"resolution": resolution,
},
}
response = requests.post(
f"{BASE_URL}/videos/generate",
headers=HEADERS,
json=payload,
)
response.raise_for_status()
return response.json()
job = submit_video_job("a paper airplane gliding through a sunlit office, slow motion")
print(job) # {"jobId": "b2c3d4e5-...", "status": "QUEUED"}Note the constraint: 1080p generations only support the 6 second duration, not 10, so validate that combination before you submit it if you are exposing these options to end users. This limit applies across the whole Hailuo lineup, so it is worth keeping in mind even if you later switch models. Our Hailuo versions comparison breaks down which version fits which use case, and Apiframe has since added the newer Hailuo 03 model if you want to compare against the latest release.
The prompt field accepts up to 4,000 characters, and model is required on every request. Other text-to-video models (Kling, Seedance, Wan) take the same top-level shape but their own model-specific parameters object, for example klingParams or seedanceParams, so check the model's own reference page for the exact field names before swapping models.
Step 3: Polling for Completion
Once you have a job ID, poll the jobs endpoint until the status changes to COMPLETED or FAILED. Video jobs take longer than image jobs, so poll every 5 to 10 seconds rather than every second or two:
import time
def wait_for_completion(job_id: str, poll_interval: int = 6, timeout: int = 600) -> dict:
elapsed = 0
while elapsed < timeout:
response = requests.get(f"{BASE_URL}/jobs/{job_id}", headers=HEADERS)
response.raise_for_status()
job = response.json()
if job["status"] == "COMPLETED":
return job
if job["status"] == "FAILED":
raise RuntimeError(f"Generation failed: {job.get('error')}")
time.sleep(poll_interval)
elapsed += poll_interval
raise TimeoutError(f"Job {job_id} did not complete within {timeout} seconds")
result = wait_for_completion(job["jobId"])
print(result["result"]) # {"videoUrl": "https://cdn2.apiframe.ai/videos/....mp4"}A note on webhooks. Polling works fine for scripts, background jobs, or low-volume use, but if you're running this at any real scale, a webhook is the better fit. Our step-by-step webhook setup guide walks through securing and testing your endpoint. webhookUrl and webhookEvents in the original request (["completed", "failed"]) and the API will POST the result to your server the moment the job finishes, instead of your code repeatedly checking in on it. Apiframe signs webhook payloads with an HMAC signature so you can verify they actually came from the API before trusting the payload.
Step 4: Handling and Storing the Output Video
Once the job is COMPLETED, the result contains a hosted video URL. Download and validate it before handing it off to the rest of your app:
def download_video(video_url: str, output_path: str) -> str:
response = requests.get(video_url, stream=True, timeout=60)
response.raise_for_status()
content_type = response.headers.get("Content-Type", "")
if "video" not in content_type:
raise ValueError(f"Unexpected content type: {content_type}")
with open(output_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
return output_path
video_url = result["result"]["videoUrl"]
download_video(video_url, "output.mp4")Generated files are stored on the provider's servers for a limited window (Apiframe keeps results for 90 days), so if your product needs to keep the video long-term, download it and move it to your own storage rather than relying on that link indefinitely. If you want to plan your storage and compute budget ahead of time, our guide on estimating AI video generation costs breaks down the numbers.
Step 5: Error Handling and Retries
A few failure modes show up often enough that it's worth handling them explicitly rather than letting an unhandled exception take down the request:
def submit_with_retry(prompt: str, max_retries: int = 3, **kwargs) -> dict:
for attempt in range(1, max_retries + 1):
try:
response = requests.post(
f"{BASE_URL}/videos/generate",
headers=HEADERS,
json={"prompt": prompt, "model": "hailuo-02", "hailuoParams": kwargs},
timeout=30,
)
if response.status_code == 429:
wait = 2 ** attempt
time.sleep(wait)
continue
response.raise_for_status()
return response.json()
except requests.exceptions.Timeout:
if attempt == max_retries:
raise
time.sleep(2 ** attempt)
raise RuntimeError("Max retries exceeded")Common failure modes to plan for:
- Timeouts: the request itself, not the generation, timing out on a slow connection. Set a reasonable
timeouton your HTTP client and wait and retry. - Content moderation rejections: a
FAILEDjob with an error like"Provider returned an error: content policy violation". These won't succeed on retry with the same prompt, so surface the error to the user instead of silently retrying. - Malformed prompts or parameters: a
400response with adetailsobject describing which field failed validation. Worth logging in full during development. - Rate limits: a
429response means you have exceeded your per-minute request limit. Back off and retry rather than hammering the endpoint. - Insufficient credits: a
402response. Since credits are deducted at submission and refunded automatically on failure, this only happens when your balance is genuinely too low to cover the job.
Putting It Together: A Minimal FastAPI Endpoint
Here are all five steps combined into a small service with two endpoints: one to start a generation, one to check on it.
import os
import time
import requests
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
API_KEY = os.environ["APIFRAME_API_KEY"]
BASE_URL = "https://api.apiframe.ai/v2"
HEADERS = {"X-API-Key": API_KEY, "Content-Type": "application/json"}
app = FastAPI()
class GenerateRequest(BaseModel):
prompt: str
duration: int = 6
resolution: str = "768p"
@app.post("/generate")
def generate_video(req: GenerateRequest):
if req.resolution == "1080p" and req.duration == 10:
raise HTTPException(400, "1080p does not support 10 second duration")
payload = {
"prompt": req.prompt,
"model": "hailuo-02",
"hailuoParams": {"duration": req.duration, "resolution": req.resolution},
}
response = requests.post(f"{BASE_URL}/videos/generate", headers=HEADERS, json=payload)
if response.status_code == 402:
raise HTTPException(402, "Insufficient credits")
response.raise_for_status()
return response.json()
@app.get("/status/{job_id}")
def get_status(job_id: str):
response = requests.get(f"{BASE_URL}/jobs/{job_id}", headers=HEADERS)
if response.status_code == 404:
raise HTTPException(404, "Job not found")
response.raise_for_status()
return response.json()Run it with uvicorn main:app --reload, POST a prompt to /generate, then poll /status/{job_id} from your frontend until the status is COMPLETED.
Next Steps: Choosing a Model for Text-to-Video
Hailuo 02 is a solid default to start with, but it is one of several text-to-video models available through the same endpoint pattern, each with its own tradeoffs. Our full AI video API guide covers how these models compare beyond just Hailuo.
- Kling: strong general-purpose quality, with newer versions (2.6 and up) supporting synchronized audio generation alongside the video.
- Seedance: a wide range of speed and resolution tiers, useful if you need to dial cost up or down depending on the use case.
- Hailuo: good realistic motion at a moderate price point, the one used throughout this tutorial.
- Wan: supports longer clips and multi-shot storytelling in its newer versions, worth a look if you need more than a single short clip.
Since all of these sit behind the same POST /v2/videos/generate endpoint with the same job and webhook pattern, swapping models in the code above is mostly a matter of changing the model field and its matching parameters object, not rebuilding the integration.
Adding a reference image later, to support image-to-video alongside text-to-video, generally just means adding an image field inside the model's parameters object. Worth planning for if you expect to add that mode down the line.
FAQ
How long does generation take?
It varies by model and settings, but video jobs commonly take anywhere from 30 seconds to a few minutes. Checking every 5 to 10 seconds, or using a webhook, is the recommended approach rather than assuming a fixed wait time.
Can I convert this to image-to-video later?
Yes. Most text-to-video models on a unified API also support image-to-video through the same endpoint, typically by adding an image URL parameter. The authentication, job checking, and download code in this tutorial stays the same either way.
What happens if my prompt gets rejected?
You will get a FAILED job status with an error message describing the rejection, most commonly a content policy violation. Credits spent on a failed job are typically refunded automatically, so retrying with a revised prompt does not cost you double.
Do I need to handle video storage myself?
For anything you want to keep beyond the provider's retention window (90 days on Apiframe), yes. Download the file and store it in your own infrastructure rather than relying on the hosted link long-term.