A blocking Rust client for the AppBlockList App Lookup API. You hand it a bundle ID, an App Store ID or an Android package name; it hands back a serde-ready App struct with the categories an MDM policy needs.
| Crate | appblocklist 1.0.0 |
| Edition | 2021 |
| Dependencies | ureq, serde, serde_json |
| Async runtime | none required |
| Endpoint | GET https://www.appblocklist.com/api/v1/app?id=<ID> |
| Source | https://github.com/explainableaixai/appblocklist-rust |
The service behind it is AppBlockList, a catalogue of iPhone, iPad and Android apps sorted into 58 filtering categories for device management.
- Install
- First lookup
- The
Appstruct - Handling
Error - Example: a policy gate CLI
- Example: generating a restrictions payload
- Example: calling it from async code
- Design notes
- Why category data
- FAQ
- Links and Related packages
cargo add appblocklistor by hand in Cargo.toml:
[dependencies]
appblocklist = "1.0"You need an API key. The App Lookup API costs $99 per month for 100,000 lookups, with higher volumes quoted on request. Keep the key out of your source tree:
export APPBLOCKLIST_API_KEY=your-keyuse appblocklist::Client;
fn main() -> Result<(), appblocklist::Error> {
let client = Client::from_env()?;
let app = client.lookup("id835599320")?;
println!("{} ({})", app.name, app.age_rating);
for (rank, cat) in app.filtering_categories.iter().enumerate() {
println!(" {}. {}", rank + 1, cat);
}
Ok(())
}TikTok - Videos, Shop & LIVE (12+)
1. Social Networking
2. Streaming Media
Client::new("key") works too, if you load secrets from somewhere other than the environment. Both constructors give a client with a 30 second timeout, two retries for temporary failures and the User-Agent appblocklist-rust/1.0.0 (+https://www.appblocklist.com).
com.zhiliaoapp.musically: an iOS bundle ID835599320orid835599320: an App Store ID, prefix optionalcom.openai.chatgpt: an Android package name
The client trims the ID and rejects empty strings, IDs with spaces inside and anything longer than 200 characters before it sends a request.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct App {
pub found: bool,
pub name: String,
pub developer: String,
pub bundle_id: String,
pub app_store_id: String, // a string, as the API sends it
pub android_package: String, // Google Play twin, or ""
pub app_store_genre: String, // one of 26 App Store genres
pub age_rating: String, // "4+", "9+", "12+", "17+"
pub filtering_categories: Vec<String>, // ranked, most descriptive first
pub developer_domain: String,
pub iab_category: String,
pub message: Option<String>, // the API's "error" text on a 404
}Field names match the JSON keys, so serde_json::to_string(&app) gives you the same document the API returned. That makes it easy to cache answers on disk or forward them to another service.
Helper methods:
| Method | Returns | Use |
|---|---|---|
primary_category() |
Option<&str> |
The first ranked category |
in_category(&["A", "B"]) |
bool |
Any-of match, ASCII case-insensitive |
on_google_play() |
bool |
An Android package name is known |
block_ids() |
Vec<&str> |
Bundle ID and Android twin, de-duplicated |
Client methods:
| Method | Purpose |
|---|---|
Client::new(key) / Client::from_env() |
Construct |
.with_base_url(url) |
Point at a mock server |
.with_agent(agent) |
Bring your own ureq::Agent (proxy, TLS roots, timeouts) |
.with_retries(n, wait) |
Retry policy for 5xx and transport errors |
.lookup(id) |
One app |
.lookup_many(ids, pause) |
Sequential lookups, duplicates skipped |
Free functions: validate_id(&str) and parse_response(status, body). The second is what lookup uses internally, exposed so you can test your own code against recorded answers.
A 404 is not an error. It returns Ok(App { found: false, .. }), with the API's explanation in message. Everything else that can fail is one enum:
| Variant | HTTP | Cause | is_temporary() |
is_fatal() |
|---|---|---|---|---|
InvalidId(msg) |
400 | ID missing or not a recognised format | no | no |
Unauthorized(msg) |
401 | Missing or invalid key | no | yes |
QuotaExceeded { message, monthly_lookups } |
403 | Monthly lookups used up | no | yes |
Server { status, message } |
500 | Temporary error | yes | no |
Unexpected { status, message } |
other | Undocumented status | no | no |
Transport(msg) |
none | DNS, TLS, timeout | yes | no |
Decode(msg) |
200 | Body was not the expected JSON | no | no |
MissingApiKey |
none | No key configured | no | yes |
Temporary errors are retried inside lookup before you see them. A typical match:
use appblocklist::{Client, Error};
fn describe(client: &Client, id: &str) -> String {
match client.lookup(id) {
Ok(app) if app.found => format!("{}: {}", app.name, app.filtering_categories.join(", ")),
Ok(app) if app.on_google_play() => format!("{} is on Google Play, not categorized yet", app.android_package),
Ok(app) => format!("{}: {}", id, app.message.unwrap_or_default()),
Err(Error::InvalidId(m)) => format!("{} is not a valid ID: {}", id, m),
Err(Error::QuotaExceeded { monthly_lookups, .. }) => {
format!("quota reached ({:?} lookups this month)", monthly_lookups)
}
Err(e) => format!("lookup failed: {}", e),
}
}Error implements std::error::Error, so it works with ?, Box<dyn Error>, anyhow and thiserror wrappers.
The crate ships a complete command line tool in examples/abl.rs. It shows the patterns most users need: reading IDs from arguments or stdin, applying a policy, and reporting through exit codes so that shell scripts and CI jobs can react.
cargo run --example abl -- lookup com.zhiliaoapp.musically com.openai.chatgpt
cargo run --example abl -- check --block "Social Networking,Streaming Media" --max-age 12 - < approved.txt
cargo run --example abl -- check --json - < approved.txt > report.json| Exit code | Meaning |
|---|---|
| 0 | Every app is within policy |
| 1 | At least one app breaks policy |
| 2 | Usage or configuration error |
| 3 | Monthly lookups used up |
The heart of the check command is one function:
fn violation(app: &App, o: &Opts) -> Option<String> {
if !app.found {
return None;
}
if app.in_category(o.block.as_slice()) {
return Some(format!("category {}", app.primary_category().unwrap_or("?")));
}
match (o.max_age, age(&app.age_rating)) {
(Some(max), Some(a)) if a > max => Some(format!("age rating {}", app.age_rating)),
_ => None,
}
}
/// "12+" -> 12
fn age(rating: &str) -> Option<u32> {
rating.trim().trim_end_matches('+').parse().ok()
}And the loop that feeds it uses lookup_many, which sends requests one after another, skips repeated IDs and stops early on a bad key or an empty quota:
let (results, fatal) = client.lookup_many(&opts.ids, Duration::from_millis(50));
if let Some(e) = fatal {
eprintln!("abl: {}", e);
exit(if matches!(e, Error::QuotaExceeded { .. }) { 3 } else { 2 });
}
for (id, result) in &results {
// ...
}Put it in a pre-merge job for the repository that holds your approved app list:
policy-check:
script:
- cargo run --release --example abl -- check --block "Social Networking" --max-age 12 - < mdm/approved-apps.txtFor a list of IDs to seed approved.txt, the free iOS bundle ID finder and Android package lookup save you digging through store pages.
Supervised iPhones and iPads accept a Restrictions payload (com.apple.applicationaccess) whose blockedAppBundleIDs key lists apps that may not launch. The keys are documented in Apple's device management reference. The abl mobileconfig subcommand prints that array for every app that breaks policy:
cargo run --example abl -- mobileconfig --block "Social Networking,Streaming Media" - < installed.txt<key>blockedAppBundleIDs</key>
<array>
<string>com.zhiliaoapp.musically</string>
</array>If you prefer to build the list in your own code, the struct makes it a filter and a map:
use appblocklist::Client;
use std::time::Duration;
fn blocked_bundle_ids(client: &Client, installed: &[String], cats: &[&str]) -> Vec<String> {
let (results, _) = client.lookup_many(installed, Duration::ZERO);
let mut ids: Vec<String> = results
.into_iter()
.filter_map(|(_, r)| r.ok())
.filter(|a| a.found && a.in_category(cats))
.map(|a| a.bundle_id)
.filter(|b| !b.is_empty())
.collect();
ids.sort();
ids.dedup();
ids
}Paste the result into a profile, or let your MDM import it. The site has a walkthrough for building a .mobileconfig app restriction profile by hand, and guides for Intune and Jamf. On Android, the same android_package values go into the app blocking policy your EMM applies through Android Enterprise.
The client is blocking on purpose: it keeps the dependency tree small and works in build scripts, CLIs and threads without a runtime. In a Tokio service, move the call onto the blocking pool:
use appblocklist::{App, Client, Error};
use std::sync::Arc;
async fn lookup_async(client: Arc<Client>, id: String) -> Result<App, Error> {
tokio::task::spawn_blocking(move || client.lookup(&id))
.await
.expect("lookup task panicked")
}Client is Clone and Send + Sync; clones share the same connection pool. Keep one per process. Because the service asks for one request at a time per key, put a tokio::sync::Semaphore with one permit in front of it if many tasks share the client:
let gate = Arc::new(tokio::sync::Semaphore::new(1));
let _permit = gate.acquire().await.unwrap();
let app = lookup_async(client.clone(), id).await?;- No surprises on 404. Unknown apps are a normal outcome in any inventory, so they are data, not errors.
- Sequential by default.
lookup_manynever fans out. That is what the service asks for, and it keeps cost predictable: only 200 answers count as lookups. - Header auth. The key goes in
X-API-Key. The API also accepts anapi_keyquery parameter, but headers stay out of access logs. - Lenient parsing.
app_store_idis a string in the API; the struct also accepts a number. Anullcategory list becomes an emptyVec. - Offline tests.
tests/parse_samples.rsreplays every documented answer throughparse_response. Runcargo testwith no network and no key.
Most app policies boil down to a few sentences: no social media on student devices, no streaming on shared kiosks, nothing rated 17+ for younger grades. The hard part is the mapping from those sentences to thousands of bundle IDs that change every week.
The lookup does that mapping. Its database covers 1,874,397 iPhone and iPad apps, links about 800,000 of them to their Android twin and tracks 3.9 million Google Play package names. Each record carries ranked filtering categories from the same 58-category scheme that is applied to 120 million web domains. If your network side already uses a categorized domain feed, the app side can reuse the policy word for word.
Schools in the US have a legal reason to care: the Children's Internet Protection Act ties E-rate funding to filtering. The CIPA-compliant web filtering service covers browsers; this crate covers what is installed on the device.
You do not always need the API:
- The MDM app blocklist feed is refreshed every month and ships in six formats, including a
.mobileconfigprofile and Intune and Jamf import files. Schools and businesses pay $99/month or $990/year per organization; districts and IT providers pay $249/month or $2,490/year and get the API with 100,000 lookups a month included. - The Mobile App Database is a licensed file with 12 columns per app: $999 one-time for the top 100,000 apps or $1,999 one-time for the top 500,000.
All plans are on the pricing page.
Because most callers do not need one, and a blocking client built on ureq adds no runtime to your binary. Wrapping it in spawn_blocking (shown above) is a few lines.
Use parse_response(status, body) with recorded JSON, or start a local HTTP server in your test and point the client at it with .with_base_url("http://127.0.0.1:PORT/api/v1").
No. Only 200 answers are counted. Errors such as 400 and 401 are free as well.
You get Error::QuotaExceeded, with monthly_lookups set when the API includes the count. lookup_many stops at that point and returns what it already collected.
Yes, when the app exists on both stores. The answer includes bundle_id, app_store_id and android_package, so one lookup covers a mixed fleet. A package that is on Google Play but not yet categorized comes back with found: false and android_package set.
Start with the curated pages for AI chat and assistant apps, VPN and proxy apps and the guide to apps schools block on iPads.
A single JSON object (RFC 8259) per request.
- AppBlockList: https://www.appblocklist.com
- API reference: https://www.appblocklist.com/app-categorization-api.php
- This crate on GitHub: https://github.com/explainableaixai/appblocklist-rust
- Product repository on GitLab: https://gitlab.com/url-classifications/appblocklist
- Questions and bugs: https://www.appblocklist.com/contact.php, info@alpha-quantum.com
The same API, other languages:
- Go: appblocklist-go
- Java: appblocklist-java
- .NET: appblocklist-dotnet
- Node.js: appblocklist (npm)
- Python: appblocklist (PyPI)
- npm and PyPI source: explainableaixai/appblocklist
Other services from Alpha Quantum: AI tools blocklist, URL categorization database, website categorization API.
MIT, Copyright (c) 2026 Alpha Quantum. See LICENSE.