← All docs

Mobile SDKs

The same engine runs for native apps with a different component vocabulary, weights and anchor set. Visitors are scoped per platform: the vocabularies do not overlap, so a cross-platform comparison could only ever be noise.

Compiled in CI, not yet run on a handset. The Kotlin and Swift sources build on every commit, and the server side is exercised end to end against recorded SDK payloads. Neither of those is the same as having run on a real device. Expect to shake out integration details on your first build.

Android

implementation("dev.antibot:antibot-android:1.0.0")
val antibot = Antibot.create(
    context,
    AntibotConfiguration(
        apiKey = "pk_live_…",
        endpoint = "https://api.antibot.dev",
    ),
)

lifecycleScope.launch {
    val result = antibot.identify(
        linkedId = user.id,
        tag = mapOf("action" to "checkout"),
        extendedResult = true,
    )
    Log.d("antibot", "${result.visitorId} ${result.confidence.score}")
}

Minimum SDK 23. Needs INTERNET and ACCESS_NETWORK_STATE. The bundled manifest declares a <queries> list so package checks keep working on Android 11+. Location is inspected only if your app already holds a location permission — the SDK never prompts.

For Google Play's Data safety form, the SDK sends a device identifier (ANDROID_ID, and a token it keeps in backed-up preferences) and other device data — the handset's hardware, display, sensors, system build, and whether it is rooted, emulated, cloned or under a hook framework — encrypted in transit, for fraud prevention and security, sent to the API endpoint you configure (and to Google's Play Integrity service when you turn attestation on), and not used for advertising. It reads the package list only for the named apps in its <queries>, never all of them (QUERY_ALL_PACKAGES is not declared), and sends coordinates never: a location your app already has is read for whether it was mocked, and of a GPS fix it keeps a digest and the fix's time on the device, to tell one that never moves — never the coordinates. What you add with linkedId or tag is yours to declare.

iOS

.package(url: "https://github.com/antibot/antibot-ios", from: "1.0.0")
let antibot = try Antibot(configuration: .init(
    apiKey: "pk_live_…",
    endpoint: URL(string: "https://api.antibot.dev")!
))

let result = try await antibot.identify(linkedId: user.id, extendedResult: true)

iOS 14+. No third-party dependencies; hashing uses CryptoKit.

The package ships Apple's privacy manifest (PrivacyInfo.xcprivacy), which Xcode folds into your app's privacy report: no tracking and no tracking domains; a device identifier and other device data collected, not linked to the user, for app functionality (fraud prevention and security) — and if your app passes linkedId (your user's id), they are linked to the user: declare the device identifier as linked, and the user ID, in your app's own privacy manifest, which Xcode merges with the SDK's; and the two APIs Apple asks a reason for — UserDefaults, for what the SDK itself stored (CA92.1, the App Attest key's id), and file timestamps, for the creation date of the app's own container (C617.1, the first-install time a reinstall is told by). Apple allows none of its listed APIs to identify a device, so the SDK reads neither the disk's size nor the boot time. CI holds the manifest to the Swift (sdk/scripts/check-privacy-manifest.mjs): a listed API called without its declaration, or a declaration with no call left, fails the build before an app upload would. Your app's App Store privacy answers are still yours: add what you send with linkedId or tag.

React Native

npm install @antibot/react-native
import { configure, useVisitorData } from "@antibot/react-native"

await configure({ apiKey: "pk_live_…" })

function Checkout() {
  const { data, isLoading, error, getData } = useVisitorData()
  return <Button title="Pay" onPress={() => getData()} />
}

Flutter

dependencies:
  antibot: ^1.0.0
await Antibot.configure(
  const AntibotConfiguration(apiKey: 'pk_live_…'),
);

final result = await Antibot.identify(linkedId: user.id);

React Native and Flutter are bridges to the native SDKs, not ports. Neither runtime has a DOM, so none of the browser agent's entropy sources exist there; identification runs in the Kotlin and Swift code. They expose configure and identify only: a receipt bound to the phone's key (bindReceipt, below) needs a proof on each of your own API calls (requestHeaders), which only the native SDKs give — from React Native or Flutter, send the receipt unbound.

Identity on mobile

Each SDK keeps two tokens with deliberately different lifetimes:

Token Android iOS Survives
persistentToken backed-up preferences keychain item app reinstall
vendorId ANDROID_ID identifierForVendor usually a reinstall
binding AndroidKeyStore HMAC keychain presence not copyable by a clone

Handset attributes — model, GPU, sensors, display, CPU — are the anchors. The tokens deliberately are not, because they legitimately change on reinstall, and anchoring on them would mint a new visitor exactly when recognising the returning one matters most.

The binding is what exposes a cloned container: a cloning app copies the data directory, and therefore the persisted token, but cannot copy a hardware-backed keystore entry or read another app's keychain. The two disagree in exactly the cloned case.

Protecting your API

Every identification returns sessionToken: the verdict as a signed receipt, valid for ten minutes — save for a device whose session you revoked, which is answered with decision.action: "block" and the revocation, and no receipt, as a browser is. Send it on your own API calls as the antibot-token header, and the edge enforcer — or your server, with the SDKs' verifySessionToken — checks it offline, with no call back here (by hand, check its environment too: the receipt's environment):

