The JMAP support in v3.0 is experimental, the API may change in v3.1 of the library
The caldav library includes a JMAP client for servers that speak
RFC 8620 (JMAP Core) and
the JMAP Calendars protocol (urn:ietf:params:jmap:calendars), which uses
RFC 8984 (JSCalendar) as its data format.
It covers calendar listing, event CRUD, incremental sync, and task CRUD — the same
operations as the CalDAV client — so the choice of protocol comes down to what the
server supports.
Note
The JMAP client targets servers implementing
urn:ietf:params:jmap:calendars. Cyrus IMAP is the primary tested server.
Task support (urn:ietf:params:jmap:tasks) requires a separate server
capability; Cyrus does not implement it yet.
from caldav.jmap import get_jmap_client
client = get_jmap_client(
url="https://jmap.example.com/.well-known/jmap",
username="alice",
password="secret",
)
calendars = client.get_calendars()
for cal in calendars:
print(cal.name):func:`~caldav.jmap.get_jmap_client` reads configuration from the same sources
as :func:`caldav.get_davclient`: explicit keyword arguments, then the
CALDAV_URL / CALDAV_USERNAME / CALDAV_PASSWORD environment variables,
then a config file. If none of those are set it returns None.
With environment variables or a config file in place, no arguments are needed:
client = get_jmap_client() # reads env vars or config fileHTTP Basic auth is used when a username is supplied alongside a password.
Bearer token auth is used when only a password (token) is given and no username.
You can also pass any requests-compatible auth object directly via the auth
parameter (niquests is API-compatible with requests).
# Basic auth
client = get_jmap_client(
url="https://jmap.example.com/.well-known/jmap",
username="alice",
password="secret",
)
# Bearer token (password argument holds the token; no username supplied)
client = get_jmap_client(
url="https://jmap.example.com/.well-known/jmap",
password="my-bearer-token",
)
# Pre-built auth object
try:
from niquests.auth import HTTPBasicAuth
except ImportError:
from requests.auth import HTTPBasicAuth
client = get_jmap_client(
url="https://jmap.example.com/.well-known/jmap",
auth=HTTPBasicAuth("alice", "secret"),
)Unlike CalDAV, JMAP does not use a 401-challenge-retry dance — credentials are sent on every request, and a 401 or 403 is a hard :class:`~caldav.jmap.error.JMAPAuthError`.
Context manager usage is supported but not required — no persistent TCP connection is held between calls (the JMAP Session object is cached after the first request, but that is just a JSON document, not a socket):
with get_jmap_client(...) as client:
calendars = client.get_calendars()calendars = client.get_calendars()
for cal in calendars:
print(cal.id, cal.name, cal.color)Each item is a :class:`~caldav.jmap.objects.calendar.JMAPCalendar` dataclass.
The fields are id, name, description, color (CSS string or None),
is_subscribed, my_rights (dict), sort_order, and is_visible.
Events are passed as iCalendar strings — the same format used by the CalDAV client — so existing iCalendar-producing code works unchanged.
The calendar-scoped API mirrors :class:`caldav.collection.Calendar`:
cal = calendars[0]
ical = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//example//EN\r\n"
"BEGIN:VEVENT\r\n"
"UID:meeting-2026-01-15@example.com\r\n"
"SUMMARY:Team meeting\r\n"
"DTSTART:20260115T100000Z\r\n"
"DTEND:20260115T110000Z\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
# Add an event to this calendar — returns the server-assigned JMAP event ID
event_id = cal.add_event(ical)
# Look up an event by its iCalendar UID — returns a VCALENDAR string
ical_str = cal.get_object_by_uid("meeting-2026-01-15@example.com")If you already have a JMAP event ID (from :meth:`~caldav.jmap.client.JMAPClient.get_sync_token` results, for example), you can also use the lower-level client methods directly:
# Fetch by JMAP event ID — returns a VCALENDAR string
ical_str = client.get_event(event_id)
# Update — pass a complete VCALENDAR string with the changes applied
updated = ical_str.replace("Team meeting", "Team standup")
client.update_event(event_id, updated)
# Delete
client.delete_event(event_id)Use :meth:`~caldav.jmap.objects.calendar.JMAPCalendar.search` on a calendar object, mirroring the CalDAV :meth:`caldav.collection.Calendar.search` interface:
cal = calendars[0]
# All events in this calendar
results = cal.search(event=True)
# Time-range filter: events that overlap [start, end)
# start — only events ending after this datetime
# end — only events starting before this datetime
results = cal.search(
event=True,
start="2026-01-01T00:00:00",
end="2026-02-01T00:00:00",
)
# Free-text search across title, description, locations, and participants
results = cal.search(text="standup")
for ical_str in results:
print(ical_str)All parameters are optional; omitting all returns every event in the calendar.
Results are returned as a list of VCALENDAR strings. The search uses a single batched
JMAP request (CalendarEvent/query + result reference into CalendarEvent/get),
so only one HTTP round-trip is made regardless of how many events match.
JMAP's state-based sync lets you fetch only what changed since the last call, without scanning the full calendar:
# Record the current state
token = client.get_sync_token()
# ... time passes, events are created/modified/deleted ...
# Fetch only the delta
added, modified, deleted = client.get_objects_by_sync_token(token)
for ical_str in added:
print("New:", ical_str)
for ical_str in modified:
print("Updated:", ical_str)
for event_id in deleted:
print("Deleted ID:", event_id)added and modified are lists of VCALENDAR strings. deleted is a list
of event IDs — the objects no longer exist on the server, so their data cannot be
fetched.
:meth:`~caldav.jmap.client.JMAPClient.get_objects_by_sync_token` raises
:class:`~caldav.jmap.error.JMAPMethodError` (error_type="serverPartialFail") if
the server truncated the change list (hasMoreChanges: true). If this happens,
call :meth:`~caldav.jmap.client.JMAPClient.get_sync_token` to establish a fresh
baseline and re-sync from scratch.
A typical pattern is to persist the token between runs:
import json
import pathlib
TOKEN_FILE = pathlib.Path("sync_token.json")
def load_token():
if TOKEN_FILE.exists():
return json.loads(TOKEN_FILE.read_text())["token"]
return None
def save_token(token):
TOKEN_FILE.write_text(json.dumps({"token": token}))
token = load_token()
if token is None:
token = client.get_sync_token()
save_token(token)
else:
added, modified, deleted = client.get_objects_by_sync_token(token)
# process changes ...
token = client.get_sync_token()
save_token(token)Task support requires a server implementing urn:ietf:params:jmap:tasks
(the JMAP Tasks specification). If the server does not support this capability,
:meth:`~caldav.jmap.client.JMAPClient.get_task_lists` will raise
:class:`~caldav.jmap.error.JMAPMethodError`.
# List task lists
task_lists = client.get_task_lists()
for tl in task_lists:
print(tl.id, tl.name)
task_list_id = task_lists[0].id
# Create a task — title is required; everything else is optional
task_id = client.create_task(
task_list_id,
title="Review pull request",
due="2026-02-15T17:00:00",
time_zone="Europe/Oslo",
)
# Fetch — returns a JMAPTask dataclass
task = client.get_task(task_id)
print(task.title) # str
print(task.progress) # "needs-action" (default)
print(task.percent_complete) # 0 (default)
# Update — pass a partial patch dict using JMAP wire property names
client.update_task(task_id, {"progress": "completed", "percentComplete": 100})
# Delete
client.delete_task(task_id)Optional kwargs for :meth:`~caldav.jmap.client.JMAPClient.create_task`:
description, start, due, time_zone, estimated_duration,
percent_complete, progress, priority.
Each item from :meth:`~caldav.jmap.client.JMAPClient.get_task` is a
:class:`~caldav.jmap.objects.task.JMAPTask` with fields id, uid,
task_list_id, title, description, start, due, time_zone,
estimated_duration, percent_complete, progress, progress_updated,
priority, is_draft, keywords, recurrence_rules,
recurrence_overrides, alerts, participants, color, privacy.
Each item from :meth:`~caldav.jmap.client.JMAPClient.get_task_lists` is a
:class:`~caldav.jmap.objects.task.JMAPTaskList` with fields id, name,
description, color, is_subscribed, my_rights, sort_order,
time_zone, role ("inbox", "trash", or None).
:class:`~caldav.jmap.async_client.AsyncJMAPClient` mirrors every method of
:class:`~caldav.jmap.client.JMAPClient` as a coroutine. Use it as an
async with context manager (sync with is not supported):
import asyncio
from caldav.jmap import get_async_jmap_client
async def main():
async with get_async_jmap_client(
url="https://jmap.example.com/.well-known/jmap",
username="alice",
password="secret",
) as client:
calendars = await client.get_calendars()
for cal in calendars:
print(cal.name)
# Calendar-scoped methods return coroutines when the calendar
# was obtained from an async client
cal = calendars[0]
results = await cal.search(event=True)
ical_str = await cal.get_object_by_uid("some-uid@example.com")
event_id = await cal.add_event(ical)
asyncio.run(main())All methods — event CRUD, search, sync, and task operations — are available as
coroutines with identical signatures. The async client uses niquests.AsyncSession
internally; niquests is a required dependency.
All JMAP errors extend :class:`~caldav.jmap.error.JMAPError`, which itself extends
:class:`~caldav.lib.error.DAVError`. Existing CalDAV error handlers will catch JMAP
errors too if they catch DAVError.
from caldav.lib.error import DAVError
from caldav.jmap.error import JMAPAuthError, JMAPCapabilityError, JMAPMethodError
try:
event_id = client.create_event(calendar_id, ical)
except JMAPAuthError:
print("Authentication failed (401/403)")
except JMAPCapabilityError:
print("Server does not support urn:ietf:params:jmap:calendars")
except JMAPMethodError as e:
print(f"Server rejected the request: {e.error_type} — {e.reason}")
except DAVError as e:
print(f"Protocol error: {e}")The three specific error classes:
- :class:`~caldav.jmap.error.JMAPAuthError` — HTTP 401 or 403. JMAP sends no 401-challenge, so this is always a hard failure.
- :class:`~caldav.jmap.error.JMAPCapabilityError` — the server's Session object
does not advertise
urn:ietf:params:jmap:calendars. - :class:`~caldav.jmap.error.JMAPMethodError` — a JMAP method call returned an error
response. The
error_typeattribute holds the RFC 8620 error type string (e.g."invalidArguments","notFound","stateMismatch").
The JMAP client reads from the same configuration file as the CalDAV client.
Connection parameters use the caldav_ prefix:
---
default:
caldav_url: https://jmap.example.com/.well-known/jmap
caldav_username: alice
caldav_password: secretWith the file in place, no arguments are needed:
client = get_jmap_client()JMAP and CalDAV settings can coexist in the same file using separate named sections:
---
default:
caldav_url: https://caldav.example.com
caldav_username: alice
caldav_password: secret
jmap:
caldav_url: https://jmap.example.com/.well-known/jmap
caldav_username: alice
caldav_password: secret
protocol: jmapfrom caldav.jmap import get_jmap_client
client = get_jmap_client(config_section="jmap")See :doc:`configfile` for file locations, section inheritance, and other options.
- :doc:`caldav/jmap_client` — :class:`~caldav.jmap.client.JMAPClient` and :class:`~caldav.jmap.async_client.AsyncJMAPClient` full method reference
- :doc:`caldav/jmap_objects` — :class:`~caldav.jmap.objects.calendar.JMAPCalendar`, :class:`~caldav.jmap.objects.event.JMAPEvent`, :class:`~caldav.jmap.objects.task.JMAPTask`, :class:`~caldav.jmap.objects.task.JMAPTaskList`, and error classes