Skip to main content

Python SDK

The Mobiscroll Connect Python SDK provides a convenient way to integrate Mobiscroll Connect in Python backend applications. It supports both synchronous and asynchronous usage and requires Python 3.9 or higher.

Setup​

Install the package using pip:

pip install mobiscroll-connect-sdk

The SDK depends on the cryptography package, which it uses to verify webhook signatures. The package manager installs it automatically.

Client Initialization​

To use the SDK, initialize MobiscrollConnectClient with your client credentials.

Class: mobiscroll_connect.MobiscrollConnectClient

constructorMobiscrollConnectClient

Constructor arguments.

client_idstr

Your Client ID obtained from the Mobiscroll Connect dashboard.

client_secretstr

Your Client Secret obtained from the Mobiscroll Connect dashboard.

redirect_uristr

Your application's redirect URI that matches the one configured in the Mobiscroll Connect dashboard.

webhook_public_keystr

Optional keyword argument. A pinned whpk_ public key for webhooks.verify_webhook. It is used only when the keys endpoint cannot be reached, and it stops verifying once Mobiscroll retires that key, so replace it on every key rotation. Leave it unset unless your handler cannot make outbound requests. AsyncMobiscrollConnectClient accepts the same argument.

Usage:

from mobiscroll_connect import MobiscrollConnectClient

client = MobiscrollConnectClient(
client_id="YOUR_CLIENT_ID",
client_secret="YOUR_CLIENT_SECRET",
redirect_uri="YOUR_REDIRECT_URI",
)

Use the client as a context manager to ensure the HTTP connection pool is released when done:

with MobiscrollConnectClient(
client_id="YOUR_CLIENT_ID",
client_secret="YOUR_CLIENT_SECRET",
redirect_uri="YOUR_REDIRECT_URI",
) as client:
calendars = client.calendars.list()

Methods​

set_credentials​

Sets the access token for the client. This is required before making any API calls that require authentication.

Method: client.set_credentials(tokens)

tokensTokenResponse

The token response object returned by client.auth.get_token(code).

on_tokens_refreshed​

Registers a callback to be invoked whenever the SDK automatically refreshes the access token. Use this to persist the updated tokens so they survive future requests.

Method: client.on_tokens_refreshed(callback)

callbackCallable[[TokenResponse], None]

A callable that receives the updated TokenResponse after a successful automatic token refresh. The async client also accepts an async callable.

Token Refresh​

The Python SDK handles token refresh automatically. When any API call returns a 401 Unauthorized response and the client has a refresh_token stored, the SDK will silently exchange it for a new access token and retry the original request — with no action required from your application.

When the refresh succeeds, the SDK invokes your on_tokens_refreshed callback with the updated TokenResponse. You must register this callback and persist the new tokens, otherwise they will be lost between requests.

from mobiscroll_connect import TokenResponse

def persist_tokens(tokens: TokenResponse) -> None:
# Persist tokens in your database or session store
# so the new access_token and refresh_token survive future requests
session["access_token"] = tokens.access_token
session["refresh_token"] = tokens.refresh_token

client.on_tokens_refreshed(persist_tokens)

If the refresh token itself is invalid or has been revoked, the SDK raises an AuthenticationError and the user must re-authorize.

Running more than one instance​

Concurrent calls on the same client that hit an expired token share one refresh — threads on a MobiscrollConnectClient and tasks on an AsyncMobiscrollConnectClient alike. That covers one client object in one process. It does not reach across processes: separate containers, cluster workers or serverless invocations each refresh on their own, from the tokens they hold in memory.

Connect accepts several processes refreshing the same token at once, but a process that refreshes with a copy that is two or more refreshes out of date revokes the user's authorization — see Refreshing from several instances. So:

  • persist the tokens from your on_tokens_refreshed callback to storage that every instance reads, and
  • when a process starts a job or handles a request, call set_credentials with the current tokens from that storage instead of relying on the copy it loaded at startup.