val result = antibot.identify()
request.newBuilder().header("antibot-token", result.sessionToken ?: "")
request.setValue(result.sessionToken, forHTTPHeaderField: "antibot-token")

Identify again before it expires (on launch and every few minutes of use is plenty). A call without a receipt is assessed like any request that never ran the agent, and an HTTP library with no receipt is challenged — which an app's API client cannot meet. Unlike a browser's, a mobile receipt is not bound to a User-Agent, because the HTTP stack that identified is rarely the one the app calls its API with; its ten minutes are its bound. No rules run on mobile, so a mobile receipt's action is allow and its score is what the edge weighs — save under attack mode.

Binding the receipt to the phone

Ten minutes is long enough for a receipt lifted from one real phone to serve a farm. Turn on bindReceipt and it cannot: the SDK keeps a P-256 key in the phone's secure hardware (StrongBox or the TEE on Android, the Secure Enclave on iOS), the receipt names that key, and every API call must carry a fresh signature by it. Let the SDK build both headers:

val antibot = Antibot.create(context, AntibotConfiguration(apiKey = "pk_live_…", endpoint = "…", bindReceipt = true))
antibot.identify()
// Off the main thread: signing takes a few milliseconds.
antibot.requestHeaders("GET", "/api/orders?page=2").forEach { (name, value) -> builder.header(name, value) }
for (name, value) in antibot.requestHeaders(method: "GET", pathAndQuery: "/api/orders?page=2") {
    request.setValue(value, forHTTPHeaderField: name)
}

