11Subscriptions
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
0 commit comments