Skip to content

About

Rust client for the AppBlockList App Lookup API: filtering categories, age rating and iOS/Android IDs for 1.87M apps

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

appblocklist (Rust)

crates.io docs.rs License: MIT

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.

Contents

  1. Install
  2. First lookup
  3. The App struct
  4. Handling Error
  5. Example: a policy gate CLI
  6. Example: generating a restrictions payload
  7. Example: calling it from async code
  8. Design notes
  9. Why category data
  10. FAQ
  11. Links and Related packages

Install

cargo add appblocklist

or 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-key

First lookup

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(())
}
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

#[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:

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. 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.txt

For a list of IDs to seed approved.txt, the free iOS bundle ID finder and Android package lookup 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. 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.


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:

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?;

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, 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.

Files instead of calls

You do not always need the API:

  • The MDM app blocklist feed 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 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.


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, VPN and proxy apps and the guide to apps schools block on iPads.

What format is the response?

A single JSON object (RFC 8259) per request.


Links

Related packages

The same API, other languages:

Other services from Alpha Quantum: AI tools blocklist, URL categorization database, website categorization API.

License

MIT, Copyright (c) 2026 Alpha Quantum. See LICENSE.

About

Rust client for the AppBlockList App Lookup API: filtering categories, age rating and iOS/Android IDs for 1.87M apps

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages