Swift: URLSession async/await for one 30-second Wan 3.0 job

A 28-line main.swift that submits wan-3.0 for 30 seconds, polls with Task.sleep and saves the MP4. Runs on macOS or Linux with swiftc.

3 min readSume
All posts

A single 30-second Wan 3.0 job from Swift is one main.swift file: URLSession with async/await, JSONSerialization for the two fields you need, and Task.sleep between polls. The request below asks for 9:16 at 720p, reserved at $3.75.

main.swift

Top-level await works in main.swift, so no @main type is needed. Compile with swiftc main.swift or run it with swift main.swift.

import Foundation
let key = ProcessInfo.processInfo.environment["SUME_API_KEY"] ?? ""
guard !key.isEmpty else { fatalError("SUME_API_KEY is empty") }
let base = "https://api.sume.com/v1/videos"
func send(_ m: String, _ url: String, _ body: [String: Any]? = nil, idem: String? = nil) async throws -> Data {
    var req = URLRequest(url: URL(string: url)!)
    req.httpMethod = m
    req.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
    if let body {
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        req.httpBody = try JSONSerialization.data(withJSONObject: body)
    }
    if let idem { req.setValue(idem, forHTTPHeaderField: "Idempotency-Key") }
    let (data, resp) = try await URLSession.shared.data(for: req)
    guard (resp as! HTTPURLResponse).statusCode < 300 else { fatalError("request failed") }
    return data
}
func json(_ d: Data) -> [String: Any] { try! JSONSerialization.jsonObject(with: d) as! [String: Any] }
let id = json(try await send("POST", base, [
    "model": "wan-3.0", "prompt": "A slow orbit around a ceramic mug on a sunlit desk",
    "duration": 30, "resolution": "720p", "aspect_ratio": "9:16"], idem: "wan-swift-001"))["id"] as! String
var status = "pending"
while status == "pending" || status == "in_progress" {
    try await Task.sleep(nanoseconds: 5_000_000_000)
    status = json(try await send("GET", "\(base)/\(id)"))["status"] as! String
}
guard status == "completed" else { fatalError("job ended as \(status)") }
try await send("GET", "\(base)/\(id)/content?index=0").write(to: URL(fileURLWithPath: "clip.mp4"))

What to notice

  • The helper accepts an optional body and an optional idempotency key, so the submit and the two reads share one function.
  • fatalError on a status of 300 or above ends the process on a 402, 409 or 429. In an app, map those to user-facing messages instead.
  • A 409 idempotency_conflict means the key was reused for a different payload. Reuse a key only for an exact retry.

Cost of what you pass in

The reserve moves with resolution and duration, nothing else:

Sume list price for wan-3.0, read 2026-10-08
resolution10 s20 s30 s
480p$0.625$1.25$1.875
720p$1.25$2.50$3.75
1080p$2.50$5.00$7.50

Smoke test before the 30-second run

Run the script once with "duration": 2 and "resolution": "480p" before you trust it. Wan 3.0 accepts 2 seconds, and that job reserves $0.125, against $3.75 for the full 720p clip. It proves the key, the polling and the file write for the price of a coffee-sized fraction of one real run. Swift's fatalError will print nothing useful on a bad key, so the cheap run is also your error-path check.

Using it from an app

Do not ship the API key in an iOS or macOS app bundle. Put this code behind your own server, and let the app call your server. For batches of clips, see the existing post on running several submits at once with a task group.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume