Turn an app identifier into a policy decision. This gem asks the AppBlockList App Lookup API what an iOS or Android app is, and hands you a plain Ruby object you can test, store and act on.
AppBlockList.lookup("com.zhiliaoapp.musically").filtering_categories
# => ["Social Networking", "Streaming Media"]The gem uses only net/http and json from the standard library and supports Ruby 2.7 and newer. It fits into a Rails app, a Sidekiq worker, a Hanami service or a cron script on the MDM jump box.
With Bundler:
# Gemfile
gem "appblocklist", "~> 1.0"bundle installOr on its own:
gem install appblocklistThen provide your key. The client looks for APPBLOCKLIST_API_KEY in the environment, which plays well with Rails credentials loaded into ENV, Kamal secrets, Heroku config vars and systemd EnvironmentFile= setups.
export APPBLOCKLIST_API_KEY=your_key_hereKeys come with an App Lookup API plan from the AppBlockList pricing page.
require "appblocklist"
client = AppBlockList::Client.new # or Client.new(api_key: "...")
tiktok = client.lookup("com.zhiliaoapp.musically")
tiktok.found? # => true
tiktok.name # => "TikTok - Videos, Shop & LIVE"
tiktok.developer # => "TikTok Ltd."
tiktok.age_rating # => "12+"
tiktok.minimum_age # => 12
tiktok.app_store_id # => "835599320"
tiktok.android_package # => "com.zhiliaoapp.musically"
tiktok.filtering_categories # => ["Social Networking", "Streaming Media"]
tiktok.primary_category # => "Social Networking"
tiktok.iab_category # => "Technology & Computing > Computing > Internet > Social Networking"
client.lookup("id835599320").bundle_id # App Store ID with prefix
# => "com.zhiliaoapp.musically"
ghost = client.lookup("com.example.not.a.real.app")
ghost.found? # => false
ghost.error # => "App not found."Three ID shapes are accepted, up to 200 characters:
- iOS bundle ID, as listed in Jamf, Intune, Kandji or Apple Business Manager inventories
- App Store ID, the digits in an
apps.apple.comURL, with or without theidprefix - Android package name, as listed on Google Play and in Android Enterprise consoles
The key travels in the X-API-Key header. Every request carries User-Agent: appblocklist-ruby/1.0.0 (+https://www.appblocklist.com).
AppBlockList::AppResult is immutable. String fields are never nil: a missing value is "", and filtering_categories is always an array.
| Reader | Type | Notes |
|---|---|---|
found? |
Boolean | false for a 404 answer |
name, developer |
String | store listing values |
bundle_id |
String | iOS bundle ID |
app_store_id |
String | digits, kept as a string |
android_package |
String | Google Play twin, "" if the app is on one store only |
app_store_genre |
String | one of 26 App Store genres |
age_rating |
String | 4+, 9+, 12+, 17+ |
filtering_categories |
Array of String | ranked, most descriptive first |
developer_domain |
String | e.g. tiktok.com |
iab_category |
String | IAB content taxonomy path |
error |
String or nil | the service message on a not-found answer |
raw |
Hash | frozen copy of the decoded JSON |
Policy helpers:
BLOCKED = ["Gaming", "Gambling", "Dating", "Adult", "VPN / Proxy / Anonymizers"].freeze
app.blocked_by?(BLOCKED) # => true / false
app.matching_categories(BLOCKED) # => ["Gaming"] (for the audit log)
app.category?("gaming") # case-insensitive
app.pending_android? # Google Play app not yet categorized
app.to_h # string keys, same names as the API
app.to_json
AppBlockList::AppResult.from_hash(hash) # rebuild from a cached HashThe 58 filtering categories are the same ones used to categorize 120 million websites. A single BLOCKED constant can therefore drive both your app policy and your DNS or proxy policy.
| Call | HTTP requests | Returns or raises |
|---|---|---|
AppBlockList.lookup(id) |
1 | shortcut on a shared client built from ENV |
Client.new(**opts) |
0 | raises AuthenticationError if no key is found |
Client#lookup(id) |
1, plus retries on 5xx | AppResult |
Client#lookup_each(ids, pause: 0) { |id, result| } |
1 per unique ID | Hash of id => AppResult or nil |
Client.parse_response(status, body) |
0 | AppResult or raises; useful in tests |
Client.normalize_id(id) |
0 | stripped ID or raises InvalidIdError |
Constructor options:
| Option | Default | Purpose |
|---|---|---|
api_key: |
ENV["APPBLOCKLIST_API_KEY"] |
your key |
timeout: |
15 |
open and read timeout, seconds |
retries: |
2 |
extra attempts after 5xx or network errors |
retry_delay: |
1.0 |
first back-off in seconds, doubled each time |
base_url: |
https://www.appblocklist.com/api/v1/app |
override for an internal proxy |
user_agent: |
appblocklist-ruby/1.0.0 (+https://www.appblocklist.com) |
|
transport: |
Net::HTTP | ->(uri, headers, timeout) { [status, body] } |
lookup_each sends requests strictly in sequence. That is what the service asks for: one ID per request, one request after another, no parallel bursts. Do not wrap the client in a thread pool to speed up a sync; spread a big job over time instead.
| Situation | Status | Raised | Typical response in a job |
|---|---|---|---|
| App found | 200 | nothing | use the result |
| Unknown app | 404 | nothing, found? == false |
mark for manual review |
| Google Play only, not yet categorized | 404 | nothing, pending_android? == true |
review, retry next month |
| Bad ID format | 400 | InvalidIdError |
log the row, continue |
| Missing or wrong key | 401 | AuthenticationError |
stop, alert ops |
| Monthly lookups used | 403 | QuotaExceededError (#monthly_lookups) |
stop, reschedule |
| Temporary failure | 500 | ServerError after retries |
let the job framework retry |
| Network, TLS, timeout | none | TransportError after retries |
let the job framework retry |
Every class inherits from AppBlockList::Error, which exposes #status and #body.
begin
app = client.lookup(params[:app_id])
rescue AppBlockList::InvalidIdError
render json: { error: "Not a bundle ID, App Store ID or package name" }, status: :unprocessable_entity
rescue AppBlockList::QuotaExceededError => e
Rails.logger.warn("AppBlockList quota: #{e.monthly_lookups}")
render json: { decision: "review" }
endSchools and companies often keep a table of apps that were approved at some point. Apps change: a note-taking app adds an AI chat, a game adds real-money spins. This job re-checks each approved app and demotes the ones that drifted into a blocked category.
# app/models/approved_app.rb
# columns: bundle_id:string, status:string, reason:string,
# categories:json, age_rating:string, checked_at:datetime
class ApprovedApp < ApplicationRecord
BLOCKED = ["Gaming", "Gambling", "Dating", "Adult", "AI / LLM Tools", "VPN / Proxy / Anonymizers"].freeze
end# app/jobs/recheck_approved_apps_job.rb
class RecheckApprovedAppsJob < ApplicationJob
queue_as :low
# Sidekiq and GoodJob both honour these.
retry_on AppBlockList::ServerError, AppBlockList::TransportError, wait: 10.minutes, attempts: 5
discard_on AppBlockList::AuthenticationError
def perform(batch_size: 500)
client = AppBlockList::Client.new(retries: 1)
ApprovedApp.where("checked_at IS NULL OR checked_at < ?", 30.days.ago)
.order(:checked_at).limit(batch_size).each do |record|
app = client.lookup(record.bundle_id)
record.update!(attributes_for(app))
sleep 0.1 # be polite on a shared key
rescue AppBlockList::InvalidIdError
record.update!(status: "invalid", checked_at: Time.current)
end
rescue AppBlockList::QuotaExceededError
self.class.set(wait_until: Time.current.next_month.beginning_of_month).perform_later
end
private
def attributes_for(app)
base = { checked_at: Time.current }
return base.merge(status: "review", reason: app.error) unless app.found?
hits = app.matching_categories(ApprovedApp::BLOCKED)
base.merge(
status: hits.empty? ? "approved" : "blocked",
reason: hits.join(", ").presence,
categories: app.filtering_categories,
age_rating: app.age_rating
)
end
endSchedule it daily with whatever you already use (Solid Queue recurring tasks, sidekiq-cron, GoodJob cron). Because only apps older than 30 days are re-checked and the batch is capped, the lookup spend stays flat as the table grows.
Once statuses are current, MDM tools need plain lists. iOS needs bundle IDs; Android Enterprise needs package names. This task writes both, using the android_package twin stored from an earlier lookup or fetched now.
# lib/tasks/mdm.rake
namespace :mdm do
desc "Write tmp/mdm/ios_blocked.txt and tmp/mdm/android_blocked.txt"
task export: :environment do
out = Rails.root.join("tmp/mdm")
FileUtils.mkdir_p(out)
client = AppBlockList::Client.new
ios = ApprovedApp.where(status: "blocked").pluck(:bundle_id).sort
android = []
client.lookup_each(ios) do |_id, app|
android << app.android_package if app&.found? && !app.android_package.empty?
end
File.write(out.join("ios_blocked.txt"), ios.join("\n") + "\n")
File.write(out.join("android_blocked.txt"), android.uniq.sort.join("\n") + "\n")
puts "iOS: #{ios.size} Android: #{android.uniq.size}"
end
endRun it from cron after the job above:
30 3 * * * cd /srv/school-mdm && RAILS_ENV=production bin/rails mdm:export >> log/mdm_export.log 2>&1
How those files reach devices depends on the console. For iOS the list ends up in the Restrictions payload of a configuration profile, documented in Apple's Device Management reference. For Android, managed Google Play and the policies described in Android Enterprise for developers control what can be installed. AppBlockList has console-specific walkthroughs for blocking apps in Intune, Jamf Pro and the Google Admin console.
The repository ships a fuller variant as examples/sync_approved_apps.rake.
Support staff get questions like "is this app allowed on student phones?" A ten-line executable answers them without opening the MDM console.
#!/usr/bin/env ruby
# bin/appcheck com.discord 985746746 com.roblox.client
require "appblocklist"
BLOCKED = %w[Gaming Gambling Dating Adult].push("VPN / Proxy / Anonymizers").freeze
AppBlockList::Client.new.lookup_each(ARGV) do |id, app|
verdict =
if app.nil? then "invalid id"
elsif !app.found? then "unknown, ask IT"
elsif app.blocked_by?(BLOCKED) then "BLOCKED (#{app.matching_categories(BLOCKED).join(', ')})"
else "allowed"
end
puts "#{id.ljust(28)} #{app&.name.to_s.ljust(36)} #{app&.age_rating.to_s.ljust(4)} #{verdict}"
end$ bin/appcheck com.roblox.robloxmobile com.duolingo.DuolingoMobile
com.roblox.robloxmobile Roblox 12+ BLOCKED (Gaming)
com.duolingo.DuolingoMobile Duolingo: Learn Languages 4+ allowed
Names, ratings and categories come from the API at run time, so the output follows the store listing as it changes.
- Lists rot, categories do not. A new VPN app published tomorrow lands in
VPN / Proxy / Anonymizerswithout anyone editing your code. - Both stores at once. About 800,000 iOS records carry their Google Play twin, and 3.9 million Google Play package names are tracked, so a mixed iPad and Chromebook or Android fleet uses one policy.
- The same vocabulary as your web filter. 58 categories shared with the website database mean the board approves one list, not two.
- Auditable. Store
matching_categorieswith each decision and you can answer "why was this blocked?" months later.
Security frameworks point the same way. NIST SP 800-124 Rev. 2 on managing mobile devices in the enterprise lists app vetting and allow or deny lists among the core controls, and the mobile device management article on Wikipedia gives a vendor-neutral overview of how those controls are pushed to devices.
| Offer | Price |
|---|---|
| App Lookup API | $99 per month for 100,000 lookups, higher volumes quoted |
| App Blocklists for MDM (School or Business) | $99/month or $990/year per organization |
| Districts and IT Providers | $249/month or $2,490/year, unlimited schools or clients, includes the API with 100,000 lookups a month |
| Mobile App Database | top 100,000 apps $999, top 500,000 apps $1,999, one-time |
Only 200 answers count as lookups. If you want the lists without writing a sync job, the MDM blocklist feed is refreshed every month in six formats: iOS bundle ID list, Android package list, .mobileconfig, Intune, Jamf and full CSV.
Each lookup opens its own Net::HTTP connection and the client holds no mutable state after construction, so sharing one instance across Puma threads is fine. Keep the total request rate modest and sequential per job, as the service asks.
Because unknown apps are ordinary in an inventory: in-house apps, TestFlight builds and regional apps. Raising would force a rescue around every row. You get found? == false and decide.
Pass a transport: lambda, or call Client.parse_response(200, json_string) to build results from fixtures:
client = AppBlockList::Client.new(api_key: "x", transport: ->(*) { [200, File.read("test/fixtures/tiktok.json")] })No, one ID per request. For bulk analysis of the whole App Store, license the mobile app database: 1,874,397 iPhone and iPad apps in 12 columns.
AppBlockList publishes an AI apps list and a VPN and proxy apps list. They are a good seed for an approved or blocked table.
Use the bundle ID lookup tool for iOS or the Android package name lookup.
bundle install
bundle exec rake testThe Minitest suite parses sample JSON and uses a fake transport. It never opens a socket.
- AppBlockList home
- Apps to block on school iPads
- .mobileconfig app blocking guide
- Contact and support, info@alpha-quantum.com
- Source code: github.com/explainableaixai/appblocklist-ruby
Sister services by Alpha Quantum:
- generative AI domain blocklist
- school and library web filtering for CIPA
- categorized domain database for filters
- URL category data files
- classify websites by API
- Node.js: appblocklist on npm
- Python: appblocklist on PyPI
- PHP: appblocklist-php (Composer package
alphaquantum/appblocklist) - Dart and Flutter: appblocklist-dart
- Elixir: appblocklist-elixir
- Go: appblocklist-go
- Java: appblocklist-java
- Rust: appblocklist-rust
- C# and .NET: appblocklist-dotnet
- Main repository: explainableaixai/appblocklist, GitLab mirror url-classifications/appblocklist
Released under the MIT License. Copyright (c) 2026 Alpha Quantum.