C# HttpClient for Sume video jobs: a Sora port in 30 lines
A .NET top-level program that posts to Sume /v1/videos with an Idempotency-Key, polls to a terminal status, and saves the mp4. For C# teams that wrapped Sora.

A .NET service that called OpenAI's Videos API needs one new client: an HttpClient that posts to https://api.sume.com/v1/videos, polls the polling_url from the 202 response, and downloads unsigned_urls[0] when the status reads completed. The program below is a complete top-level console app for .NET 8 or later, with no NuGet packages.
OpenAI's deprecations page records the Videos API and the sora-2 ids as removed on 2026-09-24 with no replacement named, so the port is yours to write. Sume's /v1/videos keeps the OpenRouter field names, which makes the client short.
Mapping the pieces to System.Net.Http
Auth is a Bearer header on DefaultRequestHeaders. The Idempotency-Key is a per-request header, so it goes on the HttpRequestMessage, not on the shared client. Reading the poll as JsonElement avoids defining classes for a payload that grows new fields.
| Status | Terminal | What the code does |
|---|---|---|
| pending | no | keep polling |
| in_progress | no | keep polling |
| completed | yes | download unsigned_urls[0] |
| failed | yes | print the error string, exit 1 |
| cancelled | yes | exit 1, nothing to download |
The program
Export SUME_API_KEY, then dotnet run in a console project. The submit checks IsSuccessStatusCode first, because a 400, 402 or 429 body has an error object and no polling_url, and GetProperty would otherwise throw a KeyNotFoundException that hides the real message.
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("SUME_API_KEY"));
var body = new { model = "gemini-omni-flash-1.1", prompt = "Slow dolly-in on a ceramic mug, steam rising", duration = 5, resolution = "720p", aspect_ratio = "16:9" };
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.sume.com/v1/videos") { Content = JsonContent.Create(body) };
req.Headers.Add("Idempotency-Key", "dotnet-demo-001");
var res = await http.SendAsync(req);
var submit = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!res.IsSuccessStatusCode) { Console.Error.WriteLine(submit); return 1; }
var pollUrl = submit.GetProperty("polling_url").GetString()!;
JsonElement job;
string status;
do
{
await Task.Delay(TimeSpan.FromSeconds(10));
job = await http.GetFromJsonAsync<JsonElement>(pollUrl);
status = job.GetProperty("status").GetString()!;
} while (status is "pending" or "in_progress");
if (status != "completed")
{
Console.Error.WriteLine(job.TryGetProperty("error", out var e) ? e.ToString() : status);
return 1;
}
var url = job.GetProperty("unsigned_urls")[0].GetString()!;
await File.WriteAllBytesAsync("clip.mp4", await new HttpClient().GetByteArrayAsync(url));
Console.WriteLine($"saved clip.mp4, usage {job.GetProperty("usage")}");
return 0;
Why the key goes on the request
HttpClient is meant to be shared, and headers set on DefaultRequestHeaders apply to every call. An idempotency key set there would be reused for the next, different video and return the first job again. Docs say a replay with the same key returns the original job, and a reuse with a different body is a conflict, so scope the key to one logical render, for example your database row id.
The download uses a second plain HttpClient with no Authorization header. The poll response already carries a URL for the file, and the Sume docs example fetches it without credentials. If your own policy says every outbound call must carry the key, test that on one clip before you rely on it.
Hardening checklist
The sample is deliberately blunt. For production, move the three timeouts into configuration and put the poll in a BackgroundService that stores the job id first and resumes after a restart.
- Wrap the poll in a CancellationTokenSource with a deadline; renders usually take 30 seconds to several minutes per the docs.
- Catch HttpRequestException on the poll and keep going; a transient network error is not a failed job.
- Log usage.cost per job so finance can see spend per model.
- Prefer callback_url with a signed webhook for anything that takes minutes, and keep this poll as the backup.
Sources
Related posts
More in Developers
- curl and jq: poll a Seedance 2.5 clip from a bash script
A bash script with curl and jq that submits a 30-second Seedance 2.5 job to Sume, polls every 30 seconds and saves the MP4, with costs at three resolutions.
- Smoke-test a video API key with curl: a $0.11 Omni clip
A 19-line curl and jq script: submit a 3-second 360p Gemini Omni Flash 1.1 job, poll it, save the MP4. At $0.0375 a second the test costs $0.1125.
- curl and jq video step for CI with exit codes 0, 2 and 3
A bash script that submits to Sume POST /v1/videos, polls with jq, and exits 2 on a rejected submit and 3 on a failed render so a CI job can tell them apart.
- curl a 30-second Sume clip: --speed-limit beats a blunt --max-time
A fixed --max-time kills a slow but healthy 30 s video download. Use --speed-limit with --speed-time to abort only stalled transfers, plus --retry.
Written by Sume