Claude batch canceled or expired results: billed? Sume cancel billing

Claude does not bill canceled or expired batch requests. Sume bills generation a run finished before a cancel, so a cancel stops spend but never refunds.

5 min readSume
All posts

On Claude's Message Batches API, a request whose result is canceled or expired is not billed, because it never reached the model. On Sume, cancelling a Format run stops further work but generation the run already completed is billed, and usage reports it. If you are porting batch cost logic, the two rules point in opposite directions for in-flight work.

Claude's rules are on its batch processing page. Sume's are in Runs and results and Errors and spend.

What result types does a Claude batch return?

Each request in a finished batch has a result that is succeeded, errored, canceled or expired. The page says a request is canceled if you cancelled the batch before it was sent to the model, and expired if the batch reached its 24-hour expiration first. Neither is billed. Results stay available for 29 days after the batch was created, counted from created_at rather than from when processing ended.

Claude batch result types (read 2026-10-02)
ResultMeaningBilled
succeededThe request ranYes
erroredThe request hit an error; error object includedSee the page for error details
canceledBatch canceled before this request was sent to the modelNo
expired24-hour expiry reached before this request was sentNo

What does cancel do on a Sume run?

A Format run is not one model call; it is an agent turn that submits generation jobs. Sume's cancel is POST /v1/format-runs/{run_id}/cancel, needs formats:write, and is idempotent. The receipt carries cancel_effect: canceled means this call stopped a run in flight, and no_op means it had already finished.

The billing line is explicit in the docs: generation the run completed before the cancel is billed, and a later step failing does not refund it. A 4xx at create, an idempotent 200 replay and a skipped run cost nothing. Check usage.billable_amount_usd_micros on the receipt to see the figure, keeping in mind it excludes the agent's own LLM turn and is a receipt, not an invoice.

What about cancelling inside a bulk run?

Cancelling a child marks that queue item canceled and frees its slot, so the next queued item starts. There is no endpoint to cancel the whole queue, so to stop a queue you cancel its children one by one, and items still queued have no run_id to cancel yet. Plan for that before a large queue: the cheaper control is a per-run generation_spend_cap_usd, which ends a run that would spend past its ceiling.

A canceled run never delivers a webhook, so a webhook-only integration must poll status_url for the final state.

How do I port the cost logic?

Do not copy the Claude rule that cancelled work is free. For Sume, compute spend from the receipt after the run is terminal, whichever way it ended.

import os, requests

def spent_usd(run_id: str) -> float:
    r = requests.get(
        f"https://api.sume.com/v1/format-runs/{run_id}",
        headers={"Authorization": f"Bearer {os.environ['SUME_API_KEY']}"},
        timeout=30,
    )
    r.raise_for_status()
    usage = r.json()["data"].get("usage")
    if usage is None:  # spend could not be read, which is not zero
        raise RuntimeError("usage unavailable, check GET /v1/usage")
    return usage["billable_amount_usd_micros"] / 1_000_000

print(spent_usd(os.environ["RUN_ID"]))

The docs say usage is null when spend could not be read, which is different from 0, so the snippet raises rather than reporting free. For billing records use `GET /v1/usage` and GET /v1/balance, not the receipt.

Sources

Related posts

More in Comparisons

All Comparisons posts

Written by Sume