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.

4 min readSume
All posts

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.

Sume /v1/videos statuses and the C# branch they take (docs.sume.com/models/videos, read 2026-10-05)
StatusTerminalWhat the code does
pendingnokeep polling
in_progressnokeep polling
completedyesdownload unsigned_urls[0]
failedyesprint the error string, exit 1
cancelledyesexit 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

All Developers posts

Written by Sume