Run fully automated scripts with application permissions to pull Microsoft Forms data, as if it were an official Graph API — without any extra licenses required
For years, there’s been no official Microsoft Forms API, despite countless requests from admins and developers who need to automate data collection, reporting, or integration workflows. While Microsoft Graph covers most services, Forms has remained an outlier — its API exists and is used by the Forms web interface, but it’s undocumented, making it tricky to use for automation.
Historically, if you wanted automation around Microsoft Forms, you were pushed towards Power Automate, PowerApps, or Power BI. Those tools are powerful, but they often require additional licenses and can introduce costs that aren’t feasible for every project.
The approach I’m sharing here avoids that problem: it uses application permissions via an Azure App Registration. That means scripts can run fully automated in the background without user interaction, pulling data straight from the undocumented Forms API — essentially treating it like an official Graph endpoint.
This post walks you through the approach, shows the reusable PowerShell code, and highlights how you can start automating your own Forms workflows.
Create an App Registration in Azure
First, we need an Azure AD App Registration with permission to read Forms.
- Go to Azure Portal → App Registrations → New registration.
- Give it a name, register it as Accounts in this organizational directory only.
- Under API Permissions → Add a permission → APIs my organization uses, search for
Microsoft Forms
. - Select Application permissions → Forms.Read.All
- Grant admin consent
- Go to Certificates & secrets → New client secret, and note the secret value.
As we’re using application permissions, this app can run completely headless — no sign-in prompts, no delegated user context. Perfect for automation.
Next, we’ll dive into the PowerShell code that makes it all work, including the reusable functions and token manager to handle authentication automatically.
Token Management
TInstead of getting a new token for every request, I created a TokenManager
class in PowerShell:
- Fetches a new token if none exists.
- Refreshes only when the token is expired or within 60 seconds of expiry.
- Stores the token for reuse across multiple API calls in the same session.
Some might say this is overkill for a few quick calls; but if you’re working with a form that has thousands of responses, the script could run for hours as it pages through responses. In that case, the Token Manager quietly refreshes tokens in the background so your script stays authorized the whole time — no manual intervention needed.
class TokenManager {
[string]$TenantId
[string]$ClientId
[string]$ClientSecret
[string]$Scope
[string]$AccessToken
[datetime]$Expiry
TokenManager([string]$TenantId, [string]$ClientId, [string]$ClientSecret, [string]$Scope) {
$this.TenantId = $TenantId
$this.ClientId = $ClientId
$this.ClientSecret = $ClientSecret
$this.Scope = $Scope
}
[string]GetToken() {
if (-not $this.AccessToken -or (Get-Date) -ge $this.Expiry) {
$this.RefreshToken()
}
return $this.AccessToken
}
[void]RefreshToken() {
$tokenEndpoint = "https://login.microsoftonline.com/$($this.TenantId)/oauth2/v2.0/token"
$body = @{
client_id = $this.ClientId
scope = $this.Scope
client_secret = $this.ClientSecret
grant_type = "client_credentials"
}
$tokenResponse = Invoke-RestMethod -Uri $tokenEndpoint -Method Post -Body $body -ContentType "application/x-www-form-urlencoded"
$this.AccessToken = $tokenResponse.access_token
$this.Expiry = (Get-Date).AddSeconds($tokenResponse.expires_in - 60)
Write-Verbose "Access token refreshed. Expires $($this.Expiry)."
}
}
Authenticated HTTP Requests
To make calling the API easier, I wrote a wrapper function that:
- Automatically attaches the Bearer token
- Retries on 401 errors (token expiry)
- Supports all HTTP verbs
This means the rest of the script doesn’t need to care about authentication — it just calls Invoke-HttpRequestWithToken
and gets JSON back.
function Invoke-HttpRequestWithToken {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet("GET", "POST", "PUT", "PATCH", "DELETE")]
[string]$Method,
[Parameter(Mandatory = $true)]
[ValidateScript({ ![string]::IsNullOrWhiteSpace($_) })]
[string]$Uri,
[Parameter(Mandatory = $false)]
[hashtable]$Headers = @{},
[Parameter(Mandatory = $false)]
[hashtable]$Body,
[Parameter(Mandatory = $true)]
[ValidateNotNull()]
[TokenManager]$TokenManager,
[Parameter(Mandatory = $false)]
[int]$MaxRetries = 5
)
begin {
$output = $null
$retry = 0
$Headers.Authorization = "Bearer $($TokenManager.GetToken())"
}
process {
while ($retry -le $MaxRetries) {
try {
Write-Verbose "Making $Method request to $Uri (attempt $($retry+1))"
if ($PSBoundParameters.ContainsKey("Body")) {
$output = Invoke-RestMethod -Uri $Uri -Method $Method -Headers $Headers -Body ($Body | ConvertTo-Json) -ContentType "application/json"
return
}
else {
$output = Invoke-RestMethod -Uri $Uri -Method $Method -Headers $Headers
return
}
}
catch [System.Net.WebException] {
if ($_.Exception.Response.StatusCode -eq 401 -and $retry -lt $MaxRetries) {
Write-Verbose "Token expired. Refreshing token..."
$TokenManager.RefreshToken();
$retry++
continue
}
else {
throw "HTTP request failed: $($_.Exception.Message)"
}
}
}
throw "Maximum retries ($MaxRetries) exceeded for $Method $Uri"
}
end {
return $output
}
}
Fetching Forms + Responses
Now let’s look at the Get-MSForm*
functions that actually retrieve data from Microsoft Forms.
Get-MSFormsByOwner
This function retrieves all the forms owned by a given user or group. It fetches metadata such as:
- Row count (number of responses)
- Form title
- Creation and modification dates
- Status
function Get-MSFormsByOwner {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[ValidateScript({ ![string]::IsNullOrWhiteSpace($_) })]
[string]$TenantId,
[Parameter(Mandatory = $true)]
[ValidateScript({ ![string]::IsNullOrWhiteSpace($_) })]
[string]$OwnerId,
[Parameter(Mandatory = $true)]
[ValidateSet("User", "Group")]
[string]$OwnerType,
[Parameter(Mandatory = $true)]
[ValidateNotNull()]
[TokenManager]$TokenManager
)
begin {
$output = New-Object System.Collections.Generic.List[PSCustomObject]
$ownerContext = switch ($OwnerType) {
"User" { "users" }
"Group" { "groups" }
}
$endpoint = "https://forms.office.com/formapi/api/$TenantId/$ownerContext/$OwnerId/light/forms?`$select=id,status,title,createdDate,modifiedDate,ownerId,version,softDeleted,type"
}
process {
try {
Write-Verbose "Fetching forms for user $UserId..."
$response = Invoke-HttpRequestWithToken -Method "GET" -Uri $endpoint -TokenManager $TokenManager
foreach ($form in $response.value) {
$formEndpoint = "https://forms.office.com/formapi/api/$TenantId/$ownerContext/$OwnerId/light/forms('$($form.id)')?`$select=rowCount"
Write-Verbose "Fetching rowCount for form '$($form.title)' ($($form.id))..."
$formResponse = Invoke-HttpRequestWithToken -Method "GET" -Uri $formEndpoint -TokenManager $TokenManager
$form | Add-Member -MemberType NoteProperty -Name rowCount -Value $formResponse.rowCount
$output.Add([PSCustomObject]$form)
}
}
catch {
throw "Failed to get forms: $($_.Exception.Message)"
}
}
end {
return $output
}
}
Get-MSFormResponse
This function retrieves a single response from a specific form, including data such as:
- Row count (number of responses)
- Form title
- Creation and modification dates
- Status
function Get-MSFormResponse {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[ValidateScript({ ![string]::IsNullOrWhiteSpace($_) })]
[string]$TenantId,
[Parameter(Mandatory = $true)]
[ValidateScript({ ![string]::IsNullOrWhiteSpace($_) })]
[string]$OwnerId,
[Parameter(Mandatory = $true)]
[ValidateSet("User", "Group")]
[string]$OwnerType,
[Parameter(Mandatory = $true)]
[ValidateScript({ ![string]::IsNullOrWhiteSpace($_) })]
[string]$FormId,
[Parameter(Mandatory = $true)]
[int]$ResponseId,
[Parameter(Mandatory = $true)]
[ValidateNotNull()]
[TokenManager]$TokenManager
)
begin {
$output = $null
$ownerContext = switch ($OwnerType) {
"User" { "users" }
"Group" { "groups" }
}
}
process {
$responsesEndpoint = "https://forms.office.com/formapi/api/$TenantId/$ownerContext/$OwnerId/light/forms('$FormId')/responses?`$filter=id eq $ResponseId"
try {
Write-Verbose "Fetching response ID $ResponseId for form $FormId..."
$response = Invoke-HttpRequestWithToken -Method "GET" -Uri $responsesEndpoint -TokenManager $TokenManager
if ($response.value) {
$output = [PSCustomObject]$response.value
}
}
catch {
throw "Failed to get response: $($_.Exception.Message)"
}
}
end {
return $output
}
}
Get-MSFormResponses
This is the workhorse function. It retrieves all responses for a given form, with full support for paging.
Key features:
- Takes
-PageSize
to control batch size - Takes
-Skip
to start from a given offset - Automatically loops through until all responses are fetched
- Adds verbose logging so you can see progress
function Get-MSFormResponses {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[ValidateScript({ ![string]::IsNullOrWhiteSpace($_) })]
[string]$TenantId,
[Parameter(Mandatory = $true)]
[ValidateScript({ ![string]::IsNullOrWhiteSpace($_) })]
[string]$OwnerId,
[Parameter(Mandatory = $true)]
[ValidateSet("User", "Group")]
[string]$OwnerType,
[Parameter(Mandatory = $true)]
[ValidateScript({ ![string]::IsNullOrWhiteSpace($_) })]
[string]$FormId,
[Parameter(Mandatory = $true)]
[int]$PageSize,
[Parameter(Mandatory = $false)]
[int]$Skip = 0,
[Parameter(Mandatory = $true)]
[ValidateNotNull()]
[TokenManager]$TokenManager
)
begin {
$output = New-Object System.Collections.Generic.List[PSCustomObject]
$ownerContext = switch ($OwnerType) {
"User" { "users" }
"Group" { "groups" }
}
$formEndpoint = "https://forms.office.com/formapi/api/$TenantId/$ownerContext/$OwnerId/light/forms('$FormId')?`$select=rowCount"
$formResponse = Invoke-HttpRequestWithToken -Uri $formEndpoint -Method "GET" -TokenManager $TokenManager
$totalResponses = $formResponse.rowCount
$totalToFetch = [math]::Max(0, $totalResponses - $Skip)
$itemsFetched = 0
$itemsToSkip = $Skip
Write-Verbose "Form has $totalResponses responses. Starting at Skip=$Skip, PageSize=$PageSize. Total to fetch: $totalToFetch."
}
process {
while ($itemsToSkip -lt $totalResponses) {
$responsesEndpoint = "https://forms.office.com/formapi/api/$TenantId/$ownerContext/$OwnerId/light/forms('$FormId')/responses?`$expand=comments&`$top=$PageSize&`$skip=$itemsToSkip&"
try {
$response = Invoke-HttpRequestWithToken -Method "GET" -Uri $responsesEndpoint -TokenManager $TokenManager
if (-not $response.value -or $response.value.Count -eq 0) {
Write-Verbose "No more responses returned from API. Ending paging."
break
}
$response.value | ForEach-Object {
$output.Add([PSCustomObject]$_)
}
$itemsFetched += $response.value.Count
$itemsToSkip += $response.value.Count
Write-Verbose ("Fetched {0} responses this page; cumulative fetched {1}/{2}; overall position {3}/{4}" -f `
$response.value.Count, `
$itemsFetched, `
$totalToFetch, `
[math]::Min($itemsToSkip, $totalResponses), `
$totalResponses)
}
catch {
throw "Failed to get responses: $($_.Exception.Message)"
}
}
}
end {
return $output
}
}
Usage Examples
Now that we’ve walked through the code and functions, let’s look at some practical usage scenarios. These examples show how you can start automating Microsoft Forms data collection right away.
Set Up TokenManager
Before calling any of the Get-MSForm*
functions, you need to create a TokenManager
instance.
This requires the three values from your App Registration in Azure AD:
- Tenant ID → Found in your App Registration → Overview
- Client ID → Found in your App Registration → Overview
- Client Secret → Generated in App Registration → Certificates & secrets
$TenantId = "<your tenant id>"
$ClientId = "<your client id>"
$ClientSecret = "<your client secret>"
$TokenManager = [TokenManager]::new(
$TenantId,
$ClientId,
$ClientSecret,
"https://forms.office.com/.default"
)
# Test by fetching a token
$TokenManager.GetToken()
If the token is valid, you’ll get a long string back. You don’t normally need to call GetToken()
directly — the wrapper functions handle that for you — but it’s a good sanity check that your App Registration and permissions are working.
Get All Forms by a User or Group
Retrieve a list of all forms owned by a specific user or group (identified by their Entra Object ID).
Get-MSFormsByOwner `
-TenantId $TenantId `
-OwnerId "<entra object id>" `
-OwnerType "User"
-TokenManager $TokenManager
This gives you an inventory of forms, complete with how many responses each has.
Get a Single Response
Fetch one response by ID for a given form.
Get-MSFormResponse `
-TenantId $TenantId `
-OwnerId "<entra object id>" `
-OwnerType "User" `
-FormId "<form id>" `
-ResponseId 1 `
-TokenManager $TokenManager
Useful for debugging or verifying individual submissions.
Get All Responses with Paging
Fetch every response from a form in batches.
Get-MSFormResponses `
-TenantId $TenantId `
-OwnerId "<entra object id>" `
-OwnerType "User" `
-FormId "<form id>" `
-PageSize 100 `
-TokenManager $TokenManager
The PageSize
parameter controls how many records are fetched per API call. This will loop automatically until all responses are retrieved — perfect for large surveys with thousands of entries.
Get Responses with Paging and Skip (Offset)
Sometimes you may only want to retrieve newer responses or continue where a previous export left off. The -Skip
parameter lets you offset results.
For example, if you already exported the first 500 responses, you can skip them and fetch the rest:
# Get responses starting from response 501
Get-MSFormResponses `
-TenantId $TenantId `
-OwnerId"<entra object id>" `
-OwnerType "User" `
-FormId "<form id>" `
-PageSize 100 `
-Skip 500 `
-TokenManager $TokenManager
This way, you don’t need to re-fetch thousands of old responses — only the new ones that came in since your last run. Perfect for incremental automation jobs.
Get Responses with Paging and Skip (Offset)
Sometimes you may only want to retrieve newer responses or continue where a previous export left off. The -Skip
parameter lets you offset results.
For example, if you already exported the first 500 responses, you can skip them and fetch the rest:
# Get responses starting from response 501
Get-MSFormResponses `
-TenantId $TenantId `
-OwnerId "<entra object id>" `
-OwnerType "User" `
-FormId "<form id>" `
-PageSize 100 `
-Skip 500 `
-TokenManager $TokenManager
This way, you don’t need to re-fetch thousands of old responses — only the new ones that came in since your last run. Perfect for incremental automation jobs.
A Real-World Example: Getting Form Responses
To make this concrete, let’s walk through a simple example using the PowerShell functions we’ve covered.
- Get the Form ID manually
Open the form’s responses page in your browser. The URL will look like this:
https://forms.office.com/Pages/ResponsePage.aspx?id=_tLqPYCGw063v1PA4swe7lcR8JWHvpdGnz9zWKI-QV1UREFTSjMyUkM2WFlaUUJWQVpXOUgwMzlTRi4u
The long alphanumeric string after id=
is the Form ID. Copy this value.
- Get the User or Group ID from Entra ID (Azure AD)
Use the user’s or group’s Object ID in Azure AD, which you’ll pass to the function to retrieve their forms. - Pull all responses with paging
<# Get All Responses with Paging Support #>
$responses = Get-MSFormResponses `
-TenantId $TenantId `
-OwnerId "<entra object id>" `
-OwnerType "User" `
-FormId "_tLqPYCGw063v1PA4swe7lcR8JWHvpdGnz9zWKI-QV1UREFTSjMyUkM2WFlaUUJWQVpXOUgwMzlTRi4u" `
-PageSize 100 `
-TokenManager $TokenManager
($responses.answers | ConvertFrom-Json)
This simple example demonstrates how easy it is to retrieve form responses programmatically. On the left, you see the standard Microsoft Forms responses page; on the right, the PowerShell output mirrors the same data in a structured format. While this example uses a single form and a single user, it proves the concept: with the token manager and paging support, you can scale this to large forms, multiple users, or even automated nightly data pulls.


Real-World Automation Scenarios
Once you can reliably fetch Forms data via PowerShell, you’re no longer locked into viewing results only in the web UI or exporting them manually. A few examples of how you might use this:
- Exporting to CSV for Reporting
Run the script nightly and dump responses into a CSV file that can be shared, archived, or imported into other systems. Perfect for lightweight reporting without needing Power BI licenses. - Saving to a Database
Pipe responses directly into SQL Server, Azure SQL, or even a simple SQLite database. This gives you historical tracking, joins with other datasets, and more advanced analytics. - Integrating with a Ticketing System
Imagine using Forms as an intake system for support or change requests. With automation, each new response could automatically create a Jira, ServiceNow, or Zendesk ticket — no manual copying and pasting required. - Triggering Workflows
Hook the script into a CI/CD pipeline or an Azure Automation runbook so that responses can trigger downstream actions (e.g., provisioning resources, adding users, kicking off approvals).
In short, once Forms data becomes API-accessible, it can plug into almost anything.
Final Thoughts
While Microsoft hasn’t yet provided an official Graph API for Forms, this approach fills that gap by giving you a reliable way to query forms, fetch responses, and automate reporting with nothing more than PowerShell and an app registration. By leveraging application permissions, you avoid license dependencies from tools like Power BI or PowerApps, and instead gain a lightweight, cost-effective, and fully scriptable solution. Whether you’re building scheduled reports, archiving responses, or integrating survey data into larger workflows, this method provides the missing automation link for Microsoft Forms.
Stay Tuned for Part 2: In the upcoming post, we’ll delve into creating, deleting, and updating Microsoft Forms programmatically using PowerShell. Don’t miss out on these advanced automation techniques!
Leave a Reply