Author: Jack

  • Unlocking the Hidden Microsoft Forms API with PowerShell & Azure App Registration PART 2

    Unlocking the Hidden Microsoft Forms API with PowerShell & Azure App Registration PART 2

    Unlock full CRUD access to Microsoft Forms through PowerShell and Azure App Registrations

    In Part 1 we reverse-engineered the read side of Microsoft Forms’ hidden API — enough to pull forms and responses straight into PowerShell without ever opening a browser.

    This time we’re going the other way. In this article we’ll unlock the mutation side of the same API:

    • Creating new forms
    • Adding questions and sections to them
    • Updating titles, descriptions and settings
    • Deleting forms when you’re done

    One change since Part 1: Microsoft has been moving the Forms API off forms.office.com and onto forms.cloud.microsoft as part of a broader push to consolidate Microsoft 365 services onto dedicated, more tightly-scoped domains for better security.

    Same disclaimer as Part 1 applies: none of this is documented by Microsoft. It’s reconstructed from watching what the Forms web app actually sends over the wire, so treat it as unofficial and test carefully before relying on it for anything important.

    Updating our App Registration in Azure

    In Part 1, we set up an Azure App Registration with Forms.Read.All and Forms.Read permissions — enough to read data from Microsoft Forms.

    To create, update, or delete forms, we need to grant our app write access instead. That means swapping the read-only scopes for:

    • Forms.ReadWrite.All — application permission
    • Forms.ReadWrite — delegated permission
    1. Go to Azure Portal → App Registrations
    2. Select the app you created in Part 1 (or create a new one if you’d rather keep read and write access separate)
    3. Under API Permissions → Add a permission → APIs my organization uses, search for Microsoft Forms
    4. Select Application permissions → Forms.ReadWrite.All (for group-owned forms, use Delegated permissions → Forms.ReadWrite instead)
    5. Grant admin consent

    That upgrades the app from read-only to full CRUD access on Forms — letting you create, edit and delete forms just like the Forms web app does internally.

    Updating the Token Manager Class to support Creating Forms and Certificate Authentication

    The TokenManager class from Part 1 supported client-secret and ROPC token flows. Support has been added for certificate-based authentication too.

    • ROPC (Resource Owner Password Credentials) — authenticating as a specific user with delegated permissions, useful when you want forms created “as” a real person rather than an app
    • Certificate-based authentication — a more secure alternative to client secrets, using a JWT client assertion signed with a certificate’s private key instead of a shared secret
    • A resource parameter — some of the older v1 token endpoints (still used by parts of the Forms create flow) expect resource rather than the v2 scope parameter

    Two implementation details are worth calling out, since they explain a couple of choices in the class below:

    • The six auth combinations are built as named static factory methods[TokenManager]::FromClientSecret(...), ::FromCertificate(...), ::FromRopcClientSecret(...) and so on — rather than overloaded constructors. PowerShell resolves constructor overloads by parameter type, and two of the six combinations (ROPC with a secret, and client-credentials with a certificate) both need exactly six string parameters in a row — an identical signature PowerShell won’t allow as two separate constructors. Named factories sidestep that collision entirely and make each call site self-documenting.
    • The client-assertion JWT header carries a PS256 algorithm and an x5t#S256 claim — the base64url-encoded SHA-256 thumbprint of the signing certificate — matching Microsoft’s current documented format for certificate credentials, rather than the older (still widely-used, but no longer the documented) RS256/SHA-1 x5t pairing.
    • The JWT’s aud claim matches whichever token endpoint this specific request is actually going to — the v1 URL for the WithResource factories, the v2.0 URL otherwise. Entra validates the assertion’s audience against the realm of the endpoint that receives the request, so a v1 request needs a v1 aud and a v2 request needs a v2 aud. Mismatching the two is exactly what produces AADSTS700023: Client assertion audience claim does not match Realm issuer — a genuinely easy mistake to make, since plenty of certificate-auth sample code only ever exercises the v2 path and hardcodes aud accordingly.

    RefreshToken() sends scope and resource together whenever both are set — the v1 endpoint just ignores the extra scope parameter, so there’s no need to withhold it.

    Here’s the class in full. It supports six combinations: client-credentials or ROPC, secret or certificate, with or without a v1 resource:

    PowerShell
    class TokenManager {
        [string]$TenantId
        [string]$ClientId
        [string]$ClientSecret
        [string]$CertificateThumbprint
        [string]$CertificateStoreLocation
        [string]$CertificateStoreName
        [string]$Username
        [string]$Pass
        [string]$Scope
        [string]$Resource
        [string]$AccessToken
        [datetime]$Expiry
        [string]$TokenEndpoint
        [bool]$RopcFlow = $false
        [bool]$UseCertificate = $false
        [bool]$HasResource = $false
    
        static [TokenManager] FromClientSecret([string]$TenantId, [string]$ClientId, [string]$ClientSecret, [string]$Scope) {
            $tm = [TokenManager]::new()
            $tm.TenantId = $TenantId
            $tm.ClientId = $ClientId
            $tm.ClientSecret = $ClientSecret
            $tm.Scope = $Scope
            $tm.TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
            $tm.RopcFlow = $false
            $tm.UseCertificate = $false
            $tm.HasResource = $false
            return $tm
        }
    
        static [TokenManager] FromCertificate([string]$TenantId, [string]$ClientId, [string]$CertificateThumbprint, [string]$CertificateStoreLocation, [string]$CertificateStoreName, [string]$Scope) {
            $tm = [TokenManager]::new()
            $tm.TenantId = $TenantId
            $tm.ClientId = $ClientId
            $tm.CertificateThumbprint = $CertificateThumbprint
            $tm.CertificateStoreLocation = $CertificateStoreLocation
            $tm.CertificateStoreName = $CertificateStoreName
            $tm.Scope = $Scope
            $tm.TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
            $tm.RopcFlow = $false
            $tm.UseCertificate = $true
            $tm.HasResource = $false
            return $tm
        }
    
        static [TokenManager] FromClientSecretWithResource([string]$TenantId, [string]$ClientId, [string]$ClientSecret, [string]$Scope, [string]$Resource) {
            $tm = [TokenManager]::new()
            $tm.TenantId = $TenantId
            $tm.ClientId = $ClientId
            $tm.ClientSecret = $ClientSecret
            $tm.Scope = $Scope
            $tm.Resource = $Resource
            $tm.TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/token"
            $tm.RopcFlow = $false
            $tm.UseCertificate = $false
            $tm.HasResource = $true
            return $tm
        }
    
        static [TokenManager] FromCertificateWithResource([string]$TenantId, [string]$ClientId, [string]$CertificateThumbprint, [string]$CertificateStoreLocation, [string]$CertificateStoreName, [string]$Scope, [string]$Resource) {
            $tm = [TokenManager]::new()
            $tm.TenantId = $TenantId
            $tm.ClientId = $ClientId
            $tm.CertificateThumbprint = $CertificateThumbprint
            $tm.CertificateStoreLocation = $CertificateStoreLocation
            $tm.CertificateStoreName = $CertificateStoreName
            $tm.Scope = $Scope
            $tm.Resource = $Resource
            $tm.TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/token"
            $tm.RopcFlow = $false
            $tm.UseCertificate = $true
            $tm.HasResource = $true
            return $tm
        }
    
        static [TokenManager] FromRopcClientSecret([string]$Username, [string]$Pass, [string]$TenantId, [string]$ClientId, [string]$ClientSecret, [string]$Scope) {
            $tm = [TokenManager]::new()
            $tm.Username = $Username
            $tm.Pass = $Pass
            $tm.TenantId = $TenantId
            $tm.ClientId = $ClientId
            $tm.ClientSecret = $ClientSecret
            $tm.Scope = $Scope
            $tm.TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
            $tm.RopcFlow = $true
            $tm.UseCertificate = $false
            $tm.HasResource = $false
            return $tm
        }
    
        static [TokenManager] FromRopcCertificate([string]$Username, [string]$Pass, [string]$TenantId, [string]$ClientId, [string]$CertificateThumbprint, [string]$CertificateStoreLocation, [string]$CertificateStoreName, [string]$Scope) {
            $tm = [TokenManager]::new()
            $tm.Username = $Username
            $tm.Pass = $Pass
            $tm.TenantId = $TenantId
            $tm.ClientId = $ClientId
            $tm.CertificateThumbprint = $CertificateThumbprint
            $tm.CertificateStoreLocation = $CertificateStoreLocation
            $tm.CertificateStoreName = $CertificateStoreName
            $tm.Scope = $Scope
            $tm.TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
            $tm.RopcFlow = $true
            $tm.UseCertificate = $true
            $tm.HasResource = $false
            return $tm
        }
    
        [string]NewJwt([string]$ClientId, [string]$TokenEndpoint, [string]$CertificateThumbprint, [string]$CertificateStoreLocation, [string]$CertificateStoreName) {
            $cert = Get-Item -Path "Cert:$CertificateStoreLocation$CertificateStoreName$CertificateThumbprint" -ErrorAction Stop
            if (-not $cert) {
                throw "Certificate with thumbprint '$CertificateThumbprint' not found in store $CertificateStoreLocation$CertificateStoreName."
            }
    
            $now = [Math]::Floor([decimal](Get-Date (Get-Date).ToUniversalTime() -UFormat "%s"))
    
            $header = @{
                alg        = "PS256"
                typ        = "JWT"
                "x5t#S256" = [Convert]::ToBase64String($cert.GetCertHash([System.Security.Cryptography.HashAlgorithmName]::SHA256)) -replace '\+', '-' -replace '/', '_' -replace '='
            } | ConvertTo-Json -Compress
    
            $payload = @{
                aud = $TokenEndpoint
                exp = $now + 600
                iss = $ClientId
                jti = [guid]::NewGuid().ToString()
                nbf = $now
                sub = $ClientId
            } | ConvertTo-Json -Compress
    
            $headerBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($header)) -replace '\+', '-' -replace '/', '_' -replace '='
            $payloadBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($payload)) -replace '\+', '-' -replace '/', '_' -replace '='
            $toSign = "$headerBase64.$payloadBase64"
    
            $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)
            if (-not $rsa) { throw "Certificate does not have an accessible private key." }
    
            $signature = $rsa.SignData([Text.Encoding]::UTF8.GetBytes($toSign), [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pss)
            $signatureBase64 = [Convert]::ToBase64String($signature) -replace '\+', '-' -replace '/', '_' -replace '='
    
            return "$toSign.$signatureBase64"
        }
    
        [string]GetToken() {
            if (-not $this.AccessToken -or (Get-Date) -ge $this.Expiry) {
                $this.RefreshToken()
            }
    
            return $this.AccessToken
        }
    
        [void]RefreshToken() {
            $body = @{
                client_id  = $this.ClientId
                grant_type = "client_credentials"
            }
    
            if ($this.Scope) {
                $body["scope"] = $this.Scope
            }
    
            if ($this.HasResource) {
                $body["resource"] = $this.Resource
            }
    
            if ($this.UseCertificate) {
                $clientAssertion = $this.NewJwt(
                    $this.ClientId,
                    $this.TokenEndpoint,
                    $this.CertificateThumbprint,
                    $this.CertificateStoreLocation,
                    $this.CertificateStoreName
                )
    
                $body["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
                $body["client_assertion"] = $clientAssertion
            }
            else {
                $body["client_secret"] = $this.ClientSecret
            }
    
            if ($this.RopcFlow) {
                $body["username"] = $this.Username
                $body["password"] = $this.Pass
                $body["grant_type"] = "password"
            }
    
            $tokenResponse = Invoke-RestMethod -Uri $this.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)."
        }
    }
    ROPC flow with username/password and client secret

    Authenticates as a specific user, using a client secret to prove the app’s identity:

    PowerShell
    $tokenManager = [TokenManager]::FromRopcClientSecret(
        "[email protected]",
        "UserPassword123!",
        "<tenant id>",
        "<client id>",
        "<client secret>",
        "https://forms.cloud.microsoft/.default"
    )
    ROPC flow with username/password and certificate

    Same delegated flow, but authenticating the app with a certificate instead of a secret:

    PowerShell
    $tokenManager = [TokenManager]::FromRopcCertificate(
        "[email protected]",
        "UserPassword123!",
        "<tenant id>",
        "<client id>",
        "<certificate thumbprint>",
        "LocalMachine",
        "My",
        "https://forms.cloud.microsoft/.default"
    )
    Client credentials flow with client secret

    The application-only flow we used in Part 1:

    PowerShell
    $tokenManager = [TokenManager]::FromClientSecret(
        "<tenant id>",
        "<client id>",
        "<client secret>",
        "https://forms.cloud.microsoft/.default"
    )
    Client credentials flow with certificate

    Application-only, authenticated with a certificate:

    PowerShell
    $tokenManager = [TokenManager]::FromCertificate(
        "<tenant id>",
        "<client id>",
        "<certificate thumbprint>",
        "LocalMachine",
        "My",
        "https://forms.cloud.microsoft/.default"
    )
    Client credentials flow with client secret and resource

    Some Forms create endpoints still expect a v1-style resource token rather than a v2 scope token — this factory targets the v1 endpoint. The resource value has to be Microsoft Forms’ own Application ID URIapi://forms.cloud.microsoft/c9a559d2-7aab-4f13-a6ed-e7e9c52aec87 — not a bare domain. A plain https://forms.cloud.microsoft still gets a token issued without error, but that token’s audience doesn’t actually match what Forms expects, and the mismatch only surfaces later as a confusing failure on the API call itself rather than a clean rejection at token time:

    PowerShell
    $tokenManager = [TokenManager]::FromClientSecretWithResource(
        "<tenant id>",
        "<client id>",
        "<client secret>",
        "https://forms.cloud.microsoft/.default",
        "api://forms.cloud.microsoft/c9a559d2-7aab-4f13-a6ed-e7e9c52aec87"
    )
    Client credentials flow with certificate and resource

    Same v1 resource flow, authenticated with a certificate:

    PowerShell
    $tokenManager = [TokenManager]::FromCertificateWithResource(
        "<tenant id>",
        "<client id>",
        "<certificate thumbprint>",
        "LocalMachine",
        "My",
        "https://forms.cloud.microsoft/.default",
        "api://forms.cloud.microsoft/c9a559d2-7aab-4f13-a6ed-e7e9c52aec87"
    )

    Creating a New Form with PowerShell

    With write scopes in place and a token manager that can handle whichever auth flow we need, we can create a form. Here’s New-MSForm:

    PowerShell
    function New-MSForm {
        [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 = $false)]
            [ValidateScript({ ![string]::IsNullOrWhiteSpace($_) })]
            [string]$Title = "Untitled form",
    
            [Parameter(Mandatory = $true)]
            [ValidateSet("Anyone", "OrgAnyone", "OrgSpecific")]
            [string]$ResponseMode,
    
            [Parameter(Mandatory = $true)]
            [ValidateNotNull()]
            [TokenManager]$TokenManager,
    
            [Parameter(Mandatory = $false)]
            [switch]$RequiresUniqueResponse,
    
            [Parameter(Mandatory = $false)]
            [switch]$NotRecordIdentity,
    
            [Parameter(Mandatory = $false)]
            [bool]$ProgressBarEnabled = $false,
    
            [Parameter(Mandatory = $false)]
            [ValidateNotNullOrEmpty()]
            [string[]]$SpecificResponders
        )
    
        begin {
            $IsAnonymous = $false
    
            switch ($ResponseMode) {
                "Anyone" {
                    $IsAnonymous = $true
                    $RequiresUniqueResponse = $false
                    $NotRecordIdentity = $false
    
                    if ($PSBoundParameters.ContainsKey('RequiresUniqueResponse')) {
                        throw "RequiresUniqueResponse cannot be set for Anyone mode"
                    }
    
                    if ($PSBoundParameters.ContainsKey('NotRecordIdentity')) {
                        throw "NotRecordIdentity cannot be set for Anyone mode"
                    }
                }
            }
    
            $ownerContext = switch ($OwnerType) {
                "User" { "users" }
                "Group" { "groups" }
            }
    
            $endpoint = "https://forms.cloud.microsoft/formapi/api/$TenantId/$ownerContext/$OwnerId/forms"
    
            $settings = @{
                RequiresUniqueResponse = [bool]$RequiresUniqueResponse
                IsAnonymous            = [bool]$IsAnonymous
                NotRecordIdentity      = [bool]$NotRecordIdentity
                IsQuizMode             = $false.ToString().ToLowerInvariant()
                PermissionForResponder = 1
            }
    
            $body = @{
                settings           = ($settings | ConvertTo-Json -Compress)
                ownerId            = $OwnerId
                ownerTenantId      = $TenantId
                progressBarEnabled = $ProgressBarEnabled.ToString().ToLowerInvariant()
                title              = $Title
            }
        }
    
        process {
            try {
                Write-Verbose "Creating new Microsoft Form titled '$Title'..."
    
                $formCreateResponse = Invoke-HttpRequestWithToken -Method POST -Uri $endpoint -TokenManager $TokenManager -Body $body
            }
            catch {
                throw "Failed to create form: $($_.Exception.Message)"
            }
    
            if ($ResponseMode -eq "OrgSpecific" -and $PSBoundParameters.ContainsKey('SpecificResponders')) {
                try {
                    Write-Verbose "Setting specific responder permissions..."
    
                    Set-MSFormResponderPermissions `
                        -TenantId $TenantId `
                        -OwnerId $OwnerId `
                        -OwnerType $OwnerType `
                        -FormId $formCreateResponse.id `
                        -Users $SpecificResponders `
                        -TokenManager $TokenManager
                }
                catch {
                    Write-Host "Failed to add users to form: $($_.Exception.Message)" -ForegroundColor Red
                }
            }
        }
    
        end {
            return $formCreateResponse
        }
    }
    Understanding the parameters

    ResponseMode is the one that matters most — it maps onto the same “who can fill this in” choices you’d see in the Forms UI:

    • Anyone — a public, anonymous link. No sign-in required, no identity recorded.
    • OrgAnyone — anyone signed into your organisation’s tenant can respond.
    • OrgSpecific — only named individuals can respond, passed via SpecificResponders.

    RequiresUniqueResponse restricts each respondent to a single submission, and NotRecordIdentity stops the form from recording who submitted a response even though they had to sign in to access it. Both only make sense for OrgAnyone or OrgSpecific forms — the function throws if you try to set either alongside Anyone, since an anonymous form has no identity to de-duplicate or record in the first place.

    Example Usages

    All three examples below assume a v1-resource token, since the create endpoint is one of the ones that still wants a resource-flavoured token:

    PowerShell
    $tokenManager = [TokenManager]::FromClientSecretWithResource(
        "<tenant id>",
        "<client id>",
        "<client secret>",
        "https://forms.cloud.microsoft/.default",
        "api://forms.cloud.microsoft/c9a559d2-7aab-4f13-a6ed-e7e9c52aec87"
    )
    Example 1: Public form

    Anyone with the link can respond — no sign-in, no identity recorded:

    PowerShell
    New-MSForm `
        -TenantId "<tenant id>" `
        -OwnerId "<entra object id>" `
        -OwnerType "User" `
        -Title "Customer Feedback" `
        -ResponseMode "Anyone" `
        -TokenManager $tokenManager
    Example 2: Anyone in the organisation

    Anyone signed into the tenant can respond, and we’re limiting each person to one response:

    PowerShell
    New-MSForm `
        -TenantId "<tenant id>" `
        -OwnerId "<entra object id>" `
        -OwnerType "User" `
        -Title "Staff Survey" `
        -ResponseMode "OrgAnyone" `
        -RequiresUniqueResponse `
        -TokenManager $tokenManager
    Example 3: Specific people only

    Only the named respondents can access the form:

    PowerShell
    New-MSForm `
        -TenantId "<tenant id>" `
        -OwnerId "<entra object id>" `
        -OwnerType "User" `
        -Title "Leadership Team Review" `
        -ResponseMode "OrgSpecific" `
        -SpecificResponders @("[email protected]", "[email protected]") `
        -TokenManager $tokenManager

    Updating Form Details

    From here on, every function needs the same four pieces of context — TenantId, OwnerId, OwnerType and FormId — to build the form’s endpoint. Rather than repeating that construction in every function, I’ve pulled it into one small helper that the rest of the library calls internally:

    PowerShell
    function Get-MSFormEndpoint {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId
        )
    
        $ownerContext = switch ($OwnerType) {
            "User" { "users" }
            "Group" { "groups" }
        }
    
        return "https://forms.cloud.microsoft/formapi/api/$TenantId/$ownerContext/$OwnerId/forms('$FormId')"
    }

    A form created with New-MSForm has a title but no description. Both are set with a PATCH straight to the form’s own resource URL — and interestingly, they’re captured as two separate requests rather than one combined update, so the functions below keep that split rather than merging them. I’m keeping the generic Invoke-HttpRequestWithToken helper from earlier in the post too — every function below is really just a thin, validated wrapper around it:

    PowerShell
    function Update-MSFormTitle {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$Title,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $endpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId
    
        return Invoke-HttpRequestWithToken -Method PATCH -Uri $endpoint -TokenManager $TokenManager -Body @{
            title           = $Title
            formsProRTTitle = $Title
        }
    }
    
    function Update-MSFormDescription {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$Description,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $endpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId
    
        return Invoke-HttpRequestWithToken -Method PATCH -Uri $endpoint -TokenManager $TokenManager -Body @{
            description           = $Description
            formsProRTDescription = $Description
        }
    }

    The duplicated title/formsProRTTitle and description/formsProRTDescription pairs aren’t a typo — the Forms web app writes both every time. Why isn’t clear from the captures alone; the “RT” naming hints at something rich-text-related, but that’s a guess rather than a confirmed reason, so both are set here to keep the two fields in sync regardless.

    PowerShell
    Update-MSFormTitle -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -Title "My Updated Form Title" -TokenManager $tokenManager
    Update-MSFormDescription -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -Description "A short description of what this form is for." -TokenManager $tokenManager

    Adding Questions to Your Form

    Every question type follows the same two-step pattern under the hood: POST to the form’s questions collection to create a question, then PATCH the specific question to reconfigure it later if needed. The functions below wrap both steps — an Add-MSForm*Question function that creates a fully-configured question in one call, and an Update-MSForm*Question function for editing it afterwards.

    Three quirks the functions handle for you, worth knowing about anyway:

    • The question’s id is chosen by the client, not returned by the server — the Forms web app generates a token like r61e46485c47f41c0b62760fb1e2bdcc5 (an “r” prefix followed by a random hex string) and sends it in the create request. Each Add- function below generates one with "r" + [guid]::NewGuid().ToString("N").
    • The order field jumps in increments of roughly 1,000,000 per question (1000500, 2000500, 3000500…) rather than counting up by one. That’s a sparse ordering scheme — it leaves enormous gaps so questions can be reordered or inserted later without renumbering everything else. Each function below defaults Order to its own slot in that scheme, but takes it as a parameter so you can override it.
    • The body that configures a question is doubly-encoded: the outer request body is a normal object, but the questionInfo field inside it is itself a JSON string, not a nested object. Every function builds this with ConvertTo-Json -Compress on an inner hashtable.
    Choice questions
    PowerShell
    function Add-MSFormChoiceQuestion {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$Title,
            [Parameter(Mandatory)] [string[]]$Choices,
            [switch]$AllowMultipleAnswers,
            [switch]$AllowOtherAnswer,
            [switch]$ShuffleOptions,
            [switch]$Required,
            [ValidateSet("None", "Exactly", "AtMost")] [string]$RestrictionType = "None",
            [int]$RestrictionValue,
            [int]$Order = 1000500,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $questionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions"
    
        $questionInfo = @{
            Choices                = @($Choices | ForEach-Object { @{ Description = $_; IsGenerated = $true } })
            ChoiceType              = if ($AllowMultipleAnswers) { 2 } else { 1 }
            AllowOtherAnswer        = [bool]$AllowOtherAnswer
            ShuffleOptions          = [bool]$ShuffleOptions
            OptionDisplayStyle      = "ListAll"
            ChoiceRestrictionType   = $RestrictionType
        }
    
        if ($RestrictionType -ne "None") {
            $questionInfo["ChoiceRestrictionValue"] = $RestrictionValue
        }
    
        return Invoke-HttpRequestWithToken -Method POST -Uri $questionsEndpoint -TokenManager $TokenManager -Body @{
            type         = "Question.Choice"
            title        = $Title
            id           = "r" + [guid]::NewGuid().ToString("N")
            order        = $Order
            isQuiz       = $false
            required     = [bool]$Required
            questionInfo = ($questionInfo | ConvertTo-Json -Compress)
        }
    }
    
    function Update-MSFormChoiceQuestion {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$QuestionId,
            [Parameter(Mandatory)] [string[]]$Choices,
            [switch]$AllowMultipleAnswers,
            [switch]$AllowOtherAnswer,
            [switch]$ShuffleOptions,
            [switch]$Required,
            [ValidateSet("None", "Exactly", "AtMost")] [string]$RestrictionType = "None",
            [int]$RestrictionValue,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $questionEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$QuestionId')"
    
        $questionInfo = @{
            Choices                = @($Choices | ForEach-Object { @{ Description = $_; IsGenerated = $true } })
            ChoiceType              = if ($AllowMultipleAnswers) { 2 } else { 1 }
            AllowOtherAnswer        = [bool]$AllowOtherAnswer
            ShuffleOptions          = [bool]$ShuffleOptions
            OptionDisplayStyle      = "ListAll"
            ChoiceRestrictionType   = $RestrictionType
        }
    
        if ($RestrictionType -ne "None") {
            $questionInfo["ChoiceRestrictionValue"] = $RestrictionValue
        }
    
        return Invoke-HttpRequestWithToken -Method PATCH -Uri $questionEndpoint -TokenManager $TokenManager -Body @{
            required     = [bool]$Required
            questionInfo = ($questionInfo | ConvertTo-Json -Compress)
        }
    }

    ChoiceType is 1 for single-select and 2 for multiple-select, which is why the functions derive it from the -AllowMultipleAnswers switch rather than taking it directly. RestrictionType can be None, Exactly, or AtMost, paired with RestrictionValue to cap how many options can be picked. To reorder options, just call Update-MSFormChoiceQuestion again with -Choices in the order you want — unlike ranking questions (below), there’s no separate per-choice endpoint here.

    PowerShell
    $choiceQuestion = Add-MSFormChoiceQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
        -Title "Which product did you purchase?" -Choices @("Option 1", "Option 2") -TokenManager $tokenManager
    
    Update-MSFormChoiceQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -QuestionId $choiceQuestion.id `
        -Choices @("Option 1", "Option 2", "Option 3") -AllowMultipleAnswers -AllowOtherAnswer -ShuffleOptions -Required `
        -RestrictionType "Exactly" -RestrictionValue 2 -TokenManager $tokenManager
    Text Field questions

    Text fields can also carry a validation rule. The rule codes aren’t documented anywhere, so here’s the reverse-engineered map Update-MSFormTextFieldQuestion is built around:

    • 0IsNumber — must be a number
    • 1Greater — greater than MinBoundary
    • 2GreaterOrEqual — greater than or equal to MinBoundary
    • 3Less — less than MaxBoundary
    • 4LessOrEqual — less than or equal to MaxBoundary
    • 5Equal — equal to MinBoundary (reuses the min-boundary field for the single comparison value)
    • 6NotEqual — not equal to MinBoundary
    • 7Between — between MinBoundary and MaxBoundary
    • 8NotBetween — outside MinBoundary and MaxBoundary
    • 9 — maximum text length, via MaxBoundary
    • 10 — minimum text length, via MinBoundary
    • 11 — must be a valid email address
    • 12Contains — text must contain TextInput
    • 13DoesntContain — text must not contain TextInput
    • 14 — must be a valid URL
    • 15WholeNumber — must be a whole number
    PowerShell
    function Add-MSFormTextFieldQuestion {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$Title,
            [switch]$Multiline,
            [switch]$Required,
            [int]$Order = 2000500,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $questionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions"
    
        return Invoke-HttpRequestWithToken -Method POST -Uri $questionsEndpoint -TokenManager $TokenManager -Body @{
            type         = "Question.TextField"
            title        = $Title
            id           = "r" + [guid]::NewGuid().ToString("N")
            order        = $Order
            isQuiz       = $false
            required     = [bool]$Required
            questionInfo = (@{ Multiline = [bool]$Multiline } | ConvertTo-Json -Compress)
        }
    }
    
    function Update-MSFormTextFieldQuestion {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$QuestionId,
            [switch]$Multiline,
            [ValidateRange(0, 15)] [int]$ValidationRule = -1,
            [double]$MinBoundary,
            [double]$MaxBoundary,
            [string]$TextInput,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $questionEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$QuestionId')"
    
        $questionInfo = @{
            Multiline             = [bool]$Multiline
            GradingBasis          = 0
            IsAnswerPlainText     = $true
            IsMathInputDefault    = $false
            InputSwitcherDisabled = $false
        }
    
        if ($ValidationRule -ge 0) {
            $questionInfo["NumberValidation"] = @{
                NumberValidationRule = $ValidationRule
                NumberMinBoundary    = $MinBoundary
                NumberMaxBoundary    = $MaxBoundary
                TextInput            = $TextInput
            }
        }
    
        return Invoke-HttpRequestWithToken -Method PATCH -Uri $questionEndpoint -TokenManager $TokenManager -Body @{
            questionInfo = ($questionInfo | ConvertTo-Json -Compress)
        }
    }

    Creating a short-answer question, then switching it to long-answer and restricting it to a whole number between 1 and 100:

    PowerShell
    $textQuestion = Add-MSFormTextFieldQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
        -Title "What's your favourite feature?" -TokenManager $tokenManager
    
    Update-MSFormTextFieldQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -QuestionId $textQuestion.id `
        -Multiline -ValidationRule 7 -MinBoundary 1 -MaxBoundary 100 -TokenManager $tokenManager
    Rating questions
    PowerShell
    function Add-MSFormRatingQuestion {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$Title,
            [ValidateRange(2, 10)] [int]$Length = 5,
            [ValidateSet("Star", "Number", "Heart", "Ribbon", "ThumbLike", "SmileFace", "Flag", "Lightbulb", "Trophy", "CheckMark")]
            [string]$RatingShape = "Star",
            [int]$Order = 3000500,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $questionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions"
    
        return Invoke-HttpRequestWithToken -Method POST -Uri $questionsEndpoint -TokenManager $TokenManager -Body @{
            type         = "Question.Rating"
            title        = $Title
            id           = "r" + [guid]::NewGuid().ToString("N")
            order        = $Order
            isQuiz       = $false
            questionInfo = (@{ Length = $Length; RatingShape = $RatingShape } | ConvertTo-Json -Compress)
        }
    }
    
    function Update-MSFormRatingQuestion {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$QuestionId,
            [ValidateRange(2, 10)] [int]$Length = 5,
            [ValidateSet("Star", "Number", "Heart", "Ribbon", "ThumbLike", "SmileFace", "Flag", "Lightbulb", "Trophy", "CheckMark")]
            [string]$RatingShape = "Star",
            [switch]$ShowRatingLabel,
            [string]$LeftDescription,
            [string]$RightDescription,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $questionEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$QuestionId')"
    
        return Invoke-HttpRequestWithToken -Method PATCH -Uri $questionEndpoint -TokenManager $TokenManager -Body @{
            questionInfo = (@{
                Length            = $Length
                RatingShape       = $RatingShape
                ShowRatingLabel   = [bool]$ShowRatingLabel
                LeftDescription   = $LeftDescription
                RightDescription  = $RightDescription
            } | ConvertTo-Json -Compress)
        }
    }
    PowerShell
    $ratingQuestion = Add-MSFormRatingQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
        -Title "How would you rate our service?" -TokenManager $tokenManager
    
    Update-MSFormRatingQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -QuestionId $ratingQuestion.id `
        -Length 10 -RatingShape "Number" -ShowRatingLabel -LeftDescription "Not likely" -RightDescription "Very likely" -TokenManager $tokenManager
    Date questions
    PowerShell
    function Add-MSFormDateQuestion {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$Title,
            [string]$Subtitle,
            [switch]$Required,
            [int]$Order = 4000500,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $questionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions"
    
        return Invoke-HttpRequestWithToken -Method POST -Uri $questionsEndpoint -TokenManager $TokenManager -Body @{
            type         = "Question.DateTime"
            title        = $Title
            subtitle     = $Subtitle
            id           = "r" + [guid]::NewGuid().ToString("N")
            order        = $Order
            isQuiz       = $false
            required     = [bool]$Required
            questionInfo = (@{} | ConvertTo-Json -Compress)
        }
    }
    
    function Update-MSFormDateQuestion {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$QuestionId,
            [string]$Subtitle,
            [switch]$Required,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $questionEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$QuestionId')"
    
        return Invoke-HttpRequestWithToken -Method PATCH -Uri $questionEndpoint -TokenManager $TokenManager -Body @{
            subtitle = $Subtitle
            required = [bool]$Required
        }
    }
    PowerShell
    Add-MSFormDateQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
        -Title "When did you make your purchase?" -Subtitle "Approximate date is fine" -Required -TokenManager $tokenManager
    Ranking questions

    Ranking questions behave differently from Choice questions — choices live under their own sub-resource, and reordering them means patching individual choices rather than resending the whole list, so they get their own dedicated functions:

    PowerShell
    function Add-MSFormRankingQuestion {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$Title,
            [switch]$AllowMultipleValues,
            [switch]$Required,
            [int]$Order = 5000500,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $questionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions"
    
        return Invoke-HttpRequestWithToken -Method POST -Uri $questionsEndpoint -TokenManager $TokenManager -Body @{
            type         = "Question.Ranking"
            title        = $Title
            id           = "r" + [guid]::NewGuid().ToString("N")
            order        = $Order
            isQuiz       = $false
            required     = [bool]$Required
            questionInfo = (@{ AllowMultipleValues = [bool]$AllowMultipleValues } | ConvertTo-Json -Compress)
        }
    }
    
    function Add-MSFormRankingChoice {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$QuestionId,
            [Parameter(Mandatory)] [string]$Description,
            [Parameter(Mandatory)] [int]$Order,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $choicesEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$QuestionId')/choices"
    
        return Invoke-HttpRequestWithToken -Method POST -Uri $choicesEndpoint -TokenManager $TokenManager -Body @{
            Description = $Description
            order       = $Order
        }
    }
    
    function Set-MSFormRankingChoiceOrder {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$QuestionId,
            [Parameter(Mandatory)] [int]$ChoiceIndex,
            [Parameter(Mandatory)] [int]$NewOrder,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $choiceEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$QuestionId')/choices($ChoiceIndex)"
    
        return Invoke-HttpRequestWithToken -Method PATCH -Uri $choiceEndpoint -TokenManager $TokenManager -Body @{ order = $NewOrder }
    }
    
    function Remove-MSFormRankingChoice {
        [CmdletBinding(SupportsShouldProcess)]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$QuestionId,
            [Parameter(Mandatory)] [int]$ChoiceIndex,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $choiceEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$QuestionId')/choices($ChoiceIndex)"
    
        if ($PSCmdlet.ShouldProcess($choiceEndpoint, "Delete ranking choice")) {
            return Invoke-HttpRequestWithToken -Method DELETE -Uri $choiceEndpoint -TokenManager $TokenManager
        }
    }
    PowerShell
    $rankingQuestion = Add-MSFormRankingQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
        -Title "Rank these features by importance" -Required -TokenManager $tokenManager
    
    Add-MSFormRankingChoice -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -QuestionId $rankingQuestion.id -Description "Speed" -Order 1 -TokenManager $tokenManager
    Add-MSFormRankingChoice -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -QuestionId $rankingQuestion.id -Description "Reliability" -Order 2 -TokenManager $tokenManager
    
    Set-MSFormRankingChoiceOrder -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -QuestionId $rankingQuestion.id -ChoiceIndex 1 -NewOrder 2 -TokenManager $tokenManager
    Set-MSFormRankingChoiceOrder -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -QuestionId $rankingQuestion.id -ChoiceIndex 2 -NewOrder 1 -TokenManager $tokenManager
    Likert (matrix) questions

    Likert questions are the odd one out structurally — a “group” question holds the response scale (the columns), and each row statement is actually its own separate question object linked back to the group via groupId. That structure carries straight through into the functions:

    PowerShell
    function Add-MSFormLikertQuestion {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$Title,
            [int]$Order = 6000500,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $questionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions"
    
        return Invoke-HttpRequestWithToken -Method POST -Uri $questionsEndpoint -TokenManager $TokenManager -Body @{
            type         = "Question.MatrixChoiceGroup"
            title        = $Title
            id           = "r" + [guid]::NewGuid().ToString("N")
            order        = $Order
            isQuiz       = $false
            questionInfo = (@{} | ConvertTo-Json -Compress)
        }
    }
    
    function Add-MSFormLikertChoice {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$GroupId,
            [Parameter(Mandatory)] [string]$Description,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $choicesEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$GroupId')/choices"
    
        return Invoke-HttpRequestWithToken -Method POST -Uri $choicesEndpoint -TokenManager $TokenManager -Body @{ Description = $Description }
    }
    
    function Add-MSFormLikertStatement {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$GroupId,
            [Parameter(Mandatory)] [string]$Title,
            [int]$Order = 7000500,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $questionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions"
    
        return Invoke-HttpRequestWithToken -Method POST -Uri $questionsEndpoint -TokenManager $TokenManager -Body @{
            type         = "Question.MatrixChoice"
            title        = $Title
            id           = "r" + [guid]::NewGuid().ToString("N")
            order        = $Order
            isQuiz       = $false
            groupId      = $GroupId
            questionInfo = (@{} | ConvertTo-Json -Compress)
        }
    }
    
    function Remove-MSFormLikertStatement {
        [CmdletBinding(SupportsShouldProcess)]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$StatementId,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $endpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$StatementId')"
    
        if ($PSCmdlet.ShouldProcess($endpoint, "Delete Likert statement")) {
            return Invoke-HttpRequestWithToken -Method DELETE -Uri $endpoint -TokenManager $TokenManager
        }
    }
    
    function Remove-MSFormLikertChoice {
        [CmdletBinding(SupportsShouldProcess)]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$GroupId,
            [Parameter(Mandatory)] [int]$ChoiceIndex,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $endpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$GroupId')/choices($ChoiceIndex)"
    
        if ($PSCmdlet.ShouldProcess($endpoint, "Delete Likert choice column")) {
            return Invoke-HttpRequestWithToken -Method DELETE -Uri $endpoint -TokenManager $TokenManager
        }
    }
    PowerShell
    $likertGroup = Add-MSFormLikertQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
        -Title "How satisfied are you with the following?" -TokenManager $tokenManager
    
    Add-MSFormLikertChoice -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -GroupId $likertGroup.id -Description "Very satisfied" -TokenManager $tokenManager
    
    Add-MSFormLikertStatement -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -GroupId $likertGroup.id -Title "Response time" -TokenManager $tokenManager
    File Upload questions

    File Upload follows the same generic-create-then-configure pattern as Date, NPS and the Likert group — questionInfo is empty on creation, and file count, size and type restrictions are set afterwards through Update-MSFormFileUploadQuestion.

    PowerShell
    function Add-MSFormFileUploadQuestion {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$Title,
            [switch]$Required,
            [string]$Subtitle,
            [int]$Order = 8000500,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $questionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions"
    
        $body = @{
            type         = "Question.FileUpload"
            title        = $Title
            id           = "r" + [guid]::NewGuid().ToString("N")
            order        = $Order
            isQuiz       = $false
            required     = [bool]$Required
            questionInfo = (@{} | ConvertTo-Json -Compress)
        }
        if ($PSBoundParameters.ContainsKey('Subtitle')) {
            $body['subtitle'] = $Subtitle
        }
    
        return Invoke-HttpRequestWithToken -Method POST -Uri $questionsEndpoint -TokenManager $TokenManager -Body $body
    }

    FileTypes has to be a nested object listing all seven file types every time, not just the ones you’re restricting to, and three fields that have nothing to do with file uploads — ShuffleOptions, ShowRatingLabel, IsMathQuiz — get sent as constant boilerplate alongside it. Leave any of that out and the request fails with the same vague, unhelpful error rather than a clear validation message.

    PowerShell
    function Update-MSFormFileUploadQuestion {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$QuestionId,
            [int]$MaxFileCount = 1,
            [int]$MaxFileSizeMB = 10,
            [switch]$Required,
            [string]$Subtitle,
            [ValidateSet("Word", "Excel", "PowerPoint", "PDF", "Image", "Video", "Audio")]
            [string[]]$AllowedFileTypes,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $questionEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$QuestionId')"
    
        $allFileTypes = @("Word", "Excel", "PowerPoint", "PDF", "Image", "Video", "Audio")
        $fileTypes = @{}
        foreach ($fileType in $allFileTypes) {
            $fileTypes[$fileType] = if ($AllowedFileTypes) { $AllowedFileTypes -contains $fileType } else { $true }
        }
    
        $questionInfo = @{
            HasSpecificFileType = [bool]$AllowedFileTypes
            FileTypes           = $fileTypes
            MaxFileCount        = $MaxFileCount
            MaxFileSize         = $MaxFileSizeMB
            ShuffleOptions      = $false
            ShowRatingLabel     = $false
            IsMathQuiz          = $false
        }
    
        return Invoke-HttpRequestWithToken -Method PATCH -Uri $questionEndpoint -TokenManager $TokenManager -Body @{
            required     = [bool]$Required
            subtitle     = $Subtitle
            questionInfo = ($questionInfo | ConvertTo-Json -Compress)
        }
    }

    MaxFileSizeMB maps straight onto the API’s MaxFileSize, which is in MB — so 1GB is -MaxFileSizeMB 1000:

    PowerShell
    $uploadQuestion = Add-MSFormFileUploadQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
        -Title "Upload your receipt" -TokenManager $tokenManager
    
    Update-MSFormFileUploadQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -QuestionId $uploadQuestion.id `
        -MaxFileCount 3 -MaxFileSizeMB 1000 -Required -Subtitle "PDF or image files only, max 3 files" -AllowedFileTypes @("PDF", "Image") -TokenManager $tokenManager
    NPS (Net Promoter Score) questions

    NPS is the only question type that ships with a non-generic default title baked into the Forms UI, so Add-MSFormNPSQuestion defaults to it too:

    PowerShell
    function Add-MSFormNPSQuestion {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [string]$Title = "How likely are you to recommend us to a friend or colleague?",
            [string]$Subtitle,
            [int]$Order = 9000500,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $questionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions"
    
        return Invoke-HttpRequestWithToken -Method POST -Uri $questionsEndpoint -TokenManager $TokenManager -Body @{
            type         = "Question.NPS"
            title        = $Title
            subtitle     = $Subtitle
            id           = "r" + [guid]::NewGuid().ToString("N")
            order        = $Order
            isQuiz       = $false
            questionInfo = (@{} | ConvertTo-Json -Compress)
        }
    }
    
    function Update-MSFormNPSQuestion {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$QuestionId,
            [string]$Subtitle,
            [string]$LeftDescription,
            [string]$RightDescription,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $questionEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/questions('$QuestionId')"
    
        return Invoke-HttpRequestWithToken -Method PATCH -Uri $questionEndpoint -TokenManager $TokenManager -Body @{
            subtitle     = $Subtitle
            questionInfo = (@{ LeftDescription = $LeftDescription; RightDescription = $RightDescription } | ConvertTo-Json -Compress)
        }
    }
    PowerShell
    $npsQuestion = Add-MSFormNPSQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -TokenManager $tokenManager
    
    Update-MSFormNPSQuestion -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -QuestionId $npsQuestion.id `
        -Subtitle "0 = not at all likely, 10 = extremely likely" -LeftDescription "Not at all likely" -RightDescription "Extremely likely" -TokenManager $tokenManager

    Adding Sections

    A section turns out to follow the same pattern as every question type in this post: a generic POST to a sub-collection — descriptiveQuestions rather than questions — followed by a PATCH to fill in the details. Add-MSFormSection creates a bare placeholder section (type/title/id/order/isQuiz only, no questionInfo), and Update-MSFormSection below is what actually sets its real title and subtitle:

    PowerShell
    function Add-MSFormSection {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [int]$Order = 10000500,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $sectionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/descriptiveQuestions"
    
        return Invoke-HttpRequestWithToken -Method POST -Uri $sectionsEndpoint -TokenManager $TokenManager -Body @{
            type   = "Question.ColumnGroup"
            title  = "Section"
            id     = "r" + [guid]::NewGuid().ToString("N")
            order  = $Order
            isQuiz = $false
        }
    }
    
    function Update-MSFormSection {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$SectionId,
            [Parameter(Mandatory)] [string]$Title,
            [string]$Subtitle,
            [int]$Order = 1500000,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $endpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/descriptiveQuestions('$SectionId')"
    
        $wrappedTitle = "<span>$Title</span>"
    
        return Invoke-HttpRequestWithToken -Method PATCH -Uri $endpoint -TokenManager $TokenManager -Body @{
            order                   = $Order
            title                   = $wrappedTitle
            formsProRTQuestionTitle = $wrappedTitle
            subtitle                = $Subtitle
        }
    }

    Once created, the section shows up as a descriptiveQuestions entry on the form, which is what Update-MSFormSection targets to set its title/description and move it, using the same sparse order scheme as questions.

    PowerShell
    $section = Add-MSFormSection -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -TokenManager $tokenManager
    
    Update-MSFormSection -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -SectionId $section.id `
        -Title "Section 2: Follow-up questions" -Subtitle "A few extra questions if you have time" -TokenManager $tokenManager

    Configuring Form Settings

    Everything under the Forms “Settings” panel — who can respond, scheduling, limits, and notifications — is a PATCH to the form’s own resource URL, just with a different settings payload each time.

    Who Can Respond

    Set-MSFormResponseMode deliberately mirrors New-MSForm‘s ResponseMode parameter, since it’s setting the exact same three options after the fact instead of at creation time:

    PowerShell
    function Set-MSFormResponseMode {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [ValidateSet("Anyone", "OrgAnyone", "OrgSpecific")] [string]$ResponseMode,
            [switch]$RequiresUniqueResponse,
            [switch]$NotRecordIdentity,
            [string[]]$SpecificResponders,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        if ($ResponseMode -eq "Anyone" -and ($RequiresUniqueResponse -or $NotRecordIdentity)) {
            throw "RequiresUniqueResponse and NotRecordIdentity cannot be set for Anyone mode"
        }
    
        $endpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId
    
        $response = Invoke-HttpRequestWithToken -Method PATCH -Uri $endpoint -TokenManager $TokenManager -Body @{
            settings = (@{
                RequiresUniqueResponse = [bool]$RequiresUniqueResponse
                IsAnonymous             = ($ResponseMode -eq "Anyone")
                NotRecordIdentity       = [bool]$NotRecordIdentity
            } | ConvertTo-Json -Compress)
        }
    
        if ($ResponseMode -eq "OrgSpecific" -and $SpecificResponders) {
            Set-MSFormResponderPermissions -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId -Users $SpecificResponders -TokenManager $TokenManager
        }
    
        return $response
    }
    
    function Set-MSFormResponderPermissions {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string[]]$Users,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $formEndpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId
    
        Invoke-HttpRequestWithToken -Method POST -Uri "$formEndpoint/responderPermissions" -TokenManager $TokenManager -Body @{
            permissionSet = "DataCreator"
            type          = "SpecificResponder"
            principalId   = "SpecificResponder"
        }
    
        foreach ($user in $Users) {
            Invoke-HttpRequestWithToken -Method POST -Uri "$formEndpoint/permissions" -TokenManager $TokenManager -Body @{
                recipients    = @(@{ email = $user })
                permissionSet = "Read"
                type          = "ShareToPeople"
            }
        }
    }

    Worth flagging: the captured responderPermissions call itself doesn’t take a list of people — its principalId is literally the string "SpecificResponder", which reads more like a mode-switch (turn on “specific responders only”) than a per-person grant. The actual list of named respondents appears to be handled separately through the same /permissions sharing endpoint used for collaborators below. I’ve combined both calls above since that matches how New-MSForm uses this function, but if your captures show something different for adding named respondents, treat that part as the least certain piece of this whole write-up. Bear in mind Forms won’t let you use Anyone mode if the form contains a File Upload question.

    PowerShell
    Set-MSFormResponseMode -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
        -ResponseMode "OrgSpecific" -SpecificResponders @("[email protected]", "[email protected]") -TokenManager $tokenManager
    Scheduling & Access Windows

    The Forms UI doesn’t let you set a schedule on a form that’s currently closed, so Set-MSFormSchedule uses parameter sets to keep “close it now” and “schedule a window” mutually exclusive:

    PowerShell
    function Set-MSFormSchedule {
        [CmdletBinding(DefaultParameterSetName = "Window")]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
    
            [Parameter(Mandatory, ParameterSetName = "Close")] [switch]$Close,
            [Parameter(ParameterSetName = "Close")] [string]$ClosedMessage,
    
            [Parameter(ParameterSetName = "Window")] [datetime]$StartDate,
            [Parameter(ParameterSetName = "Window")] [datetime]$EndDate,
    
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $endpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId
    
        $settings = if ($PSCmdlet.ParameterSetName -eq "Close") {
            @{ IsClosed = $true; ClosedMessage = $ClosedMessage }
        }
        else {
            @{
                IsClosed  = $false
                StartDate = if ($StartDate) { $StartDate.ToString("o") }
                EndDate   = if ($EndDate) { $EndDate.ToString("o") }
            }
        }
    
        return Invoke-HttpRequestWithToken -Method PATCH -Uri $endpoint -TokenManager $TokenManager -Body @{
            settings = ($settings | ConvertTo-Json -Compress)
        }
    }
    PowerShell
    Set-MSFormSchedule -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
        -Close -ClosedMessage "This form is no longer accepting responses. Thanks for your interest!" -TokenManager $tokenManager
    
    Set-MSFormSchedule -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
        -StartDate "2026-09-01T00:00:00Z" -EndDate "2026-09-30T23:59:59Z" -TokenManager $tokenManager
    Timeout / Auto-Submit
    PowerShell
    function Set-MSFormTimeLimit {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [int]$TimeLimitSeconds,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $endpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId
    
        return Invoke-HttpRequestWithToken -Method PATCH -Uri $endpoint -TokenManager $TokenManager -Body @{
            settings = (@{ FillOutTimeLimit = $TimeLimitSeconds } | ConvertTo-Json -Compress)
        }
    }
    PowerShell
    Set-MSFormTimeLimit -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -TimeLimitSeconds 600 -TokenManager $tokenManager
    Display Options
    PowerShell
    function Set-MSFormDisplayOptions {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [switch]$HideQuestionNumbers,
            [bool]$ProgressBarEnabled = $true,
            [string]$ThankYouMessage,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $endpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId
    
        return Invoke-HttpRequestWithToken -Method PATCH -Uri $endpoint -TokenManager $TokenManager -Body @{
            settings           = (@{ HideQuestionNumbers = [bool]$HideQuestionNumbers } | ConvertTo-Json -Compress)
            progressBarEnabled = $ProgressBarEnabled.ToString().ToLowerInvariant()
            thankYouMessage    = $ThankYouMessage
        }
    }

    Passing an empty -ThankYouMessage reverts to the default “Your response has been submitted” screen:

    PowerShell
    Set-MSFormDisplayOptions -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId `
        -HideQuestionNumbers -ProgressBarEnabled $false -ThankYouMessage "Thanks for taking the time to fill this in — we appreciate it." -TokenManager $tokenManager
    Respondent Permissions

    Save-and-resume versus edit-after-submit are both controlled by the same numeric PermissionForResponder field, so Set-MSFormResponderCapabilities hides the numbers behind a named set. Note edit-after-submit isn’t available once a time limit is set:

    PowerShell
    function Set-MSFormResponderCapabilities {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [ValidateSet("None", "SaveAndResume", "EditAfterSubmit")] [string]$Capability,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $permissionForResponder = switch ($Capability) {
            "None"             { 0 }
            "SaveAndResume"    { 1 }
            "EditAfterSubmit"  { 2 }
        }
    
        $endpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId
    
        return Invoke-HttpRequestWithToken -Method PATCH -Uri $endpoint -TokenManager $TokenManager -Body @{
            settings = (@{ PermissionForResponder = $permissionForResponder } | ConvertTo-Json -Compress)
        }
    }
    PowerShell
    Set-MSFormResponderCapabilities -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -Capability "EditAfterSubmit" -TokenManager $tokenManager
    Notifications & Collaborators

    Notifying the form owner by email on every new response is a two-step process: first grant a collaborator access via the /permissions sharing endpoint, then that person’s permission set is updated to include EmailNotification. Add-MSFormCollaborator does both in one call when you pass -NotifyByEmail:

    PowerShell
    function Set-MSFormResponseReceipt {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [bool]$Enabled,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $endpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId
    
        return Invoke-HttpRequestWithToken -Method PATCH -Uri $endpoint -TokenManager $TokenManager -Body @{
            settings = (@{ SendResponseReceipt = $Enabled } | ConvertTo-Json -Compress)
        }
    }
    
    function Add-MSFormCollaborator {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [string]$Email,
            [switch]$NotifyByEmail,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $permissionsEndpoint = (Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId) + "/permissions"
    
        $shareResponse = Invoke-HttpRequestWithToken -Method POST -Uri $permissionsEndpoint -TokenManager $TokenManager -Body @{
            recipients    = @(@{ email = $Email })
            permissionSet = "Read, Write, Share"
            type          = "ShareToPeople"
        }
    
        if ($NotifyByEmail) {
            return Invoke-HttpRequestWithToken -Method PATCH -Uri "$permissionsEndpoint('$($shareResponse.principalId)')" -TokenManager $TokenManager -Body @{
                permissionSet = "Read, Write, Share, EmailNotification"
            }
        }
    
        return $shareResponse
    }
    PowerShell
    Set-MSFormResponseReceipt -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -Enabled $true -TokenManager $tokenManager
    
    Add-MSFormCollaborator -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -Email "[email protected]" -NotifyByEmail -TokenManager $tokenManager
    Reading Current Settings
    PowerShell
    function Get-MSFormSettings {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $endpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId
    
        return Invoke-HttpRequestWithToken -Method GET -Uri $endpoint -TokenManager $TokenManager
    }

    The easiest way to confirm any of the changes above actually landed:

    PowerShell
    Get-MSFormSettings -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -TokenManager $tokenManager

    Deleting a Form

    Deleting follows the same rule as every other resource in this API (questions, choices, statements): a DELETE against the resource’s own URL removes it, and a form is no exception. Remove-MSForm wraps that in a plain DELETE to the form’s endpoint, guarded behind -Confirm since it’s destructive:

    PowerShell
    function Remove-MSForm {
        [CmdletBinding(SupportsShouldProcess, ConfirmImpact = "High")]
        param (
            [Parameter(Mandatory)] [string]$TenantId,
            [Parameter(Mandatory)] [string]$OwnerId,
            [Parameter(Mandatory)] [ValidateSet("User", "Group")] [string]$OwnerType,
            [Parameter(Mandatory)] [string]$FormId,
            [Parameter(Mandatory)] [TokenManager]$TokenManager
        )
    
        $endpoint = Get-MSFormEndpoint -TenantId $TenantId -OwnerId $OwnerId -OwnerType $OwnerType -FormId $FormId
    
        if ($PSCmdlet.ShouldProcess($endpoint, "Delete Microsoft Form")) {
            return Invoke-HttpRequestWithToken -Method DELETE -Uri $endpoint -TokenManager $TokenManager
        }
    }

    The -Confirm is optional rather than load-bearing here — ConfirmImpact = "High" already means PowerShell will prompt by default at the standard $ConfirmPreference level, so it’s really there as a reminder that this one is one-way.

    PowerShell
    Remove-MSForm -TenantId $tenantId -OwnerId $ownerId -OwnerType "User" -FormId $formId -TokenManager $tokenManager -Confirm

    Final Thoughts

    Between Part 1 and this post, that’s the full loop covered — read forms and responses, then create, configure, and delete forms, all from PowerShell and without touching the Forms web UI.

    The API surface is bigger than either post lets on; things like conditional branching between questions, quiz scoring, and collaborator management have more depth than there was room to cover here.

    As with everything in this series, none of it is documented or supported by Microsoft, so build in your own error handling and don’t assume today’s request shapes will still work tomorrow.

    I’m currently working on packaging all of this up as a proper PowerShell module, along with the functionality that hasn’t made it into either post — including the deeper areas mentioned above. Once it’s ready, I’ll release it as Part 3 and make the module available on GitHub.

  • Unlocking the Hidden Microsoft Forms API with PowerShell & Azure App Registration

    Unlocking the Hidden Microsoft Forms API with PowerShell & Azure App Registration

    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.

    Update (13/10/2025):

    The group endpoints in the Microsoft Forms API only work with delegated permissions. This means you must authenticate using the ROPC (Resource Owner Password Credentials) flow rather than client credentials.

    To use the ROPC flow, the account you authenticate with must be excluded from MFA (multi-factor authentication), as the flow cannot handle MFA prompts. It is recommended to use a long, secure password for this account. You’ll only be able to access forms owned by groups that the authenticated account is a member of.

    The TokenManager class has been updated to support this flow, allowing tokens to be obtained via ROPC when needed for group-level requests. I have also included a Groups TokenManager example under the “Get All Forms by a Group” section, demonstrating how to authenticate and retrieve forms for groups the account belongs to.

    Update (28/05/2026):

    A reader of the blog brought it to my attention that the Get-MSFormResponse and Get-MSFormResponses functions were only returning the question ID and question response, but not the question title. I have now updated both functions to also include the question title in the returned output, making the response data easier to read and work with.

    This turned out to be a little trickier than expected, as I had to figure out a separate endpoint to call (with a bit of guesswork involved) and then stitch the data together from both responses.

    Update (08/08/2026):

    Part 2 is now live, covering creating, updating and deleting Microsoft Forms programmatically with PowerShell.

    Also worth noting: Microsoft has been moving the Forms API off forms.office.com and onto forms.cloud.microsoft. The code in this post has been updated to reflect this change.

    Create an App Registration in Azure

    First, we need an Azure AD App Registration with permission to read Forms.

    1. Go to Azure Portal → App Registrations → New registration.
    2. Give it a name, register it as Accounts in this organizational directory only.
    3. Under API Permissions → Add a permission → APIs my organization uses, search for Microsoft Forms.
    4. Select Application permissions → Forms.Read.All (for group endpoints use Delegated permissions → Forms.Read)
    5. Grant admin consent
    6. 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.

    PowerShell
    class TokenManager {
        [string]$TenantId
        [string]$ClientId
        [string]$ClientSecret
        [string]$Username
        [string]$Pass
        [string]$Scope
        [string]$AccessToken
        [datetime]$Expiry
        [string]$TokenEndpoint
        [bool]$RopcFlow = $false
        TokenManager([string]$Username, [string]$Pass, [string]$TenantId, [string]$ClientId, [string]$ClientSecret, [string]$Scope) {
            $this.Username = $Username
            $this.Pass = $Pass
            $this.ClientId = $ClientId
            $this.ClientSecret = $ClientSecret
            $this.Scope = $Scope
            $this.TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
            $this.RopcFlow = $true
        }
        TokenManager([string]$TenantId, [string]$ClientId, [string]$ClientSecret, [string]$Scope) {
            $this.TenantId = $TenantId
            $this.ClientId = $ClientId
            $this.ClientSecret = $ClientSecret
            $this.Scope = $Scope
            $this.TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
        }
        [string]GetToken() {
            if (-not $this.AccessToken -or (Get-Date) -ge $this.Expiry) {
                $this.RefreshToken()
            }
            return $this.AccessToken
        }
        [void]RefreshToken() {
            $body = [ordered]@{
                client_id     = $this.ClientId
                client_secret = $this.ClientSecret
                grant_type    = "client_credentials"
                scope         = $this.Scope
            }
            if ($this.RopcFlow) {
                $body["username"] = $this.Username
                $body["password"] = $this.Pass
                $body["grant_type"] = "password"
            }
            $tokenResponse = Invoke-RestMethod -Uri $this.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.

    PowerShell
    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
    PowerShell
    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.cloud.microsoft/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.cloud.microsoft/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
    PowerShell
    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" }
            }
            $formEndpoint = "https://forms.cloud.microsoft/formapi/api/$TenantId/$ownerContext/$OwnerId/light/forms('$FormId')?`$select=id,title,description,formsProRTDescription,formsProRTTitle"
            $formResponse = Invoke-HttpRequestWithToken -Uri $formEndpoint -Method "GET" -TokenManager $TokenManager
        }
        process {
            $responsesEndpoint = "https://forms.cloud.microsoft/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) {
                    $answers = $response.value | Select-Object -ExpandProperty answers | ConvertFrom-Json -Depth 10
                    foreach ($answer in $answers) {
                        $questionInfo = Invoke-HttpRequestWithToken -Method "GET" -Uri "https://forms.cloud.microsoft/formapi/api/$TenantId/$ownerContext/$ownerId/light/forms('$FormId')?`$select=questions&`$expand=questions(`$filter=id eq '$($answer.questionId)';`$expand=choices)" -TokenManager $TokenManager
                        $answer | Add-Member -MemberType NoteProperty -Name title -Value $questionInfo.questions[$answers.IndexOf($answer)].title
                    }
                    $response.value[0].answers = $answers
                    $output = [PSCustomObject]$response.value
                }
            }
            catch {
                throw "Failed to get response: $($_.Exception.Message)"
            }
        }
        end {
            $output | Add-Member -MemberType NoteProperty -Name FormId -Value $formResponse.id
            $output | Add-Member -MemberType NoteProperty -Name Title -Value $formResponse.title
            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
    PowerShell
    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.cloud.microsoft/formapi/api/$TenantId/$ownerContext/$OwnerId/light/forms('$FormId')?`$select=rowCount,id,title,description,formsProRTDescription,formsProRTTitle"
            $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.cloud.microsoft/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 {
                        $answers = $_ | Select-Object -ExpandProperty answers | ConvertFrom-Json -Depth 10
                        foreach ($answer in $answers) {
                            $questionInfo = Invoke-HttpRequestWithToken -Method "GET" -Uri "https://forms.cloud.microsoft/formapi/api/$TenantId/$ownerContext/$ownerId/light/forms('$FormId')?`$select=questions&`$expand=questions(`$filter=id eq '$($answer.questionId)';`$expand=choices)" -TokenManager $TokenManager
                            $answer | Add-Member -MemberType NoteProperty -Name title -Value $questionInfo.questions[$answers.IndexOf($answer)].title
                        }
                        $_.answers = $answers
                        $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 [PSCustomObject]@{
                FormId         = $formResponse.id
                Title          = $formResponse.title
                TotalResponses = $totalResponses
                Responses      = $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
    PowerShell
    $TenantId     = "<your tenant id>"
    $ClientId     = "<your client id>"
    $ClientSecret = "<your client secret>"
    $TokenManager = [TokenManager]::new(
        $TenantId,
        $ClientId,
        $ClientSecret,
        "https://forms.cloud.microsoft/.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

    Retrieve a list of all forms owned by a specific user (identified by their Entra Object ID).

    PowerShell
    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 All Forms by a Group

    Retrieve a list of all forms owned by a specific group (identified by their Entra Object ID).

    The Microsoft Forms group endpoints require delegated permissions (Forms.Read) and must be accessed using the ROPC flow rather than client credentials. The account used for ROPC must be excluded from MFA, and it is recommended to use a long, secure password for this account. You’ll only be able to access forms owned by groups that the authenticated account belongs to. To support this, you will need to instantiate the TokenManager using a different constructor, for example:

    PowerShell
    $TenantId = "<tenant id>"
    $ClientId = "<client id>"
    $ClientSecret = "<client secret>"
    $Username = "[email protected]"
    $Pass = "k\^e+yftk)e}5@!G`%ng"
    $Scope = "https://forms.cloud.microsoft/.default"
    $TokenManager = [TokenManager]::new($Username, $Pass, $TenantId, $ClientId, $ClientSecret, $Scope)
    PowerShell
    Get-MSFormsByOwner `
        -TenantId $TenantId `
        -OwnerId "<entra object id>" `
        -OwnerType "Group" `
        -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.

    PowerShell
    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.

    PowerShell
    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:

    PowerShell
    # 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:

    PowerShell
    # 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:
    Plaintext
    https://forms.cloud.microsoft/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
    PowerShell
    <# 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.Responses.answers

    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! Part 2 is here!