Kling 3.0 is one of the stronger image-to-video models available right now, with flexible duration, resolution modes up to 4K, native audio generation, and end-frame control for guiding how a clip finishes. This tutorial walks through building a real Python integration for it, from authentication through a working FastAPI endpoint you can drop into a backend.
We are using Apiframe as the API layer here, since it exposes Kling 3.0 through a single unified endpoint alongside every other major video model, with one API key and one credit balance instead of a separate integration per provider.
What You'll Build
By the end of this tutorial you will have a Python script that sends a source image and a text prompt to Kling, polls until the video is ready, and returns a finished video URL. We will then wrap that logic in a minimal FastAPI endpoint so it can be called from a real application.
Prerequisites
You will need Python 3.10 or later, an Apiframe API key (sign up at console.apiframe.ai and generate one from the dashboard), and the requests library. httpx works as a drop-in alternative if you prefer an async client. Install what you need with:
pip install requests fastapi uvicornAuthenticating Your Requests
Every request to the Apiframe API needs an X-API-Key header. API keys always start with the prefix afk_. Before writing the real integration, it is worth running a quick sanity check to confirm your key works:
import requests
API_KEY = "afk_your_api_key_here"
response = requests.get(
"https://api.apiframe.ai/v2/me",
headers={"X-API-Key": API_KEY},
)
print(response.json())If your key is valid, this returns your account details along with your team's current credit balance. A 401 here means the key is missing, invalid, or has been revoked, in which case double-check what you copied from the dashboard.
Sending Your First Image-to-Video Request
Kling's image-to-video generation runs through the same endpoint as text-to-video: POST /v2/videos/generate, with model set to "kling-3.0". What makes it image-to-video is passing a start_image URL inside klingParams, that is, the image Kling will animate as the first frame.
Required parameters
prompt: the text description of what should happen in the video (1 to 4,000 characters)model:"kling-3.0"klingParams.start_image: a URL pointing to the source image you want animated
Optional parameters
klingParams.duration: video length in seconds, from 3 to 15 (defaults to 5)klingParams.mode:"standard"(720p),"pro"(1080p, the default), or"4k"(2160p)klingParams.aspect_ratio: aspect ratio as a"W:H"string, like"16:9"klingParams.end_image: a URL for the image Kling should end on, useful for controlled transitionsklingParams.negative_prompt: things to avoid in the output (up to 2,500 characters)klingParams.generate_audio: whether to generate synced audio (defaults totrue)klingParams.multi_prompt: a JSON array of timed prompts if you need timeline-level control over the clip
Here is a full working request that animates a source image with a 10-second prompt:
import requests
API_KEY = "afk_your_api_key_here"
response = requests.post(
"https://api.apiframe.ai/v2/videos/generate",
headers={
"X-API-Key": API_KEY,
"Content-Type": "application/json",
},
json={
"prompt": "the camera slowly pushes in as the subject turns toward the light",
"model": "kling-3.0",
"klingParams": {
"start_image": "https://example.com/your-source-image.jpg",
"duration": 10,
"mode": "pro",
"aspect_ratio": "16:9",
"generate_audio": True,
},
},
)
job = response.json()
print(job) # {"jobId": "...", "status": "QUEUED"}The request returns immediately, before the video actually exists. Apiframe queues the job and hands you a jobId, which you use to check on progress.
Polling for the Generated Video
Video generation is asynchronous everywhere, Kling included, so the pattern is always submit, then poll (or use a webhook). Apiframe recommends polling every 5 to 10 seconds for video jobs, since they take noticeably longer than image generations.
import time
import requests
API_KEY = "afk_your_api_key_here"
def wait_for_video(job_id: str, timeout_seconds: int = 600, poll_interval: int = 8) -> str:
headers = {"X-API-Key": API_KEY}
deadline = time.time() + timeout_seconds
while time.time() < deadline:
response = requests.get(
f"https://api.apiframe.ai/v2/jobs/{job_id}",
headers=headers,
)
job = response.json()
status = job["status"]
if status == "COMPLETED":
return job["result"]["videoUrl"]
elif status == "FAILED":
raise RuntimeError(f"Generation failed: {job['error']}")
print(f"Status: {status} ({job['progress']}%)")
time.sleep(poll_interval)
raise TimeoutError(f"Job {job_id} did not complete within {timeout_seconds} seconds")A COMPLETED job returns a result object shaped like {"videoUrl": "https://cdn2.apiframe.ai/videos/....mp4"}. That file stays on Apiframe's CDN for 90 days, so download it to your own storage if you need it longer term.
If you would rather not poll at all, pass a webhookUrl and webhookEvents (["completed", "failed"]) in the original generation request and Apiframe will POST the result to your server when the job finishes. That is generally the better option for production traffic, since it avoids holding a connection or a background loop open per job.
Handling Errors and Retries
The error format is consistent across the API: a JSON body with an error message and a details object for field-level validation issues. The status codes worth handling explicitly:
| Status | Meaning | What to do |
|---|---|---|
| 400 | Validation failed, check details | Fix the request, do not retry as-is |
| 401 | Missing or invalid API key | Check your X-API-Key header |
| 402 | Insufficient credits | Top up before retrying |
| 403 | Forbidden, key inactive or admin required | Check key status in the console |
| 404 | Resource not found | Verify the job ID |
| 429 | Rate limit exceeded | Back off and retry |
| 503 | Service temporarily unavailable | Retry with exponential backoff |
A job can also come back with status: "FAILED" after being accepted, most often from a content-moderation rejection on the prompt or source image. The good news: credits are refunded automatically whenever a job fails, so a failed generation does not cost you anything.
For retry logic, avoid blindly resubmitting on every failure. Content-policy failures will fail again with the same input, so retrying only helps for transient errors like 429 or 503. If you are worried about accidentally submitting the same job twice during a retry, pass an Idempotency-Key header. Sending the same key again returns the original job instead of creating a duplicate and charging you twice.
import uuid
import requests
def submit_with_idempotency(payload: dict, api_key: str) -> dict:
idempotency_key = str(uuid.uuid4())
response = requests.post(
"https://api.apiframe.ai/v2/videos/generate",
headers={
"X-API-Key": api_key,
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
json=payload,
)
return response.json()Wrapping It in a FastAPI Endpoint
Putting the pieces together, here is a minimal FastAPI endpoint that accepts an image URL and a prompt, submits the Kling job, waits for it to complete, and returns the finished video URL:
import os
import time
import requests
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
API_KEY = os.environ["APIFRAME_API_KEY"]
BASE_URL = "https://api.apiframe.ai/v2"
class ImageToVideoRequest(BaseModel):
image_url: str
prompt: str
duration: int = 5
mode: str = "pro"
@app.post("/generate-video")
def generate_video(req: ImageToVideoRequest):
submit_response = requests.post(
f"{BASE_URL}/videos/generate",
headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
json={
"prompt": req.prompt,
"model": "kling-3.0",
"klingParams": {
"start_image": req.image_url,
"duration": req.duration,
"mode": req.mode,
},
},
)
if submit_response.status_code != 202:
raise HTTPException(status_code=submit_response.status_code, detail=submit_response.json())
job_id = submit_response.json()["jobId"]
deadline = time.time() + 600
while time.time() < deadline:
job = requests.get(f"{BASE_URL}/jobs/{job_id}", headers={"X-API-Key": API_KEY}).json()
if job["status"] == "COMPLETED":
return {"videoUrl": job["result"]["videoUrl"], "jobId": job_id}
elif job["status"] == "FAILED":
raise HTTPException(status_code=500, detail=job["error"])
time.sleep(8)
raise HTTPException(status_code=504, detail="Generation timed out")For anything beyond a demo, swap the blocking while loop for a webhook callback or a background task queue (Celery, arq, or FastAPI's own BackgroundTasks), so the endpoint returns the jobId immediately instead of holding the HTTP connection open for the full generation time.
Kling Pricing Recap for This Workflow
Kling 3.0 through Apiframe bills per second of output, with the rate depending on mode and whether audio is enabled: standard mode runs 29 credits per second (43 with audio), pro mode runs 39 credits per second (58 with audio), and 4K runs a flat 72 credits per second regardless of audio. That means a 5-second pro clip with audio costs 290 credits, and a 10-second standard clip without audio also lands at 290 credits since the two rates roughly offset at that length. Budget accordingly if you are planning to run this at volume, and check Apiframe's pricing page for current rates, or read our full breakdown in the AI video API pricing guide if you are comparing Kling against Runway, Hailuo, or Seedance for this use case.
FAQ
Does Kling have an official public API?
Yes, Kling AI runs its own developer platform with prepaid resource packages. Going through Apiframe instead gets you the same model behind a single unified endpoint alongside every other major video and image model, so you are not managing a separate account and credit system just for Kling.
Which Python version and libraries does this need?
Python 3.10 or newer, plus requests (or httpx for an async client). The FastAPI example above also needs fastapi and uvicorn to run as a server.
How long does a typical generation take?
Video jobs on Apiframe generally take one to five minutes depending on duration and mode, with 4K and longer clips at the slower end of that range. That is why polling every 5 to 10 seconds, or using a webhook instead, works better than a tight polling loop.