User closed the tab mid-render: cancel the Sume job or let it finish?
Cancel works only before generation starts; after that you get 409 job_generation_already_started and the job bills. A tested tab-close handler.

When a user closes the tab while a video renders, send one cancel request and treat the answer as information, not as a failure: it succeeds only before generation work starts, and after that Sume returns 409 job_generation_already_started with details.cancelable: false and the job runs to completion. Sume jobs and results says the same, and adds that cancelling a job that is already canceled is idempotent. So the right design is cancel-if-possible, then keep the job id so the finished clip is waiting when the user returns.
Whatever you choose, write it down as a product decision, because support will be asked about it. Do not assume that dropping the connection stops anything. A browser disconnect, a closed SSE stream or a client timeout ends your wait and nothing else.
Which policy should the tab-close handler use?
There are two honest policies. The first is to cancel on leave, which saves money when the job is still queued and does nothing harmful when it is not. The second is to never cancel and let every job finish, which suits a product where the result is saved to a library anyway. A mixed policy is common: cancel when the user explicitly presses Stop, keep the job when they merely navigate away. The code below implements the decision as a pure function so you can test it without a network. The cancel argument is any function that returns a status and a body, which lets the unit test fake every answer the API can give, including a transient 503 where the right move is to try again shortly rather than to guess.
What does the handler do with each answer?
def on_leave(cancel, job_id, user_pressed_stop):
"""cancel(job_id) -> (http_status, body). Returns the action taken."""
if not user_pressed_stop:
return "keep"
status, body = cancel(job_id)
if status == 200:
return "canceled"
err = (body or {}).get("error", {})
if status == 409 and err.get("code") == "job_generation_already_started":
return "running-keep-and-save"
if status == 409 and err.get("code") == "job_not_cancelable":
return "terminal-fetch-result"
return "retry-later"
def fake(status, code=None):
body = {"error": {"code": code}} if code else {}
return lambda job_id: (status, body)
assert on_leave(fake(200), "j1", True) == "canceled"
assert on_leave(fake(200), "j1", False) == "keep"
started = fake(409, "job_generation_already_started")
assert on_leave(started, "j1", True) == "running-keep-and-save"
assert on_leave(fake(503), "j1", True) == "retry-later"
print("tab-close policy ok")Why does the 409 branch keep the job?
The running-keep-and-save branch matters most. It means: stop showing a spinner, record that the job id belongs to this user, and let a webhook or a later poll attach the artifact to their library. Sume bills the job, so throwing the id away turns a wanted clip into a pure loss. The terminal-fetch-result branch is the opposite case, where the job already finished and the only useful step is a GET result_url.
Keep the cancel call itself safe to repeat. A repeated cancel of a canceled job is idempotent, so a flaky network that retries it does no harm, and you can reuse the same retry wrapper as your other idempotent calls.
| Answer | Meaning | Your next step |
|---|---|---|
| 200 canceled | Stopped before generation | Free the user's slot |
| 409 job_generation_already_started | Generation began, runs to completion | Save the id and fetch later |
| 409 job_not_cancelable | Already terminal | Fetch the result or the error |
| Repeat cancel on a canceled job | Idempotent | Treat as success |
What do I tell the user?
One boundary to respect: a client timeout is not a cancel and a cancel is not a refund promise. The billing rules for failed and canceled work live in Sume errors and credits, and you should read them before you describe the outcome to a user. In your own interface, say what happened in plain words, such as This render had already started, so it will finish and appear in your library, rather than showing a raw error code. Log the job id and the branch taken, so a later complaint of the form I closed the page and was still charged can be answered from a record: the job had already started when the cancel arrived, and the clip is in the library. If you offer a Stop button, put the same message next to it before the click, so people learn that stopping late does not recover the spend.
Sources
Related posts
More in Developers
- v0 MCP tool approval and Sume: ask first before paid calls
v0 lets you disable tools and set ask-for-approval or auto-use per MCP. Pair that with Sume's dry_run and max_spend_usd so a paid call never runs unreviewed.
- callback_url on Sume /v1/videos: HTTPS only, not OpenRouter events
POST /v1/videos takes callback_url. It must be HTTPS, and Sume sends its own job.completed envelope, not OpenRouter's video.generation events.
- Verify a Sume webhook in Node: raw body, timing-safe compare
A short Node verifier for Sume webhooks: sign timestamp.rawBody with HMAC SHA-256, compare sume-v1 entries with timingSafeEqual, and refuse an empty secret.
- Verify a Sume TTS transcript_receipt SHA-256 yourself in Python
Recompute submitted_transcript_sha256 from your script with NFC and LF canonicalization and compare it to the transcript_receipt on a finished Sume TTS job.
Written by Sume