# appblocklist (Rust)
[](https://crates.io/crates/appblocklist)
[](https://docs.rs/appblocklist)
[](LICENSE)
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](https://www.appblocklist.com), a catalogue of iPhone, iPad and Android apps sorted into 58 filtering categories for device management.
## Contents
1. [Install](#install)
2. [First lookup](#first-lookup)
3. [The `App` struct](#the-app-struct)
4. [Handling `Error`](#handling-error)
5. [Example: a policy gate CLI](#example-a-policy-gate-cli)
6. [Example: generating a restrictions payload](#example-generating-a-restrictions-payload)
7. [Example: calling it from async code](#example-calling-it-from-async-code)
8. [Design notes](#design-notes)
9. [Why category data](#why-category-data)
10. [FAQ](#faq)
11. [Links](#links) and [Related packages](#related-packages)
---
## Install
```sh
cargo add appblocklist
```
or by hand in `Cargo.toml`:
```toml
[dependencies]
appblocklist = "1.0"
```
You need an API key. The [App Lookup API](https://www.appblocklist.com/app-categorization-api.php) costs $99 per month for 100,000 lookups, with higher volumes quoted on request. Keep the key out of your source tree:
```sh
export APPBLOCKLIST_API_KEY=your-key
```
---
## First lookup
```rust
use 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(())
}
```
```text
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)`.
### Accepted IDs
- `com.zhiliaoapp.musically`: an iOS bundle ID
- `835599320` or `id835599320`: an App Store ID, prefix optional
- `com.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.
---
## The `App` struct
```rust
#[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.
---
## Handling `Error`
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:
```rust
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.
---
## Example: a policy gate CLI
The crate ships a complete command line tool in [`examples/abl.rs`](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.
```sh
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:
```rust
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:
```rust
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:
```yaml
policy-check:
script:
- cargo run --release --example abl -- check --block "Social Networking" --max-age 12 - < mdm/approved-apps.txt
```
For a list of IDs to seed `approved.txt`, the free [iOS bundle ID finder](https://www.appblocklist.com/tools/bundle-id-lookup.php) and [Android package lookup](https://www.appblocklist.com/tools/package-name-lookup.php) save you digging through store pages.
---
## Example: generating a restrictions payload
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](https://developer.apple.com/documentation/devicemanagement). The `abl mobileconfig` subcommand prints that array for every app that breaks policy:
```sh
cargo run --example abl -- mobileconfig --block "Social Networking,Streaming Media" - < installed.txt
```
```xml
<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:
```rust
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](https://www.appblocklist.com/mdm/mobileconfig-block-apps.php) by hand, and guides for [Intune](https://www.appblocklist.com/mdm/block-apps-intune.php) and [Jamf](https://www.appblocklist.com/mdm/block-apps-jamf.php). On Android, the same `android_package` values go into the app blocking policy your EMM applies through [Android Enterprise](https://developer.android.com/work).
---
## Example: calling it from async code
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:
```rust
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:
```rust
let gate = Arc::new(tokio::sync::Semaphore::new(1));
let _permit = gate.acquire().await.unwrap();
let app = lookup_async(client.clone(), id).await?;
```
---
## Design notes
- **No surprises on 404.** Unknown apps are a normal outcome in any inventory, so they are data, not errors.
- **Sequential by default.** `lookup_many` never 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 an `api_key` query parameter, but headers stay out of access logs.
- **Lenient parsing.** `app_store_id` is a string in the API; the struct also accepts a number. A `null` category list becomes an empty `Vec`.
- **Offline tests.** `tests/parse_samples.rs` replays every documented answer through `parse_response`. Run `cargo test` with no network and no key.
---
## Why category data
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](https://www.webfilteringdatabase.com), 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](https://www.fcc.gov/consumers/guides/childrens-internet-protection-act) ties E-rate funding to filtering. The [CIPA-compliant web filtering service](https://www.cipawebfiltering.com) covers browsers; this crate covers what is installed on the device.
### Files instead of calls
You do not always need the API:
- The [MDM app blocklist feed](https://www.appblocklist.com/mdm-app-blocklist-feed.php) is refreshed every month and ships in six formats, including a `.mobileconfig` profile 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](https://www.appblocklist.com/mobile-app-database.php) 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](https://www.appblocklist.com/pricing.php).
---
## FAQ
### Why is there no async client?
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.
### How do I test code that uses this crate without spending lookups?
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")`.
### Does a 404 count against my monthly quota?
No. Only 200 answers are counted. Errors such as 400 and 401 are free as well.
### What happens when the quota runs out?
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.
### Can I send an Android package and get the iOS bundle ID back?
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.
### Where can I see which apps are typically blocked?
Start with the curated pages for [AI chat and assistant apps](https://www.appblocklist.com/lists/ai-apps-list.php), [VPN and proxy apps](https://www.appblocklist.com/lists/vpn-proxy-apps-list.php) and the guide to [apps schools block on iPads](https://www.appblocklist.com/schools/apps-to-block-on-school-ipads.php).
### What format is the response?
A single JSON object ([RFC 8259](https://www.rfc-editor.org/rfc/rfc8259)) per request.
---
## Links
- 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
## Related packages
The same API, other languages:
- Go: [appblocklist-go](https://github.com/explainableaixai/appblocklist-go)
- Java: [appblocklist-java](https://github.com/explainableaixai/appblocklist-java)
- .NET: [appblocklist-dotnet](https://github.com/explainableaixai/appblocklist-dotnet)
- Node.js: [appblocklist (npm)](https://www.npmjs.com/package/appblocklist)
- Python: [appblocklist (PyPI)](https://pypi.org/project/appblocklist/)
- npm and PyPI source: [explainableaixai/appblocklist](https://github.com/explainableaixai/appblocklist)
Other services from Alpha Quantum: [AI tools blocklist](https://www.aitoolsblocklist.com), [URL categorization database](https://www.urlcategorizationdatabase.com), [website categorization API](https://www.websitecategorizationapi.com).
## License
MIT, Copyright (c) 2026 Alpha Quantum. See [LICENSE](LICENSE).