Featured image of post Find Personal Bookings URL of Any User in Your Tenant

Find Personal Bookings URL of Any User in Your Tenant

In this blog post I'll show you how you can use PowerShell to find the Personal Bookings URL of any user in your tenant and how you can confirm if a user has already set up their Personal Bookings page/any bookable appointment types.

Usually you can only get a hold of someone’s Microsoft Personal Bookings URL if the owner of a Personal Bookings page shares their URL with you. However, as an administrator, you might want to find someone’s Personal Bookings URL without asking them for it. Or maybe you already asked them but didn’t get a response from the user.

Why would you want to find the Personal Bookings URL of users if they didn’t explicitly share it with you anyway? I can think of a couple of uses cases.

  1. You want to automatically populate users signatures with their Bookings URL
  2. You want to check if a user has successfully set up their bookable meeting types
  3. You want to create a report of users who have configured the service and users who didn’t bother to configure their Personal Bookings page.

Obviously solution Nr. 1 requires additional work but there is a great example from Loryan Strant on that topic. His blog post explains that the bookings with me URL consists of a user’s Exchange GUID and the email domain. So naturally, my script is using the same technique.

However, my example script goes one step further and makes additional requests and actually tries to fetch available services and available times for a specific user. If the response includes services and time slots, it means that the user has already accessed their Bookings page at least once. When a user accessed the Bookings app in Outlook for the first time, some default meeting types are created automatically.

I want to point out that I didn’t do any additional research if there are better ways to report on whether users are utilizing Personal Bookings or at least have configured their page. This blog post simply shows the result of my research on the topic.

PowerShell Script Example

The script below allows you to display the Personal Bookings URL for any user that has used Bookings in your tenant.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
<#

    .SYNOPSIS
        Get the Microsoft Bookings URL of a user in Exchange Online.

    .DESCRIPTION
        This script retrieves the Microsoft Bookings URL for a user in Exchange Online. It checks if a user has Microsoft Bookings enabled and if they have set up their personal bookings page already. If a user has not set up their personal bookings page, it will be reflected in the output.

    .NOTES
        Author:     Martin Heusser
        Link:       https://heusser.pro
                    https://github.com/sponsors/mozziemozz?frequency=one-time&sponsor=mozziemozz
                    https://buymeacoffee.com/martin.heusser
        Version:    1.0.0
        Date:       2025-06-21

#>

$exoConnection = Get-ConnectionInformation

if (!$exoConnection) {

    Connect-ExchangeOnline -ShowBanner:$false

}

$tenantId = $exoConnection.TenantID

$graphSession = Get-MgUser -Top 1 -ErrorAction SilentlyContinue

if (!$graphSession) {

    Connect-MgGraph -Scopes "User.Read.All" -NoWelcome

}

if (-not $allUsers) {

    $allUsers = Get-MgUser -Filter "assignedPlans/any(s:s/servicePlanId eq 199a5c09-e0ca-4e37-8f7c-b05d533e1ea2)" -Property Id, UserPrincipalName, DisplayName -CountVariable Count -ConsistencyLevel eventual -All

}

$userPrincipalName = $allUsers | Select-Object DisplayName, Id, UserPrincipalName | Out-GridView -Title "Select a user to get their Microsoft Bookings URL (Note: Only Bookings Licensed users are shown)" -PassThru | Select-Object -ExpandProperty UserPrincipalName

$domain = $userPrincipalName.Split("@")[1]

$exoMailBox = Get-EXOMailbox -Identity $userPrincipalName -Properties ExchangeGUID

$mailBox = Get-Mailbox -Identity $userPrincipalName

if ($mailBox.PersistedCapabilities -notcontains "BPOS_S_BookingsAddOn") {

    Write-Host "User '$($exoMailBox.DisplayName)' does not have Microsoft Bookings enabled in Exchange Online." -ForegroundColor Red

}