Async Usage​

The SDK ships an async client with an identical API surface. Import it from the aio subpackage and use async with to manage the connection pool:

from mobiscroll_connect.aio import AsyncMobiscrollConnectClient

async with AsyncMobiscrollConnectClient(
client_id="YOUR_CLIENT_ID",
client_secret="YOUR_CLIENT_SECRET",
redirect_uri="YOUR_REDIRECT_URI",
) as client:
client.auth.set_credentials(tokens)
calendars = await client.calendars.list()

All resource methods on the async client are coroutines. The on_tokens_refreshed callback may be sync or async — both are supported.

Error Handling​

All SDK methods raise exceptions that extend MobiscrollConnectError. You can catch the base class or any specific subclass.

ExceptionHTTP Statuserror.code
AuthenticationError401, 403AUTHENTICATION_ERROR
CalendarPermissionError403CALENDAR_PERMISSION_REQUIRED
ValidationError400, 422VALIDATION_ERROR
NotFoundError404NOT_FOUND_ERROR
RateLimitError429RATE_LIMIT_ERROR
ServerError5xxSERVER_ERROR
NetworkError—NETWORK_ERROR
WebhookVerificationError—WEBHOOK_VERIFICATION_ERROR

ValidationError exposes a details property with field-level validation errors. RateLimitError exposes retry_after (seconds) and ServerError exposes status_code. WebhookVerificationError is raised by webhooks.verify_webhook and verify_webhook_signature, not by API calls; its reason says why the delivery was rejected.

from mobiscroll_connect.exceptions import (
AuthenticationError,
ValidationError,
RateLimitError,
MobiscrollConnectError,
)

try:
response = client.events.list(start="2024-01-01")
except AuthenticationError:
# Token expired and refresh failed — re-authorize the user
pass
except ValidationError as e:
print(e.details)
except RateLimitError as e:
print(f"Retry after {e.retry_after}s")
except MobiscrollConnectError as e:
# Catch-all for any other SDK error
pass

Auth API​

The client.auth resource handles the OAuth authorization flow, including generating authorization URLs, exchanging codes for tokens, managing connection status, and disconnecting providers.

Each account returned by get_connection_status reports granted_scopes and calendar_permission_granted. A false flag means the account connected but withheld calendar access on the provider's consent screen, so it can list no calendars until the user reconnects — see Partial consent. Each account also reports syncState: reauth_required means the provider has since rejected the stored credentials and the account has stopped syncing — a different question from calendarPermissionGranted, which only records what was agreed at connect time. See Connection health.

When no connected account has calendar access, calendar and event calls raise CalendarPermissionError — accounts names the accounts that must reconnect. It subclasses AuthenticationError, so existing handlers keep working.

To localize the Connect pages, pass an optional lng to generate_auth_url, e.g. generate_auth_url(user_id=..., lng='es'). When omitted, the UI falls back to the browser's Accept-Language header, then English; Arabic, Hebrew and Persian render right-to-left. See Supported languages for the languages Connect supports.


auth.generate_auth_url​

Builds the URL that starts the authorization flow. Redirect the user to it: once they connect their calendars, Connect redirects them back to your redirect URI with an authorization code. The URL is built locally, without an API request. See GET /authorize.

Method: client.auth.generate_auth_url(user_id, *, scope, state, lng)

Returns: str

user_idstr

Your identifier for the user. scope, state and lng are optional keyword arguments.

Usage:

# Generate the authorization URL
auth_url = client.auth.generate_auth_url(
user_id='user-456',
# Optional parameters:
# scope='read-write',
# state='xyz789',
# lng='es', # localize the Connect pages
)

# Redirect the user to auth_url
# return redirect(auth_url)

auth.get_token​

Exchanges the authorization code from your redirect URI for an access token and a refresh token. The client stores the returned tokens and uses them for the calls that follow. See POST /token.

