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.

4 min readSume
All posts

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.

What the Go client reads from /v1/videos (Sume docs, read 2026-10-05)
StepCallField you read
SubmitPOST /v1/videosid, polling_url, status (pending)
PollGET polling_urlstatus: pending, in_progress, completed, failed, cancelled
Failsame pollerror (string) when failed
DownloadGET unsigned_urls[0]the mp4 bytes
Billsame pollusage.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

All Developers posts

Written by Sume