Skip to main content
Question

Populate Jamf Pro Warranty Information with ABM/ASM API + MUT

  • February 23, 2026
  • 1 reply
  • 19 views

bwoods
Forum|alt.badge.img+14

With Apple recently expanding access to the ABM/ASM API, I set out to recreate the GSX experience. By leveraging Jamf’s new MCP feature, Claude Code, and MUT, I believe I’ve come very close. Additional details are outlined below.

I also plan to publish a GitHub repository for anyone interested in contributing or enhancing the project.

#!/bin/zsh

# ==============================================================================
# Script Name: abm_warranty_report.zsh
# Author: Brandon Woods
# Date: February 23, 2026
# ==============================================================================
#
# Pulls device and AppleCare / warranty coverage data from Apple Business
# Manager and writes two MUT-compatible CSV files:
#
# ComputerTemplate.csv — Mac devices (productFamily = "Mac")
# MobileDeviceTemplate.csv — iPhone, iPad, Apple TV, iPod, Vision Pro, etc.
#
# Incremental mode:
# If the output CSV files already exist at the configured paths, the script
# loads the serials already present and skips them — only newly added ABM
# devices are fetched and appended. If no new devices are found the script
# exits and tells you so.
#
# Populated fields (all others left blank):
# Both templates:
# Serial Number — device serial / ID from ABM
# PO Number — orderNumber from ABM device record
# Vendor — purchaseSourceType from ABM device record
# Purchase Price — not available in ABM API (always blank)
# PO Date — orderDateTime from ABM device record (date only)
# Warranty Expires — Limited Warranty endDateTime (date only)
# AppleCare ID — AppleCare agreement number (blank if none)
#
# Each device row is written to disk immediately after its coverage is fetched.
#
# Prerequisites:
# - ABM API account with Client ID, Key ID, and .pem private key
# - jq (brew install jq)
# - openssl + xxd (built-in on macOS)
#
# Usage:
# ./abm_warranty_report.zsh
# ./abm_warranty_report.zsh --key /path/to/key.pem \
# --client-id BUSINESSAPI.xxxx \
# --key-id xxxx \
# --outdir /path/to/output/folder \
# --computer-file MyMacs.csv \
# --mobile-file MyMobileDevices.csv
# ==============================================================================

# ---------- Configuration (edit these) ---------------------------------------
ABM_PRIVATE_KEY_PATH="/path/to/private-key.pem"
ABM_CLIENT_ID="BUSINESSAPI.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
ABM_KEY_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
OUTPUT_DIR="."
COMPUTER_FILENAME="ComputerTemplate.csv"
MOBILE_FILENAME="MobileDeviceTemplate.csv"

# API endpoints
ABM_AUTH_URL="https://account.apple.com/auth/oauth2/token"
ABM_API_BASE="https://api-business.apple.com/v1"

# Pause between per-device coverage API calls to avoid rate limiting (seconds)
RATE_LIMIT_DELAY=0.2

# ---------- Parse command-line flags -----------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--key) ABM_PRIVATE_KEY_PATH="$2"; shift 2 ;;
--client-id) ABM_CLIENT_ID="$2"; shift 2 ;;
--key-id) ABM_KEY_ID="$2"; shift 2 ;;
--outdir) OUTPUT_DIR="$2"; shift 2 ;;
--computer-file) COMPUTER_FILENAME="$2"; shift 2 ;;
--mobile-file) MOBILE_FILENAME="$2"; shift 2 ;;
--delay) RATE_LIMIT_DELAY="$2"; shift 2 ;;
--help|-h)
sed -n '3,38p' "$0" | sed 's/^# \{0,1\}//'
exit 0 ;;
*)
echo "Unknown option: $1" >&2
exit 1 ;;
esac
done

# Ensure filenames end in .csv
[[ "$COMPUTER_FILENAME" != *.csv ]] && COMPUTER_FILENAME="${COMPUTER_FILENAME}.csv"
[[ "$MOBILE_FILENAME" != *.csv ]] && MOBILE_FILENAME="${MOBILE_FILENAME}.csv"

