Msg.Subscriptions (msg v0.3.8)
Manage Microsoft Graph change notification subscriptions (webhooks).
Subscriptions enable real-time notifications when Microsoft 365 resources change, eliminating the need for polling.
Maximum Subscription Duration by Resource
- Calendar events: 4230 minutes (~3 days)
- Messages: 4230 minutes (~3 days)
- Contacts: 4230 minutes (~3 days)
- Group events: 4230 minutes (~3 days)
- OneDrive items: 42300 minutes (~30 days)
Webhook Validation
When creating a subscription, Microsoft sends a validation request:
GET https://your-app.com/webhook?validationToken=abc123Your endpoint must respond with the validation token as plain text within 10 seconds, or the subscription creation will fail.
Example validation endpoint (Phoenix):
# router.ex
get "/api/webhooks/microsoft", WebhookController, :validate
post "/api/webhooks/microsoft", WebhookController, :webhook
# webhook_controller.ex
def validate(conn, %{"validationToken" => token}) do
conn
|> put_resp_content_type("text/plain")
|> send_resp(200, token)
endNotification Format
Notifications arrive as HTTP POST with JSON body:
{
"value": [
{
"subscriptionId": "sub-id",
"clientState": "secret-token",
"changeType": "created",
"resource": "users/user@contoso.com/events/AAMk...",
"resourceData": {
"@odata.type": "#Microsoft.Graph.Event",
"@odata.id": "Users/user@contoso.com/Events/AAMk...",
"id": "AAMk..."
}
}
]
}Authentication Requirements
- User calendars:
Calendars.ReadWrite(application or delegated) - Group calendars:
Calendars.ReadWrite.Shared(delegated only) - Same permissions as the resource being monitored
Examples
# Create subscription for user calendar
{:ok, subscription} = Subscriptions.create(client, %{
change_type: "created,updated,deleted",
notification_url: "https://yourapp.com/api/webhooks/calendar",
resource: "/users/user@contoso.com/events",
expiration_date_time: DateTime.add(DateTime.utc_now(), 3 * 24 * 60 * 60, :second),
client_state: "secret-validation-token"
})
# Renew before expiry
{:ok, renewed} = Subscriptions.renew(client, subscription["id"], days: 3)
# Validate incoming notification
case Subscriptions.validate_notification(payload, "secret-validation-token") do
:ok -> process_notification(payload)
{:error, :invalid_client_state} -> reject_notification()
end
# Parse notification
notifications = Subscriptions.parse_notification(payload)
Enum.each(notifications, fn notif ->
handle_change(notif.change_type, notif.resource)
end)References
Summary
Functions
Creates a new webhook subscription.
Deletes a subscription, stopping all notifications.
Gets details for a specific subscription.
Lists all active subscriptions for the authenticated application.
Parses a notification payload into structured format.
Renews a subscription by extending its expiration date.
Updates a subscription. Primarily used for renewal.
Validates an incoming webhook notification.
Functions
@spec create(Req.Request.t(), map()) :: {:ok, map()} | {:error, term()}
Creates a new webhook subscription.
Important: Your notification URL must be publicly accessible via HTTPS and respond to Microsoft's validation request within 10 seconds.
Parameters
client- Authenticated Req.Request clientsubscription- Map with subscription properties::change_type(required) - Comma-separated: "created,updated,deleted":notification_url(required) - HTTPS webhook endpoint:resource(required) - Resource path (e.g., "/users/{id}/events"):expiration_date_time(required) - DateTime struct for expiration:client_state(optional but recommended) - Secret validation token
Returns
{:ok, subscription}- Created subscription with ID{:error, {:missing_required_fields, fields}}- Missing required fields{:error, {:validation_timeout, _}}- Webhook validation failed (10s timeout){:error, term}- Other errors
Examples
# User calendar subscription
{:ok, subscription} = Subscriptions.create(client, %{
change_type: "created,updated,deleted",
notification_url: "https://yourapp.com/api/webhooks/calendar",
resource: "/users/user@contoso.com/events",
expiration_date_time: DateTime.add(DateTime.utc_now(), 3 * 24 * 60 * 60, :second),
client_state: "secret-token-#{:crypto.strong_rand_bytes(16) |> Base.encode64()}"
})
# Group calendar subscription (requires delegated auth)
{:ok, subscription} = Subscriptions.create(delegated_client, %{
change_type: "created,updated",
notification_url: "https://yourapp.com/webhooks",
resource: "/groups/group-id/calendar/events",
expiration_date_time: DateTime.add(DateTime.utc_now(), 2 * 24 * 60 * 60, :second),
client_state: "group-calendar-secret"
})
@spec delete(Req.Request.t(), String.t()) :: :ok | {:error, term()}
Deletes a subscription, stopping all notifications.
Parameters
client- Authenticated Req.Request clientsubscription_id- Subscription ID
Returns
:ok- Subscription deleted successfully{:error, term}- Error (404 is treated as success)
Examples
:ok = Subscriptions.delete(client, subscription_id)
@spec get(Req.Request.t(), String.t()) :: {:ok, map()} | {:error, term()}
Gets details for a specific subscription.
Parameters
client- Authenticated Req.Request clientsubscription_id- Subscription ID
Returns
{:ok, subscription}- Subscription details{:error, :not_found}- Subscription doesn't exist{:error, term}- Other errors
Examples
{:ok, subscription} = Subscriptions.get(client, "sub-id-123")
{:ok, expiration, _} = DateTime.from_iso8601(subscription["expirationDateTime"])
if DateTime.diff(expiration, DateTime.utc_now(), :hour) < 2 do
# Renew soon!
end
@spec list(Req.Request.t()) :: {:ok, [map()]} | {:error, term()}
Lists all active subscriptions for the authenticated application.
Parameters
client- Authenticated Req.Request client
Returns
{:ok, [subscription]}- List of active subscriptions (may be empty){:error, term}- Error
Examples
{:ok, subscriptions} = Subscriptions.list(client)
Enum.each(subscriptions, fn sub ->
IO.puts("Subscription: #{sub["id"]}")
IO.puts("Resource: #{sub["resource"]}")
IO.puts("Expires: #{sub["expirationDateTime"]}")
end)
@spec parse_notification(map()) :: [ %{ subscription_id: String.t(), client_state: String.t() | nil, change_type: String.t(), resource: String.t(), resource_data: map() } ]
Parses a notification payload into structured format.
A single webhook POST can contain multiple notifications.
Parameters
payload- The JSON payload from Microsoft's POST request
Returns
List of notification maps with standardized keys:
subscription_id- Which subscription triggered thisclient_state- The validation tokenchange_type- "created", "updated", or "deleted"resource- Resource path that changedresource_data- Details about the changed resource
Examples
notifications = Subscriptions.parse_notification(webhook_payload)
Enum.each(notifications, fn notif ->
case notif.change_type do
"created" -> handle_created(notif.resource)
"updated" -> handle_updated(notif.resource)
"deleted" -> handle_deleted(notif.resource)
end
end)
@spec renew(Req.Request.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()}
Renews a subscription by extending its expiration date.
This is a convenience wrapper around update/3 for the common renewal case.
Parameters
client- Authenticated Req.Request clientsubscription_id- Subscription IDopts- Options::days- Number of days to extend (default: 3)
Returns
{:ok, subscription}- Renewed subscription with new expiration{:error, term}- Error
Examples
# Renew for 3 more days (default)
{:ok, renewed} = Subscriptions.renew(client, subscription_id)
# Renew for 2 days
{:ok, renewed} = Subscriptions.renew(client, subscription_id, days: 2)
# Renew for maximum duration (30 days for OneDrive)
{:ok, renewed} = Subscriptions.renew(client, subscription_id, days: 29)
@spec update(Req.Request.t(), String.t(), map()) :: {:ok, map()} | {:error, term()}
Updates a subscription. Primarily used for renewal.
Parameters
client- Authenticated Req.Request clientsubscription_id- Subscription IDupdates- Map of fields to update (typically justexpiration_date_time)
Returns
{:ok, subscription}- Updated subscription{:error, :not_found}- Subscription doesn't exist{:error, term}- Other errors
Examples
# Extend subscription by 3 more days
new_expiration = DateTime.add(DateTime.utc_now(), 3 * 24 * 60 * 60, :second)
{:ok, updated} = Subscriptions.update(client, subscription_id, %{
expiration_date_time: new_expiration
})
Validates an incoming webhook notification.
Checks that the clientState in the notification matches the expected value.
This prevents accepting forged notifications.
Parameters
notification_payload- The JSON payload from Microsoft's POST requestexpected_client_state- The client state you provided when creating subscription
Returns
:ok- Notification is valid{:error, :invalid_client_state}- Client state doesn't match{:error, :invalid_payload}- Payload structure is invalid
Examples
# In your webhook handler
def handle_webhook(conn, params) do
case Subscriptions.validate_notification(params, "my-secret-token") do
:ok ->
# Process notification
notifications = Subscriptions.parse_notification(params)
handle_notifications(notifications)
send_resp(conn, 204, "")
{:error, reason} ->
Logger.warning("Invalid notification: #{inspect(reason)}")
send_resp(conn, 401, "Invalid notification")
end
end