DocsIntegrations
iOS with Swift
A SwiftUI app that uploads a video straight to Videxo with an upload URL from your backend, has your backend create the job and follows it to the downloads.
What you build#
A SwiftUI app that picks a video, uploads it straight to Videxo and shows the outputs when the
job ends. The app never holds an API key: it talks only to your backend, which creates the upload
and the job with its key. The backend is any server guide in this section; they all serve the same
routes (POST /api/uploads, POST /api/jobs, GET /api/jobs/{id} and
GET /api/jobs/{id}/outputs/{alias}). The app uses URLSession with plain HTTP to your backend.
The whole project: videxo-swift.zip, or browse it in the examples repository (opens in a new tab).
Before you start#
- A backend from a server guide, running: Express,
Next.js, SvelteKit,
FastAPI, Go or any other in this
section. It holds
VIDEXO_API_KEYandVIDEXO_WEBHOOK_SECRET. - Xcode, and an iOS 16 or newer app target (the code uses Swift concurrency).
Never put the API key in the app: anyone can read it out of the app bundle.
Install#
Nothing to install: URLSession and SwiftUI come with iOS. Create an iOS App project in Xcode,
add the three Swift files of the example, and set baseURL in VidexoExampleApp.swift to your
backend.
Create a job#
The app asks your backend to create the job from the upload; the backend sends the JSON body to Videxo with its key and an idempotency key. Then the app reads the job every 2 seconds until it ends:
/// Asks the backend to create the job from the upload: "transcode" (one MP4) or "jobs" (several outputs).
func createJob(inputURL: String, kind: String) async throws -> Job {
try await send("POST", "api/jobs", body: ["kind": kind, "input_url": inputURL])
}
/// Reads the job every 2 seconds until it ends. A push from your backend (after its webhook) can replace the polling.
func waitForJob(_ job: Job) async throws -> Job {
var current = job
while current.isActive {
try await Task.sleep(nanoseconds: 2_000_000_000)
current = try await send("GET", "api/jobs/\(current.id)")
}
return current
}Copy failed. The text is selected; copy it with your keyboard.
Every call goes through one send function. An error response is problem details, as
Videxo returns them and as the backends pass them on; it becomes a Problem error whose
description the app shows:
private func send<T: Decodable>(_ method: String, _ path: String, body: [String: Any]? = nil) async throws -> T {
var request = URLRequest(url: baseURL.appendingPathComponent(path))
request.httpMethod = method
if let body {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
}
let (data, response) = try await session.data(for: request)
try check(data, response)
return try JSONDecoder().decode(T.self, from: data)
}
/// An answer outside 2xx becomes its problem details, so the app can show the detail.
private func check(_ data: Data, _ response: URLResponse) throws {
guard let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) else { return }
throw (try? JSONDecoder().decode(Problem.self, from: data))
?? Problem(title: "The server answered with an error.", detail: "HTTP \(http.statusCode)", code: nil)
}Copy failed. The text is selected; copy it with your keyboard.
Receive the webhook#
On your backend, never in the app: Videxo signs each webhook with your account's secret, which only the backend holds. The server guides show the route and the signature check. To tell the app without polling, have that route send a push notification (APNs) to the device that started the job, and let the app read the job once when the push arrives.
Upload a file#
The app asks your backend for a one-time upload URL and sends the file there with PUT. The URL
holds no API key and takes one PUT, so the app never retries it: a failed PUT throws, and the
next try creates a new upload. The file goes straight to Videxo, not through your backend:
/// Asks the backend for a one-time upload URL, then sends the file straight to Videxo with it.
/// The upload URL holds no API key and takes one PUT, so the app never retries the PUT: when
/// it fails this throws, and the next call creates a new upload.
func upload(file: URL, contentType: String) async throws -> String {
let size = try FileManager.default.attributesOfItem(atPath: file.path)[.size] as? Int ?? 0
struct Created: Decodable {
let input_url: String
let upload_url: URL
}
let created: Created = try await send(
"POST", "api/uploads",
body: ["filename": plainName(file.lastPathComponent), "size_bytes": size, "content_type": contentType])
var put = URLRequest(url: created.upload_url)
put.httpMethod = "PUT"
put.setValue(contentType, forHTTPHeaderField: "Content-Type")
let (data, response) = try await session.upload(for: put, fromFile: file)
try check(data, response)
return created.input_url
}Copy failed. The text is selected; copy it with your keyboard.
The screen runs the whole flow: pick, upload, create, wait.
private func process(_ file: URL) async {
running = true
defer { running = false }
problem = nil
job = nil
// A file from the picker is outside the app's sandbox until it asks for access.
let access = file.startAccessingSecurityScopedResource()
defer { if access { file.stopAccessingSecurityScopedResource() } }
let contentType = UTType(filenameExtension: file.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
do {
status = "Uploading"
let inputURL = try await backend.upload(file: file, contentType: contentType)
let created = try await backend.createJob(inputURL: inputURL, kind: kind)
status = "Job \(created.id): \(created.state)"
let ended = try await backend.waitForJob(created)
status = "Job \(ended.id): \(ended.state)"
job = ended
} catch {
status = ""
problem = error.localizedDescription
}
}Copy failed. The text is selected; copy it with your keyboard.
See Files for the size limit and how long an upload lasts.
Download the outputs#
Each output opens your backend's download route, which redirects to a fresh signed link:
/// The backend's download route: it redirects to a fresh signed link. Opened with a `Link`, the
/// browser follows it; with `session.download(from:)` the app saves the file itself.
func downloadURL(job: Job, output: Output) -> URL {
baseURL.appendingPathComponent("api/jobs/\(job.id)/outputs/\(output.alias)")
}Copy failed. The text is selected; copy it with your keyboard.
Run it#
- Start a backend from a server guide on port 3000.
- Run the app in the iOS simulator;
http://localhost:3000reaches the backend on your Mac. For a device, use the backend'shttpsaddress. Plainhttptolocalhostin the simulator may need an App Transport Security exception for local networking. - Pick a video and choose the job.
Next steps#
- Swift SDK: uploads in a background session and job status through a client token, so the app needs no backend route per step.
- Android with Kotlin: the same app for Android.
- Webhooks: what your backend receives when a job ends.
- Files: upload limits and how long links last.