COMPUTER_CSV="${OUTPUT_DIR}/${COMPUTER_FILENAME}"
MOBILE_CSV="${OUTPUT_DIR}/${MOBILE_FILENAME}"

# ---------- Dependency checks -------------------------------------------------
for cmd in jq openssl curl xxd; do
if ! command -v "$cmd" &>/dev/null; then
echo "ERROR: '$cmd' is required but not found. Install with: brew install $cmd" >&2
exit 1
fi
done

if [[ ! -f "$ABM_PRIVATE_KEY_PATH" ]]; then
echo "ERROR: Private key not found at: $ABM_PRIVATE_KEY_PATH" >&2
exit 1
fi

if [[ ! -d "$OUTPUT_DIR" ]]; then
echo "ERROR: Output directory does not exist: $OUTPUT_DIR" >&2
exit 1
fi

# ---------- Detect existing CSVs and load known serials ----------------------
# knownComputerSerials and knownMobileSerials are associative arrays used as
# sets — key = serial number, value = 1. Lookup is O(1).
typeset -A knownComputerSerials
typeset -A knownMobileSerials

computerFileExists=false
mobileFileExists=false

if [[ -f "$COMPUTER_CSV" ]]; then
computerFileExists=true
# Read col 1 (serial), skip header row, strip surrounding quotes
while IFS=, read -r serial rest; do
serial="${serial//\"/}"
[[ -n "$serial" && "$serial" != "Computer Serial" ]] && knownComputerSerials[$serial]=1
done < "$COMPUTER_CSV"
echo "-> Existing computer file detected: $COMPUTER_CSV"
echo " ${#knownComputerSerials} known serials loaded — will append new devices only"
else
echo "-> No existing computer file found — will create: $COMPUTER_CSV"
fi

if [[ -f "$MOBILE_CSV" ]]; then
mobileFileExists=true
while IFS=, read -r serial rest; do
serial="${serial//\"/}"
[[ -n "$serial" && "$serial" != "Mobile Device Serial" ]] && knownMobileSerials[$serial]=1
done < "$MOBILE_CSV"
echo "-> Existing mobile file detected: $MOBILE_CSV"
echo " ${#knownMobileSerials} known serials loaded — will append new devices only"
else
echo "-> No existing mobile file found — will create: $MOBILE_CSV"
fi

# ---------- Helper: base64url encode -----------------------------------------
base64url() {
openssl base64 -A | tr '+/' '-_' | tr -d '='
}

# ---------- Step 1: Build and sign the JWT client assertion -------------------
echo "-> Generating JWT client assertion..."

nowTimestamp=$(date -u +%s)
expTimestamp=$(( nowTimestamp + 15552000 )) # 180 days
jti=$(uuidgen | tr '[:upper:]' '[:lower:]')

headerJson=$(printf '{"alg":"ES256","kid":"%s","typ":"JWT"}' "$ABM_KEY_ID")
jwtHeader=$(printf '%s' "$headerJson" | base64url)

payloadJson=$(printf '{"sub":"%s","aud":"https://account.apple.com/auth/oauth2/v2/token","iat":%d,"exp":%d,"jti":"%s","iss":"%s"}' \
"$ABM_CLIENT_ID" "$nowTimestamp" "$expTimestamp" "$jti" "$ABM_CLIENT_ID")
jwtPayload=$(printf '%s' "$payloadJson" | base64url)

signingInput="${jwtHeader}.${jwtPayload}"

asn1Out=$(printf '%s' "$signingInput" \
| openssl dgst -sha256 -sign "$ABM_PRIVATE_KEY_PATH" 2>/dev/null \
| openssl asn1parse -inform DER 2>&1)

if [[ $? -ne 0 ]]; then
echo "ERROR: openssl signing failed. Verify your .pem contains a valid EC private key." >&2
echo "$asn1Out" >&2
exit 1
fi

rHex=$(echo "$asn1Out" | awk '/INTEGER/{gsub(/.*INTEGER[[:space:]]+:/,"",$0); gsub(/ /,"",$0); if(++n==1) print}')
sHex=$(echo "$asn1Out" | awk '/INTEGER/{gsub(/.*INTEGER[[:space:]]+:/,"",$0); gsub(/ /,"",$0); if(++n==2) print}')

