Public API

The Pckgr API lets you pull your own data out of Pckgr and into the tools you already use: Power BI, Excel, a data warehouse or a scheduled PowerShell script. You get devices, custom fields, patch compliance, deployment results, script runs, apps and groups.

It only reads. Nothing you do with it can change a device, a deployment or a setting.

Get started in five minutes

1. Create a key

In the portal, open Settings and then the API Keys tab. Account Owners can create keys. One key reads every tenant in your account, including tenants you add later, so an MSP needs just one key for all of its clients. Give the key a name that says what it is for, such as "RMM reporting", and copy it straight away: it is shown once and cannot be shown again.

2. Make your first call

In PowerShell:

$env:PCKGR_API_KEY = "pckgr_..."   # paste your key here, or load it from a secret store
Invoke-RestMethod -Uri "https://api.pckgr.com/api/public/v1/me" -Headers @{ Authorization = "Bearer $env:PCKGR_API_KEY" }

You should see your account's name:

accountId   : 5b1e7d20-...
accountName : Northwind IT
keyAccess   : account
tenantId    :
tenantName  :
apiKeyName  : RMM reporting
scopes      : {read}
serverTime  : 2026-09-25T10:43:15Z

Or from any terminal with curl (on Windows, type curl.exe):

curl -H "Authorization: Bearer $PCKGR_API_KEY" https://api.pckgr.com/api/public/v1/me

3. Pick a tenant

Everything else reads one tenant at a time, and you name it on every call with ?tenant= and the tenant's id. List your tenants to get their ids:

Invoke-RestMethod -Uri "https://api.pckgr.com/api/public/v1/tenants" -Headers @{ Authorization = "Bearer $env:PCKGR_API_KEY" } |
    Select-Object -ExpandProperty data
id                                   name           createdAt
--                                   ----           ---------
0f9c2a6e-...                         Contoso Dental 2026-03-02T09:14:00Z
7d41b0c3-...                         Fabrikam Legal 2026-05-19T22:40:11Z

Match them against your own client list by name. /tenants?name=contoso finds any tenant with that in its name. A call without ?tenant=, or naming a tenant outside your account, gets an error rather than someone else's data.

4. Add the helper, then use the recipes

Lists come back one page at a time. Rather than handle that in every script, paste this helper once at the top of your script. It fetches every page for you and adds the tenant to every call, and every PowerShell recipe below uses it. Set $PckgrTenant to the tenant you want, or pass -Tenant to read a different one.

$PckgrBase    = "https://api.pckgr.com/api/public/v1"
$PckgrHeaders = @{ Authorization = "Bearer $env:PCKGR_API_KEY" }
$PckgrTenant  = "0f9c2a6e-..."   # the tenant to read: an id from /tenants

# Fetches every row of a list, however many pages it takes.
#   -Path          the list, for example "/devices"
#   -Filter        optional filters, for example @{ status = "Failed" }
#   -ChangedSince  optional: only rows that changed after this time (UTC)
#   -Tenant        optional: a different tenant from $PckgrTenant for this call
function Get-PckgrAll {
    param(
        [Parameter(Mandatory)] [string] $Path,
        [hashtable] $Filter = @{},
        [string] $ChangedSince,
        [string] $Tenant = $PckgrTenant
    )
    $query = @{ pageSize = 500 } + $Filter
    if ($Tenant) { $query.tenant = $Tenant }
    if ($ChangedSince) { $query.updatedSince = $ChangedSince } else { $query.page = 1 }

    # Keep dates exactly as the API sends them. Without this, PowerShell 7 turns anything
    # that looks like a date, including a Text custom field, into your local time.
    $json = @{}
    if ((Get-Command ConvertFrom-Json).Parameters.ContainsKey("DateKind")) { $json.DateKind = "String" }

    while ($true) {
        $queryString = ($query.GetEnumerator() | ForEach-Object {
            "$($_.Key)=$([uri]::EscapeDataString([string]$_.Value))" }) -join "&"
        $response = (Invoke-WebRequest -Uri "$PckgrBase$($Path)?$queryString" -Headers $PckgrHeaders -UseBasicParsing).Content |
            ConvertFrom-Json @json
        $response.data

        if ($response.meta.nextCursor) {
            # Only-what-changed lists hand back a bookmark for the next page.
            $query.Remove("updatedSince")
            $query.cursor = $response.meta.nextCursor
        }
        elseif ($response.meta.hasMore -and $query.page) {
            $query.page++
        }
        else { break }
    }
}

