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

bash
// 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

bash
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

bash
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_reached or key_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-Key for 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.

Get API keyDocumentation

api.zukko.pro · fetch_*

Mobile app on Claude/GPT API — architecture and code · Zukko