if [[ -z "$rHex" || -z "$sHex" ]]; then
echo "ERROR: Failed to extract r/s from ASN.1 signature." >&2
echo "$asn1Out" >&2
exit 1
fi

rHex=$(printf '%s' "$rHex" | sed 's/^00//')
sHex=$(printf '%s' "$sHex" | sed 's/^00//')
while [[ ${#rHex} -lt 64 ]]; do rHex="00${rHex}"; done
while [[ ${#sHex} -lt 64 ]]; do sHex="00${sHex}"; done

signature=$(printf '%s%s' "$rHex" "$sHex" | xxd -r -p | base64url)
clientAssertion="${signingInput}.${signature}"
echo " OK Client assertion generated"

# ---------- Step 2: Exchange client assertion for bearer token ---------------
echo "-> Requesting bearer token..."

tokenResponse=$(curl -s -w "\n__STATUS__%{http_code}" -X POST \
-H "Host: account.apple.com" \
-H "Content-Type: application/x-www-form-urlencoded" \
"${ABM_AUTH_URL}?grant_type=client_credentials&client_id=${ABM_CLIENT_ID}&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion=${clientAssertion}&scope=business.api")

httpStatus=$(echo "$tokenResponse" | grep '__STATUS__' | sed 's/__STATUS__//')
tokenBody=$(echo "$tokenResponse" | grep -v '__STATUS__')

if [[ "$httpStatus" != "200" ]]; then
echo "ERROR: Token request failed (HTTP $httpStatus):" >&2
echo "$tokenBody" >&2
exit 1
fi

accessToken=$(echo "$tokenBody" | jq -r '.access_token // empty')
if [[ -z "$accessToken" ]]; then
echo "ERROR: No access_token in response:" >&2
echo "$tokenBody" >&2
exit 1
fi

echo " OK Bearer token obtained (valid ~1 hour)"

# ---------- Step 3: Initialize output files -----------------------------------
# Write header only if the file does not already exist
echo "-> Initializing output files..."

if [[ "$computerFileExists" == false ]]; then
printf '%s\n' "Computer Serial,Display Name,Asset Tag,Barcode 1,Barcode 2,Username,Real Name,Email Address,Position,Phone Number,Department,Building,Room,PO Number,Vendor,Purchase Price,PO Date,Warranty Expires,Is Leased,Lease Expires,AppleCare ID,Site (ID or Name)" > "$COMPUTER_CSV"
echo " Created: $COMPUTER_CSV"
else
echo " Appending to: $COMPUTER_CSV"
fi

if [[ "$mobileFileExists" == false ]]; then
printf '%s\n' "Mobile Device Serial,Display Name,Enforce Name,Asset Tag,Username,Real Name,Email Address,Position,Phone Number,Department,Building,Room,PO Number,Vendor,Purchase Price,PO Date,Warranty Expires,Is Leased,Lease Expires,AppleCare ID,Airplay Password (tvOS Only),Site (ID or Name)" > "$MOBILE_CSV"
echo " Created: $MOBILE_CSV"
else
echo " Appending to: $MOBILE_CSV"
fi

# ---------- Step 4: Enumerate devices and fetch coverage page by page --------
echo "-> Fetching devices from ABM..."

totalDevices=0
newComputerCount=0
newMobileCount=0
skippedCount=0
errorCount=0
nextCursor=""
pageCount=0

while true; do
pageCount=$(( pageCount + 1 ))

if [[ -n "$nextCursor" ]]; then
pageUrl="${ABM_API_BASE}/orgDevices?cursor=${nextCursor}"
else
pageUrl="${ABM_API_BASE}/orgDevices"
fi

pageResponse=$(curl -sf \
-H "Authorization: Bearer ${accessToken}" \
"$pageUrl")

if [[ $? -ne 0 ]]; then
echo "ERROR: Failed to fetch device list (page $pageCount)" >&2
exit 1
fi

pageDeviceCount=$(echo "$pageResponse" | jq '.data | length')
echo " Page $pageCount: $pageDeviceCount devices"

while IFS= read -r serial \
&& IFS= read -r productFamily \
&& IFS= read -r orderNumber \
&& IFS= read -r purchaseSourceType \
&& IFS= read -r orderDateTime; do

totalDevices=$(( totalDevices + 1 ))

# Skip devices already present in the existing CSV
if [[ "$productFamily" == "Mac" ]]; then
if (( ${+knownComputerSerials[$serial]} )); then
skippedCount=$(( skippedCount + 1 ))
continue
fi
targetCSV="$COMPUTER_CSV"
newComputerCount=$(( newComputerCount + 1 ))
else
if (( ${+knownMobileSerials[$serial]} )); then
skippedCount=$(( skippedCount + 1 ))
continue
fi
targetCSV="$MOBILE_CSV"
newMobileCount=$(( newMobileCount + 1 ))
fi

# Normalize null jq values to empty string
[[ "$orderNumber" == "null" ]] && orderNumber=""
[[ "$purchaseSourceType" == "null" ]] && purchaseSourceType=""
[[ "$orderDateTime" == "null" ]] && orderDateTime=""

# Trim time portion from ISO 8601 timestamp — keep date only
poDate="${orderDateTime%%T*}"

echo " New device: $serial ($productFamily)"

# Fetch AppleCare coverage for this device
coverageResponse=$(curl -sf \
-H "Authorization: Bearer ${accessToken}" \
"${ABM_API_BASE}/orgDevices/${serial}/appleCareCoverage")

if [[ $? -ne 0 ]]; then
# Coverage unavailable — write serial and PO fields, leave warranty blank
if [[ "$productFamily" == "Mac" ]]; then
printf '"%s",,,,,,,,,,,,,"%s","%s",,"%s",,,,,\n' \
"$serial" "$orderNumber" "$purchaseSourceType" "$poDate" >> "$targetCSV"
else
printf '"%s",,,,,,,,,,,,"%s","%s",,"%s",,,,,,\n' \
"$serial" "$orderNumber" "$purchaseSourceType" "$poDate" >> "$targetCSV"
fi
errorCount=$(( errorCount + 1 ))
sleep "$RATE_LIMIT_DELAY"
continue
fi

# Limited Warranty end date -> Warranty Expires (date only)
warrantyExpires=$(echo "$coverageResponse" | jq -r '
[ .data[] | select(.attributes.description == "Limited Warranty") ]
| first
| .attributes.endDateTime // ""')
warrantyExpires="${warrantyExpires%%T*}"

# AppleCare agreement number -> AppleCare ID (prefer ACTIVE entry)
applecareID=$(echo "$coverageResponse" | jq -r '
[ .data[] | select(.attributes.description != "Limited Warranty") ]
| sort_by(.attributes.status == "ACTIVE" | not)
| first
| .attributes.agreementNumber // ""')

# Write row immediately — Purchase Price always blank (not in ABM API)
if [[ "$productFamily" == "Mac" ]]; then
# Computer: 22 cols
# Col: 1=Serial 14=PO# 15=Vendor 16=Price(blank) 17=PODate 18=WarrantyExpires 21=AppleCareID
printf '"%s",,,,,,,,,,,,,"%s","%s",,"%s","%s",,,"%s",\n' \
"$serial" \
"$orderNumber" "$purchaseSourceType" \
"$poDate" "$warrantyExpires" \
"$applecareID" >> "$targetCSV"
else
# Mobile: 22 cols
# Col: 1=Serial 13=PO# 14=Vendor 15=Price(blank) 16=PODate 17=WarrantyExpires 20=AppleCareID
printf '"%s",,,,,,,,,,,,"%s","%s",,"%s","%s",,,"%s",,\n' \
"$serial" \
"$orderNumber" "$purchaseSourceType" \
"$poDate" "$warrantyExpires" \
"$applecareID" >> "$targetCSV"
fi

sleep "$RATE_LIMIT_DELAY"

done < <(echo "$pageResponse" | jq -r '.data[] | (
.id,
(.attributes.productFamily // "Unknown"),
(.attributes.orderNumber // "null"),
(.attributes.purchaseSourceType // "null"),
(.attributes.orderDateTime // "null")
)')

echo " Page $pageCount complete — New: $newComputerCount computers, $newMobileCount mobile | Skipped: $skippedCount | Errors: $errorCount"

nextCursor=$(echo "$pageResponse" | jq -r '.meta.paging.nextCursor // empty')
[[ -z "$nextCursor" ]] && break
done

# ---------- Summary -----------------------------------------------------------
newDevicesTotal=$(( newComputerCount + newMobileCount ))

echo ""
echo "============================================"
echo " ABM Warranty Report Complete"
echo "============================================"
echo " Total devices in ABM : $totalDevices"
echo " Already in CSV : $skippedCount (skipped)"
echo " New computers added : $newComputerCount -> $(basename "$COMPUTER_CSV")"
echo " New mobile added : $newMobileCount -> $(basename "$MOBILE_CSV")"
echo " Coverage errors : $errorCount"
echo "============================================"

if [[ $newDevicesTotal -eq 0 ]]; then
echo ""
echo " No new devices were found in ABM."
echo " Both CSV files are already up to date."
echo "============================================"
fi

ABM Warranty Report — Setup & Usage Guide

This guide walks through everything needed to run abm_warranty_report.zsh, a script that pulls warranty and AppleCare coverage data from Apple Business Manager (ABM) and produces CSV files ready for import into the Mass Update Tool (MUT).

Prerequisites

  • A Mac running macOS
  • Administrator access to Apple Business Manager
  • jq installed — if you don't have it, install it with Homebrew:

 

 

  brew install jq
  • openssl, curl, and xxd — all included with macOS by default

Step 1 — Create a Working Directory

Create a dedicated folder to store the script, your private key, and the generated CSV files. Keeping everything together makes the script easier to configure and run.

Open Terminal and run:

 

 

zsh

mkdir ~/abm-warranty

You can name and place this folder wherever makes sense for your environment. Just note the full path — you'll need it shortly.

Step 2 — Create an API Account in Apple Business Manager

Note: You must have the Administrator role in ABM to complete this step.

  1. Sign in to Apple Business Manager.
  2. Select your name at the bottom of the sidebar, then select Preferences.
  3. Select API from the preferences panel.
  4. Select Get Started, enter a name for the account (e.g., Warranty Report), then select Create.
  5. Select Generate Private Key. A .pem file will automatically download to your browser's download location.
  6. Select Manage on the newly created API account and note the following two values — you will need them when configuring the script:
    • Client ID — looks like BUSINESSAPI.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
    • Key ID — looks like xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

Important: The .pem file can only be downloaded once. If it is lost, you will need to revoke the key and generate a new one. Store it securely.

For full details on this process, refer to Apple's official documentation: Create an API account in Apple Business Manager

Step 3 — Store the Private Key

Move the downloaded .pem file into the working directory you created in Step 1.

In Terminal:

 

 

zsh

mv ~/Downloads/your-private-key.pem ~/abm-warranty/

Replace your-private-key.pem with the actual filename of the downloaded file.

Step 4 — Place the Script in the Working Directory

Move or copy abm_warranty_report.zsh into the same working directory:

 

 

zsh

mv ~/Downloads/abm_warranty_report.zsh ~/abm-warranty/

Then make it executable:

 

 

zsh

chmod +x ~/abm-warranty/abm_warranty_report.zsh

Step 5 — Configure the Script

Open the script in a text editor to fill in your credentials and paths. You can use:

  • Terminal with a built-in editor:

 

 

zsh

  nano ~/abm-warranty/abm_warranty_report.zsh
  • Visual Studio Code:

 

 

zsh

  code ~/abm-warranty/abm_warranty_report.zsh
  • CodeRunner (or any other editor of your choice) — open the file from the working directory

Find the configuration block near the top of the script — it looks like this:

 

 

zsh

# ---------- Configuration (edit these) ---------------------------------------
ABM_PRIVATE_KEY_PATH="/path/to/private-key.pem"
ABM_CLIENT_ID="BUSINESSAPI.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
ABM_KEY_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
OUTPUT_DIR="."
COMPUTER_FILENAME="ComputerTemplate.csv"
MOBILE_FILENAME="MobileDeviceTemplate.csv"

Replace each placeholder with your actual values:

Variable What to enter
ABM_PRIVATE_KEY_PATH Full path to your .pem file, e.g. /Users/yourname/abm-warranty/private-key.pem
ABM_CLIENT_ID The Client ID from your ABM API account
ABM_KEY_ID The Key ID from your ABM API account
OUTPUT_DIR Path to the folder where CSVs should be saved, e.g. /Users/yourname/abm-warranty
COMPUTER_FILENAME Name for the Mac CSV file (default: ComputerTemplate.csv)
MOBILE_FILENAME Name for the mobile device CSV file (default: MobileDeviceTemplate.csv)

Save and close the file when done.

Step 6 — Run the Script

In Terminal, navigate to your working directory and run the script:

 

 

zsh

cd ~/abm-warranty
./abm_warranty_report.zsh

The script will print its progress as it runs, showing each page of devices fetched and a count of new vs. skipped devices per page.

What the Script Does

Two separate output files

The script separates devices into two CSV files:

  • ComputerTemplate.csv — contains all Mac computers found in ABM
  • MobileDeviceTemplate.csv — contains all other Apple devices: iPhones, iPads, Apple TVs, iPod touches, Apple Vision Pro, and any other non-Mac products

Each row is written to disk immediately as it is processed, so if the script is interrupted, any data already fetched is preserved.

Fields populated

Both files include the following fields for each device (all other columns are left blank):

Field Source
Serial Number Device serial number from ABM
PO Number Order number from ABM device record
Vendor Purchase source from ABM device record
Purchase Price Not available in the ABM API — always blank
PO Date Order date from ABM (date only)
Warranty Expires Limited Warranty end date from ABM
AppleCare ID AppleCare agreement number, if the device has AppleCare coverage

Incremental updates

The script is designed to be run repeatedly as new devices are added to ABM. When run again:

  • If the output CSV files already exist at the configured paths, the script reads the serial numbers already present in each file
  • It then compares those against all devices currently in ABM
  • Only new devices that are not already in the CSV are fetched and appended — existing rows are never modified
  • If no new devices are found, the script exits with a clear message confirming that both files are already up to date

This means you can run the script on a regular schedule and the CSVs will grow over time to reflect your fleet without duplication.

Using the CSVs with MUT

The generated CSV files are formatted to match MUT's default templates exactly — the column headers and order are identical to what MUT expects out of the box.

To import into Jamf Pro using MUT:

  1. Open MUT and connect to your Jamf Pro server
  2. For Mac warranty data, select Computers and import ComputerTemplate.csv
  3. For mobile device and Apple TV warranty data, select Mobile Devices and import MobileDeviceTemplate.csv
  4. MUT will match each row to the device by serial number and update only the fields that have values — blank columns in the CSV are ignored

Note: MUT updates records by matching the serial number column. Devices in the CSV that do not exist in Jamf Pro will be skipped by MUT without causing errors.

Customizing Output Filenames

If you want to use different filenames for the generated CSVs — for example, to include a date or distinguish between environments — you can pass them as flags when running the script:

 

 

zsh

./abm_warranty_report.zsh \
--computer-file "Macs_$(date +%Y-%m-%d).csv" \
--mobile-file "Mobile_$(date +%Y-%m-%d).csv"

You can also change COMPUTER_FILENAME and MOBILE_FILENAME directly in the configuration block at the top of the script.

Note: If you change the filename between runs, the script will not recognize the old file and will treat all devices as new. Use consistent filenames across runs to take advantage of the incremental update behavior.

Troubleshooting

"Private key not found" — double-check that ABM_PRIVATE_KEY_PATH points to the exact location of your .pem file, including the filename and extension.

"Token request failed" — verify that your ABM_CLIENT_ID and ABM_KEY_ID match exactly what is shown in the ABM API account management screen. Both values are case-sensitive.

"jq not found" — install jq with brew install jq and re-run the script.

Warranty fields are blank in Jamf after MUT import — confirm that the column headers in the CSV match the field names in Jamf Pro exactly. The script uses MUT's default column names, so no changes should be needed on a standard Jamf setup.

1 reply

mattjerome
Forum|alt.badge.img+11
  • Jamf Heroes
  • February 23, 2026

This is fantastic! Great work!