Keep the key out of the script itself. Load $env:PCKGR_API_KEY from a secret store, or from the scheduled task's environment.

Recipes

Every client in one go

Loop over /tenants and read each one in turn. Any recipe below works the same way inside the loop.

# Every device of every tenant in one CSV, with the tenant's name on each row.
Get-PckgrAll -Path "/tenants" | ForEach-Object {
    $client = $_
    Get-PckgrAll -Path "/devices" -Tenant $client.id |
        Select-Object @{ n = "client"; e = { $client.name } }, hostname, osName, osVersion, lastSeenAt
} | Export-Csv .\all-clients-devices.csv -NoTypeInformation

Pick a client by name

# Look a tenant up by the name you know it by, then read it.
$PckgrTenant = (Get-PckgrAll -Path "/tenants" | Where-Object name -eq "Contoso Dental").id

Export every device to a CSV

Get-PckgrAll -Path "/devices" |
    Select-Object hostname, platform, osName, osVersion, manufacturer, model, serialNumber, lastSeenAt |
    Export-Csv .\devices.csv -NoTypeInformation

Keep a copy up to date on a schedule

Fetching everything every time is fine for a few hundred devices. For a scheduled job, fetch only what changed since the last run. The first run gets everything; after that, each run only gets devices that changed.

$copy     = ".\devices.json"          # your copy of every device
$lastSeen = ".\devices-changed.txt"   # the newest change you have already got

# Load what we already have, keyed by device id.
$devices = @{}
if (Test-Path $copy) {
    Get-Content $copy -Raw | ConvertFrom-Json | ForEach-Object { $devices[$_.id] = $_ }
}

# First run: 2000 means "everything". Later runs: only what changed since last time.
$since = if (Test-Path $lastSeen) { Get-Content $lastSeen } else { "2000-01-01T00:00:00Z" }
$changed = @(Get-PckgrAll -Path "/devices" -ChangedSince $since)

# A device can come back more than once over time, so replace by id rather than append.
foreach ($device in $changed) { $devices[$device.id] = $device }

if ($changed) {
    # Remember the newest updatedAt we received, written in UTC so the next run can read it back.
    $newest = $changed | ForEach-Object { [DateTimeOffset]$_.updatedAt } | Sort-Object | Select-Object -Last 1
    $newest.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", [cultureinfo]::InvariantCulture) |
        Set-Content $lastSeen
}

@($devices.Values) | ConvertTo-Json -Depth 5 | Set-Content $copy
"$($changed.Count) changed, $($devices.Count) devices in total"

Deleted devices are not reported as changes. They just stop appearing. If your copy needs to drop them, fetch everything now and then (weekly is plenty) and keep only the ids you got back.

Custom fields as a table

One row per device and one column per custom field, ready for Excel:

# One row per device, one column per custom field.
Get-PckgrAll -Path "/devices" -Filter @{ includeCustomFields = "true" } | ForEach-Object {
    $row = [ordered]@{ hostname = $_.hostname }
    foreach ($field in $_.customFields) {
        $row[$field.fieldKey] = if ($field.error) { "ERROR: $($field.error)" } else { $field.value }
    }
    [pscustomobject]$row
} | Export-Csv .\custom-fields.csv -NoTypeInformation

Answer a question about one custom field

# Which devices report edr_healthy = false?
Get-PckgrAll -Path "/custom-fields/edr_healthy/values" -Filter @{ value = "false" } |
    Select-Object deviceHostname, value, collectedAt

# Which devices sent a value that did not fit the field's type?
Get-PckgrAll -Path "/custom-fields/edr_healthy/values" -Filter @{ hasError = "true" } |
    Select-Object deviceHostname, error, collectedAt

# Which devices have never reported the field at all?
Get-PckgrAll -Path "/custom-fields/edr_healthy/values" |
    Where-Object { -not $_.collectedAt } |
    Select-Object deviceHostname

Failed deployments this week, grouped by app and exit code

$weekAgo = [DateTime]::UtcNow.AddDays(-7).ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", [cultureinfo]::InvariantCulture)

Get-PckgrAll -Path "/deployments" -Filter @{ status = "Failed"; createdFrom = $weekAgo } |
    Group-Object appName, failureType, exitCode |
    Sort-Object Count -Descending |
    Select-Object Count, Name

"18 failures were Contoso VPN, ExecutionFailed, exit code 1603" is the kind of answer this gives you. The failure message itself stays in the portal, on the deployment.

Devices that have not checked in for a week

$cutoff = [DateTimeOffset]::UtcNow.AddDays(-7)

