Shopper leaves mid try-on: cancel the Format run, mind the gap
Cancel a Sume Format run with POST /v1/format-runs/{id}/cancel when a shopper leaves a try-on. Canceled runs send no webhook, and finished work is billed.

When a shopper leaves a try-on, cancel the run with POST /v1/format-runs/{run_id}/cancel. It needs formats:write, is idempotent, and returns the current receipt with a cancel_effect of canceled if it stopped a run in flight or no_op if it had already finished. Generation completed before the cancel is billed, and usage reports it. A canceled run never sends a webhook, so a webhook-only integration hears nothing.
This matters more after ChatGPT's new Try on button made one-tap try-ons a normal expectation (read 2026-10-03, OpenAI help page). Shoppers tap, get bored and leave. A try-on run that makes images and a clip keeps spending while nobody is watching.
What cancel does and does not do
A cancel stops a run in flight. It does not refund what the run already generated. Cancel early and the bill is small; cancel after the first frame is built and you pay for that frame. Set generation_spend_cap_usd on the create call as the other half of the guard, since a run can never spend past its own cap.
Because the call is idempotent, you can send it from more than one place, for example when the page unloads and again from a server job, without worrying about a second effect. Read cancel_effect to know which happened.
| Situation | Result | What to do |
|---|---|---|
| Run in flight | cancel_effect: canceled | Mark your row canceled |
| Run already finished | cancel_effect: no_op | Use the finished receipt |
| Webhook armed | No delivery for a canceled run | Use the receipt the cancel call returns |
| Spent before cancel | Billed, shown in usage | Log billable_amount_usd_micros |
import os, requests
def cancel_tryon(run_id: str):
r = requests.post(
f"https://api.sume.com/v1/format-runs/{run_id}/cancel",
headers={"Authorization": f"Bearer {os.environ['SUME_API_KEY']}"},
timeout=30,
)
r.raise_for_status()
data = r.json()["data"] if "data" in r.json() else r.json()
return data.get("cancel_effect"), data.get("usage", {}).get("billable_amount_usd_micros")The webhook gap
A webhook-driven integration marks a try-on finished when format.run.terminal arrives. Cancel is the one path where nothing arrives, so a row you mark as canceled yourself would otherwise sit in a running state forever. Write the canceled state from the cancel response, and let the webhook handle the completed and failed cases.
Also handle the race. A shopper can leave just as a run finishes, so the cancel returns no_op and the webhook arrives anyway. Make your handler idempotent on run_id, which is stable across retries and is the dedupe key, and treat the later of the two events as authoritative only if it moves the row forward.
Do not poll a canceled run waiting for a terminal webhook. Fetch the receipt once from GET /v1/format-runs/{run_id} if you want the final usage figure.
When to cancel and when to let it finish
A useful rule is to cancel when the output is no longer wanted and nothing else depends on it. If the result will still be useful later, for example the shopper might come back, let it finish and keep the URL, since you will have paid for most of it anyway. The Sume result URLs are durable media.sume.com links.
For retry after a failure without double charging, reuse the same Idempotency-Key as in the double-click post, and to extend a finished run use previous_run_id as described in the continuation errors post. For catalogue-scale work, see the bulk post.
Logging what a cancelled try-on cost
Record three numbers for every run: when it was created, whether it ended completed, failed or canceled, and the billable amount from the receipt. After a week you can answer a useful question: how much did abandoned try-ons cost, and would a lower spend cap or an earlier cancel have saved it. Sume gives usage.billable_amount_usd_micros in millionths of a dollar, so divide by one million before you show it.
If the abandoned share is high, change the UI before you change the code. Show progress, set an expectation of how long a try-on takes, and let the shopper continue browsing while it runs, then notify them. A shopper who knows a result is coming leaves less often.
Sources
Related posts
More in Developers
- Short ends before the voice: timeline_gap_filled and last-frame hold
When Timeline clips cover less than the audio, you get a held last frame and a warning, not an error. The two codes, and how to fix them before upload.
- Sora video ids in your database after the shutdown: what to keep
OpenAI's Videos API shut down 2026-09-24. Old video_ids no longer resolve, so store your own file URL and the model used. Schema fields included.
- Keep a source log for Shorts cut from long video: CSV from video trim
A source log shows which long video, timestamp and edit produced each Short. Build one from video trim results using actual_start_seconds.
- source_too_large on trim or filter: the 300 MiB source cap
Sume's video trim and filter refuse a hosted source over 300 MiB (314,572,800 bytes) at submit. What is checked, the free check call, and ways around it.
Written by Sume