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=abc123

Your 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)
end

Notification 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

create(client, subscription)

@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 client
  • subscription - 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"
})

delete(client, subscription_id)

@spec delete(Req.Request.t(), String.t()) :: :ok | {:error, term()}

Deletes a subscription, stopping all notifications.

Parameters

  • client - Authenticated Req.Request client
  • subscription_id - Subscription ID

Returns

  • :ok - Subscription deleted successfully
  • {:error, term} - Error (404 is treated as success)

Examples

:ok = Subscriptions.delete(client, subscription_id)

get(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 client
  • subscription_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

list(client)

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

parse_notification(payload)

@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 this
  • client_state - The validation token
  • change_type - "created", "updated", or "deleted"
  • resource - Resource path that changed
  • resource_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)

renew(client, subscription_id, opts \\ [])

@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 client
  • subscription_id - Subscription ID
  • opts - 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)

update(client, subscription_id, updates)

@spec update(Req.Request.t(), String.t(), map()) :: {:ok, map()} | {:error, term()}

Updates a subscription. Primarily used for renewal.

Parameters

  • client - Authenticated Req.Request client
  • subscription_id - Subscription ID
  • updates - Map of fields to update (typically just expiration_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
})

validate_notification(notification_payload, expected_client_state)

@spec validate_notification(map(), String.t() | nil) :: :ok | {:error, atom()}

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 request
  • expected_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