Cancelling a running video job before it finishes billing
Cancel returns the job record with its state and cost so far. When cancelling saves money, when it's already too late, and the user-facing button worth adding.
A video generation you can already tell is wrong — the wrong prompt, the wrong aspect ratio, a duration someone fat-fingered — is worth stopping. POST /v1/video/cancel/{id} on Infrai takes the job_id and returns the full job record, including state and whatever cost_usd has accrued. Since generation bills per second of output, stopping a job before it produces output is the difference between paying for a clip and paying for part of one.
The catch is timing, and it’s short.
Cancel
curl -sS -X POST "https://api.infrai.cc/v1/video/cancel/vid_2fVc8nRqLmT4xBzY" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"job_id": "vid_2fVc8nRqLmT4xBzY"}'
{
"ok": true,
"data": {
"job_id": "vid_2fVc8nRqLmT4xBzY",
"state": "cancelled",
"model": "Kling:2.5",
"vendor": "tencent_vod",
"prompt": "an overhead shot of espresso being poured into a glass, slow motion",
"duration_seconds": 5,
"aspect_ratio": "9:16",
"video_url": null,
"cost_usd": 0.0,
"created_at": "2026-09-21T03:30:00Z",
"finished_at": "2026-09-21T03:30:41Z",
"retention_days": 7
}
}
state: "cancelled", video_url: null, and a cost_usd you should read rather than assume. That field is the honest answer to “did cancelling save me anything” — and it’s the number to log, because it turns a guess into a measurement.
When it helps and when it doesn’t
| Job state when you cancel | Effect |
|---|---|
queued | nothing has started; best case |
running, early | work stops; partial cost at most |
running, nearly done | most of the work is done; little saved |
succeeded | too late — there’s output and a charge |
failed | nothing to cancel |
Check before you act rather than cancelling blind:
curl -sS "https://api.infrai.cc/v1/video/status/vid_2fVc8nRqLmT4xBzY" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"job_id": "vid_2fVc8nRqLmT4xBzY",
"state": "running",
"progress_pct": 42,
"eta_seconds": 95,
"current_step": "denoising"
}
}
progress_pct: 42 is worth stopping.
Ninety-four percent is not, and the reasoning is worth spelling out because the instinct runs the other way: at that point you will pay for very nearly the whole render and receive nothing at all for it, whereas letting the last few seconds finish costs a fraction more and leaves you with a clip you can actually look at, compare against the prompt, and learn something from. Cancel early or not at all.
A guard that decides for you
import os
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
# Past this, cancelling costs nearly as much as finishing and leaves you with
# nothing to look at. Below it, stopping is a saving.
CANCEL_CEILING_PCT = 70
def status(job_id: str) -> dict:
resp = SESSION.get(f"{API}/v1/video/status/{job_id}", timeout=25)
resp.raise_for_status()
return resp.json()["data"]
def cancel(job_id: str) -> dict:
resp = SESSION.post(f"{API}/v1/video/cancel/{job_id}",
json={"job_id": job_id}, timeout=30)
resp.raise_for_status()
return resp.json()["data"]
def cancel_if_worthwhile(job_id: str) -> dict:
state = status(job_id)
if state.get("state") in {"succeeded", "failed", "cancelled"}:
return {"action": "none", "reason": f"already {state.get('state')}"}
progress = state.get("progress_pct") or 0
if progress >= CANCEL_CEILING_PCT:
return {"action": "let_it_finish", "reason": f"{progress}% done; cancelling saves little"}
job = cancel(job_id)
return {"action": "cancelled", "cost_usd": job.get("cost_usd"), "state": job.get("state")}
if __name__ == "__main__":
print(cancel_if_worthwhile(os.environ["JOB_ID"]))
A ceiling expressed in progress percent is the right shape, because it survives any change to the rate.
Give users the button
Most wasted generation isn’t a bug — it’s a person realising mid-render that they described the wrong thing. If your UI shows progress_pct and eta_seconds, it should also show a cancel button, because the alternative is them opening a second job while the first one keeps running.
Wire it to your own endpoint, check ownership, then cancel. One extra endpoint, and it removes the most common source of accidental spend.
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const headers = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };
export async function cancelForUser(jobId, ownsJob) {
// Ownership is YOUR check. A cancel endpoint that takes a job id from the
// request body without one lets any user stop any other user's render.
if (!(await ownsJob(jobId))) {
const error = new Error("not your job");
error.status = 403;
throw error;
}
const res = await fetch(`${API}/v1/video/cancel/${encodeURIComponent(jobId)}`, {
method: "POST",
headers,
body: JSON.stringify({ job_id: jobId }),
});
const body = await res.json();
if (!body.ok) throw new Error(body.error?.code ?? "cancel_failed");
return { state: body.data.state, costUsd: body.data.cost_usd };
}
Cancelling isn’t cleanup
A cancelled job leaves a record, and a job that already produced output leaves a file. DELETE /v1/video/delete/{id} removes the job and its artefact when you want it gone — cancelling stops the work, deleting removes the result.
Both are worth doing for an abandoned render: cancel so it stops, delete so nothing lingers into your storage accounting.
Limitations
Cancellation is best-effort against work already dispatched to a vendor, so cost_usd on a cancelled job isn’t guaranteed to be zero — read the field rather than assuming, which is exactly why it’s on the response. And there’s no bulk cancel: a runaway loop that submitted forty jobs needs forty calls, which is an argument for a per-user concurrency limit in your own submit path rather than a fast cancel loop afterwards.
Going direct to a single model provider sometimes gets you finer job control, and Runway or Kling’s own interfaces will always expose more of their own lifecycle than a common shape across a dozen models. What you keep here is that the cancel, the delete, the queue that submitted the job with POST /v1/queue/publish and the spend it avoided in GET /v1/account/usage are one credential and one invoice. Generation bills per second of output — live figure in GET /v1/discovery/video.generate, approximate: true because it varies by vendor (verified 2026-09-21) — and those rates drift downward as vendor contracts improve.