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.
- You want to automatically populate users signatures with their Bookings URL
- You want to check if a user has successfully set up their bookable meeting types
- 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.

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.

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.

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.


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

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.

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.

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

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.

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.


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.