Get-PckgrAll -Path "/devices" |
    Where-Object { [DateTimeOffset]$_.lastSeenAt -lt $cutoff } |
    Sort-Object lastSeenAt |
    Select-Object hostname, lastSeenAt, osName

Patching: the summary and the devices that are behind

# The headline numbers
Invoke-RestMethod -Uri "$PckgrBase/patch-compliance/summary?tenant=$PckgrTenant" -Headers $PckgrHeaders

# Every device that is behind or overdue, worst first
Get-PckgrAll -Path "/patch-compliance" -Filter @{ state = "Overdue,Behind"; sort = "-daysBehind" } |
    Select-Object hostname, state, daysBehind, updateRingName, lastUpdateScanAt

Python

The same helper in Python, using only the standard library. The example counts failed deployments for each of your tenants:

import json
import os
import urllib.parse
import urllib.request

BASE = "https://api.pckgr.com/api/public/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['PCKGR_API_KEY']}"}


def get_all(path, tenant=None, changed_since=None, **filters):
    """Yields every row of a list, however many pages it takes."""
    query = {"pageSize": 500, **filters}
    if tenant:
        query["tenant"] = tenant
    if changed_since:
        query["updatedSince"] = changed_since
    else:
        query["page"] = 1

    while True:
        url = f"{BASE}{path}?{urllib.parse.urlencode(query)}"
        with urllib.request.urlopen(urllib.request.Request(url, headers=HEADERS)) as response:
            body = json.load(response)
        yield from body["data"]

        meta = body["meta"]
        if meta.get("nextCursor"):
            query.pop("updatedSince", None)
            query["cursor"] = meta["nextCursor"]
        elif meta["hasMore"] and "page" in query:
            query["page"] += 1
        else:
            return


for client in get_all("/tenants"):
    failed = list(get_all("/deployments", tenant=client["id"], status="Failed"))
    print(f"{client['name']}: {len(failed)} failed deployments")

Power BI and Excel

This query loads every custom field value for one tenant and turns it into one row per device with one column per field. Put the tenant's id on the Tenant line at the top. Power BI and Excel run it the same way:

  • Power BI Desktop: Get data, then Blank query, then Advanced editor.
  • Excel: Data, then Get Data, From Other Sources, Blank Query, then Advanced Editor.

Replace everything in the editor with this and choose Done:

let
    // The tenant to read: an id from /tenants.
    Tenant = "0f9c2a6e-...",

    // Fetches every page of a list. path is for example "/custom-field-values".
    PckgrAll = (path as text) =>
        let
            Fetch = (page as number) =>
                Json.Document(
                    Web.Contents(
                        "https://api.pckgr.com",
                        [
                            RelativePath = "api/public/v1" & path,
                            Query = [ tenant = Tenant, pageSize = "500", page = Text.From(page) ]
                        ]
                    )
                ),
            Pages = List.Generate(
                () => [ Page = 1, Response = Fetch(1) ],
                each _ <> null,
                each if _[Response][meta][hasMore]
                     then [ Page = _[Page] + 1, Response = Fetch(_[Page] + 1) ]
                     else null,
                each _[Response][data]
            )
        in
            Table.FromRecords(List.Combine(Pages)),

    // One row per device, one column per custom field.
    Values = PckgrAll("/custom-field-values"),
    Slim   = Table.SelectColumns(Values, { "deviceHostname", "fieldKey", "value" }),
    Wide   = Table.Pivot(Slim, List.Distinct(Slim[fieldKey]), "fieldKey", "value")
in
    Wide

When asked how to connect to https://api.pckgr.com, choose Basic. Enter pckgr as the user name (any name works) and paste your key as the password. Apply the setting to https://api.pckgr.com/. Power BI and Excel keep the key in their own credential store, not in the report file, so it is not shared when you share the file. Do not use the Web API option: it sends the key in the web address, and the API refuses it.

If you already tried another sign-in method, clear it first under Data source settings. To load a different list, change "/custom-field-values" to, for example, "/devices" and drop the last three lines.

Custom fields

Custom fields are the values your collector scripts report for each device. There are several ways to read them, depending on the question you are asking:

You wantUse
The fields you have defined, and how many devices have a valueGET /custom-fields
Every device next to every field, for a reportGET /devices?includeCustomFields=true or GET /custom-field-values
One field across every device, including devices that never reported itGET /custom-fields/{key}/values
Every field for one deviceGET /devices/{id}/custom-fields
Only values collected since your last runGET /custom-field-values?updatedSince=...