antibot-pop is t=<unix ms>,s=<signature>: ECDSA P-256 over <t>\n<METHOD>\n<path and query>, at most two minutes old — not the body, and no nonce: within the two minutes, a proof taken with its receipt passes again for the same method and path, whatever the body. It proves the phone made a request there, not what it asked; hold what matters to the body by its own means (your API's own request signing, an idempotency key). The edge verifies it with the key in the receipt; your own server can with the server SDKs' verifyRequestProof, and the Node and Python middlewares do. A bound receipt without a valid proof — the receipt alone, copied off the phone — counts as no receipt, at the edge, in the middlewares and in an assessment alike (session:proof); send the header to assess as proof beside the token. In the SDKs the private key never leaves the hardware — but nothing the server sees proves the key is in hardware: the receipt names whatever key the identification named, so binding stops a receipt copied off a phone, not a client that made its own key to begin with. If the phone cannot make a key, identification goes ahead with an unbound receipt; the response's receiptBound says which.

TLS certificate pinning

Both SDKs support optional SHA-256 SPKI pinning (the same hash CertificatePinner uses) for the API endpoint. It is opt-in because many integrators front us with their own first-party proxy domains — leave it off unless you control the certificate chain end to end.

Generate a pin from a live endpoint or a certificate file:

# From the live endpoint (leaf):
openssl s_client -connect api.antibot.dev:443 -servername api.antibot.dev </dev/null 2>/dev/null \
  | openssl x509 -pubkey -noout \
  | openssl pkey -pubin -outform DER \
  | openssl dgst -sha256 -binary \
  | openssl base64

# From a certificate file:
openssl x509 -in cert.pem -pubkey -noout \
  | openssl pkey -pubin -outform DER \
  | openssl dgst -sha256 -binary \
  | openssl base64

Always include a backup pin — typically the SPKI of your next certificate or of an offline backup key — so a certificate rotation can never lock every client out.

AntibotConfiguration(
    apiKey = "pk_live_…",
    endpoint = "https://api.antibot.dev",
    certificatePins = listOf(
        "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",  // current
        "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",  // backup
    ),
)
AntibotConfiguration(
    apiKey: "pk_live_…",
    endpoint: URL(string: "https://api.antibot.dev")!,
    certificatePins: [
        "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",  // current
        "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",  // backup
    ]
)

The hash matches if any certificate in the presented chain matches any configured pin, so pinning an intermediate or root is also valid. When a request fails pin validation, the SDK refuses the connection and the certificatePinningFailure tamper flag flips to true. Because evidence is collected before the request runs, a failure on request N is reported on request N+1 — the flag is sticky for the process lifetime.

Request signing

Every request carries {timestamp, nonce, signature} — HMAC-SHA256 over a canonical string covering the platform, package name, app signature digest, timestamp, nonce and sorted component hashes. The server enforces a five-minute skew window and remembers spent nonces for the app, wherever a request is sent — another environment or another organization that registered the same app finds it spent — and an attestation on a request older than the window is not verified (requestStale).

Since wire 2 the SDKs send everything else — the evidence, the device and app details, the receipt key, linkedId and tag — as one JSON string, signed, beside the envelope (apiKey, platform, request, attestation), and the canonical string ends with signed:<SHA-256 of that string, hex>. The server hashes the string exactly as it arrived, so the signature, App Attest's assertion and Play Integrity's request hash cover every field: a proxy the device was made to trust cannot clear a jailbreak flag or swap the receipt key under a valid attestation. A field sent beside signed is not covered, and is not read. A request unwrapped into wire 1 on its way has a different canonical string, so its attestation fails. The answer says which it was: tampering_details.evidence_signed. Wire 1 (older SDKs) is still accepted, but an attestation on a wire-1 request is not verified (evidenceUnsigned): its evidence rides beside the signature, and a genuine verdict would stand beside whatever a proxy wrote there. Once every install runs a wire-2 SDK, a rule on products.mobile.tampering_details.evidence_signed != true holds the rest.

This is not app attestation. The key is your app's public API key, which ships inside the binary. It detects payload edits, replays and casual scripted clients; it is not proof of authenticity against someone who extracted the key.

For that, attach a Play Integrity verdict. The SDK does not depend on the Play Integrity library; you hand it a provider, and it asks for a verdict on every identification with a request hash — the hash of the same canonical string the signature covers — so a verdict is good for one request only:

val integrity = IntegrityManagerFactory.createStandard(context)
val tokenProvider = integrity.prepareIntegrityToken(
    PrepareIntegrityTokenRequest.builder().setCloudProjectNumber(CLOUD_PROJECT_NUMBER).build(),
).await()

val antibot = Antibot.create(
    context,
    AntibotConfiguration(
        apiKey = "pk_live_…",
        endpoint = "https://api.antibot.dev",
        integrityTokenProvider = { requestHash ->
            tokenProvider.request(
                StandardIntegrityTokenRequest.builder().setRequestHash(requestHash).build(),
            ).await().token()
        },
    ),
)

(A classic request works too: pass the request hash as its nonce.) A provider that throws or returns null sends no verdict, and the identification goes ahead.

Setup. The server judges a verdict against what the environment registered, never against the package and certificate the app reports about itself — a repackaged app reports whatever it likes. Register the app, its signing certificate digests (SHA-256, as Play Console's App signing page shows them; hex with or without colons, or base64) and how to decode the verdict:

PATCH /api/management/v1/environments/{environmentId}
{
  "androidApps": [
    { "packageName": "com.example.app", "certificateDigests": ["AB:CD:…"] }
  ],
  "playIntegrity": {
    "serviceAccount": { …the service account's JSON key… },
    "local": { "decryptionKey": "…", "verificationKey": "…" }
  }
}

The registration does a second thing, without Play Integrity: an app that reports a signing certificate not registered for its package — a copy re-signed with someone else's key — is tampering_details with signatureDigestMismatch, whatever the device says of itself. The SDK reads the first certificate of the app's signing lineage, so after a key rotation register the original as well as the current one. A copy patched to report the registered digest is not caught by this; a verdict is what catches it.

Give one or both. serviceAccount decodes through Google (decodeIntegrityToken), which works for standard and classic requests: create a service account in the Cloud project linked in Play Console and paste its JSON key. local decrypts on this server with the response keys from Play Console (App integrity → Response encryption → Manage and download keys), with no call to Google — classic requests only. Neither is ever returned by the API; GET reports which are set. Without either, a verdict reports unavailable / notConfigured and moves nothing. The traffic page's setup checklist (attestation) warns when most of a week's verdicts fail alike for a reason only the setup gives — keys that are not the app's read as forgeries, every genuine phone's — and says which.

What is checked: the token decrypts and its signature verifies under the app's own key (or Google decodes it); the verdict is for this request (requestHash or nonce), at most ten minutes old, for a registered package and a registered signing certificate; Google recognizes the binary (PLAY_RECOGNIZED) and the device meets device or strong integrity. A forged token or a genuine one lifted from another request reports forged (+40, requestMismatch); a genuine verdict whose contents condemn the app or device reports rejected (+30), with reasons such as deviceIntegrityNotMet (rooted, hooked, or not a real device), virtualDevice, basicIntegrityOnly, unlicensed, playProtect:HIGH_RISK, controllingAppDetected (an unknown app controlling the screen through accessibility — the shape of on-device automation), capturingAppDetected (one capturing it) and highDeviceActivity (the device asks for verdicts at device-farm volume) — and, of the app, packageNotRegistered (a package this environment has not registered), packageMismatch, certificateMismatch, appVerdict:<verdict> (Google does not recognize the binary) and staleVerdict (older than ten minutes). A verdict presented a second time is rejected / verdictReplayed. Both surface on attestation and feed tampering_details. Google being unreachable reports unavailable and moves nothing. A verified verdict adds nothing to the score — it is the absence of a reason, not a credit — but it is the one mobile input the device cannot mint for itself, and what attack mode asks a phone for.

A failure surfaces on tampering_details rather than rejecting the request, so the policy stays yours.

Shipping outside Play too — Galaxy Store, an enterprise channel, a test build — Google does not recognize that binary (UNRECOGNIZED_VERSION) and Play does not license its install (unlicensed), so every genuine install from there reads rejected. Mark the app "outsidePlay": true in androidApps: a build signed with one of its registered certificates is then not held to either. A copy signed by anyone else is still certificateMismatch, and the device is held to integrity all the same.

iOS: App Attest

iOS has no verdict token; it has a ceremony. Enable it in configuration after registering your app id (below):

let antibot = try Antibot(configuration: .init(
    apiKey: "pk_live_…",
    endpoint: URL(string: "https://api.antibot.dev")!,
    appAttest: true
))

The SDK then drives Apple's DCAppAttestService for you:

  1. Registration, once per install. The device generates a Secure-Enclave key pair, fetches a single-use challenge from POST /api/v4/attestation/challenge, and asks Apple to certify the key against it. The SDK posts the attestation object to POST /api/v4/attestation/app-attest; the server verifies the certificate chain against the pinned Apple App Attestation Root CA, the nonce binding to the challenge, the rpId hash against your registered app ids, and the AAGUID's environment byte (development vs production), then stores the public key. Challenges are single-use and expire after five minutes, so a captured attestation replays into nothing.
  2. Assertion, per identify. The device signs over authData ‖ clientDataHash, where the client data hash is SHA-256 of the exact canonical string the request HMAC covers — the platform, the app, the time and nonce, the component hashes the device is identified by and, since wire 2, the digest of the rest of the request — so the assertion binds all of it to a Secure-Enclave key: a client that rewrites the request on its way cannot change the evidence under a valid assertion. (A hook inside the app can still change what the SDK collects before it is signed; that is what the hooking evidence is for.) The signature is sent as { "attestation": { "provider": "app-attest", "keyId", "token" } }.

The assertion's authenticator counter is the clone detector: the Secure Enclave signs with each counter once, so a counter presented twice reports forged / counterReused. Counters may arrive out of order — an app's identifications at once, asserted in one order and reaching the server in another — so one a little behind the newest is verified, its request's nonce and timestamp holding it to this moment; one far behind (32 or more) is refused, rejected / counterRegression, and condemns nothing. An assertion naming a key that never registered reports forged / unknownKeyId, one naming none forged / missingKeyId, and one that does not verify forged with what failed (assertionInvalid when nothing more is known). These feed tampering_details exactly like a forged Play Integrity token. A key is also one phone, and a phone is one device: the key signs whatever canonical it is handed, so a genuine iPhone could vouch for every payload a farm writes, each its own device. A key whose assertions speak for more than two devices in a day reports rejected / oneKeyManyDevices — for the rest of the day, its own device's included. A key speaks for the app it was attested in: a request naming another of the environment's apps (app.packageName, the bundle id) reports rejected / packageMismatch, as Play Integrity's does. A device that cannot attest (unsupported hardware, jailbreak) sends no attestation at all. Its absence moves nothing and is never treated as forgery — save under attack mode, below — and the SDK reports appAttestSupported in its integrity evidence so the dashboard can tell the difference.

Setup. App Attest's rpId hash is SHA-256 of "TEAMID.bundleId", so the server cannot verify anything until the environment knows your app ids. Register them through the Management API (or dashboard):

PATCH /api/management/v1/environments/{environmentId}
{ "appleAppIds": ["ABCDE12345.com.example.yourapp"] }

Your app also needs the App Attest capability enabled in Xcode (App Groups → App Attest) and the App Attest environment entitlement matches automatically — development builds attest against Apple's sandbox CA, App Store and TestFlight builds against production, and the server records which one each key came from as attestationEnvironment. A production environment takes production keys only: a development key does not register there, and one that did before reports rejected / developmentKey.

Attack mode

The app's public key ships in the app, so a script that holds it signs a payload as well as the app does, and outside an attestation nothing tells the two apart: a payload without one is scored on what it says. Under attack mode a phone has to bring a verified attestation where the environment can check one — App Attest with the app's id registered, Play Integrity with its keys or account set and the package registered — as a browser has to bring its answer to its program. One without is identified as before, but its receipt says challenge ("attackMode": "unproven" in the response), which the edge and the server middlewares hold under attack mode, and which an app cannot pass: its API calls are refused until the switch ends. That is the switch's cost, paid only by installs that could have attested and did not — a phone without Google Play services, one whose attestation failed — and only while it is on. An app for which nothing is set up is left alone: a genuine install could bring nothing either.