Skip to content

Commit bbdcc9c

Browse files
committed
Rewritten to match the style of other docs pages
1 parent 69182e4 commit bbdcc9c

File tree

2 files changed

+156
-91
lines changed

2 files changed

+156
-91
lines changed
Lines changed: 155 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,33 @@
11
Subscriptions
22
=============
33

4-
Subscriptions provides the ability to create and manage webhook subscriptions against Microsoft Graph. Read here for more details on MS Graph subscriptions
4+
Subscriptions provides the ability to create and manage webhook subscriptions for change notifications against Microsoft Graph. Read here for more details on MS Graph subscriptions
55

66
- https://learn.microsoft.com/en-us/graph/api/resources/subscription?view=graph-rest-1.0
77
- https://learn.microsoft.com/en-us/graph/change-notifications-delivery-webhooks?tabs=http
88

9+
Create a Subscription
10+
^^^^^^^^^^^^^^^^^^^^^
911

10-
**Example on how to use and setup webhooks**
11-
12-
Quickstart for this example:
13-
14-
#. Run Flask locally with the following command:
15-
16-
- ``flask --app examples/subscriptions_example.py run --debug``
17-
18-
#. Expose HTTPS via a tunnel to your localhost:5000:
19-
20-
- Free: `pinggy <https://pinggy.io/>`_ to get ``https://<subdomain>.pinggy.link`` -> http://localhost:5000
21-
- Paid/free-tier: `ngrok <https://ngrok.com/>`_: ngrok http 5000, note the https URL.
22-
23-
#. Use the tunnel HTTPS URL as notification_url pointing to /webhook, URL-encoded.
24-
#. To create a subscription, follow the example request below:
25-
26-
- ``https://<your-tunnel-host>/subscriptions?notification_url=https%3A%2F%2F<your-tunnel-host>%2Fwebhook&client_state=abc123``
27-
28-
#. To list subscriptions, follow the example request below:
29-
30-
- ``http://<your-tunnel-host>/subscriptions/list``
31-
32-
#. To renew a subscription, follow the example request below:
33-
34-
- ``http://<your-tunnel-host>/subscriptions/<subscription_id>/renew?expiration_minutes=55``
35-
36-
#. To delete a subscription, follow the example request below:
37-
38-
- ``http://<your-tunnel-host>/subscriptions/<subscription_id>/delete``
39-
40-
Graph will call ``https://<your-tunnel-host>/webhook``; this app echoes validationToken and returns 202 for notifications.
12+
Assuming a web host (example uses `flask`) and an authenticated account, create a subscription to be notified about new emails.
4113