Refer to a field by its key, such as edr_healthy. Keys never change, and capitals do not matter. The field's id works too.

Reading a value

Each value comes with an error and a collectedAt. Read them together:

valueerrorWhat it means
SetemptyThe collector reported this value.
emptySetThe collector reported something that does not fit the field’s type, such as "abc" for a Number field. The error says what it got.
emptyemptyNothing reported yet. If collectedAt is also empty, the collector has never run on this device.

"We do not know yet" and "the device reported something wrong" are different answers, so keep them apart in your report.

Values always come back as text in one standard form: Booleans as true or false, Numbers like 2 or 3.5, Dates like 2026-09-22T00:00:00.0000000+00:00.

Filtering by value

GET /custom-fields/{key}/values?value=... returns the devices whose value matches. Your filter is read the same way as a collector's report, so you do not need the exact stored form:

  • Boolean: true, True, yes and 1 all find devices reporting true.
  • Number: 2 and 2.0 are the same.
  • Date: 2026-09-22 finds that date at midnight UTC.
  • Text: an exact match that ignores capitals.

A filter that cannot be that type, such as value=maybe on a Boolean field, is refused with an error rather than quietly returning no devices.

How lists work

The helper above deals with all of this for you. Read on if you are writing your own client, or want to know what it is doing.

Pages

Every list returns its rows in data and a description of the page in meta:

{
  "data": [ { "id": "91908c52-...", "hostname": "PCKGR-0003", ... } ],
  "meta": {
    "page": 1,
    "pageSize": 50,
    "totalItems": 1240,
    "totalPages": 25,
    "hasMore": true,
    "serverTime": "2026-09-24T10:43:15Z",
    "nextCursor": null
  }
}
  • Ask for a page with page=2. Pages hold 50 rows unless you ask for more with pageSize, up to 500.
  • Keep going while hasMore is true.
  • Sort with sort=hostname, or sort=-lastSeenAt for newest first. Each list's sort options are in the reference.

When you page through a whole list, sort by something that stays put, like the default. Sorting by a time that keeps changing, such as updatedAt, can make rows jump between pages while you read, and you can miss some.

Only what changed since last time

Most lists accept updatedSince: pass a time and you get only rows that changed after it, oldest change first. Instead of page numbers, each response gives you a bookmark, nextCursor. Send it back as cursor to get the next page, and stop when it comes back empty.

GET /api/public/v1/devices?updatedSince=2026-09-24T10:30:00Z&pageSize=500
   "meta": { "hasMore": true, "nextCursor": "MTc1ODcxMjQwMD..." }

GET /api/public/v1/devices?cursor=MTc1ODcxMjQwMD...&pageSize=500
   ... keep going until nextCursor is null
  • Keep any filters on every request. The bookmark only remembers where you got to.
  • For the next run, save the newest updatedAt you received rather than your own clock. (On custom field values it is collectedAt. The reference lists which time each list uses.)
  • A row can turn up in more than one run, so replace it by id rather than adding it again.
  • page and a different sort cannot be combined with updatedSince, and you get an error if you try.

Times

All times are UTC. Send them as 2026-09-24T10:30:00Z. If a time you send carries an offset such as +10:00, URL-encode it, because a bare + in a URL turns into a space.

Why devices have no online or offline status

Whether a device is online depends on when you ask, so a saved status goes out of date without anything changing on the device. Devices carry lastSeenAt instead. Work out the status from that when your report runs: online within 7 minutes, away within 30 minutes, offline after that, and dormant after the tenant's dormant setting (30 days unless you have changed it). To ask which devices are in a state right now, use GET /devices?connection=Offline,Dormant.

Looking after your keys

  • A key reads everything in every tenant of your account, including tenants you add later. Treat it accordingly: keep it in a secret store and give it a short expiry.
  • Only Account Owners can create and revoke keys. Every tenant's audit log records when a key that can read it is created or revoked.
  • Keys created before 25 September 2026 read only the tenant they were made in, and need no ?tenant=. They keep working until they expire or are revoked. Replace one with an account key whenever it suits you.
  • Pckgr keeps only a fingerprint of the key. If you lose it, create a new one and revoke the old one.
  • A key belongs to the account, not the person who made it, so a scheduled report keeps working when that person leaves. It also means removing a user does not stop their keys. Check your keys when someone leaves; the API Keys tab flags keys whose creator is no longer an Account Owner.
  • Revoking a key stops it on the very next request.
  • Keys expire after the period you pick when you create them, 365 days by default.
  • Never put a key in a web page, a mobile app or anything else other people can open. Web pages on other sites cannot call the API, for exactly this reason.

