An asset record that only gets created when someone remembers to type it in is wrong by the end of the quarter. I wanted Snipe-IT to be right without anyone touching it, which meant feeding it from the two systems that already know the truth: Apple Business Manager (ABM) knows what we ordered, and Jamf Pro knows what actually enrolled.

That became two scripts. abm2snipe covers the front of a device’s life, and jamf2snipe covers everything after enrollment. They share conventions (Keychain-first credentials, settings.conf for the non-secret parts, --dryrun, Slack run summaries), and they have to agree on who owns which field.


jamf2snipe: The Fork

jamf2snipe is a fork of the grokability/jamf2snipe project. The upstream does the hard part: match Jamf computers and mobile devices to Snipe-IT assets by serial number, create what’s missing, and update what’s stale. My changes are on top of that:

  • Credentials come from the macOS Keychain first (via Python’s keyring), then fall back to settings.conf
  • Snipe-IT’s asset status is written back to a Jamf Extension Attribute, so smart groups and reports can use it
  • Bulk fetching and parallel Jamf lookups, because the original N+1 pattern took about 13 minutes per run
  • A filter that skips personally owned mobile devices

The sync rule is simple. If the Jamf record’s timestamp is newer than the Snipe-IT record’s updated_at, Jamf wins and the mapped fields are updated. Otherwise it skips, unless I pass --force:

snipe_time = snipe['rows'][0]['updated_at']['datetime']
...
if ( jamf_time > snipe_time ) or ( user_args.force ):

The one deliberate exception is asset tags. Snipe-IT is authoritative: the tag is pushed into Jamf, never the other way around for an asset that already exists.


The Pagination Bug

The performance fix was straightforward. Instead of one byserial lookup per device, fetch every Snipe-IT hardware asset up front in pages of 500 and build a serial-to-asset dictionary. The original per-device pattern took about 13 minutes per run, and this was meant to cut that to a few.

Then, a couple of weeks later, the log started filling with “Asset creation failed” errors for devices that clearly existed in Snipe-IT. Snipe-IT was rejecting the creates with serial must be unique and asset_tag must be unique, which is the correct response to a duplicate. The question was why the script thought they were missing.

My bulk fetch hadn’t specified a sort order. Offset pagination over a collection whose default ordering can shift while you read it will skip rows: an asset updated between page 2 and page 3 can move across the boundary and never appear in the map. The fix is one query-string change:

api_url = '{}/api/v1/hardware?limit={}&offset={}&sort=id&order=asc'.format(snipe_base, limit, offset)

Sorting by id gives a stable order regardless of edits. I also added a belt-and-suspenders fallback: when the map says NoMatch, ask Snipe-IT directly via /api/v1/hardware/byserial/{serial} before attempting a create. If that finds something, the script logs that the bulk fetch missed it and patches the in-memory map:

if snipe == 'NoMatch':
    snipe = search_snipe_asset(jamf['general']['serial_number'])
    if snipe != 'NoMatch':
        logging.info("Bulk fetch missed serial {} - found via byserial fallback.".format(...))
        snipe_serial_map[jamf['general']['serial_number']] = snipe.get('rows', [])

The sort fix prevents the problem. The fallback exists so that if something else produces the same symptom, the answer is a log line instead of a failed create.


The BYOD Filter That Didn’t Filter

Personally owned phones enrolled in Jamf shouldn’t be company assets in Snipe-IT. I added an exclude_personal_mobiles setting (default false, enabled in our deployment), and I did not guess the field name. I ran a dry run with debug logging against the real Jamf instance and confirmed the Classic API exposes ownership as general.device_ownership_level.

First version: ownership_level.lower() == 'personal'. Shipped, ran, and a handful of personal devices still showed up in the dry run.

Querying one of those devices directly showed why. Devices enrolled through Account-Driven User Enrollment report "Personal (Account-Driven User Enrollment)", not the bare "Personal" I’d matched on. Every BYOD device enrolled that way walked right past the filter. The fix:

if ownership_level and ownership_level.lower().startswith('personal'):
    logging.info("Skipping mobile device '{}' (JAMFID: {}) - it's marked as personally owned in JAMF.".format(...))
    sync_counters['skipped'] += 1
    continue