4214
.. code-block:: python
4315
4416
from flask import Flask, abort, jsonify, request
45-
from O365 import Account
46-
47-
CLIENT_ID = "YOUR CLIENT ID"
48-
CLIENT_SECRET = "YOUR CLIENT SECRET"
49-
credentials = (CLIENT_ID, CLIENT_SECRET)
50-
51-
account = Account(credentials)
52-
# Pick the scopes that are relevant to you here
53-
account.authenticate(
54-
scopes=[
55-
"https://graph.microsoft.com/Mail.ReadWrite",
56-
"https://graph.microsoft.com/Mail.Send",
57-
"https://graph.microsoft.com/Calendars.ReadWrite",
58-
"https://graph.microsoft.com/MailboxSettings.ReadWrite",
59-
"https://graph.microsoft.com/User.Read",
60-
"https://graph.microsoft.com/User.ReadBasic.All",
61-
'offline_access'
62-
])
6317
6418
RESOURCE = "/me/mailFolders('inbox')/messages"
65-
DEFAULT_EXPIRATION_MINUTES = 10069 # Maximum expiration is 10,070 in the future.
19+
DEFAULT_EXPIRATION_MINUTES = 10069 # Maximum expiration is 10,070 in the future for Outlook message.
6620
6721
app = Flask(__name__)
6822
69-
70-
def _int_arg(name: str, default: int) -> int:
71-
raw = request.args.get(name)
72-
if raw is None:
73-
return default
74-
try:
75-
return int(raw)
76-
except ValueError:
77-
abort(400, description=f"{name} must be an integer")
78-
79-
8023
@app.get("/subscriptions")
8124
def create_subscription():
25+
"""Create a subscription."""
8226
notification_url = request.args.get("notification_url")
8327
if not notification_url:
8428
abort(400, description="notification_url is required")
8529
86-
expiration_minutes = _int_arg("expiration_minutes", DEFAULT_EXPIRATION_MINUTES)
30+
expiration_minutes = int(request.args.get("expiration_minutes", DEFAULT_EXPIRATION_MINUTES))
8731
client_state = request.args.get("client_state")
8832
resource = request.args.get("resource", RESOURCE)
8933
@@ -96,59 +40,179 @@ Graph will call ``https://<your-tunnel-host>/webhook``; this app echoes validati
9640
)
9741
return jsonify(subscription), 201
9842
43+
@app.post("/webhook")
44+
def webhook_handler():
45+
"""Handle Microsoft Graph webhook calls.
46+
47+
- During subscription validation, Graph sends POST with ?validationToken=... .
48+
We must echo the token as plain text within 10 seconds.
49+
- For change notifications, Graph posts JSON; we just log/ack.
50+
"""
51+
validation_token = request.args.get("validationToken")
52+
if validation_token:
53+
# Echo back token exactly as plain text with HTTP 200.
54+
return validation_token, 200, {"Content-Type": "text/plain"}
55+
56+
# Change notifications: inspect or log as needed.
57+
payload = request.get_json(silent=True) or {}
58+
print("Received notification payload:", payload)
59+
return ("", 202)
60+
61+
Use this url:
62+
63+
``https://<your-tunnel-host>/subscriptions?notification_url=https%3A%2F%2F<your-tunnel-host>%2Fwebhook&client_state=abc123``
64+
65+
HTTP status 201 and the following should be returned:
66+
67+
.. code-block:: JSON
68+
69+
{
70+
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#subscriptions/$entity",
71+
"applicationId": "12345678-bad9-4c34-94d6-f9a1388522f8",
72+
"changeType": "created",
73+
"clientState": "abc123",
74+
"creatorId": "12345678-a5c7-46da-8107-b25090a1ed66",
75+
"encryptionCertificate": null,
76+
"encryptionCertificateId": null,
77+
"expirationDateTime": "2026-01-07T11:20:42.305776Z",
78+
"id": "548355f8-c2c0-47ae-aac7-3ad02b2dfdb1",
79+
"includeResourceData": null,
80+
"latestSupportedTlsVersion": "v1_2",
81+
"lifecycleNotificationUrl": null,
82+
"notificationQueryOptions": null,
83+
"notificationUrl": "https://<your-tunnel-host>/webhook",
84+
"notificationUrlAppId": null,
85+
"resource": "/me/mailFolders('inbox')/messages"
86+
}
87+
88+
List Subscriptions
89+
^^^^^^^^^^^^^^^^^^
90+
91+
.. code-block:: python
9992
10093
@app.get("/subscriptions/list")
10194
def list_subscriptions():
102-
limit_raw = request.args.get("limit")
103-
limit = None
104-
if limit_raw is not None:
105-
try:
106-
limit = int(limit_raw)
107-
except ValueError:
108-
abort(400, description="limit must be an integer")
109-
if limit <= 0:
110-
abort(400, description="limit must be a positive integer")
111-
95+
"""List all subscriptions."""
96+
limit = int(request.args.get("limit"))
11297
subscriptions = account.subscriptions().list_subscriptions(limit=limit)
11398
return jsonify(list(subscriptions)), 200
11499
100+
Use this url:
101+
102+
``https://<your-tunnel-host>/subscriptions/list``
103+
104+
HTTP status 200 and the following should be returned:
105+
106+
.. code-block:: JSON
107+
108+
[
109+
{
110+
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#subscriptions/$entity",
111+
"applicationId": "12345678-bad9-4c34-94d6-f9a1388522f8",
112+
"changeType": "created",
113+
"clientState": "abc123",
114+
"creatorId": "12345678-a5c7-46da-8107-b25090a1ed66",
115+
"encryptionCertificate": null,
116+
"encryptionCertificateId": null,
117+
"expirationDateTime": "2026-01-07T11:20:42.305776Z",
118+
"id": "548355f8-c2c0-47ae-aac7-3ad02b2dfdb1",
119+
"includeResourceData": null,
120+
"latestSupportedTlsVersion": "v1_2",
121+
"lifecycleNotificationUrl": null,
122+
"notificationQueryOptions": null,
123+
"notificationUrl": "https://<your-tunnel-host>/webhook",
124+
"notificationUrlAppId": null,
125+
"resource": "/me/mailFolders('inbox')/messages"
126+
}
127+
]
128+
129+
Renew a Subscription
130+
^^^^^^^^^^^^^^^^^^^^
131+
132+
.. code-block:: python
115133
116134
@app.get("/subscriptions/<subscription_id>/renew")
117135
def renew_subscription(subscription_id: str):
118-
expiration_minutes = _int_arg("expiration_minutes", DEFAULT_EXPIRATION_MINUTES)
136+
"""Renew a subscription."""
137+
expiration_minutes = int(request.args.get("expiration_minutes", DEFAULT_EXPIRATION_MINUTES))
119138
updated = account.subscriptions().renew_subscription(
120139
subscription_id,
121140
expiration_minutes=expiration_minutes,
122141
)
123142
return jsonify(updated), 200
124143
144+
Use this url:
145+
146+
``http://<your-tunnel-host>/subscriptions/548355f8-c2c0-47ae-aac7-3ad02b2dfdb1/renew?expiration_minutes=10069``
147+
148+
HTTP status 200 and the following should be returned:
149+
150+
.. code-block:: JSON
151+
152+
{
153+
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#subscriptions/$entity",
154+
"applicationId": "12345678-bad9-4c34-94d6-f9a1388522f8",
155+
"changeType": "created",
156+
"clientState": "abc123",
157+
"creatorId": "12345678-a5c7-46da-8107-b25090a1ed66",
158+
"encryptionCertificate": null,
159+
"encryptionCertificateId": null,
160+
"expirationDateTime": "2026-01-07T11:35:40.301594Z",
161+
"id": "548355f8-c2c0-47ae-aac7-3ad02b2dfdb1",
162+
"includeResourceData": null,
163+
"latestSupportedTlsVersion": "v1_2",
164+
"lifecycleNotificationUrl": null,
165+
"notificationQueryOptions": null,
166+
"notificationUrl": "https://<your-tunnel-host>/webhook",
167+
"notificationUrlAppId": null,
168+
"resource": "/me/mailFolders('inbox')/messages"
169+
}
170+
171+
Delete a Subscription
172+
^^^^^^^^^^^^^^^^^^^^^
173+
174+
.. code-block:: python
125175
126176
@app.get("/subscriptions/<subscription_id>/delete")
127177
def delete_subscription(subscription_id: str):
178+
"""Delete a subscription."""
128179
deleted = account.subscriptions().delete_subscription(subscription_id)
129180
if not deleted:
130181
abort(404, description="Subscription not found")
131182
return ("", 204)
132183
184+
Use this url:
133185