Method: client.auth.get_token(code)

Returns: TokenResponse

codestr

The authorization code received on your redirect URI.

Usage:

# Exchange authorization code for access token
# The client is automatically authenticated after get_token()
token_response = client.auth.get_token(code)

# Persist tokens for future requests
session['access_token'] = token_response.access_token
session['refresh_token'] = token_response.refresh_token

Calendars API​

The client.calendars resource allows you to list available calendars from all connected providers (Google, Outlook, etc.). It corresponds to the /calendars endpoints.


calendars.list​

Lists the calendars of every account the user has connected. See GET /calendars.

Method: client.calendars.list()

Returns: list[Calendar]

Usage:

calendars = client.calendars.list()

Events API​

The client.events resource provides methods to create, read, update, and delete calendar events across all connected accounts. It corresponds to the /events endpoints.


events.list​

Lists events from the user's calendars, one page at a time. When the response contains a next_page_token, pass it to the next call to get the following page. See GET /events.

Method: client.events.list(*, start=None, end=None, calendar_ids=None, page_size=None, next_page_token=None, single_events=None)

Returns: EventsListResponse

Calendar IDs grouped by provider, for example {'google': ['work@company.com']}. All arguments are optional and keyword-only.

Usage:

# Fetch initial events with date range filter
response = client.events.list(
page_size=50,
start='2025-10-01T00:00:00Z',
end='2025-10-31T23:59:59Z',
)

# Load more events using next_page_token
next_response = client.events.list(
page_size=50,
next_page_token=response.next_page_token,
)

# Filter by specific calendars
filtered_events = client.events.list(
page_size=25,
calendar_ids={'google': ['personal@gmail.com', 'work@company.com']},
)

# Get recurring event series masters (not expanded)
masters = client.events.list(
page_size=50,
single_events=False,
)

events.create​

Creates an event in the given calendar. Set recurrence to create a recurring series. See POST /event.

Method: client.events.create(event)

Returns: CalendarEvent

A dict with the event fields, using the API field names. provider, calendarId, title, start and end are required. Optional fields include description, location, allDay and recurrence.

Usage:

# Create a simple event
event = client.events.create({
'provider': 'google',
'calendarId': 'primary',
'title': 'Team Meeting',
'description': 'Discuss project updates',
'start': '2025-11-01T10:00:00Z',
'end': '2025-11-01T11:00:00Z',
'location': 'Conference Room A',
})

# Create a recurring event
recurring_event = client.events.create({
'provider': 'microsoft',
'calendarId': 'AAMkAGVmMDEz...',
'title': 'Weekly Standup',
'start': '2025-11-01T09:00:00Z',
'end': '2025-11-01T09:30:00Z',
'allDay': False,
'recurrence': {
'frequency': 'WEEKLY',
'interval': 1,
'count': 10,
'byDay': ['MO', 'WE', 'FR'],
},
})

# Create an all-day event
all_day_event = client.events.create({
'provider': 'apple',
'calendarId': 'https://caldav.icloud.com/.../calendars/...',
'title': 'Conference',
'start': '2025-11-15T00:00:00Z',
'end': '2025-11-16T00:00:00Z',
'allDay': True,
'description': 'Annual tech conference',
})

events.update​

Updates an existing event. For an occurrence of a recurring series, set recurringEventId, and use updateMode to choose which occurrences change: this, following or all. See PUT /event.

Method: client.events.update(event)

Returns: CalendarEvent

A dict with provider, calendarId and eventId, plus the fields you want to change. Add recurringEventId and updateMode for recurring events.

Usage:

# Update a simple event
updated_event = client.events.update({
'provider': 'google',
'calendarId': 'primary',
'eventId': 'event123abc',
'title': 'Updated Team Meeting',
'start': '2025-11-01T14:00:00Z',
'end': '2025-11-01T15:00:00Z',
})

