Complete Guide: Automating Apple Business Manager Device Exports with Python on macOS

11 min read
Apple Business ManagermacOSPythonAPIAutomationDevOps

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)
  • 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, correct kid, 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

  1. Key Storage: Store private keys in ~/secrets with chmod 600
  2. Backup Keys: Always backup original keys before conversion
  3. Token Handling: Access tokens expire in 60 minutes - script fetches fresh tokens each run
  4. Network Security: Script uses HTTPS and retry logic for resilience
  5. 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! 🚀