Copilot usage metrics with a GitHub App, no PAT needed
If you report on GitHub Copilot adoption, sooner or later you’ll automate pulling the usage metrics. The quick way is a personal access token. The problem is that a PAT belongs to a person: it carries their access, it expires on their schedule, and it breaks when they change roles.
A GitHub App is a better fit for automation. You keep a private key, sign a short-lived JWT on every run, and swap it for an installation token that expires after an hour. Nothing is tied to a person.
Here’s the flow I use, in plain PowerShell 7 with no modules. The full guide, with every step and a troubleshooting table, is in this gist.
How it works
Private key (.pem) ──sign──► JWT (max 10 min)
JWT ──► GET /enterprises/{ent}/installation (or /orgs/{org}/installation) ──► installation id
JWT ──► POST /app/installations/{id}/access_tokens ──► installation token (1 h)
Installation token ──► GET .../copilot/metrics/reports/... ──► signed download links (NDJSON)
Setup checklist
- Policy. In Enterprise → Policies → Copilot, set Copilot usage metrics to Enabled everywhere. If you skip this, the endpoints return 403 or 404.
- Permissions. Give the app Enterprise Copilot metrics: Read and/or Organization Copilot metrics: Read.
- Install it where you query. An enterprise installation only covers
/enterprises/...endpoints. For org-level reports, install the app on each organization. - Approve permission changes. If you add a permission later, an owner has to accept it, and you need a new token afterwards.
- Private key. Generate one under App settings → Private keys. In automation, keep it in Key Vault and sign a fresh JWT on every run. You don’t need a client secret.
Sign the JWT
$clientId = "<CLIENT_ID>" # or App ID
$pem = Get-Content ".\<APP_NAME>.private-key.pem" -Raw
function ConvertTo-B64Url([byte[]]$b) {
[Convert]::ToBase64String($b).TrimEnd('=').Replace('+','-').Replace('/','_')
}
$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$header = @{ alg = 'RS256'; typ = 'JWT' } | ConvertTo-Json -Compress
$payload = @{ iat = $now - 60; exp = $now + 540; iss = $clientId } | ConvertTo-Json -Compress
$unsigned = (ConvertTo-B64Url ([Text.Encoding]::UTF8.GetBytes($header))) + '.' +
(ConvertTo-B64Url ([Text.Encoding]::UTF8.GetBytes($payload)))
$rsa = [System.Security.Cryptography.RSA]::Create()
$rsa.ImportFromPem($pem)
$sig = $rsa.SignData([Text.Encoding]::UTF8.GetBytes($unsigned),
[Security.Cryptography.HashAlgorithmName]::SHA256,
[Security.Cryptography.RSASignaturePadding]::Pkcs1)
$jwt = "$unsigned." + (ConvertTo-B64Url $sig)
$h = @{ Authorization = "Bearer $jwt"; Accept = "application/vnd.github+json"; "X-GitHub-Api-Version" = "2026-03-10" }
iat is set 60 seconds in the past to absorb clock skew, and exp stays under GitHub’s 10-minute limit. RSA.ImportFromPem needs .NET 5 or newer, which is why this requires PowerShell 7.
Get a token and the report
$inst = Invoke-RestMethod "https://api.github.com/enterprises/<ENTERPRISE_SLUG>/installation" -Headers $h
$tok = Invoke-RestMethod -Method Post "https://api.github.com/app/installations/$($inst.id)/access_tokens" -Headers $h
$h.Authorization = "Bearer $($tok.token)"
$r = Invoke-RestMethod "https://api.github.com/enterprises/<ENTERPRISE_SLUG>/copilot/metrics/reports/enterprise-1-day?day=<YYYY-MM-DD>" -Headers $h
# The download links are pre-signed: don't send the Authorization header
$records = $r.download_links | ForEach-Object {
(Invoke-RestMethod -Uri $_ | Out-String) -split '\r?\n' | Where-Object { $_.Trim() }
} | ForEach-Object { $_ | ConvertFrom-Json }
For org-level, user-level reports, swap in /orgs/<ORG>/installation and /orgs/<ORG>/copilot/metrics/reports/users-1-day.
Full guide with troubleshooting: gist · API reference: Copilot usage metrics
I wrote this post with help from Claude, based on my own guide and scripts.