GeoInsight API Docs Log in Get an API key

Using GeoInsight in a mobile app

Why an API key inside an app is a rotation problem, how to configure an app so it ships without one, how to verify every registration with App Attest or Play Integrity, and what your client code has to do.

The problem is rotation, not secrecy

Any secret shipped inside an app can be extracted. The binary can be decompiled, the traffic read through a proxy with your own certificate, the memory dumped on a rooted device. Certificate pinning, obfuscation and native storage raise the effort from minutes to hours — nothing more. The attacker owns the device.

That alone would be manageable. What makes it hard is the release cycle: a key inside a binary can only be replaced by shipping a new version and waiting for users to install it. Store review plus adoption is a fortnight at best, and the tail of old versions runs for months. So "revoke and reissue" is not an incident response for a mobile app.

There are only two ways out, and everything below is one of them: make the secret unnecessary, or make its loss survivable.

Best: the key never reaches the device

Register your app with us and it ships no API key at all. Each install registers itself on first run and receives its own credential. A compromised device is revoked on its own, in seconds, without a store release — which is the one thing a shared key can never do.

If you do run your own backend, the alternative is simpler still: keep a normal API key there, restrict it to your server's IP addresses, and let your backend hand short-lived tokens to the app. Both models are supported; the rest of this page covers the first.

Registering your app

1. Add the app

In Mobile apps, register one entry per platform — an iOS and an Android build are two apps, because their identifiers and their attestation come from different vendors.

FieldiOSAndroid
IdentifierBundle ID, e.g. com.example.appPackage name, e.g. com.example.app
Team IDRequired — the 10-character Apple Team ID. App Attest binds the proof to TeamID.BundleID, so we cannot verify anything without it.Not used

Registering the app also creates an API key you never see. Its secret is generated and discarded — it is not shown, not downloadable, and never sent anywhere. It exists so that everything you already know about keys applies to the app: endpoint scope, daily limit, alert threshold, auto-suspend and billing.

2. Narrow the app's key

Open that key under API Keys — it is named after the app — and set:

  • Endpoint scope. Untick everything the app does not call. Costs differ by two orders of magnitude, so this is the cheapest cap there is.
  • Daily credit limit for the whole app — the ceiling on a bad day.
  • Alert threshold below that limit, so you hear about a runaway before it is cut off.
  • Auto-suspend only if downtime is cheaper for you than the credits. It stops every install at once.

3. Set the per-install limit

Back in Mobile apps, under Settings, set the daily credit limit per install. This is what stops one device — or one script pretending to be one — from spending the whole app's budget. Size it from real usage: if a normal session costs 40 credits and a heavy user opens the app five times a day, a few hundred is generous and still bounded.

The same screen has Allow installs to register without a verified attestation. It is on by default and you need it while integrating — but read Before you ship before leaving it on.

In the app

Before registering: fetch a challenge

A challenge is a one-time nonce that binds your attestation to this specific registration. It expires in five minutes and is consumed on first use — fetch a fresh one right before you register, not at app start.

POST https://geoinsight.dev/v1/register/challenge

200 OK
{"challenge": "…", "expires_in": 300}

If this endpoint returns 503 service_unavailable, our challenge store is briefly down and no challenge can be issued. Retry with backoff and honour the Retry-After header — the app can do nothing else here.

First run: register

Once per install, not once per launch. Store the secret in the Keychain (iOS) or EncryptedSharedPreferences / Keystore (Android) and never send it anywhere else.

POST https://geoinsight.dev/v1/register
Content-Type: application/json

{
  "platform": "ios",
  "bundleId": "com.example.app",
  "challenge": "…",
  "attestation": {
    "keyId": "…",
    "attestationObject": "…"
  }
}

201 Created
{"installSecret": "gi_inst_...", "attested": true, "note": "…"}

The secret is shown once. Lose it and the app registers again — this is not an error and does not replace the old install: it creates a new one with its own secret, and the previous install stays valid until you revoke it. So keep the secret in the Keychain / Keystore across launches and reinstalls; only register when you have none.

During integration: register without a proof

While Allow installs to register without a verified attestation is on, you can register with no attestation field at all — omit it entirely (the challenge is optional too in this mode). You get a working install marked attested: false, so you can build the rest of the flow before wiring up App Attest / Play Integrity:

