Assert the Sume usage ledger in CI: canceled job debits zero
After canceling a queued Sume job, read GET /v1/usage?job_id= and assert summary.final is true and debited_usd_micros is 0. A short Python CI check.

Call GET /v1/usage?job_id=... after canceling a queued job and assert two fields of the summary: final is true and debited_usd_micros is 0. The Usage docs define final as true when no hold is open, and debited_usd_micros as what the wallet deducted, so together they say the reservation was released and nothing was spent.
That is a ledger-level test of your cancel path, and it catches the mistake that unit tests cannot: a cancel that your code sent but that arrived too late.
What the ledger statuses mean
The docs list three row statuses. A reserved row is an estimate held before provider execution, captured is billable usage after a successful completion, and refunded is a reservation released after a failure or a cancellation before capture. A refunded row keeps its hold amount in billable_amount_usd_micros, so you cannot read spend off that field alone; use the summary.
The docs also say never to sum the rows yourself. The summary folds every row the scope caused, and limit only caps the listed rows.
| Field | Meaning | Assertion after a clean cancel |
|---|---|---|
| debited_usd_micros | Amount the wallet deducted | equals 0 |
| held_usd_micros | Holds still open, not spend | equals 0 |
| refunded_usd_micros | Holds given back after failure or cancel | greater than 0 for a paid job |
| final | True when no hold is open | is true |
The test
Pass a job id that you submitted and that is still queued. The script cancels it, reads the usage for that job, and asserts. If generation already started, Sume answers 409 with details.cancelable: false, and the script exits with that body, which is the right failure: the job will run and be billed.
import json, os, sys, urllib.error, urllib.request
BASE = "https://api.sume.com"
HEAD = {"x-api-key": os.environ["SUME_API_KEY"], "Content-Type": "application/json"}
def call(method: str, path: str, body: dict | None = None) -> dict:
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data, HEAD, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
except urllib.error.HTTPError as e:
sys.exit(f"{method} {path} -> {e.code} {e.read().decode()}")
job_id = sys.argv[1] # a job you submitted that is still queued
call("POST", f"/v1/jobs/{job_id}/cancel")
summary = call("GET", f"/v1/usage?job_id={job_id}&limit=20")["summary"]
assert summary["final"] is True, "a hold is still open"
assert summary["debited_usd_micros"] == 0, summary["debited_usd_micros"]
print("canceled, nothing debited, refunded:", summary["refunded_usd_micros"], "micros")Making it reliable in CI
Submit a job you know will queue, for example by filling the concurrency slots in a dev workspace first, otherwise the job can start before your cancel lands. The admission docs explain that a job waits as queued while the workspace is at its concurrency limit.
Run this on the dev host with a development key, with a spend cap on the submit, and keep it out of pull request checks that run in parallel; a nightly schedule is enough to notice a regression in your cancel handling.
The money values are integers in millionths of a dollar, so compare them with == and never convert to floats in a test.
Sources
Related posts
More in Developers
- AsyncAPI 3.1 for your Sume webhook receiver: a 29-line spec
Describe the receiver Sume calls in AsyncAPI 3.1.0: one receive operation, two signature headers, three job events. Parsed with the AsyncAPI parser.
- Idempotency keys for Sume batches: item index plus payload hash
A deterministic Idempotency-Key makes a rerun return the original jobs, and a changed payload gets a new key, avoiding 409 idempotency_conflict.
- Bun 1.4.3 ERR_PROXY_TUNNEL: is it a Sume error or your proxy?
Bun 1.4.3 rejects fetch with ERR_PROXY_TUNNEL on a failed CONNECT. A Sume error always carries error.request_id; use it to tell the two apart.
- Bun 1.4.3 fake timers: test a Sume poll loop with request timeouts
Bun 1.4.3 fixes advanceTimersByTime spinning with AbortSignal.timeout. Test a Sume job poll loop that honors next_poll_after_seconds without real waits.
Written by Sume