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:15ZOr 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 dataid 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 -NoTypeInformationPick 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 -NoTypeInformationKeep 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 -NoTypeInformationAnswer 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 deviceHostnameFailed 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, osNamePatching: 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, lastUpdateScanAtPython
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
WideWhen 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 want | Use |
|---|---|
| The fields you have defined, and how many devices have a value | GET /custom-fields |
| Every device next to every field, for a report | GET /devices?includeCustomFields=true or GET /custom-field-values |
| One field across every device, including devices that never reported it | GET /custom-fields/{key}/values |
| Every field for one device | GET /devices/{id}/custom-fields |
| Only values collected since your last run | GET /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:
| value | error | What it means |
|---|---|---|
| Set | empty | The collector reported this value. |
| empty | Set | The collector reported something that does not fit the field’s type, such as "abc" for a Number field. The error says what it got. |
| empty | empty | Nothing 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,yesand1all find devices reporting true. - Number:
2and2.0are the same. - Date:
2026-09-22finds 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 withpageSize, up to 500. - Keep going while
hasMoreis true. - Sort with
sort=hostname, orsort=-lastSeenAtfor 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
updatedAtyou received rather than your own clock. (On custom field values it iscollectedAt. The reference lists which time each list uses.) - A row can turn up in more than one run, so replace it by
idrather than adding it again. pageand a differentsortcannot be combined withupdatedSince, 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"
}
}| Status | code | What to do |
|---|---|---|
| 400 | invalid_parameter | Fix the parameter named in details. A missing tenant is reported here. |
| 401 | unauthorized | The key is missing, wrong, expired or revoked. For safety the API does not say which. |
| 404 | not_found | Nothing with that id or key in the tenant you named, or the key cannot read that tenant. |
| 405 | method_not_allowed | Only GET is supported. |
| 429 | rate_limited | Wait for the number of seconds in Retry-After, then try again. |
| 500 | internal_error | Our fault. Contact support and quote the requestId. |
| 503 | service_unavailable | The 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.
| Endpoint | Filters | Sort by | Changed time |
|---|---|---|---|
| GET /me | None. Shows the key and the account it belongs to. | ||
| GET /tenants | name (contains). The tenants the key can read, with the id to pass as tenant. | name, createdAt | |
| GET /devices | platform, status (Active, Inactive, Blocked), connection (Online, Away, Offline, Dormant), groupId, hostname (contains), includeCustomFields | hostname, lastSeenAt, enrolledAt, updatedAt | updatedAt |
| GET /devices/{id} | Full detail for one device: hardware, groups and custom fields | ||
| GET /devices/{id}/custom-fields | Every custom field for one device | ||
| GET /devices/{id}/update-history | Windows updates the device has reported installing | installedAt, reportedAt | reportedAt |
| GET /custom-fields | Your field definitions | key, name, createdAt, updatedAt | updatedAt |
| GET /custom-fields/{key}/values | deviceId, hasError, value | hostname, collectedAt | |
| GET /custom-field-values | deviceId, fieldKey | hostname, fieldKey, collectedAt | collectedAt |
| GET /patch-compliance | state (UpToDate, WithinDeferral, Behind, Overdue, Unknown, NotReporting), groupId, ringId, hostname (contains) | hostname, daysBehind, state, lastUpdateScanAt | |
| GET /patch-compliance/summary | Same filters, counts only | ||
| GET /deployments | status (Pending, InProgress, Succeeded, Failed, Cancelled, Skipped, Abandoned, Deferred), intent (Install, Uninstall, Update, UpdateOnly, Available), deviceId, appId, groupId, createdFrom, createdTo | createdAt, completedAt, updatedAt | updatedAt |
| GET /deployments/{id} | One deployment | ||
| GET /deployments/summary | Counts by status. deviceId, appId, groupId, from, to | ||
| GET /apps, GET /apps/{id} | platform, name (contains). Includes group assignments. | name, publisher, createdAt, updatedAt | updatedAt |
| GET /groups | name (contains) | name, createdAt, updatedAt, deviceCount | updatedAt |
| GET /groups/{id}/devices | Group members. Removals are not reported as changes. | hostname, addedAt | addedAt |
| GET /scripts | shell, isCollector, name (contains) | name, createdAt, updatedAt | updatedAt |
| GET /script-runs, GET /scripts/{id}/runs | deviceId, status, and scriptId on /script-runs | createdAt, completedAt, updatedAt | updatedAt |