POST https://geoinsight.dev/v1/register
Content-Type: application/json

{"platform": "ios", "bundleId": "com.example.app"}

201 Created
{"installSecret": "gi_inst_...", "attested": false, "note": "Registered without a verified attestation (reason: no_attestation_provided). …"}

Once you turn the toggle off, this same request is refused with 403 registration_refused — omitting the proof is a dev convenience, not a fallback for production.

iOS: Use DCAppAttestService to generate the attestation with clientDataHash = SHA256(challenge).

Android: Use Play Integrity API with setNonce(challenge) to generate the token, then post the integrityToken as {"integrityToken": "…"}.

Pass the challenge through untouched. It is already URL-safe base64 with no padding — exactly the shape setNonce() wants. Do not decode it, re-encode it, pad it, hash it or trim it. Fetch it once, put that string in the proof, and send that same string in the registration body.

On Android this is the single most common integration bug, because re-encoding is easy to do by accident: Base64.DEFAULT and Base64.NO_WRAP both add = padding. If you must encode, it is Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP — but the string needs no encoding at all.

Trailing = padding is the one deviation we forgive, because it carries no information. Every other change — a different alphabet (+/ instead of -_), a hash, a fresh second challenge — fails with nonce_mismatch.

While you are integrating (see below), a proof that does not verify still returns 201 — but with attested: false and the specific reason in note. That is where the reason codes below surface:

201 Created
{"installSecret": "gi_inst_...", "attested": false, "note": "nonce_mismatch"}

Every session: exchange for a token

You may call the API with the install secret directly, but prefer a token: it expires in 15 minutes, so anything captured on the wire is worthless shortly after.

POST https://geoinsight.dev/v1/token
X-API-Key: gi_inst_...
Content-Type: application/json

{"deviceId": "optional — see below"}

200 OK
{"token": "gi_tok_...", "expires_in": 900, "header": "X-API-Key"}

Send the token in the same X-API-Key header as a key, so your networking layer has one code path. Refresh it when a call returns 401, not on a timer — the clock on a phone is not something to plan around.

Checking this install's daily budget

Call GET /v1/usage with the install credential (or a token minted from it) and the response carries an install block on top of the account fields:

GET https://geoinsight.dev/v1/usage
X-API-Key: gi_inst_...   (or a gi_tok_... minted from it)

{"install": {"daily_limit": 5000, "used": 1240, "remaining": 3760, "resets_at": "…T00:00:00+00:00"}}

This is the free, always-allowed way for the app to show the user how much of their device's daily budget is left, and to stop before hitting 429 install_daily_limit. remaining is null when the app has no per-install limit set. You (the account owner) see the same figures across all installs, ranked by spend, in Mobile apps → Install usage. Full field list on the usage endpoint page.

deviceId is optional and, today, only recorded. Send identifierForVendor on iOS or ANDROID_ID on Android if you have it; it becomes the anchor for per-device rules and for attestation later, and sending it now means nothing to change then.

DoDo not
Register once, on first runRegister on every launch — you would create thousands of installs and hit the rate limit
Keep the install secret in the Keychain / KeystoreLog it, back it up to a server, or put it in UserDefaults
Refresh the token on 401Refresh before every call — the exchange is free but it is still a round trip
Cache results you already haveRe-query the same coordinate on every screen — terrain does not move

What the app should do on each error

Most of these are configuration, not bugs — the app's behaviour matters because a wrong reaction turns a limit into a loop.

ResponseMeansThe app should
401Token expired, or the install was revokedExchange a new token once. If that also fails, register again — do not loop.
403 registration_refusedApp unknown, or registration is closedStop and surface it. Retrying will not help; this is our side.
403 endpoint_not_allowedThe app's key is not scoped to this endpointTreat as a bug in your build — the call should not be made at all.
403 key_suspendedThe whole app is suspendedDegrade gracefully, show a real message. Every install is affected.
429 install_daily_limitThis device hit its own capStop until tomorrow (UTC) and say so. Other users are unaffected.
429 key_daily_limitThe whole app hit its capBack off; honour Retry-After.
429 too_many_registrationsToo many registrations from one addressAlmost always a bug: you are registering more than once per install.
402The account is out of creditsNothing the app can fix — surface it and stop calling.
503Terrain service briefly unavailableRetry with backoff. Costs nothing.

