Complete Guide: Automating Apple Business Manager Device Exports with Python on macOS
Introduction
Exporting device data from Apple Business Manager (ABM) is essential for IT administrators managing large device fleets across multiple organizations. While ABM provides a web interface, automating exports via the REST API enables better integration with asset management systems and eliminates manual work.
This guide provides a complete workflow for building a multi-tenant Python exporter on macOS, including handling OAuth2 authentication, fixing common key format issues, and automating the entire process.
Prerequisites
- macOS (tested on macOS 15.x)
- Python 3.9+ (Homebrew recommended)
- Apple Business Manager API credentials for each tenant:
- Client ID (format:
BUSINESSAPI.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
) - Key ID (UUID format)
- Private key (
.pem
file, EC P‑256)
- Client ID (format:
- Python packages:
python3 -m venv .venv source .venv/bin/activate pip install PyJWT requests
Step 1: Setup Project Structure
Create your project directory:
mkdir -p ~/abm-export
cd ~/abm-export
python3 -m venv .venv
source .venv/bin/activate
pip install PyJWT requests
Create a secure directory for your private keys:
mkdir -p ~/secrets
chmod 700 ~/secrets
Step 2: Fix Private Key Format Issues
Apple provides API keys in EC format, but Python's cryptography
library works best with PKCS#8 format. Here's an automated conversion script.
Create convert_pems.sh
:
#!/bin/bash
# Script to convert all EC private keys to PKCS#8 format for PyJWT compatibility
# Usage: ./convert_pems.sh
SECRETS_DIR="$HOME/secrets"
BACKUP_DIR="${SECRETS_DIR}/backup_$(date +%Y%m%d_%H%M%S)"
echo "=== Converting PEM files to PKCS#8 format ==="
echo "Secrets directory: ${SECRETS_DIR}"
echo "Backup directory: ${BACKUP_DIR}"
# Create backup directory
mkdir -p "${BACKUP_DIR}"
# Check if secrets directory exists
if [ ! -d "${SECRETS_DIR}" ]; then
echo "ERROR: Secrets directory ${SECRETS_DIR} does not exist"
exit 1
fi
# Find all .pem files
PEM_FILES=$(find "${SECRETS_DIR}" -name "*.pem" -type f)
if [ -z "${PEM_FILES}" ]; then
echo "No .pem files found in ${SECRETS_DIR}"
exit 0
fi
echo "Found PEM files:"
echo "${PEM_FILES}"
echo ""
# Process each PEM file
for pem_file in ${PEM_FILES}; do
filename=$(basename "${pem_file}")
echo "Processing: ${filename}"
# Check if it's an EC private key
if grep -q "BEGIN EC PRIVATE KEY" "${pem_file}"; then
echo " - Found EC private key format"
# Backup original
cp "${pem_file}" "${BACKUP_DIR}/${filename}.backup"
echo " - Backed up to: ${BACKUP_DIR}/${filename}.backup"
# Convert to PKCS#8
temp_file="${pem_file}.tmp"
if openssl pkey -in "${pem_file}" -out "${temp_file}" 2>/dev/null; then
# Verify the conversion worked
if openssl pkey -in "${temp_file}" -text -noout >/dev/null 2>&1; then
mv "${temp_file}" "${pem_file}"
echo " - ✅ Converted to PKCS#8 format"
# Verify it's P-256
curve_info=$(openssl pkey -in "${pem_file}" -text -noout 2>/dev/null | grep -A2 "Private-Key" | grep -E "(prime256v1|secp256r1)")
if [ -n "${curve_info}" ]; then
echo " - ✅ Verified P-256 curve"
else
echo " - ⚠️ Warning: Could not verify P-256 curve"
fi
else
rm -f "${temp_file}"
echo " - ❌ Conversion failed - verification error"
fi
else
rm -f "${temp_file}"
echo " - ❌ Conversion failed - OpenSSL error"
fi
elif grep -q "BEGIN PRIVATE KEY" "${pem_file}"; then
echo " - Already in PKCS#8 format, skipping"
elif grep -q "BEGIN CERTIFICATE" "${pem_file}"; then
echo " - ⚠️ This is a certificate, not a private key - skipping"
elif grep -q "BEGIN ENCRYPTED PRIVATE KEY" "${pem_file}"; then
echo " - ⚠️ This is an encrypted private key - manual intervention needed"
echo " Run: openssl pkey -in '${pem_file}' -out '${pem_file}.decrypted'"
else
echo " - ❓ Unknown format, skipping"
fi
echo ""
done
echo "=== Conversion Summary ==="
echo "Backup location: ${BACKUP_DIR}"
echo "Original files backed up before conversion"
echo ""
# Test load one converted file with Python
echo "Testing Python compatibility..."
python3 - <<'PY'
import os
from cryptography.hazmat.primitives import serialization
secrets_dir = os.path.expanduser("~/secrets")
if os.path.exists(secrets_dir):
test_files = [f for f in os.listdir(secrets_dir) if f.endswith('.pem')]
if test_files:
test_file = os.path.join(secrets_dir, test_files[0])
try:
with open(test_file, "rb") as f:
key = serialization.load_pem_private_key(f.read(), password=None)
print(f"✅ Python can load: {test_files[0]}")
except Exception as e:
print(f"❌ Python load failed for {test_files[0]}: {e}")
else:
print("No .pem files found for testing")
else:
print("Secrets directory not found")
PY
echo ""
echo "Done! You can now run your ABM export script."
Run the conversion:
chmod +x convert_pems.sh
./convert_pems.sh
Step 3: Configure Tenants
Create tenants.json
with your tenant configurations:
{
"tenants": [
{
"name": "TenantA",
"client_id": "BUSINESSAPI.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"key_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"private_key_pem": "/Users/youruser/secrets/TenantA_Device_Export.pem",
"scope": "business.api"
},
{
"name": "TenantB",
"client_id": "BUSINESSAPI.yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
"key_id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
"private_key_pem": "/Users/youruser/secrets/TenantB_Device_Export.pem",
"scope": "business.api"
}
]
}
Note: For Apple School Manager, use "scope": "school.api"
instead.
Step 4: The Complete Export Script
Create abm_export.py
with the full multi-tenant export functionality:
#!/usr/bin/env python3
import time, uuid, csv, json, os
from typing import Dict, Any, List, Optional, Tuple
from dataclasses import dataclass
from pathlib import Path
import jwt # PyJWT
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
TOKEN_URL = "https://account.apple.com/auth/oauth2/token"
ASSERTION_AUDIENCE = "https://account.apple.com/auth/oauth2/v2/token"
ORG_DEVICES_URL = "https://api-business.apple.com/v1/orgDevices"
@dataclass
class TenantConfig:
name: str
client_id: str
key_id: str
private_key_pem: str
scope: str = "business.api"
def __post_init__(self):
if not all([self.name, self.client_id, self.key_id, self.private_key_pem]):
raise ValueError(f"Missing required config for tenant {self.name}")
class ABMClient:
def __init__(self, timeout: int = 60):
self.session = requests.Session()
self.timeout = timeout
# Configure retry strategy for resilience
retry_strategy = Retry(
total=3,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "POST"]
)
adapter = HTTPAdapter(max_retries=retry_strategy)
self.session.mount("http://", adapter)
self.session.mount("https://", adapter)
def create_client_assertion(self, tenant: TenantConfig, lifetime_minutes: int = 5) -> str:
"""Create JWT client assertion for OAuth2 token exchange"""
now = int(time.time())
headers = {
"alg": "ES256",
"kid": tenant.key_id,
"typ": "JWT"
}
payload = {
"iss": tenant.client_id,
"sub": tenant.client_id,
"aud": ASSERTION_AUDIENCE,
"iat": now,
"exp": now + lifetime_minutes * 60,
"jti": str(uuid.uuid4())
}
return jwt.encode(payload, tenant.private_key_pem, algorithm="ES256", headers=headers)
def get_access_token(self, tenant: TenantConfig) -> Tuple[str, int]:
"""Exchange client assertion for access token"""
assertion = self.create_client_assertion(tenant)
data = {
"grant_type": "client_credentials",
"client_id": tenant.client_id,
"client_assertion": assertion,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"scope": tenant.scope
}
response = self.session.post(TOKEN_URL, data=data, timeout=self.timeout)
response.raise_for_status()
token_data = response.json()
return token_data["access_token"], token_data.get("expires_in", 3600)
def fetch_all_devices(self, access_token: str) -> List[Dict[str, Any]]:
"""Fetch all organization devices with automatic pagination"""
headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/json"
}
devices: List[Dict[str, Any]] = []
url = ORG_DEVICES_URL
params = {"limit": 1000} # Maximum allowed by API
while url:
response = self.session.get(url, headers=headers, params=params, timeout=self.timeout)
response.raise_for_status()
data = response.json()
batch = data.get("data", [])
# Flatten device data structure
for item in batch:
flat_device = {
"id": item.get("id"),
"type": item.get("type")
}
# Merge attributes into flat structure
attributes = item.get("attributes", {}) or {}
flat_device.update(attributes)
devices.append(flat_device)
# Handle pagination
links = data.get("links", {}) or {}
url = links.get("next")
params = None # Don't reuse params for next page
# Safety check to prevent infinite loops
if not batch:
break
return devices
def write_csv(rows: List[Dict[str, Any]], path: str):
"""Write device data to CSV file"""
os.makedirs(os.path.dirname(path), exist_ok=True)
if not rows:
# Create empty CSV with basic headers
with open(path, "w", newline="", encoding="utf-8") as f:
csv.writer(f).writerow(["id", "type", "serialNumber", "deviceModel", "status"])
return
# Get all unique keys from all rows for headers
headers = sorted({key for row in rows for key in row.keys()})
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=headers)
writer.writeheader()
for row in rows:
writer.writerow(row)
def write_json(rows: List[Dict[str, Any]], path: str):
"""Write device data to JSON file"""
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(rows, f, indent=2, ensure_ascii=False)
def load_tenants(config_path: str) -> List[TenantConfig]:
"""Load tenant configurations from JSON file"""
with open(config_path, "r") as f:
data = json.load(f)
tenants = []
for tenant_data in data.get("tenants", []):
pem_path = tenant_data["private_key_pem"]
# Load PEM content from file if it's a path
if pem_path.endswith(".pem") and os.path.exists(pem_path):
with open(pem_path, "r") as pem_file:
pem_content = pem_file.read()
else:
pem_content = pem_path # Assume it's inline PEM content
tenants.append(TenantConfig(
name=tenant_data["name"],
client_id=tenant_data["client_id"],
key_id=tenant_data["key_id"],
private_key_pem=pem_content,
scope=tenant_data.get("scope", "business.api")
))
return tenants
def export_tenant(tenant: TenantConfig, output_dir: str, format_type: str) -> str:
"""Export devices for a single tenant"""
client = ABMClient()
# Get access token
access_token, ttl = client.get_access_token(tenant)
print(f" - Got access token (expires in {ttl} seconds)")
# Fetch all devices
devices = client.fetch_all_devices(access_token)
print(f" - Fetched {len(devices)} devices")
# Generate output filename with timestamp
timestamp = int(time.time())
output_file = os.path.join(output_dir, f"abm_devices_{tenant.name}_{timestamp}.{format_type}")
# Write to file
if format_type == "json":
write_json(devices, output_file)
else:
write_csv(devices, output_file)
return output_file
def main():
import argparse
parser = argparse.ArgumentParser(description="Export ABM devices for multiple tenants")
parser.add_argument("--config", required=True, help="Path to tenants.json configuration file")
parser.add_argument("--output-dir", default="./exports", help="Output directory for exports")
parser.add_argument("--format", choices=["csv", "json"], default="csv", help="Output format")
parser.add_argument("--tenant", help="Export only specific tenant (by name)")
args = parser.parse_args()
# Load tenant configurations
try:
tenants = load_tenants(args.config)
except Exception as e:
print(f"Error loading tenant config: {e}")
return 1
# Filter to specific tenant if requested
if args.tenant:
tenants = [t for t in tenants if t.name == args.tenant]
if not tenants:
print(f"Tenant '{args.tenant}' not found in configuration")
return 1
# Create output directory
os.makedirs(args.output_dir, exist_ok=True)
# Process each tenant
results = []
for tenant in tenants:
print(f"\nProcessing tenant: {tenant.name}")
try:
output_path = export_tenant(tenant, args.output_dir, args.format)
print(f" - ✅ Export completed: {output_path}")
results.append((tenant.name, "SUCCESS", output_path))
except Exception as e:
print(f" - ❌ Export failed: {e}")
results.append((tenant.name, "FAILED", str(e)))
# Print summary
print(f"\n{'='*50}")
print("EXPORT SUMMARY")
print(f"{'='*50}")
for name, status, detail in results:
status_icon = "✅" if status == "SUCCESS" else "❌"
print(f"{status_icon} {name}: {status}")
if status == "SUCCESS":
print(f" File: {detail}")
else:
print(f" Error: {detail}")
# Return error count for shell scripting
failed_count = sum(1 for _, status, _ in results if status == "FAILED")
return failed_count
if __name__ == "__main__":
raise SystemExit(main())
Step 5: Running the Export
Make the script executable and run exports:
chmod +x abm_export.py
# Export all tenants to CSV
python3 abm_export.py --config tenants.json --output-dir ./exports
# Export specific tenant to JSON
python3 abm_export.py --config tenants.json --tenant "TenantA" --format json
# Export with custom output directory
python3 abm_export.py --config tenants.json --output-dir /path/to/exports
Step 6: Automation and Scheduling
Daily Export with cron
Add to your crontab (crontab -e
):
# Export ABM devices daily at 6 AM
0 6 * * * cd /path/to/abm-export && source .venv/bin/activate && python3 abm_export.py --config tenants.json --output-dir ./exports
Weekly Cleanup Script
Create cleanup_old_exports.sh
:
#!/bin/bash
# Remove exports older than 30 days
find ./exports -name "abm_devices_*.csv" -mtime +30 -delete
find ./exports -name "abm_devices_*.json" -mtime +30 -delete
Troubleshooting
Common Issues and Solutions
1. Key Format Errors
Could not deserialize key data...
- Solution: Run the
convert_pems.sh
script to convert EC keys to PKCS#8
2. Invalid Client Assertion
400 invalid_client / invalid_grant
- Solution: Verify
iss=sub=client_id
, correctkid
, and audience URL
3. 401 Unauthorized on Device API
401 Unauthorized
- Solution: Use the
access_token
(not assertion) in Bearer header
4. Empty Device List
Fetched 0 devices
- Solution: Verify tenant has devices assigned and API user has correct permissions
Quick Connectivity Test
Test with curl:
# Get access token first, then:
ACCESS_TOKEN="your_token_here"
curl "https://api-business.apple.com/v1/orgDevices" \
-H "Authorization: Bearer ${ACCESS_TOKEN}"
Output Examples
CSV Export Sample
id,type,serialNumber,deviceModel,productFamily,status,addedToOrgDateTime
12345,devices,C02ABC123DEF,MacBook Pro,Mac,Assigned,2024-01-15T10:30:00Z
67890,devices,DMPABC456GHI,iPad Air,iPad,Available,2024-02-01T14:20:00Z
JSON Export Sample
[
{
"id": "12345",
"type": "devices",
"serialNumber": "C02ABC123DEF",
"deviceModel": "MacBook Pro",
"productFamily": "Mac",
"status": "Assigned",
"addedToOrgDateTime": "2024-01-15T10:30:00Z"
}
]
Security Best Practices
- Key Storage: Store private keys in
~/secrets
withchmod 600
- Backup Keys: Always backup original keys before conversion
- Token Handling: Access tokens expire in 60 minutes - script fetches fresh tokens each run
- Network Security: Script uses HTTPS and retry logic for resilience
- Logging: Avoid logging sensitive data (tokens, keys)
Extending the Solution
Add MDM Server Information
To include assigned MDM server names, extend the script to call:
GET /v1/orgDevices/{id}/assignedServer?fields[mdmServers]=serverName
Integration with Asset Management
Export data can be imported into:
- Jamf Pro (via API)
- Microsoft Intune (via Graph API)
- ServiceNow (CMDB integration)
- Custom databases or spreadsheets
Monitoring and Alerting
Add monitoring for:
- Export success/failure rates
- Device count changes
- New device additions
- Status changes (Available → Assigned)
Conclusion
This complete workflow automates Apple Business Manager device exports across multiple tenants with:
- ✅ Automated key format conversion
- ✅ Multi-tenant support
- ✅ Robust error handling and retries
- ✅ Flexible output formats (CSV/JSON)
- ✅ Pagination handling for large device lists
- ✅ Security best practices
The solution scales from single tenant to enterprise deployments, eliminates manual export work, and provides clean data for integration with other systems.
Next Steps: Set up scheduling, add monitoring, and integrate with your asset management workflow.
Happy automating! 🚀