To add DeepSeek to an iOS or Android app, send the app’s requests to a backend you control and let that backend call the DeepSeek API. Swift and Kotlin only need to speak to your own HTTPS endpoint. The DeepSeek key stays on the server, where you can also control the model, conversation size, and spending.
Start with one text conversation that sends a message and displays an answer. Once that works, add history, cancellation, streaming, and images in that order as your feature needs them. The examples below use the same request and response on both platforms, so the server does not need separate implementations for iOS and Android.
This is a guide to building your own app feature. If you want to install DeepSeek’s official consumer app, use the official download links instead.
Table of Contents
How the mobile app, backend, and DeepSeek fit together
The app handles the conversation screen: the text field, messages, loading state, and Cancel button. Your backend checks who is asking, accepts a small application request, and creates the provider request. DeepSeek generates the answer; your backend turns that result into the response your app expects.
There are two credentials in this arrangement. The app has a short-lived access token from your sign-in system. Your server has the DeepSeek API key. These tokens are not interchangeable. Shipping the DeepSeek key in an IPA, APK, remote configuration value, or cross-platform JavaScript bundle gives users access to a credential that can spend your account balance. Obfuscating the key does not make it a server secret.
You will need a DeepSeek Platform account with an API key and available credit, a backend reachable over HTTPS, and an existing way to sign users into your app. Our API key guide covers the provider account setup. The integration here assumes you already have the mobile project and its sign-in screen; it does not require a special DeepSeek mobile SDK.
Choose the model on the server
The current official model table lists deepseek-flash, backed by DeepSeek-V4.1-Flash, for text and image input, and deepseek-v4-pro for text input. Flash is a reasonable starting point for this chat example because its published token prices are lower. That is a cost-based starting choice, not a claim that it will answer every task better or faster.
Both support thinking and non-thinking modes. The example explicitly disables thinking and limits output to 800 tokens. Those are application choices for a short chat response, not DeepSeek’s maximum capabilities. If your feature needs longer reasoning, change the server configuration and the waiting experience together. Letting the app select arbitrary model names or token budgets makes that policy harder to control.
Build one backend endpoint for both platforms
The app will call POST /v1/mobile/chat with a JSON object containing messages. For the first turn, that array contains one user message. Your backend adds its own system instruction and sends the request to DeepSeek.
{
"messages": [
{
"role": "user",
"content": "Give me three ideas for a weekend walk."
}
]
}
A successful application response has a text message and a Boolean complete. For example, {"message":"Try a riverside path, a local park, or a short woodland trail.","complete":true} illustrates the shape, not a recorded model answer. If DeepSeek stops at the output limit, the route returns the available text with complete: false, so your screen can label it as incomplete.
Install and configure the server
This example uses Node.js 22.12 or newer, Express, jose for signed access-token verification, and express-rate-limit for a small per-user request limit. In an empty server directory, install the dependencies:
npm init -y
npm install express@5 jose@6 express-rate-limit@8
Create a server-only .env file. Fill in the issuer, API audience, and HTTPS JWKS URL from your identity provider’s documentation. The sample accepts RS256-signed JWT access tokens intended for this backend. If your sign-in system uses opaque tokens, another signing algorithm, or a provider-specific validation SDK, replace the authentication function with that system’s documented verification flow. Do not replace it with a shared secret embedded in the phone app.
DEEPSEEK_API_KEY=replace_with_your_server_side_key
AUTH_ISSUER=https://your-identity-provider.example/
AUTH_AUDIENCE=your-mobile-backend-api
AUTH_JWKS_URL=https://your-identity-provider.example/your-documented-jwks-path
PORT=3000
Keep this file out of source control and out of the mobile build. In production, use your hosting platform’s secret configuration. The identity settings must come from your server configuration, never from a URL or issuer supplied by the requesting app.
The text-chat route
Save the following as server.mjs. It verifies the app session, validates a bounded text history, calls DeepSeek, and maps the result to the two response fields above. It uses Node’s built-in fetch and cancellation APIs; no provider SDK is required for this request.
import express from "express";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { rateLimit } from "express-rate-limit";
const required = ["DEEPSEEK_API_KEY", "AUTH_ISSUER", "AUTH_AUDIENCE", "AUTH_JWKS_URL"];
for (const name of required) {
if (!process.env[name]) throw new Error(`Missing ${name}`);
}
const jwksURL = new URL(process.env.AUTH_JWKS_URL);
if (jwksURL.protocol !== "https:") throw new Error("JWKS must use HTTPS");
const jwks = createRemoteJWKSet(jwksURL);
const app = express();
app.disable("x-powered-by");
async function authenticate(req, res, next) {
const token = /^Bearer (\S+)$/i.exec(req.get("authorization") || "")?.[1];
if (!token) return res.status(401).json({ code: "sign_in_required" });
try {
const { payload } = await jwtVerify(token, jwks, {
issuer: process.env.AUTH_ISSUER,
audience: process.env.AUTH_AUDIENCE,
algorithms: ["RS256"],
requiredClaims: ["sub", "exp"],
});
if (typeof payload.sub !== "string" || !payload.sub) {
return res.status(401).json({ code: "invalid_session" });
}
req.appUser = payload.sub;
next();
} catch (error) {
const invalid = [
"ERR_JWT_EXPIRED", "ERR_JWT_CLAIM_VALIDATION_FAILED", "ERR_JWT_INVALID",
"ERR_JWS_INVALID", "ERR_JWS_SIGNATURE_VERIFICATION_FAILED",
"ERR_JOSE_ALG_NOT_ALLOWED", "ERR_JWKS_NO_MATCHING_KEY",
].includes(error.code);
res.status(invalid ? 401 : 503).json({
code: invalid ? "invalid_session" : "sign_in_unavailable",
});
}
}
const chatLimit = rateLimit({
windowMs: 60_000,
limit: 10,
keyGenerator: (req) => req.appUser,
standardHeaders: "draft-8",
legacyHeaders: false,
message: { code: "too_many_requests" },
});
function validMessages(value) {
return Array.isArray(value) && value.length >= 1 && value.length <= 11
&& value.length % 2 === 1
&& value.every((m, i) => m && m.role === (i % 2 ? "assistant" : "user")
&& typeof m.content === "string" && m.content.trim().length > 0
&& m.content.length <= 4000)
&& value.reduce((n, m) => n + m.content.length, 0) <= 16000;
}
app.post("/v1/mobile/chat", authenticate, chatLimit,
express.json({ limit: "32kb" }), async (req, res) => {
if (!validMessages(req.body?.messages)) {
return res.status(400).json({ code: "invalid_messages" });
}
const messages = req.body.messages.map(({ role, content }) => ({ role, content }));
const disconnected = new AbortController();
const onClose = () => { if (!res.writableEnded) disconnected.abort(); };
res.on("close", onClose);
const signal = AbortSignal.any([
disconnected.signal, AbortSignal.timeout(60_000),
]);
try {
const upstream = await fetch("https://api.deepseek.com/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DEEPSEEK_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "deepseek-flash",
messages: [
{ role: "system", content: "Answer the user's question clearly. Use plain text." },
...messages,
],
thinking: { type: "disabled" },
max_tokens: 800,
stream: false,
}),
signal,
});
if (!upstream.ok) {
await upstream.body?.cancel();
const busy = upstream.status === 429 || upstream.status >= 500;
return res.status(busy ? 503 : 502).json({
code: busy ? "ai_temporarily_unavailable" : "ai_configuration_error",
});
}
const data = await upstream.json();
const choice = data.choices?.[0];
const text = typeof choice?.message?.content === "string"
? choice.message.content.trim() : "";
if (!text || !["stop", "length"].includes(choice?.finish_reason)) {
return res.status(502).json({ code: "ai_no_usable_answer" });
}
res.json({ message: text, complete: choice.finish_reason === "stop" });
} catch (error) {
if (disconnected.signal.aborted || res.destroyed) return;
const timedOut = signal.aborted && signal.reason?.name === "TimeoutError";
res.status(timedOut ? 504 : 502).json({
code: timedOut ? "ai_timeout" : "ai_connection_error",
});
} finally {
res.off("close", onClose);
}
});
app.use((error, req, res, next) => {
if (res.headersSent) return next(error);
const tooLarge = error.type === "entity.too.large";
res.status(tooLarge ? 413 : 400).json({
code: tooLarge ? "request_too_large" : "invalid_request",
});
});
app.listen(Number(process.env.PORT || 3000), "127.0.0.1");
Start it with node --env-file=.env server.mjs. The sample listens on loopback at port 3000. Put it behind your host’s HTTPS reverse proxy, or connect the route to your existing backend. The public URL used by the phone must reach that server; localhost on a physical phone points to the phone itself. Use a development HTTPS endpoint with a trusted certificate rather than disabling transport checks across the app.
The route also caps the entire JSON body at 32 KiB; non-ASCII text can reach this byte limit before the character limits. The limits—10 requests per minute per signed-in user, 11 alternating messages, 4,000 characters per message, and 16,000 characters total—are deliberately explicit example policies. Characters are not tokens. Adapt them to the feature and languages you support, and leave room for the output within the model’s context limit. The default rate-limit store is process-local; use a shared store when running multiple instances. Before opening access widely, also enforce feature entitlements, concurrent-request limits, and an account spending budget. Authentication alone does not cap your bill.
The route follows the Chat Completions response contract: a non-empty string is not enough to prove completion. It accepts stop as complete and marks length as partial. Other terminal states, missing content, malformed responses, and provider failures become application errors. It does not execute model-generated actions or return raw provider errors to the phone.
Check the connection before building the chat screen
After signing in through your app’s identity system, send the JSON above to your endpoint with Authorization: Bearer YOUR_APP_ACCESS_TOKEN and Content-Type: application/json. A missing or expired app token should produce 401 before the DeepSeek request is made. A valid session and request should produce the application response shape described above. A real successful generation uses API credit.
If the request fails here, fix this path before debugging SwiftUI or Compose. Check the HTTPS route, audience and issuer, then the server’s DeepSeek key and credit. Record safe operational details such as the upstream status and a request identifier in your monitoring; do not log the authorization header or the user’s conversation. The sample keeps its outward errors small so it cannot accidentally send private provider diagnostics to the device.
Call the backend from an iOS app with Swift
For an iOS 15-or-later target, URLSession supports an asynchronous request that fits this endpoint. The client below includes the request and response types. Replace the example URL with your HTTPS backend and pass a current access token from your existing sign-in flow.
import Foundation
struct ChatMessage: Codable {
let role: String
let content: String
}
struct ChatRequest: Encodable {
let messages: [ChatMessage]
}
struct ChatReply: Decodable {
let message: String
let complete: Bool
}
enum ChatError: Error {
case invalidResponse
case http(Int)
}
struct MobileChatClient {
let endpoint: URL
func send(messages: [ChatMessage], accessToken: String) async throws -> ChatReply {
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.timeoutInterval = 70
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.httpBody = try JSONEncoder().encode(ChatRequest(messages: messages))
let (data, response) = try await URLSession.shared.data(for: request)
try Task.checkCancellation()
guard let http = response as? HTTPURLResponse else {
throw ChatError.invalidResponse
}
guard http.statusCode == 200 else {
throw ChatError.http(http.statusCode)
}
return try JSONDecoder().decode(ChatReply.self, from: data)
}
}
// Call from an async task with your signed-in user's access token.
func askFromIOS(accessToken: String) async throws -> ChatReply {
let client = MobileChatClient(
endpoint: URL(string: "https://your-api.example/v1/mobile/chat")!
)
return try await client.send(
messages: [ChatMessage(role: "user", content: "Give me three ideas for a weekend walk.")],
accessToken: accessToken
)
}
Call askFromIOS from a task started by the Send button. Set the screen to a loading state before the call, then display reply.message. When reply.complete is false, add an “Answer stopped before completion” state instead of displaying an ordinary finished message. Update SwiftUI state on the main actor and restore the Send button in both success and error paths.
Keep a reference to the task so the Cancel button can cancel it. Treat cancellation as a user action rather than a red error banner. Handle HTTP errors separately from decoding or network errors; for a 401, refresh the app session once through your identity SDK or ask the user to sign in again. Apple’s App Transport Security applies to URLSession connections, so serve the backend with valid HTTPS rather than adding a blanket exception.
Call the same endpoint from Android with Kotlin
Android’s networking guide recommends keeping network work off the main thread and describes Retrofit as one option. Add android.permission.INTERNET to your manifest. Add ACCESS_NETWORK_STATE if the app also observes connectivity; these are normal permissions, not a runtime permission dialog.
The example uses Retrofit 3.0.0 and its Gson converter. Add these dependencies to the app module’s Gradle configuration, using mavenCentral() in the project’s repositories:
implementation("com.squareup.retrofit2:retrofit:3.0.0")
implementation("com.squareup.retrofit2:converter-gson:3.0.0")
The Retrofit dependency supplies the OkHttp client used below. Reuse the client rather than rebuilding it for each tap:
import okhttp3.OkHttpClient
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.POST
import java.util.concurrent.TimeUnit
data class ChatMessage(val role: String, val content: String)
data class ChatRequest(val messages: List<ChatMessage>)
data class ChatReply(val message: String, val complete: Boolean)
interface MobileChatApi {
@POST("v1/mobile/chat")
suspend fun send(
@Header("Authorization") authorization: String,
@Body request: ChatRequest
): ChatReply
}
object MobileChat {
private val http = OkHttpClient.Builder()
.callTimeout(70, TimeUnit.SECONDS)
.readTimeout(70, TimeUnit.SECONDS)
.retryOnConnectionFailure(false)
.build()
val api: MobileChatApi = Retrofit.Builder()
.baseUrl("https://your-api.example/")
.client(http)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(MobileChatApi::class.java)
}
// Call from a lifecycle-aware coroutine with a current app access token.
suspend fun askFromAndroid(accessToken: String): ChatReply {
return MobileChat.api.send(
authorization = "Bearer $accessToken",
request = ChatRequest(listOf(
ChatMessage("user", "Give me three ideas for a weekend walk.")
))
)
}
Call askFromAndroid from a lifecycle-aware coroutine, such as a job in your ViewModel. Show the returned message and use complete in the same way as the iOS client. Catch HttpException for HTTP failures and handle connectivity failures separately. If you catch a broad exception, rethrow CancellationException so cancellation continues to work.
Keep the in-flight job and conversation state in the ViewModel so a screen rotation does not send the prompt again. Cancel the job when the user presses Cancel, and prevent duplicate Send taps while it is active. The 70-second client timeouts are example values set slightly above the backend’s 60-second deadline; align them with your proxy and product behavior. They are not a promise about model response time.
Keep enough conversation history for the next question
Chat Completions requests do not acquire conversation memory just because they share an API key. DeepSeek’s multi-round conversation guide shows the client supplying the messages needed for each turn. After a complete reply, append it as an assistant message, then append the next user message and send the resulting array.
{
"messages": [
{"role": "user", "content": "Suggest a short walk for Saturday."},
{"role": "assistant", "content": "A riverside route could work."},
{"role": "user", "content": "How should I prepare if it rains?"}
]
}
That is an illustrative conversation. For this route, retain complete user/assistant pairs and finish with the new user message. Keep at most the five most recent pairs before that new message, while also staying under the character caps. If you remove older context, tell users when a new question may need background again. Do not silently save a canceled or partial answer as if it had completed.
For a private prototype, keeping this bounded history on the device is simple. For cross-device history, store conversations on your backend and authorize every conversation lookup against the signed-in user. The app can then send a conversation ID and new message, while the backend builds the history. Treat client-submitted history as conversational input, never proof of identity, payment, or permission. Tool use and access to private records need their own server checks.
Send only the messages needed for the current task. Longer histories add input tokens and can make requests slower or more expensive; a large context window does not make every old message useful. The section on growing conversation history explains how earlier turns use context and what to retain for the next request.
Add streaming and cancellation when they improve the screen
The first implementation waits for a complete response. Streaming becomes useful when users would otherwise stare at a spinner while a longer answer is generated. It changes how the backend and mobile client read the response, so setting stream: true alone is not a complete change: the JSON decoders above would also need replacing.
DeepSeek sends Server-Sent Events containing text deltas, a terminal finish_reason, and a data: [DONE] marker. Under the current Chat Completions contract, usage accompanies the final chunk with a non-null finish reason; do not assume it arrives as a separate empty-choices chunk. See the streaming response reference.
Parse that stream on your backend and expose a small event format you own, such as text, done, and error. Buffer incomplete network chunks until a full SSE event arrives, ignore keep-alive comments, and append only the answer text you intend to show. A socket closing without a terminal result should leave the answer marked incomplete. If you later enable thinking, decide explicitly whether reasoning content belongs in your interface; it is a separate field.
On iOS, URLSession’s asynchronous byte APIs can receive incremental data; on Android, use a streaming HTTP response body or an SSE-capable client. Prevent the reverse proxy from buffering the whole response. Keep the mobile connection authenticated, maintain an idle timeout as well as a total deadline, and stop pending upstream work when the client disconnects. The non-streaming server above already requests an upstream abort on disconnect.
A Cancel button should stop local rendering immediately and cancel the request. It does not prove that the provider instantly stopped inference or that no tokens will be billed. If the product must continue work while the app is suspended, use a backend job with an authorized status endpoint; do not rely on an open foreground connection surviving indefinitely.
Add photos or screenshots as a separate feature
The current Vision guide supports JPEG, PNG, GIF, and WebP with deepseek-flash. The old Vision Exp model name is a legacy alias, not a separate model you need to select for a new feature. The text-only route above deliberately rejects image blocks; add a dedicated media route rather than letting arbitrary provider payloads pass through it.
A practical mobile flow is: select an image, show what will be sent, upload the file to your authenticated backend, validate it there, and send the approved image together with the user’s question. Use the platform’s photo picker where appropriate. Avoid building large Base64 strings on the UI thread; a binary multipart upload to your own server is usually easier to bound.
On the server, verify the decoded format, byte size, dimensions, and any animation policy. Preserve orientation and remove location metadata unless the feature needs it. Choose a sensible product limit—for example, one image within a few megabytes for a simple question—rather than exposing the provider’s maximum allowance as a default mobile workflow. That example limit is your policy, not an official requirement.
| Provider limit | Current documented value | What it means for the app |
|---|---|---|
| Inline request body | 48 MiB | Base64 data adds transport size; enforce a lower upload budget. |
| Single URL or Base64 image | 32 MiB | Validate actual bytes before forwarding. |
| Single image referenced by file_id | 64 MiB | Use a verified, user-owned upload reference. |
| Images per request | 600 | Set a much smaller limit when the feature needs only a photo or two. |
| Total image bytes | 64 MiB without file_id images; up to 200 MiB including them | Check the combined request, not just each file. |
| Image dimensions | 8,192 px per side; 4,096 px for requests with 15 or more images | Inspect dimensions before expensive decoding or inference. |
| External image URL | 8,192 characters; download within 60 seconds | Prefer media your backend controls. |
These are service ceilings from the linked Vision documentation, not recommended mobile settings. In Chat Completions, image blocks belong in a user message. Your server can use an image URL, inline Base64, or an uploaded file_id; an inline file block can use file_data instead of file_id, but not both in the same block.
If images will be reused, the Files API lets the backend upload them once. Keep the resulting provider ID behind an application asset record with an owner, expiry, and deletion policy. Check ownership on every reuse or delete request. An ID supplied by the app is not proof that the current user owns the image. Uploading once also does not make subsequent model inference free; image tokens remain billable input.
If you accept remote URLs, use an allowlist where possible. If your server fetches them, block private and metadata destinations, validate redirects and DNS results, and bound download time and size to prevent server-side request forgery. Keep signed URLs, Base64, and provider file IDs out of crash reports. The image-input guide provides the provider request formats when you implement this extension.
Handle errors without creating retry loops
The phone should explain what the user can do next, while the backend distinguishes account configuration from temporary availability. DeepSeek’s official error reference is the starting point for provider failures. An upstream 401 is about your server’s DeepSeek credential; it should not send the mobile user back to your app’s sign-in screen.
| Situation | Backend behavior | Mobile behavior |
|---|---|---|
| Missing or expired app session | Reject before calling DeepSeek. | Refresh the session once or sign in. |
| Bad messages or oversized upload | Return a clear 400 or 413-style application error. | Ask for a shorter message or smaller file. |
| DeepSeek 400/422, 401, or 402 | Investigate request, credential, or balance; do not retry unchanged. | Show a service error without exposing provider details. |
| Application rate limit | Return 429 with retry information. | Disable rapid resubmission and show when to try again. |
| Provider 429 or 500/503 | Reduce load or use bounded backoff when safe. | Offer a deliberate retry after a pause. |
| Timeout or lost connection | Abort when possible and record request state. | Keep the user’s draft; do not silently resend. |
| Truncated or interrupted answer | Return an explicit partial state or an error. | Label it incomplete instead of presenting it as finished. |
The sample makes one provider attempt. That keeps the first integration predictable: retries at the phone, proxy, and server cannot multiply without you noticing. When adding retries, assign an application request ID and deduplicate your own job/result handling. A request ID does not automatically make a provider generation idempotent. A second attempt can produce a different answer and another charge.
What to finish before releasing the feature
A working endpoint is a useful milestone, but the mobile experience also needs to survive ordinary interruptions. Check losing connectivity, backgrounding the app, rotating the Android screen, canceling halfway through an answer, and pressing Send twice. Keep the user’s unfinished text and give loading, partial, canceled, and failed states distinct behavior. Use accessible labels, scalable text, and screen-reader-friendly progress updates.
For backend checks, use synthetic responses to cover invalid sessions, malformed requests, provider errors, partial output, and disconnects before spending API credit. For images, include wrong formats, oversized dimensions, and attempts to access another user’s asset. These are suggested checks for your implementation, not claims that this article’s examples were tested against a live DeepSeek account.
Apply authorization to saved conversations and media, and keep logs limited to the operational fields you need. Treat model output as untrusted text; do not execute it or render unrestricted HTML. Define retention and deletion for device caches, backend records, uploaded images, and logs, and make the hosted processing clear to users. A provider key must not appear in analytics, crash reports, app binaries, or remote configuration.
Track input, cached input, output, image use, retries, and concurrent work as the feature grows. API usage is separate from the consumer app, and the provider’s account-wide capacity is not a per-user allowance. Review DeepSeek API pricing for USD rates and billing units; hosting, authentication, and storage are additional costs. Put budget enforcement on the backend, where a modified client cannot bypass it.
Frequently asked questions
Can an iOS or Android app call DeepSeek directly?
It can make the HTTP request, but embedding your shared DeepSeek key exposes that credential. For an app funded by your provider account, route requests through your authenticated backend. Storing the same provider key in Keychain or encrypted device storage does not turn it into a server-side secret.
Does this work with Flutter or React Native?
Yes. The application endpoint accepts ordinary HTTPS and JSON, so a Flutter or React Native client can use the same contract. Its authentication, loading, cancellation, and partial-answer states still need implementing. The server-side DeepSeek credential stays out of the mobile bundle regardless of framework.
Can this feature work offline?
The hosted API request requires a network connection. You can preserve drafts and previously saved conversations locally, but generating a new answer offline needs a separately deployed on-device model and runtime. That is a different architecture from the API integration shown here.
Can users upload PDFs or Word documents?
The current Files API described here is for supported images, not a general PDF or DOCX store. For document analysis, your backend must extract appropriate text or render selected pages as images, preserve their order, and apply the same ownership and privacy checks. Do not send a document through the image route just by changing its extension.
Why is there a long wait before the answer appears?
Check which stage is waiting: app authentication, your server queue, the provider request, or response delivery. The sample waits for the whole answer and explicitly disables thinking. If you enable thinking or request longer output, adjust the interface and timeout policy. Streaming can show text earlier once answer deltas arrive, but it does not remove upstream queueing or reasoning time.
Start with the complete text round trip
Get one signed-in user from a Send tap to a clearly displayed answer, then confirm that cancellation, expired sessions, and incomplete responses leave the screen in a sensible state. That gives you a reliable base for both iOS and Android. Add images or streaming when the feature calls for them, keeping the same server-owned credentials, limits, and conversation rules.
