How to transcribe audio from a mobile app (iOS/Swift, Android/Kotlin, React Native)
There's no AssemblyAI mobile SDK — and you shouldn't ship your API key to a phone anyway. This tutorial shows the right pattern: a thin backend proxy plus lightweight Swift, Kotlin, and React Native clients that record audio and call your server, never AssemblyAI directly.



If you searched for an AssemblyAI mobile SDK, here's the short version: there isn't one. The official SDKs are Python (pip install assemblyai) and JavaScript/TypeScript (npm install assemblyai). No Swift, no Kotlin, no React Native package.
That's not the problem you think it is. The bigger constraint is security: don't ship your API key to client-side code. A mobile binary can be decompiled, and anyone who pulls your key out of it can spend your account balance. So even if a native SDK existed, calling AssemblyAI directly from a phone would be the wrong move.
The right pattern is a thin backend you control. Your mobile app records audio and sends it to your server. Your server holds the API key, talks to AssemblyAI over the REST API, and returns plain text. That's the whole lesson, and it's a good one—you get key security, request logging, rate limiting, and the freedom to swap models later without shipping a new app build.
Here's what you'll build:
- A backend (Node or Python) that receives an audio file, uploads it to AssemblyAI, creates a transcript, polls for the result, and returns the text.
- Three thin clients—Swift, Kotlin, and React Native—that record audio and call your backend, never AssemblyAI.
Prerequisites
- An AssemblyAI account and API key. Get your free API key from the dashboard.
- Node 18+ or Python 3.8+ for the backend.
- Xcode for iOS, Android Studio for Android, or a React Native toolchain—whichever platforms you're targeting.
If you're new to the API, the transcribe an audio file quickstart covers the async flow end to end. This tutorial adapts it for mobile.
Architecture overview
One rule shapes everything: the API key lives on the server, and only the server talks to AssemblyAI.
┌──────────────┐ ┌──────────────────────┐ ┌─────────────────┐
│ Mobile app │ │ Your backend │ │ AssemblyAI │
│ (Swift / │ │ (holds API key) │ │ REST API │
│ Kotlin / │ │ │ │ │
│ RN) │ │ │ │ │
│ │──audio─▶│ POST /transcribe │ │ │
│ │ file │ 1. POST /v2/upload ─┼──bytes─▶ upload_url │
│ │ │ 2. POST /v2/transcript──JSON─▶ transcript id │
│ │ │ 3. GET /v2/transcript/{id}──poll──▶ completed │
│ │◀─text──│ return { text } │ │ │
└──────────────┘ └──────────────────────┘ └─────────────────┘Note what's not in the diagram: the mobile client never sees the API key and never has AssemblyAI's URL. It only knows about your endpoint. If you rotate the key or change models, the app doesn't change.
Why a backend proxy specifically? For pre-recorded audio there's no temporary-token mechanism—temporary tokens exist only for streaming. So for file transcription, the key has to live somewhere trusted, and that's your server.
The AssemblyAI async flow
Everything the backend does is three REST calls against https://api.assemblyai.com. Auth is your API key in the authorization header—no Bearer prefix.
- Upload (optional): POST /v2/upload with the raw audio bytes as the body. Returns { "upload_url": "..." }. Skip this if your audio already lives at a public URL.
- Create transcript: POST /v2/transcript with JSON { "audio_url": "..." }. Returns a transcript object with an id and a status.
- Get result: GET /v2/transcript/{id}. Poll every few seconds until status is completed or error.
The status lifecycle is queued → processing → completed (or error).
One gotcha that bites people on the upload step: the body must be raw bytes, not JSON. If you send a JSON envelope, the upload appears to succeed but transcription fails later with "Transcoding failed." Send the file bytes directly.
Backend implementation
Pick your language. Both versions expose a single POST /transcribe endpoint that accepts a multipart file upload from the mobile client and returns { "text": "..." }. Both read the API key from an environment variable—never hardcode it.
Node (Express)
npm install express multer
export ASSEMBLYAI_API_KEY="your_key_here"
import express from "express";
import multer from "multer";
const app = express();
const upload = multer({ storage: multer.memoryStorage() });
const API_KEY = process.env.ASSEMBLYAI_API_KEY;
const BASE_URL = "https://api.assemblyai.com";
// Poll until the transcript is done or errors out.
async function pollTranscript(id) {
while (true) {
const res = await fetch(`${BASE_URL}/v2/transcript/${id}`, {
headers: { authorization: API_KEY },
});
const transcript = await res.json();
if (transcript.status === "completed") return transcript;
if (transcript.status === "error") {
throw new Error(transcript.error);
}
await new Promise((r) => setTimeout(r, 3000)); // poll every 3s
}
}
app.post("/transcribe", upload.single("audio"), async (req, res) => {
try {
// 1. Upload the raw audio bytes. Body is the buffer, NOT JSON.
const uploadRes = await fetch(`${BASE_URL}/v2/upload`, {
method: "POST",
headers: { authorization: API_KEY },
body: req.file.buffer,
});
const { upload_url } = await uploadRes.json();
// 2. Create the transcript.
const createRes = await fetch(`${BASE_URL}/v2/transcript`, {
method: "POST",
headers: {
authorization: API_KEY,
"content-type": "application/json",
},
body: JSON.stringify({ audio_url: upload_url }),
});
const { id } = await createRes.json();
// 3. Poll for the result and return just the text.
const transcript = await pollTranscript(id);
res.json({ text: transcript.text });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.listen(8080, () => console.log("Listening on :8080"));
Python (FastAPI)
pip install fastapi "uvicorn[standard]" python-multipart requests
export ASSEMBLYAI_API_KEY="your_key_here"
import os
import time
import requests
from fastapi import FastAPI, UploadFile, HTTPException
app = FastAPI()
API_KEY = os.environ["ASSEMBLYAI_API_KEY"]
BASE_URL = "https://api.assemblyai.com"
HEADERS = {"authorization": API_KEY}
def poll_transcript(transcript_id: str) -> dict:
while True:
res = requests.get(f"{BASE_URL}/v2/transcript/{transcript_id}", headers=HEADERS)
transcript = res.json()
if transcript["status"] == "completed":
return transcript
if transcript["status"] == "error":
raise RuntimeError(transcript["error"])
time.sleep(3) # poll every 3s
@app.post("/transcribe")
async def transcribe(audio: UploadFile):
try:
audio_bytes = await audio.read()
# 1. Upload the raw bytes. data=audio_bytes sends them as-is, not JSON.
upload = requests.post(
f"{BASE_URL}/v2/upload", headers=HEADERS, data=audio_bytes
)
upload_url = upload.json()["upload_url"]
# 2. Create the transcript.
create = requests.post(
f"{BASE_URL}/v2/transcript",
headers={**HEADERS, "content-type": "application/json"},
json={"audio_url": upload_url},
)
transcript_id = create.json()["id"]
# 3. Poll and return the text.
transcript = poll_transcript(transcript_id)
return {"text": transcript["text"]}
except Exception as err:
raise HTTPException(status_code=500, detail=str(err))
Run it with uvicorn main:app --port 8080.
A few production notes that apply to both:
- Polling vs. webhooks. Polling every 3 seconds is simple and fine for short clips. For longer audio, hold the HTTP connection open only if your infra tolerates it—otherwise return the transcript id immediately and let the client poll a GET /transcribe/{id} route, or register a webhook so AssemblyAI notifies you when the job finishes.
- Limits. The upload endpoint accepts files up to 2.2 GB. The transcript request body caps at 5 GB, and audio duration must be between 160ms and 10 hours.
- Add features here, not on the client. Want speaker labels or summarization? Add the options to the /v2/transcript JSON body server-side. The app doesn't change.
For every field you can pass, see the submit transcript reference, the upload reference, and the get transcript reference.
Client implementations
All three clients do the same three things: record audio to a file, POST that file to your backend as multipart form data, and show the returned text. None of them know AssemblyAI exists.
Set BACKEND_URL to wherever your server runs. Use http://localhost:8080 for a simulator, or your machine's LAN IP for a physical device.
iOS / Swift
Record with AVAudioRecorder, then upload with URLSession. Add NSMicrophoneUsageDescription to your Info.plist first.
import AVFoundation
final class Transcriber: NSObject, AVAudioRecorderDelegate {
private var recorder: AVAudioRecorder?
private let backendURL = URL(string: "http://localhost:8080/transcribe")!
private lazy var fileURL: URL = {
FileManager.default.temporaryDirectory.appendingPathComponent("recording.m4a")
}()
func startRecording() throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .default)
try session.setActive(true)
let settings: [String: Any] = [
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 1,
]
recorder = try AVAudioRecorder(url: fileURL, settings: settings)
recorder?.record()
}
func stopAndTranscribe() async throws -> String {
recorder?.stop()
let audioData = try Data(contentsOf: fileURL)
let boundary = "Boundary-\(UUID().uuidString)"
var request = URLRequest(url: backendURL)
request.httpMethod = "POST"
request.setValue(
"multipart/form-data; boundary=\(boundary)",
forHTTPHeaderField: "Content-Type"
)
// Build the multipart body under the "audio" field name.
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append(
"Content-Disposition: form-data; name=\"audio\"; filename=\"recording.m4a\"\r\n"
.data(using: .utf8)!
)
body.append("Content-Type: audio/m4a\r\n\r\n".data(using: .utf8)!)
body.append(audioData)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
let (data, _) = try await URLSession.shared.upload(for: request, from: body)
let result = try JSONDecoder().decode(TranscriptResponse.self, from: data)
return result.text
}
}
struct TranscriptResponse: Decodable {
let text: String
}No API key anywhere. The client's entire world is backendURL.
Android / Kotlin
Record with MediaRecorder, upload with OkHttp. Declare RECORD_AUDIO in your manifest and request it at runtime.
import android.media.MediaRecorder
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import org.json.JSONObject
import java.io.File
class Transcriber(private val cacheDir: File) {
private val client = OkHttpClient()
private val backendUrl = "http://10.0.2.2:8080/transcribe" // 10.0.2.2 = host from emulator
private val outputFile = File(cacheDir, "recording.m4a")
private var recorder: MediaRecorder? = null
fun startRecording() {
recorder = MediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioChannels(1)
setOutputFile(outputFile.absolutePath)
prepare()
start()
}
}
fun stopAndTranscribe(): String {
recorder?.apply { stop(); release() }
recorder = null
// Multipart upload under the "audio" field name.
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"audio",
"recording.m4a",
outputFile.asRequestBody("audio/m4a".toMediaType())
)
.build()
val request = Request.Builder().url(backendUrl).post(body).build()
client.newCall(request).execute().use { response ->
val json = JSONObject(response.body!!.string())
return json.getString("text")
}
}
}
Call stopAndTranscribe() off the main thread (a coroutine on Dispatchers.IO works well).
React Native
Record with a library like react-native-audio-recorder-player, then upload with the built-in fetch and FormData:
import AudioRecorderPlayer from "react-native-audio-recorder-player";
const recorder = new AudioRecorderPlayer();
const BACKEND_URL = "http://localhost:8080/transcribe";
export async function startRecording() {
return recorder.startRecorder(); // returns the file path
}
export async function stopAndTranscribe() {
const filePath = await recorder.stopRecorder();
const form = new FormData();
form.append("audio", {
uri: filePath,
name: "recording.m4a",
type: "audio/m4a",
});
const response = await fetch(BACKEND_URL, {
method: "POST",
body: form, // don't set Content-Type; fetch sets the multipart boundary
});
const { text } = await response.json();
return text;
}Because JavaScript is a first-class citizen here, you might be tempted to reach for the assemblyai npm package inside your React Native app. Don't. The Node SDK is built for server environments, and using it on the client means embedding your API key in the bundle—the exact thing we're avoiding. The fetch-to-your-backend pattern above is the correct one for React Native.
Testing and validation
Test the backend on its own before wiring up a phone. Record or grab any short audio file and hit the endpoint with curl:
curl -X POST http://localhost:8080/transcribe \
-F "audio=@sample.m4a"You should get back {"text":"..."} within a few seconds for a short clip. If you don't:
- "Transcoding failed" almost always means the upload body wasn't raw bytes. Confirm you're sending the buffer/bytes directly, not a JSON wrapper.
- 401 Unauthorized means the authorization header is missing or wrong. Check that ASSEMBLYAI_API_KEY is set in the server's environment—and remember, no Bearer prefix.
- The request hangs on long audio because polling holds the connection open. Switch to the return-id-and-poll pattern or a webhook.
Once curl works, point the app at the backend. On a physical device, localhost won't resolve to your dev machine—use its LAN IP (for example http://192.168.1.20:8080/transcribe), and on Android the emulator reaches the host at 10.0.2.2.
Realtime from mobile (next step)
If you need live captions instead of file-at-a-time transcription, AssemblyAI has a streaming API over WebSocket at wss://streaming.assemblyai.com/v3/ws. There's still no mobile-native streaming SDK, but streaming has something file transcription doesn't: short-lived tokens.
Your backend calls GET /v3/token?expires_in_seconds=60 (tokens last 1–600s and are single-use), hands the token to the app, and the app opens the WebSocket with the token as a token query param. The key still never leaves your server; the token is safe to give the client because it expires fast and works once. Audio goes up as mono 16-bit PCM.
That flow is documented in authenticate with a temporary token. For a working streaming client to model your backend on, see real-time transcription in Python.
Next steps
You now have a mobile transcription pipeline that keeps your API key where it belongs. From here:
- Add speech understanding. Speaker diarization, summarization, and more are options on the /v2/transcript body—turn them on server-side and your app gets them for free. Start with what is speech-to-text and the Speech-to-Text product page.
- Harden the backend. Add authentication on your /transcribe route, rate limiting, and request logging—the proxy is the natural place for all of it.
- Explore every parameter. The full API reference documents the complete transcript object.
Get your free API key and ship it.
Frequently asked questions
Is there an official AssemblyAI iOS or Android SDK?
No. The only official SDKs are Python (pip install assemblyai) and JavaScript/TypeScript (npm install assemblyai). For mobile, call the REST API at https://api.assemblyai.com from a backend you control.
Can I call the AssemblyAI API directly from my mobile app?
Technically the REST API works from anywhere, but you shouldn't. Calling it directly means embedding your API key in the app binary, where it can be extracted. Route every request through your own backend instead.
How do I keep my API key safe in a mobile app?
Never ship it to the client. Store it as an environment variable on your server, have the app send audio to your backend, and let the backend attach the authorization header when it calls AssemblyAI. That's the architecture in this tutorial.
Does AssemblyAI support React Native?
There's no React Native SDK. Use fetch with FormData to send audio to your backend, exactly like the web. Avoid the assemblyai npm package on the client—it's a server SDK and would expose your key.
Can I do realtime transcription on mobile?
Yes, over the streaming WebSocket at wss://streaming.assemblyai.com/v3/ws. There's no mobile streaming SDK, so your backend mints a one-time token via GET /v3/token?expires_in_seconds=60 and the app connects with it. Audio is mono 16-bit PCM. See the temporary token docs.
What are the file size and duration limits?
The upload endpoint accepts files up to 2.2 GB. The transcript request body caps at 5 GB, and audio must run between 160ms and 10 hours.
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.