# Update a single instance of a recurring event
updated_instance = client.events.update({
'provider': 'microsoft',
'eventId': 'instance456',
'recurringEventId': 'series123',
'calendarId': 'AAMkAGVmMDEz...',
'updateMode': 'this',
'title': 'Standup - Special Topic Today',
'start': '2025-11-01T09:00:00Z',
'end': '2025-11-01T10:00:00Z',
})

# Update all future occurrences
updated_future = client.events.update({
'provider': 'apple',
'eventId': 'recurring-event-id',
'calendarId': 'https://caldav.icloud.com/.../calendars/...',
'updateMode': 'following',
'location': 'New Conference Room B',
})

events.delete​

Deletes an event. For an occurrence of a recurring series, set recurringEventId, and use deleteMode to choose which occurrences are deleted: this, following or all. See DELETE /event.

Method: client.events.delete(params)

Returns: None

A dict with provider, calendarId and eventId. recurringEventId and deleteMode are optional.

Usage:

# Delete a simple event
client.events.delete({
'provider': 'google',
'calendarId': 'primary',
'eventId': 'event123abc',
})

# Delete a single instance of a recurring event
client.events.delete({
'provider': 'microsoft',
'calendarId': 'AAMkAGVmMDEz...',
'eventId': 'instance456',
'recurringEventId': 'series123',
'deleteMode': 'this',
})

# Delete entire recurring series
client.events.delete({
'provider': 'apple',
'calendarId': 'https://caldav.icloud.com/.../calendars/...',
'eventId': 'recurring-event-id',
'deleteMode': 'all',
})

Webhooks API​

The client.webhooks resource lets you subscribe a calendar to change notifications, unsubscribe an existing channel, and verify the deliveries Connect sends to your webhook URL. Subscribing and unsubscribing correspond to the /subscribe-webhook and /unsubscribe-webhook endpoints. Connect subscribes and renews channels automatically when a calendar is connected — those two methods are for managing a specific channel explicitly.


webhooks.subscribe_webhook​

Subscribes a calendar to change notifications. See POST /subscribe-webhook.

Method: client.webhooks.subscribe_webhook(provider, calendar_id, *, channel_id=None, expiration=None)

Returns: SubscribeWebhookResponse

providerstr

The calendar's provider: google, microsoft, apple or caldav.

calendar_idstr

The calendar to subscribe. channel_id is optional and generated by the server when omitted. expiration is an optional Unix timestamp in milliseconds.

Usage:

subscription = client.webhooks.subscribe_webhook(
'google',
'work@company.com',
channel_id='my-channel-123'
)

webhooks.unsubscribe_webhook​

Removes an existing webhook subscription. See POST /unsubscribe-webhook.

Method: client.webhooks.unsubscribe_webhook(provider, channel_id, *, resource_id=None)

Returns: UnsubscribeWebhookResponse

providerstr

The calendar's provider: google, microsoft, apple or caldav.

channel_idstr

The channel to remove. resource_id is the provider resource ID returned by subscribe_webhook, where the provider uses one.

Usage:

client.webhooks.unsubscribe_webhook(
'google',
'my-channel-123',
resource_id='resource-abc'
)

webhooks.verify_webhook​

Checks that a delivery to your webhook URL came from Mobiscroll Connect and was not changed on the way, then returns it parsed. It verifies the Ed25519 signature and rejects a webhook-timestamp more than 5 minutes from your server's clock — see Verifying deliveries. Available from version 1.6.0.

Method: client.webhooks.verify_webhook(payload, headers)

On the async client it is a coroutine: await client.webhooks.verify_webhook(payload, headers).

Returns: WebhookDelivery

Raises: WebhookVerificationError when the delivery is not genuine. Respond with a 4xx and do not process it.

payloadstr | bytes | bytearray | memoryview

The raw request body, exactly as received. Do not pass a parsed object: re-serializing a parsed JSON body changes the bytes, and every signature check fails.