Limits and errors

Each key can make 60 requests a minute for each tenant, with short bursts of up to 120, so reading all of your clients in turn does not slow any one of them down. With 500 rows a page, that is 30,000 rows a minute per tenant, which is plenty for a scheduled refresh. Everything from one address together is capped at 300 requests a minute. Go over it and you get a 429 with a Retry-After header saying how many seconds to wait.

Every error looks the same:

{
  "error": {
    "code": "invalid_parameter",
    "message": "pageSize must be an integer between 1 and 500.",
    "details": [ { "field": "pageSize", "message": "..." } ],
    "requestId": "0HN7A2K3JQ:00000003"
  }
}
StatuscodeWhat to do
400invalid_parameterFix the parameter named in details. A missing tenant is reported here.
401unauthorizedThe key is missing, wrong, expired or revoked. For safety the API does not say which.
404not_foundNothing with that id or key in the tenant you named, or the key cannot read that tenant.
405method_not_allowedOnly GET is supported.
429rate_limitedWait for the number of seconds in Retry-After, then try again.
500internal_errorOur fault. Contact support and quote the requestId.
503service_unavailableThe API is not available right now. Contact support.

What the API does not include

Script contents, detection and install scripts, package download locations, file hashes, dynamic group rules and device credentials are not available through the API.

Neither are error messages from failed deployments or the output of script runs. They are written by installers and scripts, and can contain anything those printed, including passwords. The API gives you failureType, exitCode and whether there is a log or output to look at; the full text stays in the portal.

Device detail does include lastLoggedOnUser and privateIpAddresses. These identify people, so store what you pull with the same care as any other personal data.

Endpoint reference

Every path starts with https://api.pckgr.com/api/public/v1. The full field-by-field description is at GET /api/public/v1/openapi.json, which needs no key. You can load it into Postman or use it to generate a client.

Every endpoint except /me and /tenants takes tenant, the id of the tenant to read. Every list takes page, pageSize and sort. Lists with a "changed" time also take updatedSince and cursor. Filters that take several values accept a comma-separated list, such as status=Failed,Abandoned.

EndpointFiltersSort byChanged time
GET /meNone. Shows the key and the account it belongs to.
GET /tenantsname (contains). The tenants the key can read, with the id to pass as tenant.name, createdAt
GET /devicesplatform, status (Active, Inactive, Blocked), connection (Online, Away, Offline, Dormant), groupId, hostname (contains), includeCustomFieldshostname, lastSeenAt, enrolledAt, updatedAtupdatedAt
GET /devices/{id}Full detail for one device: hardware, groups and custom fields
GET /devices/{id}/custom-fieldsEvery custom field for one device
GET /devices/{id}/update-historyWindows updates the device has reported installinginstalledAt, reportedAtreportedAt
GET /custom-fieldsYour field definitionskey, name, createdAt, updatedAtupdatedAt
GET /custom-fields/{key}/valuesdeviceId, hasError, valuehostname, collectedAt
GET /custom-field-valuesdeviceId, fieldKeyhostname, fieldKey, collectedAtcollectedAt
GET /patch-compliancestate (UpToDate, WithinDeferral, Behind, Overdue, Unknown, NotReporting), groupId, ringId, hostname (contains)hostname, daysBehind, state, lastUpdateScanAt
GET /patch-compliance/summarySame filters, counts only
GET /deploymentsstatus (Pending, InProgress, Succeeded, Failed, Cancelled, Skipped, Abandoned, Deferred), intent (Install, Uninstall, Update, UpdateOnly, Available), deviceId, appId, groupId, createdFrom, createdTocreatedAt, completedAt, updatedAtupdatedAt
GET /deployments/{id}One deployment
GET /deployments/summaryCounts by status. deviceId, appId, groupId, from, to
GET /apps, GET /apps/{id}platform, name (contains). Includes group assignments.name, publisher, createdAt, updatedAtupdatedAt
GET /groupsname (contains)name, createdAt, updatedAt, deviceCountupdatedAt
GET /groups/{id}/devicesGroup members. Removals are not reported as changes.hostname, addedAtaddedAt
GET /scriptsshell, isCollector, name (contains)name, createdAt, updatedAtupdatedAt
GET /script-runs, GET /scripts/{id}/runsdeviceId, status, and scriptId on /script-runscreatedAt, completedAt, updatedAtupdatedAt