Every response carries X-RateLimit-* headers, so you can show a real quota rather than guessing from failures.

Before you ship

Read this section rather than skimming it — one item on it is a genuine hole, not a preference.

  • Open registration is a hole, now closable. While Allow installs to register without a verified attestation is on, anyone who knows your bundle identifier — a public string, printed in every app listing — can register an install and spend your credits. The only brake is a per-address rate limit and the per-install cap. It exists so you can integrate before attestation is verified, but now that attestation works, it is not a setting to ship with — disable it in Mobile appsSettings once you have verified proofs on both platforms.
  • Until then, the per-install limit is your real ceiling. Set it deliberately: multiplied by however many installs someone bothers to register, it is the worst case.
  • Watch the first week. The dashboard shows hourly usage per key. A step change with no release behind it is the signal that matters.
  • Check attested. Every install records whether its registration was proven. Once attestation is live, installs registered on trust remain false — you can revoke them in bulk and let devices re-register properly.

Rotating without breaking everyone

With registered installs there is usually nothing to rotate: revoke the individual install and that device re-registers on next launch. If the app's own key has to change — a scope you no longer trust, a suspension you want to make permanent — the key list lets you run two keys at once, so you can ship an update and retire the old one when adoption allows.

Be honest about the endgame though: if something is being abused and you cannot ship an update for two weeks, tightening the app's daily limit degrades every user rather than only the guilty one. That trade-off does not disappear; per-install credentials are how you avoid reaching it.

Attestation: proving the registration

How we verify the proof

iOS: App Attest binds each proof to your app and device using Apple's Secure Enclave. We verify: the certificate chain back to Apple's public root, the nonce matches the challenge you sent, and the app identifier is your registered TeamID.BundleID.

Android: Play Integrity returns a verdict of device state as a token that is signed and then encrypted to your app. We verify: we can decrypt it with your decryption key and validate its signature with your verification key, the nonce matches the challenge, the package name matches your registered identifier, and the verdict includes MEETS_DEVICE_INTEGRITY (indicating an unmodified, original OS).

What you need to provide

  • iOS: Nothing new. Your Team ID and Bundle ID are already in the app registration, both public. App Attest verifies against Apple's public root — no secrets change hands.
  • Android: Your Play Integrity decryption key (base64 AES-256, for the JWE) and verification key (base64 EC public key, for the JWS signature). In Google Play Console: Protected with Play → Play Integrity API → ManageClassic requestsResponse encryption → choose Manage and download my response encryption keys, generate and upload the .pem as prompted, then download the keys. Response encryption defaults to Managed by Google — while that is set, decryption happens on Google's servers and there are no keys to download, so you must switch it. Paste both into Mobile apps → edit the app → Play Integrity keys. These are stored encrypted.

What Play Console hands you is a <package>.enc file — your keys encrypted to the public key you uploaded. Decrypt it locally with the matching private key. The padding must be OAEP; with OpenSSL's default (PKCS#1 v1.5) the command often succeeds and writes garbage, with no error to warn you:

openssl pkeyutl -decrypt -inkey private.pem \
  -pkeyopt rsa_padding_mode:oaep -in com.example.app.enc > api_keys.txt

cat api_keys.txt
DECRYPTION_KEY=XXXXXXXXXXXXX
VERIFICATION_KEY=YYYYYYYYYYYYY

Paste the values after the =, not the whole line. Pasting DECRYPTION_KEY=… verbatim surfaces later as token_decryption_failed, which does not hint at the cause. The RSA key pair is only a transport wrapper for this one download: keep the private key off your servers and out of your repository — we never need it.

Error reasons

Registration may fail at the challenge stage or the proof stage. Each reason tells you what to check:

These reasons are a debugging aid, visible only during integration. While Allow installs to register without a verified attestation is on, a failed proof still returns 201 with attested: false and the reason in the note field — use that to get your proofs verifying.

Once you turn the toggle off (see below), a failed proof returns an opaque 403 registration_refused with no reason — deliberately, so an attacker cannot learn which check they failed. Do your reason-driven debugging now; production client code must treat 403 registration_refused as a flat "refused", not switch on a reason it will never receive.