headersMapping[str, Any]

The request headers. Any mapping works, such as Flask's request.headers or Starlette's request.headers, and name lookup is case-insensitive. Must include webhook-id, webhook-timestamp and webhook-signature.

The SDK fetches the public keys from /.well-known/webhook-keys on the first delivery, not when the client is created, and caches them for the whole process, shared by every client instance, sync and async. It refreshes them as the endpoint's Cache-Control header allows. When no signature matches, it fetches the keys again before rejecting the delivery, at most once a minute, so a key rotation needs no action from you. If a refresh fails, it keeps using the keys it already has. The pinned webhook_public_key is used only when no keys could be fetched at all.

error.reason is a string, one of:

reasonMeaning
missing_headerswebhook-id, webhook-timestamp or webhook-signature is missing
invalid_timestampwebhook-timestamp is not a Unix time in seconds
timestamp_out_of_tolerancewebhook-timestamp is more than 5 minutes from your server's clock
no_public_keysNo keys could be fetched and no webhook_public_key is configured
no_matching_signatureNo signature matches any key — a forged or modified delivery
invalid_payloadThe body is not bytes or str (for example a parsed object), or is not a JSON object

Usage:

from flask import Flask, request
from mobiscroll_connect import WebhookVerificationError

app = Flask(__name__)

@app.post("/webhooks/mobiscroll")
def mobiscroll_webhook():
try:
delivery = client.webhooks.verify_webhook(request.get_data(), request.headers)
except WebhookVerificationError as error:
# 503 lets Connect retry when the keys could not be loaded; 401 is final.
return "", 503 if error.reason == "no_public_keys" else 401
handle_delivery(delivery)
return "", 204

With the async client, in a FastAPI handler:

from fastapi import FastAPI, Request, Response
from mobiscroll_connect import WebhookVerificationError

app = FastAPI()

@app.post("/webhooks/mobiscroll")
async def mobiscroll_webhook(request: Request):
try:
delivery = await client.webhooks.verify_webhook(await request.body(), request.headers)
except WebhookVerificationError as error:
return Response(status_code=503 if error.reason == "no_public_keys" else 401)
handle_delivery(delivery)
return Response(status_code=204)

verify_webhook_signature​

Verifies a delivery against public keys you supply, with no network request. Use it in handlers that cannot make outbound requests; otherwise prefer webhooks.verify_webhook, which keeps the keys current for you. It is a plain function, not a method of the client, and works the same with the sync and async clients. Available from version 1.6.0.

Function: mobiscroll_connect.verify_webhook_signature(payload, headers, public_keys, *, tolerance_seconds=300, now=None)

Returns: None

Raises: WebhookVerificationError, with the same reason values as webhooks.verify_webhook, except that invalid_payload is raised only for a body that is not bytes or str: the function does not parse the body. no_public_keys means none of the supplied keys is a valid whpk_ key.

payloadstr | bytes | bytearray | memoryview

The raw request body, exactly as received.

headersMapping[str, Any]

The request headers. Any mapping works, and name lookup is case-insensitive.

public_keysIterable[str]

whpk_ public keys. The delivery is genuine if any signature verifies against any of them.

tolerance_secondsfloat

Optional keyword argument. The allowed webhook-timestamp age in either direction, in seconds. The default is also available as mobiscroll_connect.WEBHOOK_TOLERANCE_SECONDS.

Default: 300
nowfloat | None

Optional keyword argument. Overrides the current Unix time, in seconds. Defaults to the system clock.

Usage:

import json
import os
from mobiscroll_connect import verify_webhook_signature

raw_body = request.get_data()
verify_webhook_signature(
raw_body,
request.headers,
[os.environ["MOBISCROLL_WEBHOOK_PUBLIC_KEY"]],
)
delivery = json.loads(raw_body)

A pinned key stops verifying when Mobiscroll retires it, so you must replace it on every key rotation.