An Android app protected by Akamai BMP proves itself on every API call with a sensor header its mobile SDK computes. This post shows how to generate that header from Python with an AkamaiBmpTask, keep the device identity stable between runs, and send it as x-acf-sensor-data on the app's protected request. There is no browser, page URL, widget or sitekey anywhere in this flow: the inputs are SDK and app details, and the output is one request header your client applies itself.
What the task produces
BMP is the mobile counterpart of the web sensor. Where the web flow returns _abck cookies, a BMP solve returns header — the generated sensor payload, sent unchanged as the x-acf-sensor-data request header — plus version, deviceId, deviceManufacturer, deviceModel and osVersion, which describe the device the payload was generated for.
A ready solve is a generated header, not an accepted request. Whether the protected endpoint accepts it depends on the app's own integration, which is why this flow ends with an explicit acceptance check instead of a console.log of the solution.
If your target is a browser flow — _abck cookies or an SBSD interstitial — use the web Akamai guide instead. A BMP header does not substitute for those cookies.
The app-side inputs
Every input describes one application build. Read them from the app's own integration, not from a web page:
| Field | Required | Where it comes from |
|---|---|---|
version |
Yes | The BMP SDK version the generator should model — the packaged version is currently 4.2.1. This is the SDK's version inside the app, not the app's own version. |
appPackage |
Yes | The Android package name, as in com.example.app. |
appVersion |
Yes | The app's versionName from the same build. |
appVersionCode |
Yes | The integer versionCode of the same build — not the version name. |
serverURL |
Yes | The API server URL configured in the app's BMP SDK. It identifies the protected app endpoint; it is not a sensor script URL. |
appSignatureSHA1 |
No | The signing certificate SHA-1, 40 hexadecimal characters without separators. |
deviceId |
No | A UUID pinning the device identity. Omit it on the first call and store what the solve returns. |
The SDK also accepts optional locale, touch-event, sensor-event and server-challenge inputs that let the payload reflect the integration's requirements; the BMP API card lists their names and limits. Mixing values from two app builds — a version name from one and a version code from another — produces a payload for an app that does not exist.
Python
The same flow is generated for Node and for Python; the card below is the Python script. With the constants at the top filled in, it runs end to end: it creates the task, polls getTaskResult, persists the returned device UUID to a file for the next run, sends the header on the app's API request, and checks a marker that only the accepted response contains.
# akamai-bmp-python.py - generate the Akamai BMP header and send it with the app API request.
# Install: pip install requests
# Run: python akamai-bmp-python.py
import os
import time
import requests
# Your SurSolver API key.
CLIENT_KEY = "YOUR_API_KEY"
# The SDK version packaged by SurSolver (currently 4.2.1).
BMP_VERSION = "4.2.1"
# The Android package name of the app.
APP_PACKAGE = "com.example.app"
# The versionName of the app.
APP_VERSION = "1.2.3"
# The versionCode of the app.
APP_VERSION_CODE = 123
# The protected API URL configured in the Akamai SDK.
SDK_SERVER_URL = "https://api.example.com"
# The app's protected API request URL.
APP_API_URL = "https://example.com/app-api"
# Its HTTP method; GET when unset.
APP_API_METHOD = None
# File that keeps the device id between runs; device-id.txt when unset.
DEVICE_ID_FILE = None
# Text that appears only in a response the site accepted.
SUCCESS_TEXT = "PASTE_ACCEPTED_MARKER"
API = "https://api.sursolver.com"
POLL_ATTEMPTS = 60
def call(path, body):
# API errors are HTTP 200 with errorId 1; anything else is a transport failure.
response = requests.post(f"{API}{path}", json=body, timeout=30)
response.raise_for_status()
data = response.json()
if data["errorId"] != 0:
raise RuntimeError(f'{data["errorCode"]}: {data["errorDescription"]}')
return data
def solve(task):
created = call("/createTask", {"clientKey": CLIENT_KEY, "task": task})
for _ in range(POLL_ATTEMPTS):
result = call("/getTaskResult", {"clientKey": CLIENT_KEY, "taskId": created["taskId"]})
if result["status"] == "ready":
return result["solution"]
time.sleep(3)
raise TimeoutError(f'task {created["taskId"]} was not ready in {POLL_ATTEMPTS} polls')
def without_empty(fields):
# Optional task fields are sent only when they have a value.
return {name: value for name, value in fields.items() if value not in (None, "")}
def main():
device_file = DEVICE_ID_FILE or "device-id.txt"
# One installation keeps one device id; reuse the id a previous run saved.
device_id = None
if os.path.exists(device_file):
with open(device_file, encoding="utf-8") as handle:
device_id = handle.read().strip()
solution = solve({
"type": "AkamaiBmpTask",
"version": BMP_VERSION,
"appPackage": APP_PACKAGE,
"appVersion": APP_VERSION,
"appVersionCode": APP_VERSION_CODE,
"serverURL": SDK_SERVER_URL,
**without_empty({"deviceId": device_id}),
})
with open(device_file, "w", encoding="utf-8") as handle:
handle.write(solution["deviceId"])
headers = {"x-acf-sensor-data": solution["header"]}
# Add the headers and body the app sends with this request.
response = requests.request(APP_API_METHOD or "GET", APP_API_URL, headers=headers, timeout=30)
if not response.ok or SUCCESS_TEXT not in response.text:
raise SystemExit(f"The site did not accept the result (HTTP {response.status_code}).")
print("The site accepted the result.")
if __name__ == "__main__":
main()
The script's structure maps one-to-one onto the flow you want in production:
- The task carries no proxy and fetches no page. Everything the generator needs arrives in the payload.
deviceIdis read fromdevice-id.txtwhen it exists and sent as an optional field; after the solve, the returned UUID is written back. On the first run the field is simply absent.solution["header"]goes into thex-acf-sensor-dataheader verbatim — not decoded, not trimmed, not wrapped.- The request is the app's own API call; add whatever headers and body the app normally sends, including its authentication.
- Success is checked on the response body (
SUCCESS_TEXT), not on the solve status.
Node.js
The Node variant is the same flow with nothing to install: fill in the constants at the top and run node akamai-bmp-node.js. It keeps the device UUID in the same device file and sends solution.header as x-acf-sensor-data on the app's request, checked against the same success marker.
// akamai-bmp-node.js - generate the Akamai BMP header and send it with the app API request.
// Install: Node.js 18 or newer (no packages).
// Run: node akamai-bmp-node.js
const fs = require('node:fs');
const { setTimeout: sleep } = require('node:timers/promises');
// Your SurSolver API key.
const CLIENT_KEY = 'YOUR_API_KEY';
// The SDK version packaged by SurSolver (currently 4.2.1).
const BMP_VERSION = '4.2.1';
// The Android package name of the app.
const APP_PACKAGE = 'com.example.app';
// The versionName of the app.
const APP_VERSION = '1.2.3';
// The versionCode of the app.
const APP_VERSION_CODE = 123;
// The protected API URL configured in the Akamai SDK.
const SDK_SERVER_URL = 'https://api.example.com';
// The app's protected API request URL.
const APP_API_URL = 'https://example.com/app-api';
// Its HTTP method; GET when unset.
const APP_API_METHOD = '';
// File that keeps the device id between runs; device-id.txt when unset.
const DEVICE_ID_FILE = '';
// Text that appears only in a response the site accepted.
const SUCCESS_TEXT = 'PASTE_ACCEPTED_MARKER';
const API = 'https://api.sursolver.com';
const POLL_ATTEMPTS = 60;
// API errors are HTTP 200 with errorId 1; anything else is a transport failure.
async function call(path, body) {
const res = await fetch(`${API}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data.errorId !== 0) throw new Error(`${data.errorCode}: ${data.errorDescription}`);
return data;
}
async function solve(task) {
const created = await call('/createTask', { clientKey: CLIENT_KEY, task });
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
const result = await call('/getTaskResult', {
clientKey: CLIENT_KEY,
taskId: created.taskId,
});
if (result.status === 'ready') return result.solution;
await sleep(3000);
}
throw new Error(`task ${created.taskId} was not ready in ${POLL_ATTEMPTS} polls`);
}
// Optional task fields are sent only when they have a value.
function withoutEmpty(fields) {
return Object.fromEntries(
Object.entries(fields).filter(([, value]) => value !== undefined && value !== ''),
);
}
async function main() {
const deviceFile = DEVICE_ID_FILE || 'device-id.txt';
// One installation keeps one device id; reuse the id a previous run saved.
const deviceId = fs.existsSync(deviceFile)
? fs.readFileSync(deviceFile, 'utf8').trim()
: undefined;
const solution = await solve({
type: 'AkamaiBmpTask',
version: BMP_VERSION,
appPackage: APP_PACKAGE,
appVersion: APP_VERSION,
appVersionCode: APP_VERSION_CODE,
serverURL: SDK_SERVER_URL,
...withoutEmpty({ deviceId }),
});
fs.writeFileSync(deviceFile, solution.deviceId);
const headers = { 'x-acf-sensor-data': solution.header };
// Add the headers and body the app sends with this request.
const response = await fetch(APP_API_URL, { method: APP_API_METHOD || 'GET', headers });
const text = await response.text();
if (!response.ok || !text.includes(SUCCESS_TEXT)) {
throw new Error(`The site did not accept the result (HTTP ${response.status}).`);
}
console.log('The site accepted the result.');
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Keep one device UUID per installation
The device identity is the part that outlives a single request. On the first call, omit deviceId: the solve generates a UUID and returns it. Store that value and send it on every later call from the same installation — reusing it selects the same device and its derived Android ID. The returned UUID is lowercase, and uppercase input identifies the same device.
Two mistakes are common. Sending the short hexadecimal Android ID from the handset: deviceId is a UUID, not that value. And minting a fresh identity per request: an app that suddenly presents a new device on every call looks nothing like the installed app you are modelling.
Confirm the app's API accepted the header
The solve ending ready says the payload was generated; it does not say the endpoint accepted it. The scoring itself happens on the protection's side and is not observable from outside the app — the app's own response is the only verdict you can see. Check acceptance where it happens, on the app's API:
- Send the protected request without the header first and observe the rejection your site produces — that is your negative control, and it also confirms the endpoint is the protected one.
- Send it again with
x-acf-sensor-dataset fromsolution["header"], keeping the app's usual authentication and body. - Confirm the behaviour only an accepted request produces — the
SUCCESS_TEXTmarker of the script, a specific response field, or the business action landing.
If the header is rejected, check the header arrived byte-for-byte (some HTTP clients rewrite or fold long headers), that serverURL matches the endpoint the SDK in the real app talks to, and that the app details all come from the build you are modelling. Those three cover most rejections before you touch anything else.
When it fails
| Symptom | Likely cause | What to do |
|---|---|---|
createTask answers an unsupported-version error |
The version field does not match a packaged SDK generator. |
Send 4.2.1; if your app's SDK version differs, contact support with that version and the package name to request it. |
ValueError on int(APP_VERSION_CODE) |
The version name was passed where the integer code belongs. | Read versionCode from the build; 12.25.0-prod.01 and a build like 312250001 are different values. |
| The endpoint rejects a request that carries the header | The header was decoded, trimmed or re-encoded in transit, or the app details describe a different build. | Log the outgoing header length and compare it with len(solution["header"]); send the value verbatim; align appVersion, appVersionCode, appSignatureSHA1 and serverURL on one build. |
| A UUID is rejected as malformed | The short Android ID or another non-UUID value was sent as deviceId. |
Send the stored UUID from the first solve, or omit the field to get a new one. |
| Every request looks like a brand-new device | A fresh deviceId was generated per call. |
Persist the returned UUID per installation and reuse it, as the script does with its device file. |
| The request succeeds without the header too | You are probing an endpoint this SDK does not protect. | Confirm serverURL and the app's SDK configuration; the negative control from the previous section must fail before a pass means anything. |
Where this fits
The Akamai BMP solver guide keeps recognition, inputs and the header contract in one page, and the BMP API card carries the exact request and solution shapes. The Node and Python scripts of this flow are generated from one source — the examples index lists both. For the web flow of the same product family, see the Akamai Bot Manager guide.