Speech to text API in Swift: transcribe audio with URLSession
Transcribe audio in Swift with async URLSession: submit to Sume STT, poll the job, print text. No packages, 28 lines, one cent per audio minute of audio.

To transcribe an audio file from Swift, send one authenticated POST to https://api.sume.com/v1/stt-1.0/transcribe with a public HTTPS audio_url, then poll the job until it is completed and read text and words[] from the result. Sume STT 1.0 is $0.01 per audio minute, so a 2 minute clip is two cents. The script uses Foundation only, with async URLSession calls and top-level code in main.swift.
The endpoint is a job API, not a streaming one: you get a job id back and ask for the result when it is ready. That is the right shape for recorded files, and it keeps the client small.
The request and the result
The body needs audio_url. Optional fields are language_code (a hint such as en or ko; omit it for auto-detect), duration_seconds (1 to 600, which lets Sume reserve the right amount; omit it and Sume reserves one minute), segmentation for sentence rows, and metadata, which is stored with your job and never sent to the provider. Send an Idempotency-Key header so a retry does not create a second paid job.
A submit returns 202 with request_id, which is the job id. Poll GET /v1/jobs/{id}/status with a pause between reads, and stop on completed, failed or canceled. Then GET /v1/jobs/{id}/result returns text, language_code, words[] with word, start and end in seconds from the audio start, and segments[] if you asked for them.
| Step | Call | In the script |
|---|---|---|
| Submit | POST /v1/stt-1.0/transcribe | call("POST", ...) with key |
| Wait | GET /v1/jobs/{id}/status | while loop, Task.sleep 2 s |
| Read | GET /v1/jobs/{id}/result | print(text) |
The script
Save it as main.swift and run swiftc main.swift -o stt && ./stt https://media.sume.com/artifacts/artf_demo/clip.wav stt-001 on macOS or Linux, with SUME_API_KEY exported.
import Foundation
func call(_ method: String, _ path: String, _ body: [String: Any]? = nil, key: String) async throws -> [String: Any] {
var req = URLRequest(url: URL(string: "https://api.sume.com" + path)!)
req.httpMethod = method
req.setValue("Bearer " + (ProcessInfo.processInfo.environment["SUME_API_KEY"] ?? ""), forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue(key, forHTTPHeaderField: "Idempotency-Key")
if let body { req.httpBody = try JSONSerialization.data(withJSONObject: body) }
let (data, res) = try await URLSession.shared.data(for: req)
guard let code = (res as? HTTPURLResponse)?.statusCode, code < 300 else {
throw NSError(domain: String(data: data, encoding: .utf8) ?? "", code: 1)
}
return try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
}
let args = CommandLine.arguments
let job = try await call("POST", "/v1/stt-1.0/transcribe",
["audio_url": args[1], "duration_seconds": 120], key: args[2])
let id = job["request_id"] as! String
var status = ""
while status != "completed" {
try await Task.sleep(nanoseconds: 2_000_000_000)
status = try await call("GET", "/v1/jobs/\(id)/status", key: args[2])["status"] as? String ?? ""
if status == "failed" || status == "canceled" { fatalError("job ended \(status)") }
}
let result = try await call("GET", "/v1/jobs/\(id)/result", key: args[2])
print(result["text"] as? String ?? "")Swift details worth knowing
Top-level await works in a main.swift file, which is why the script has no @main type. In a library or an app, move the body into an async function and call it from a Task.
Use JSONSerialization for a quick script, or Codable structs if you want typed results. The result fields are text, language_code, words (each with word, start, end, and sometimes type) and segments. words is always present on an STT result, and may be empty.
On an iOS or macOS app, do not ship an API key inside the binary. Put your server between the app and Sume and let the server hold SUME_API_KEY. Only a server should call Sume with a bearer token that spends your balance.
The script reads SUME_API_KEY from the process environment and takes the audio URL and an idempotency key as its two arguments. It throws on a non-2xx response with the body in the error, and calls fatalError if the job ends failed or canceled.
Limits and prices
One job takes at most 10 minutes of audio. For longer recordings, cut the audio into slices of up to 600 seconds and send one job per slice, adding each slice's start offset to its word times when you merge. The audio must be at a public HTTPS URL, and Sume media URLs are the preferred source. If the audio is inside a video, audio detach extracts a 16 kHz mono WAV for $0.01 per job.
If the request is refused for balance, the API returns 402; a changed body under a reused idempotency key returns 409; a rate limit returns 429. Do not resubmit a paid request just because your own process timed out, because the job may still be running. Read the status first, as the jobs docs advise.
Keep the returned job id next to your own record of the file. If a result looks wrong later, that id is what lets you fetch the same job again without paying for a second transcription, and the metadata object you sent at submit time is stored with it, so you can tag each job with your own file or ticket id and find it again.
Sources
Related posts
More in Developers
- Split a 31 or 61 second video into valid Sume jobs: Python by model
A 31 or 61 second video cannot be 30 s plus 1 s on Wan 3.0 or Seedance 2.5, since the last job is under the minimum. Split evenly with this Python script.
- Split a 181-second Reel into valid AI video jobs (even split)
Greedy 30-second jobs leave a 1-second tail no model accepts. An even split gives 7 Seedance 2.5 jobs or 19 Omni jobs. Python planner and prices at 720p.
- Spot-check burned-in captions with video frames at STT word times
Check captions on a rendered video by pulling stills at word midpoints from a Sume STT result with POST /v1/video-frames, then compare text to speech.
- SQLite: add a sume_job_id column to rows that used sora-2 snapshots
OpenAI removed the Videos API and every sora-2 snapshot on Sept 24, 2026. A migration that tags old rows, then re-renders each once through Sume.
Written by Sume