Building a mobile app on the Claude or GPT API
A key baked into a mobile binary ends up in someone else's hands sooner or later. A backend-proxy architecture with working Kotlin, Swift, and Node examples.
4 min · · Updated · development
The first question when wiring an LLM into a mobile app isn't "which model," it's "where does the key live." The answer to that shapes the rest of the architecture.
The key should never end up inside an APK or IPA
A fetch_... key is full access to your account balance. Bake it into app code and anyone with apktool or a basic MITM proxy pulls it out in minutes — after that, spend runs against your balance, not your users'. The right shape is a thin backend sitting between the app and api.zukko.pro: the mobile client only talks to your own server, and the server holds the key in an environment variable and makes the call to the gateway.
[Android / iOS] → [your backend] → https://api.zukko.pro/v1/...
This isn't a layer for its own sake: a backend is where you track per-user spend, apply your own limits, cache repeat queries, and swap models without shipping a new app build.
Backend: a minimal Node proxy
// server.js
import express from "express";
const app = express();
app.use(express.json());
app.post("/api/ask", async (req, res) => {
const { message, userId, requestId } = req.body;
const r = await fetch("https://api.zukko.pro/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.ZUKKO_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `${userId}-${requestId}`
},The Idempotency-Key header isn't decorative here: on mobile networks requests drop and get retried far more often than on desktop. The same idempotency key reused within 24 hours guarantees that a retry caused by a timeout doesn't get billed twice. That's why the key is built not from the server's clock (then every retry would get a new key) but from a requestId the app generates once per logical request and sends unchanged on every retry. The key goes in the headers of that specific request, not in the client's default settings — otherwise the next, different request gets a 422 idempotency_conflict.
iOS: calling your own backend
func ask(_ message: String, requestId: String) async throws -> String {
var request = URLRequest(url: URL(string: "https://your-backend.example.com/api/ask")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(["message": message, "userId": currentUserId, "requestId": requestId])
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw AppError.serverError
}
let decoded = try JSONDecoder().decode(AskResponse.self, from: data)
return decoded.reply
}Android: the same thing in Kotlin
suspend fun ask(message: String, userId: String, requestId: String): String {
val body = JSONObject().apply {
put("message", message)
put("userId", userId)
put("requestId", requestId)
}.toString().toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("https://your-backend.example.com/api/ask")
.post(body)
.build()
okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) throw IOException("server error: ${resp.code}")
val json = JSONObject(resp.body!!.string())
return json.getString("reply")
}
}Camera photos — just use vision, there's no separate "mode"
The Claude and GPT models in the catalogue accept images — there's no separate "vision model" to pick, claude-sonnet-5 and gpt-5.6-sol read pictures the same way they read text. glm-5.3 and glm-5.3-flash have no vision, so don't send photo requests to them. For a "snap a photo and ask" flow, encode the picture to base64 on the device and send it to your backend, which builds the right shape for /v1/messages or /v1/chat/completions. Before sending a large photo over mobile data, it's worth running it through the free /v1/messages/count_tokens first to estimate the cost before the request actually goes out.
Cap the damage at the key level, not just in code
The key's page in the dashboard (https://zukko.pro/app/api-keys) lets you set a daily and monthly dollar spend limit and a list of allowed models. For a mobile backend this is the first line of defense: even if a bug in the app loops requests, the counter stops at the limit you set instead of draining the whole balance.
Handling errors specific to mobile traffic
- 429 with
key_daily_limit_reachedorkey_monthly_limit_reached— this is your key's own limit, not a global service limit. Distinguish it from "model is overloaded" in the app's UI — users need a different message for each. - 402 insufficient_balance — the balance ran out before the request ever reached a model, so the failed attempt costs nothing. Worth surfacing as its own screen rather than a generic network error.
- Dropped connections on weak networks — almost always a client timeout, not a token limit. On mobile it's worth keeping the HTTP client's timeout noticeably higher than on desktop, and leaning on
Idempotency-Keyfor retries.
Tracking spend per user
GET /v1/usage returns the charge history with cursor-based pagination — account-wide, with no breakdown by key, let alone by your users. Every regular (non-streaming) response from /v1/chat/completions or /v1/messages carries X-Zukko-Cost-Micros (that request's cost in micro-dollars) and X-Fetch-Balance-Cents (remaining balance after the charge — despite the name, also in micro-dollars); streaming responses don't include these headers. So the per-user counter is easiest to keep yourself: write X-Zukko-Cost-Micros straight into your own spend table on the backend with every response — no separate reconciliation job needed, just a daily check of the total against /v1/usage.
Topping up balance for production
For a production server it's worth setting up a separate administrative top-up process, not tied to one developer's personal card: the dashboard accepts SBP, a bank card via PayPear, crypto, and — since 2026-09-16 — foreign Mastercard EU cards via Fride (a fee of about 13%, amounts $2–$594). The minimum top-up is $1. Keep in mind there is no starting balance on regular signup anymore: that practice was dropped on 2026-09-18 after mass signups with throwaway emails, so a test key for CI or staging needs to be topped up manually before you run automated tests against it. The only free way to get a small amount of balance is subscribing to the Telegram channel @zukkopro, which grants a one-time $0.50 bonus once Telegram is linked in the dashboard.
Support for integration issues
If a gateway response doesn't match the docs, or the backend keeps getting an unexpected error code, the fastest path is describing the exact request and response to support over Telegram at https://t.me/zukkopro?direct, with the request body attached minus the key itself. For a mobile project where production bugs aren't always visible right away, that direct channel is usually faster than guessing from logs alone.
zukko
Pay 25×
less
for Claude Code & ChatGPT
One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.
Related articles
- Prompt Injection: What It Is and How to Protect a Bot and an AgentHow prompt injection works through user input and third-party documents, and which measures limit the damage: data isolation, tool permissions, key limits.
- Your Own Neural Network Proxy on Node.js: Keep the API Key Off the FrontendA small Express server between your frontend and the model API: the key stays on the server, and models, request size and rate are capped.
- DeepSeek API in Python: Requests, Streaming, JSON and Error HandlingReady code for DeepSeek V4.1 Flash and V4 Pro on the OpenAI SDK: a request, streaming, JSON parsing and careful error handling.
- Prompt Engineering Examples: 12 Ready-Made Templates for WorkTwelve prompt templates for everyday tasks: what each one says, why it is built that way, and what a single API call costs.