else {

    Write-Host "User '$($exoMailBox.DisplayName)' has Microsoft Bookings enabled in Exchange Online." -ForegroundColor Green

    $exchangeGUIDWithHyphens = $exoMailBox.ExchangeGUID.Guid

    $exchangeGUID = $exoMailBox.ExchangeGUID.Guid.Replace("-", "")

    $serviceId = $null
    $response = $null
    $checkPersonalBookingsPage = $null
    $personalBookingsURL = $null
    $checkPersonalBookingsPageBody = $null

    $personalBookingsURL = "https://outlook.office.com/bookwithme/user/$exchangeGUID%40$domain"

    try {

        $session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
        $session.Cookies.Add((New-Object System.Net.Cookie("ClientId", "4DB0129438C34891BDD6F2D6141584B9", "/", "outlook.office.com")))
        $session.Cookies.Add((New-Object System.Net.Cookie("OIDC", "1", "/", "outlook.office.com")))
        $response = Invoke-WebRequest -UseBasicParsing -Uri "https://outlook.office.com/BookingsService/api/V1/bookingBusinessesc2/mbx:$($exchangeGUID)@$($tenantId)/services" `
        -WebSession $session `
        -Headers @{
        "authority"="outlook.office.com"
        "method"="GET"
        "path"="/BookingsService/api/V1/bookingBusinessesc2/mbx:$($exchangeGUID)@$($tenantId)/services"
        "scheme"="https"
        "accept"="*/*"
        "accept-encoding"="gzip, deflate, br, zstd"
        "accept-language"="en-US,en;q=0.9"
        "prefer"="exchange.behavior=`"IncludeThirdPartyOnlineMeetingProviders`""
        "priority"="u=1, i"
        "sec-ch-ua-mobile"="?0"
        "sec-ch-ua-platform"="`"Windows`""
        "sec-fetch-dest"="empty"
        "sec-fetch-mode"="cors"
        "sec-fetch-site"="same-origin"
        "x-anchormailbox"="mbx:$($exchangeGUID)@$($tenantId)"
        "x-edge-shopping-flag"="0"
        "x-owa-canary"="X-OWA-CANARY_cookie_is_null_or_empty"
        "x-owa-hosted-ux"="false"
        "x-req-source"="BookWithMe"
        } `
        -ContentType "application/json; charset=utf-8" -ErrorAction Stop

        # $serviceId = $response.Content | ConvertFrom-Json | Select-Object -ExpandProperty service | Where-Object { $_.isPrivate -eq $true } | Select-Object -ExpandProperty serviceId -First 1
        $serviceId = $response.Content | ConvertFrom-Json | Select-Object -ExpandProperty service | Select-Object -ExpandProperty serviceId -First 1

        $checkPersonalBookingsPageBody = [pscustomobject]@{
            staffIds      = @("1c1c7887-e0fc-479b-ac7d-6983d0f026ff") 
            startDateTime = @{
                dateTime = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ss")
                timeZone = (Get-TimeZone).Id
            }
            endDateTime   = @{
                dateTime = (Get-Date).AddDays(1).ToString("yyyy-MM-ddTHH:mm:ss")
                timeZone = (Get-TimeZone).Id
            }
            serviceId     = $serviceId
        } | ConvertTo-Json -Depth 5 -Compress
        
        $session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
        $session.Cookies.Add((New-Object System.Net.Cookie("ClientId", "09A51AD7CB19435B9DCFE57421FB3DD6", "/", "outlook.office.com")))
        $session.Cookies.Add((New-Object System.Net.Cookie("OIDC", "1", "/", "outlook.office.com")))
        $checkPersonalBookingsPage = Invoke-WebRequest -UseBasicParsing -Uri "https://outlook.office.com/BookingsService/api/V1/bookingBusinessesc2/mbx:$($exchangeGUID)@$($tenantId)/getStaffAvailability" `
        -Method "POST" `
        -WebSession $session `
        -Headers @{
            "authority"="outlook.office.com"
            "method"="POST"
            "path"="/BookingsService/api/V1/bookingBusinessesc2/mbx:$($exchangeGUID)@$($tenantId)/getStaffAvailability"
            "scheme"="https"
            "accept"="*/*"
            "accept-encoding"="gzip, deflate, br, zstd"
            "accept-language"="de-CH,de;q=0.9"
            "cache-control"="no-cache"
            "dnt"="1"
            "origin"="https://outlook.office.com"
            "pragma"="no-cache"
            "prefer"="exchange.behavior=`"IncludeThirdPartyOnlineMeetingProviders`""
            "priority"="u=1, i"
            "sec-ch-ua-mobile"="?0"
            "sec-fetch-dest"="empty"
            "sec-fetch-mode"="cors"
            "sec-fetch-site"="same-origin"
            "x-anchormailbox"="mbx:$($exchangeGUID)@$($tenantId)"
            "x-edge-shopping-flag"="0"
            "x-owa-canary"="X-OWA-CANARY_cookie_is_null_or_empty"
            "x-owa-hosted-ux"="false"
            "x-req-source"="BookWithMe"
        } `
        -ContentType "application/json; charset=utf-8" `
        -Body $checkPersonalBookingsPageBody -ErrorAction Stop

        Write-Host "User '$($exoMailBox.DisplayName)' set up their Personal Bookings page already." -ForegroundColor Green

        $personalBookingsPageConfigured = $true

    }
    catch {
        
        Write-Host "User '$($exoMailBox.DisplayName)' didn't set up their Personal Bookings page yet." -ForegroundColor Yellow

        $personalBookingsPageConfigured = $false

    }


    if ($personalBookingsPageConfigured) {

        $personalBookingsURLAnonymous = "$($personalBookingsURL)?anonymous"

        $personalBookingsURLAnonymous | Set-Clipboard

        Write-Host "Microsoft Bookings URL of user '$($exoMailBox.DisplayName)' is '$personalBookingsURL'." -ForegroundColor Cyan

        Write-Host "Microsoft Bookings anonymous URL of user '$($exoMailBox.DisplayName)': is '$personalBookingsURLAnonymous'" -ForegroundColor Magenta

        Write-Host "The URL has been copied to the clipboard." -ForegroundColor Green

    }

}

Comparing Different Results and User Experience

Since this is an example script, it only runs for a single user at a time. If you want this to run for every user in your organization, you’ll need to adjust the script and implement a foreach loop on your own. The script uses Out-GridView -PassThru to allow you to select a user. Only users which have the Microsoft Bookings service plan (199a5c09-e0ca-4e37-8f7c-b05d533e1ea2) enabled will be selectable.

Out-GridView selection of Bookings licensed users.

Example of User who Already Configured their Bookings Page

In the case where a user has already configured their Personal Bookings page, the script will show you both the URLs for authenticated and anonymous access.

Output of the script for a user which has already configured their Personal Bookings page.

Example of User who didn’t Already Configure their Bookings Page

This is what the output will look like in case a user has a Bookings license but did not yet set up their Personal Bookings page. This is the case when a user never opened the Bookings app in Outlook.

Output of the script for a user which didn’t already set up their Personal Bookings page.

Technically, the script is able to construct the Personal Bookings URL even if a user never opened the Bookings app in Outlook before. However, when the page is accessed, there’s an error message that looks like this. Therefore, the script will not output the user’s Personal Bookings URL in this case.

Error message when a user never used Bookings before or the page isn’t provisioned yet.After the license is assigned, it takes a couple of minutes until the user is able to access their Bookings page.

Once the Bookings license has been activated, a Go to my booking page button will appear in the user’s calendar in Outlook on the web. Alternatively, users can also open the Bookings app in Outlook (new or classic).

Go to my booking page button in Outlook.

At this point, the script will still report the booking page as not set up yet because the Personal Bookings page only gets activated once a user clicks on that Go to my booking page link for the first time. When the link is clicked, Bookings will automatically create some meeting types for the user’s Personal Booking page.

Personal Booking page with default meeting types.

Even then, it will usually take a few minutes until the page is accessible by other users as well. Once that is the case, the script is able to detect that the page has been set up and will output the URL.

Output before and after a user has accessed their Personal Booking page for the first time.

All you need to do to open a user’s Personal Booking page is to click the link in Terminal.

User’s Personal Booking page opened in browser from the URL in PowerShell output.

EWS Capabilities

To use Bookings, the EWS (Exchange Web Services) capability BPOS_S_BookingsAddOn must also be enabled. In my test tenant, this was enabled by default for all users with a Bookings service plan assigned.

1
2
3
4
5
6
Get-Mailbox -Identity $userPrincipalName | Select-Object PersistedCapabilities

# Output
PersistedCapabilities
---------------------
{GRAPH_CONNECTORS_SEARCH_INDEX, BPOS_S_BookingsAddOn, MYANALYTICSP2, BPOS_S_Standard}

In my testing, one user slipped through and was included in Out-GridView despite not having a Bookings service plan assigned. This is likely due the filter on assignedPlans requiring the eventual consistency level. In that case, or when you set the user principal name manually, the script will tell you that Bookings isn’t enabled for that user because the EWS capability is missing.

Output in case a user does not have Bookings enabled in EWS capabilities.

Summary and Additional Thoughts

It took me a while to get this right. At first I thought that the serviceId in the request body to the /getStaffAvailability endpoint was a static value. But when I was testing with other users, I realized that the service ids are actually the ids of configured meeting types which are unique per user. Therefore, I had to implement an additional step to get the services first. Services are the different bookable meeting types a user has created.

Since Personal Booking pages are public websites, they’re still accessible, even when a user doesn’t have any public meeting types configured.

Even though this user’s Personal Booking page says that no public meetings are available, the page can still be opened.

User with only private meeting types.Personal Bookings page without any public meeting types.

In that case, the service property will be empty. This request is performed on lines 75-101 in the script above. Invoke-WebRequest is inside a Try-Catch block and uses -ErrorAction Stop. If a user didn’t configure their Personal Bookings page already, this request will fail. If a user doesn’t have any public meeting types configured, the request will be successful despite an empty response.

1
2
3
4
5
6
$response.Content | ConvertFrom-Json

# Output
service count nextSkipToken
------- ----- -------------
{}          0

The second check to the /getStaffAvailability endpoint happens on lines 119-150. In the case where no public meetings are available, the response will still include availabilityItems but they will show as status BOOKINGSAVAILABILITYSTATUS_OUT_OF_OFFICE. If both requests are successful, the script will deem the Personal Bookings page as active.

1
2
3
4
5
6
7
$checkPersonalBookingsPage.Content | ConvertFrom-Json | Select-Object -ExpandProperty staffAvailabilityResponse | Select-Object -ExpandProperty availabilityItems

# Output
status                                   startDateTime
------                                   -------------
BOOKINGSAVAILABILITYSTATUS_OUT_OF_OFFICE @{dateTime=21.06.2025 00:00:00; timeZone=(UTC+01:00) Amsterdam, Berlin, Bern…
BOOKINGSAVAILABILITYSTATUS_OUT_OF_OFFICE @{dateTime=22.06.2025 00:00:00; timeZone=(UTC+01:00) Amsterdam, Berlin, Bern…

The behavior is the same even if all meeting types are deleted. The script is still able to detect that a booking page is set up. I don’t really have a need for it right now, but to know that it’s possible to query a users configured meeting types and to get available time slots programmatically is pretty cool.

Here’s an example that shows available meeting types for a user with some select properties.

1
2
3
4
5
6
7
8
$response.Content | ConvertFrom-Json | Select-Object -ExpandProperty Service | ft title, defaultDuration, serviceId, isLocationOnline

# Output
title              defaultDuration serviceId                            isLocationOnline
-----              --------------- ---------                            ----------------
60 minutes meeting PT1H            192c91e9-906c-44cb-9169-6dd1e4d8b5b2            False
30 minutes meeting PT30M           8d0ddf36-81e4-4890-bc5b-7b59d1289970             True
15 minutes meeting PT15M           528b7a49-817b-4351-a556-87de2f0d988b             True

And here I’m checking the availability of a user programmatically. Since the script only needs any serviceId, it just uses the first one (line 104). The $checkPersonalBookingsPage variable is populated when the script runs with that first serviceId. If you want to check the availability of a specific serviceId you’ll need to make sure to use the correct id on line 116. The code example below uses some custom code to format the output for better viewing and demonstration.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$checkPersonalBookingsPage.Content | ConvertFrom-Json | Select-Object -ExpandProperty staffAvailabilityResponse | Select-Object -ExpandProperty availabilityItems | ft status, @{Name="startDateTime";expression={$_.startDateTime.dateTime}}, @{Name="endDateTime";expression={$_.endDateTime.dateTime}}

# Output
status                                   startDateTime       endDateTime
------                                   -------------       -----------
BOOKINGSAVAILABILITYSTATUS_OUT_OF_OFFICE 23.06.2025 00:00:00 23.06.2025 08:00:00
BOOKINGSAVAILABILITYSTATUS_AVAILABLE     23.06.2025 08:00:00 23.06.2025 09:00:00
BOOKINGSAVAILABILITYSTATUS_BUSY          23.06.2025 09:00:00 23.06.2025 13:00:00
BOOKINGSAVAILABILITYSTATUS_AVAILABLE     23.06.2025 13:00:00 23.06.2025 14:00:00
BOOKINGSAVAILABILITYSTATUS_BUSY          23.06.2025 14:00:00 23.06.2025 16:00:00
BOOKINGSAVAILABILITYSTATUS_AVAILABLE     23.06.2025 16:00:00 23.06.2025 17:00:00
BOOKINGSAVAILABILITYSTATUS_OUT_OF_OFFICE 23.06.2025 17:00:00 24.06.2025 00:00:00
BOOKINGSAVAILABILITYSTATUS_OUT_OF_OFFICE 24.06.2025 00:00:00 24.06.2025 08:00:00

If we compare that with the user’s Personal Booking page, we can see that the information checks out. In other words, the web request in PowerShell returns the same information as the user’s Personal Booking page displays.

Available meeting times on Personal Bookings page.

Licensed under CC BY-NC-SA 4.0
comments powered by Disqus
Hosted on GitHub Pages
Built with Hugo
Theme Stack designed by Jimmy