ReasonMeansWhat to check
challenge_invalidThe challenge is unknown, already used, or expiredEcho back the opaque challenge string from /v1/register/challenge verbatim, and fetch a fresh one right before registering — it is one-time and expires in five minutes.
challenge_unavailableOur store was unreachable while validating your challenge during /v1/registerTransient on our side. Retry the whole registration (fetch a new challenge, then register again) with backoff.
no_attestation_providedNo attestation field was sentExpected while integrating — this is what you get from the dev request above, and the install is usable (attested: false). To make it attested: true, send the proof. Once you require attestation, this same call becomes a 403.
unsupported_platformPlatform is neither ios nor androidCheck the platform field — only iOS and Android are supported.
cert_chain_invalidiOS: the certificate chain does not lead to Apple's rootThe attestation object is corrupt or forged. Use the genuine DCAppAttestService API.
nonce_mismatchThe nonce in the proof does not match the challengePass the exact challenge you received: iOS uses SHA256(challenge) as the client data hash, Android calls setNonce(challenge). On Android the reason carries both values shortened to their ends — nonce_mismatch (challenge K7pQ…T_wQ, nonce aB9x…XrM2) — which tells the two causes apart: similar ends mean the string was re-encoded on the way, unrelated ends mean two different challenges were fetched, one for the proof and one for the request body.
app_id_mismatchiOS: TeamID.BundleID does not match your registrationCheck the Team ID and Bundle ID in your app registration against your provisioning profile.
counter_not_zeroiOS: attestation counter is not zero on first registrationThe Secure Enclave counter should be zero for a new key. This is an OS bug or the device is compromised.
unknown_aaguidiOS: the AAGUID (authenticator ID) is not Apple'sThe attestation is not from App Attest. Use the platform's native attestation API.
key_id_mismatchiOS: the key ID in the attestation does not match the one you are usingYou may have used different keys for different calls. Use the same key for the challenge and registration.
credential_id_mismatchiOS: the credential ID does not match the registered oneEnsure you register only once per install and use the same credential throughout the install's lifetime.
unexpected_formatiOS: the attestation format is not Apple's App Attest formatCheck that you are using the App Attest API, not some other attestation.
attestation_malformediOS: the attestation object cannot be parsedThe attestation is corrupt. Re-generate it from DCAppAttestService.
play_integrity_keys_missingAndroid: we cannot verify Play Integrity without keysPaste the decryption key and verification key from Play Console into Mobile apps → edit the app → Play Integrity keys. Both are required.
token_decryption_failedAndroid: we cannot decrypt the tokenThe decryption key may be wrong, or the token is corrupt. Check that you are using the correct key from Play Console.
token_signature_invalidAndroid: the token signature does not verifyThe verification key may be wrong, or the token is forged. Use the genuine Play Integrity API and the verification key that came with your decryption key.
package_mismatchAndroid: the package name in the verdict does not match your appCheck the package name in your app registration against the one in Play Integrity.
token_staleAndroid: the token is too oldFetch the challenge and generate the token immediately before calling register — do not cache it.
app_not_recognizedAndroid: Play Integrity does not know your appEnsure the app is uploaded to Play Console and published (even to internal testing).
device_integrity_not_metAndroid: the device is not genuine or the OS is modifiedTest on an unmodified device with Google Play Services installed. Emulators and rooted devices fail this check.
certificate_digest_mismatchAndroid: the app's signing certificate does not matchThe binary was signed with the wrong certificate. Check the certificate digest in Play Console.

Closing the registration hole

While Allow installs to register without a verified attestation is on, any attacker can register installs using your public bundle identifier. You can see in the dashboard which installs were attested: the attested flag is true when the proof verified, false otherwise.

Once you have tested both platforms and both kinds of proofs are verifying reliably, turn off Allow installs to register without a verified attestation in Mobile appsSettings. From then on, every registration requires a valid proof. The hole closes.

After that, a proof that does not verify no longer returns 201 with a note — it returns an opaque 403 registration_refused with no reason, by design. So finish your reason-driven debugging before you flip the toggle; from then on your client should treat 403 registration_refused as a flat refusal.