134-
@app.post("/webhook")
135-
def webhook_handler():
136-
"""Handle Microsoft Graph webhook calls.
186+
``http://<your-tunnel-host>/subscriptions/548355f8-c2c0-47ae-aac7-3ad02b2dfdb1/delete``
137187

138-
- During subscription validation, Graph sends POST with ?validationToken=... .
139-
We must echo the token as plain text within 10 seconds.
140-
- For change notifications, Graph posts JSON; we just log/ack.
141-
"""
142-
validation_token = request.args.get("validationToken")
143-
if validation_token:
144-
# Echo back token exactly as plain text with HTTP 200.
145-
return validation_token, 200, {"Content-Type": "text/plain"}
188+
HTTP status 204 should be returned.
146189

147-
# Change notifications: inspect or log as needed.
148-
payload = request.get_json(silent=True) or {}
149-
print("Received notification payload:", payload)
150-
return ("", 202)
190+
Webhook
191+
^^^^^^^
151192

193+
With a subscription as described above and an email sent to the inbox, a webhook will be received as below:
194+
195+
.. code-block:: python
152196
153-
if __name__ == "__main__":
154-
app.run(debug=True, ssl_context=("examples/cert.pem", "examples/key.pem"))
197+
{
198+
'value': [
199+
{
200+
'subscriptionId': '548355f8-c2c0-47ae-aac7-3ad02b2dfdb12',
201+
'subscriptionExpirationDateTime': '2026-01-07T11:35:40.301594+00:00',
202+
'changeType': 'created',
203+
'resource': 'Users/12345678-a5c7-46da-8107-b25090a1ed66/Messages/<long_guid>=',
204+
'resourceData': {
205+
'@odata.type': '#Microsoft.Graph.Message',
206+
'@odata.id': 'Users/12345678-a5c7-46da-8107-b25090a1ed66/Messages/<long_guid>=',
207+
'@odata.etag': 'W/"CQAAABYACCCoiRErLbiNRJDCFyMjq4khBBnH4N7A"',
208+
'id': '<long_guid>='
209+
},
210+
'clientState': 'abc123',
211+
'tenantId': '12345678-abcd-1234-abcd-1234567890ab'
212+
}
213+
]
214+
}
215+
216+
The client state should be validated for accuracy and if correct, the message can be acted upon as approriate for the type of subscription.
217+
218+
An example application can be found in the examples directory here - https://github.com/O365/python-o365/blob/master/examples/subscriptions_example.py

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ dev = [
3535
"pytest>=8.3.4",
3636
"sphinx>=7.4.7",
3737
"sphinx-rtd-theme>=3.0.2",
38+
"flask"
3839
]
3940

4041
[build-system]

0 commit comments

Comments
 (0)