A re-run showed the previously leaking devices all counted as skipped. The lesson is the usual one: an exact match against an enum you’ve only seen one value of is a bug waiting for the second value.


abm2snipe: Before the Box Is Opened

jamf2snipe can’t see a Mac until it enrolls. For configure-to-order hardware that can be weeks after the purchase, during which IT has no record of which serial is which model. abm2snipe closes that gap. It pulls org devices from ABM, diffs the serials against what’s already in Snipe-IT, and creates pre-deployment assets for the new ones. It only ever creates; it never updates or deletes.

Authenticating to ABM

Apple’s Business API uses OAuth2 client credentials, but instead of a client secret you prove identity with a signed JWT (a client assertion). If you read my TestFlight post, this is the same family of problem, except this time PyJWT does the ES256 signing and I don’t have to unpack DER signatures with asn1parse:

claims = {
    'sub': abm_client_id,
    'aud': ABM_TOKEN_AUD,
    'iat': now,
    'exp': now + 900,
    'jti': str(uuid.uuid4()),
    # ABM has no separate Team ID in its UI - iss is just the client_id again.
    'iss': abm_client_id,
}
client_assertion = jwt.encode(claims, abm_private_key, algorithm='ES256', headers={'kid': abm_key_id})

That assertion is posted to the token endpoint with grant_type=client_credentials, the JWT-bearer client_assertion_type, and scope=business.api. The returned access token is cached and re-minted a minute before it expires. Devices come from GET /v1/orgDevices, requested with an explicit fields[orgDevices] list and paged with a cursor from meta.paging.nextCursor.

What ABM Doesn’t Tell You

The API returns model name, part number, storage capacity, color, order number, and dates. It does not return RAM or processor. For configure-to-order machines the part number is often an opaque order code with no public decode table. So the pre-deployment record is honest about what it knows: serial, a model name built from model, capacity, and color, and the part number stashed in a dedicated SKU custom field. RAM and CPU stay blank until Jamf reports the real hardware. For anyone who wants to fill those in from an invoice, --export-snipeit-csv writes a file in Snipe-IT’s importer format with those columns left empty.

Two Decisions That Keep the Tools From Fighting

The asset tag is deliberately omitted. We use Snipe-IT’s auto-incrementing tags, and jamf2snipe pushes Snipe-IT’s tag into Jamf and never overwrites it on an existing asset. Whatever abm2snipe set at creation would become that machine’s permanent tag. So it sends only the serial, a name, a status, and the model:

asset_payload = {
    'serial': serial,
    'name': '{} - {}'.format(model_name, serial),
    'status_id': default_status_id,
}

There’s a circuit breaker. max_new_assets_per_run aborts the entire run, creating nothing, if the diff is larger than the threshold, and sends a Slack alert. A missing product-family filter shouldn’t be able to import an entire fleet as new assets.


What I’d Do Differently

The seam between the two tools is the part I haven’t solved. jamf2snipe’s update path never touches model_id, so an asset created by abm2snipe stays on its placeholder model forever, even after Jamf knows the real model identifier. Snipe-IT ends up with two disjoint model catalogs: Jamf-native models keyed by identifier, and ABM placeholders keyed by name, capacity, and color. The asset data itself is always correct; only Model-based grouping fragments.

I’ve written the options down in the repo (accept it, clean up periodically, a post-enrollment reconciliation script, or one shared placeholder model) but haven’t picked one. The reconciliation script is the right answer, and it needs to run after enrollment, keyed by serial, since that’s the first moment the mapping is knowable.

I also found while building abm2snipe that jamf2snipe’s RAM and HDD field mappings may point at stale custom field keys after the field IDs were renumbered. That’s flagged, not yet fixed or verified against a freshly enrolled machine, so I won’t claim it’s broken or fine.


The Short Version

  • Serial number is the join key everywhere; the dictionary lookup beats per-device API calls, but only if pagination is stable
  • Sort by id when paging anything you’re going to treat as complete
  • Verify enum values against live data; startswith beat == for ownership levels
  • When two tools write to one record, decide field ownership first (Snipe-IT owns the tag, Jamf owns the hardware facts)
  • Anything that creates records in bulk deserves a threshold that aborts instead of proceeding