Go client for a ported Sora worker: submit, poll, download
A standard-library Go program that submits to Sume POST /v1/videos, polls until the job is terminal, and saves the mp4. Replaces a Go wrapper around Sora.

If a Go service called OpenAI's Videos API, the port to Sume is one HTTP client: POST a JSON body to https://api.sume.com/v1/videos, poll the returned polling_url until the status is no longer pending or in_progress, then download the first entry of unsigned_urls. The program below does that with the standard library only and no SDK.
OpenAI's deprecations page lists the Videos API and the sora-2 model ids as removed on 2026-09-24 and names no replacement, so there is no drop-in client to swap in. You write the thin client once and keep the model id in config.
What changes in the wire contract
Three things matter to a Go caller. The submit returns 202 with a bare object, not an envelope. The poll uses OpenRouter-shaped status words. And the download link is a field on the finished job, so there is no separate file-id call.
Sume's own docs describe this surface as field-for-field OpenRouter-compatible, with a handful of documented differences such as the missing /api path segment and the extra Idempotency-Key header.
| Step | Call | Field you read |
|---|---|---|
| Submit | POST /v1/videos | id, polling_url, status (pending) |
| Poll | GET polling_url | status: pending, in_progress, completed, failed, cancelled |
| Fail | same poll | error (string) when failed |
| Download | GET unsigned_urls[0] | the mp4 bytes |
| Bill | same poll | usage.cost in USD |
The program
Set SUME_API_KEY, save this as main.go, and run go run main.go. The model is gemini-omni-flash-1.1 at 5 seconds, 720p, 16:9, which sits inside that model's documented 3 to 10 second window. The poll sleeps 10 seconds between calls; the docs suggest roughly 30 seconds for production, and shorter is fine for a demo.
package main
import ("bytes";"encoding/json";"fmt";"io";"net/http";"os";"time")
func do(method, url string, body []byte) map[string]any {
req, _ := http.NewRequest(method, url, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("SUME_API_KEY"))
req.Header.Set("Content-Type", "application/json")
if method == "POST" { req.Header.Set("Idempotency-Key", "go-demo-001") }
res, err := http.DefaultClient.Do(req)
if err != nil { panic(err) }
defer res.Body.Close()
var out map[string]any
json.NewDecoder(res.Body).Decode(&out)
return out
}
func main() {
job := do("POST", "https://api.sume.com/v1/videos", []byte(`{"model":"gemini-omni-flash-1.1","prompt":"Slow dolly-in on a ceramic mug, steam rising","duration":5,"resolution":"720p","aspect_ratio":"16:9"}`))
if job["polling_url"] == nil { fmt.Println(job); os.Exit(1) }
for s := job["status"]; s == "pending" || s == "in_progress"; s = job["status"] {
time.Sleep(10 * time.Second)
job = do("GET", job["polling_url"].(string), nil)
}
if job["status"] != "completed" { fmt.Println(job["status"], job["error"]); os.Exit(1) }
res, _ := http.Get(job["unsigned_urls"].([]any)[0].(string))
f, _ := os.Create("clip.mp4"); io.Copy(f, res.Body); f.Close()
fmt.Println("saved clip.mp4, cost", job["usage"])
}
Decisions worth copying
The Idempotency-Key header goes on the POST only. If the process dies after the request leaves but before the response arrives, a restart that sends the same key gets the original job back instead of a second paid one. Derive the key from your own row id, not from a random value, or the protection is gone.
The loop stops on any status that is not pending or in_progress, so completed, failed and cancelled all end it. The code then treats everything except completed as a failure and prints the error field. Do not resubmit a failed job in a tight loop; read the error first, because a bad reference URL and a provider outage need different fixes.
- Keep the base URL and model id in environment variables so the next vendor change is a config edit.
- Add a context deadline around the whole poll loop; the sample trusts the job to finish.
- Store the job id before you start polling, so a restarted worker can resume instead of resubmitting.
Where this stops
The sample decodes into map[string]any to stay short. In a real service, define a struct with json tags for id, polling_url, status, error, unsigned_urls and usage, and check the HTTP status code before decoding. A 402 means the workspace balance is below the reserve, a 429 means rate limited, and a 400 with unsupported_capability means the model does not advertise the value you sent.
For long renders, prefer a callback_url and a signed webhook receiver, and keep this poll loop as the backstop. The Go stdlib verifier post linked below covers the receiving side.
Sources
Related posts
More in Developers
- Go: poll a Sume job with a context deadline, next_poll_after_seconds
A Go net/http poller for Sume jobs: 20-minute context deadline, sleeps set by next_poll_after_seconds, stops on data.terminal. Standard library only.
- gpt-image-1 cutover calendar: 18 days to Oct 23, 57 to Dec 1
A dated plan for the gpt-image-1 shutdown on 2026-10-23: inventory, canary, cut over and cleanup, with the Dec 1 ids on the same Sume migration.
- GPT Image 2.5 1536x1024 preset vs custom sizes on Sume
1536x1024 is an OpenAI preset and passes Sume's custom-size rules too: edges multiples of 16, max edge 3840, ratio up to 3:1, 655,360-8,294,400 pixels.
- Porting to Sume images: 400 unsupported_parameter checklist
Porting an image client to Sume: seed, stream, output_compression and size WxH return 400, and background works only on GPT Image 2.5. A fix for each.
Written by Sume