Ruby Net::HTTP: create a Sume bulk queue and poll to an exit code
A 30-line Ruby script with only the standard library: create a Sume bulk queue from items.json, back off the poll, skip 429 and 503, and exit 1 on failed items.

Ruby can drive a Sume bulk queue with net/http and json alone. Create the queue with POST /v1/formats/{handle}/{slug}/bulk-runs, check for 202, then poll the receipt's status_url with a doubling gap until status is completed, and turn counts.failed plus counts.canceled into the process exit code. The script below does that in 30 lines and was run against a stub server before it was written up here.
Keep the queue id it prints. There is no endpoint that lists queues, so that line is the only record you have of which queue holds which rows.
The script
Set SUME_API_KEY, SUME_FORMAT (handle/slug) and BATCH_KEY. The key needs formats:write to create and formats:read to poll. items.json is an array of item bodies, each with at least one of instruction, input, previous_run_id or attachments.
require "json"
require "net/http"
BASE = URI(ENV.fetch("SUME_BASE", "https://api.sume.com/v1/"))
KEY = ENV.fetch("SUME_API_KEY")
def call(req)
Net::HTTP.start(req.uri.host, req.uri.port, use_ssl: req.uri.scheme == "https", read_timeout: 30) { |h| h.request(req) }
end
def authed(req, idem = nil)
req["Authorization"] = "Bearer #{KEY}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idem if idem
req
end
post = authed(Net::HTTP::Post.new(BASE + "formats/#{ENV.fetch('SUME_FORMAT')}/bulk-runs"), ENV.fetch("BATCH_KEY"))
post.body = JSON.generate(concurrency: 4, items: JSON.parse(File.read("items.json")))
res = call(post)
abort "create #{res.code}: #{res.body}" unless res.code == "202"
q = JSON.parse(res.body)["data"]
puts "queue #{q['id']}"
gap = 15
until q["status"] == "completed"
sleep gap
gap = [gap * 2, 60].min
res = call(authed(Net::HTTP::Get.new(URI(q["status_url"]))))
next if %w[429 503].include?(res.code)
abort "poll #{res.code}: #{res.body}" unless res.code == "200"
q = JSON.parse(res.body)["data"]
end
puts q["counts"].to_json
exit(q["counts"].values_at("failed", "canceled").sum.zero? ? 0 : 1)Why it stops where it stops
Net::HTTP does not raise on 4xx or 5xx; it hands you the response. That is why the script checks res.code itself. On create, anything other than "202" aborts with the body printed, because every create error is final: the API charges nothing for a 4xx, and retrying a 403 insufficient_scope in a loop is the most common and most expensive mistake on this surface. A 400 invalid_request names the bad item in details.index.
On poll, 429 and 503 are skipped with next, and the doubling gap does the waiting. The docs say a queue keeps draining through those. Any other poll status aborts. The queue is not canceled by that, because it is a server-side list: stopping your Ruby process only stops the poller.
| Status | Code | Cause |
|---|---|---|
| 400 | invalid_request | concurrency outside 1 to 16, items empty or above 100, or a bad item (details.index) |
| 402 | insufficient_credits | The wallet cannot fund the run; fund it and send the same key again |
| 403 | insufficient_scope or workspace_key_required | Key lacks formats:write, or a personal key was used on a team Format |
| 409 | idempotency_conflict | This key was used with a different body; details.queue_id names the original |
| 413 | payload_too_large | Request body above 4 MiB |
Reading the finish
completed means every item is terminal, and not that every item worked. The exit status is the check that matters. When it is 1, open GET /v1/format-runs/{run_id} for each failed item. The item error is only format_run_failed, format_run_canceled, or, for a child that never started, the create-run error with run_id set to null.
If you want per-item output instead of a single code, q["items"] holds index, status, run_id and error for each row, in the order you sent them.
Two limits are worth knowing before you load a large file. The concurrency field you send is the window of children running at once, from 1 to 16, and the plan's write limit is per minute and separate from it: one bulk create counts as one write however many items it carries. Each item is checked as a single-run body, so an item over 2 MiB of input, or with more than 64 top-level input keys, fails the whole create with 400 and the index of the bad item before any queue exists.
For a rerun after a partial failure, build a new items.json from only the failed indexes and use a new BATCH_KEY. Reusing the old key with a different body returns 409 idempotency_conflict.
Sources
Related posts
More in Developers
- SHA-256 the batch body into the Sume idempotency key for bulk chunks
Derive each bulk chunk's Idempotency-Key from a hash of its items so replays reuse the queue and edited rows get a new key instead of a 409 conflict.
- Should my backend call Sume over hosted MCP or the REST API?
REST from a backend, hosted MCP from an agent client. Where they differ: auth, wait limits, REST-only Image 1.0 and Video 1.0, and write budgets.
- Retiring a webhook endpoint: Sume runs already carrying it still POST
Any run created with a webhook_url can POST when it ends, even after you decommission the endpoint. Retries run 10 times, and the receipt holds the real result.
- Speaking rate in words per minute from Sume STT word times (Python)
Compute words per minute for a recording from the words[] start and end times Sume STT returns, plus a per-minute pacing table. Offline Python, no API call.
Written by Sume