Skip to navigation

Recurring Events

Beta
Repeat events with RFC 5545 rules and change individual dates.

Calendar is in private beta in US production (api.agentmail.to). The beta is not available in EU production (api.agentmail.eu). Organizations without access receive a 403 with the message Calendar access is not enabled for this organization. Email support@agentmail.to to request access. Upgrade to the latest Python or TypeScript SDK to use the calendar methods.

A recurring event is one series plus the dates it generates. You create and change the series by its UUID, and address each date by a dated ID.

Create a recurring event

Add recurrence to a normal create request. start and end describe the first date; the rule generates the rest.

from agentmail import AgentMail
client = AgentMail()
created = client.inboxes.calendar.create_event(
"scheduler@agentmail.to",
title="Team standup",
location="https://meet.example.com/standup",
start="2026-10-05T09:00:00", # a Monday
end="2026-10-05T09:15:00",
timezone="America/New_York",
recurrence={"rule": "FREQ=WEEKLY;BYDAY=MO,WE,FR"},
)
series = created.event
print(series.kind, series.event_id) # series 7c4e9b2a-...

Each date happens at 09:00 New York time, before and after the November daylight-saving change. Recurring timed events default to duration_mode: nominal, so each date also keeps its 09:15 end.

start must itself be a date the rule produces. Starting the example above on a Tuesday returns 400 with DTSTART must match recurrence.rule; it does not satisfy BYDAY.

Writing rules

recurrence.rule is an RFC 5545 RRULE, with or without the RRULE: prefix.

ScheduleRule
Every weekdayFREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR
Every other TuesdayFREQ=WEEKLY;INTERVAL=2;BYDAY=TU
The 15th of every monthFREQ=MONTHLY;BYMONTHDAY=15
The last Friday of every monthFREQ=MONTHLY;BYDAY=-1FR
The first Monday of every quarterFREQ=MONTHLY;INTERVAL=3;BYDAY=1MO
Every day, ten timesFREQ=DAILY;COUNT=10
Weekly until the end of 2026FREQ=WEEKLY;UNTIL=20261231T235959Z
Every yearFREQ=YEARLY

Supported parts are FREQ, INTERVAL, COUNT, UNTIL, WKST, BYMONTH, BYWEEKNO, BYYEARDAY, BYMONTHDAY, BYDAY, BYHOUR, BYMINUTE, BYSECOND and BYSETPOS. COUNT and UNTIL cannot be combined. Timed events require a UTC date-time for UNTIL (20261231T235959Z); all-day events require a date (20261231). The cutoff is inclusive.

The stored rule is normalized, so the rule you read back can differ textually from the one you sent while meaning the same thing.

Skipping and adding dates

exdates removes dates the rule would produce, and rdates adds dates it would not. Use the same format as start: date-times for timed events, dates for all-day events.

{
"title": "Team standup",
"start": "2026-10-05T09:00:00",
"end": "2026-10-05T09:15:00",
"timezone": "America/New_York",
"recurrence": {
"rule": "FREQ=WEEKLY;BYDAY=MO,WE,FR",
"exdates": ["2026-11-27T09:00:00"],
"rdates": ["2026-11-28T09:00:00"]
}
}

An exdates entry must match the date’s original start exactly. For timed events, an rdates entry can also be a period, such as {"start": "2026-11-28T09:00:00", "duration": "PT30M"}.

To skip a single date later, cancel that date; it adds the date to exdates for you. Cancelled dates count toward the 366 exdates limit, and cancelling a date beyond it returns 400 validation_error without changing the event or charging invitation emails. To skip dates on a regular pattern, change the rule instead.

Limits

LimitValue
Dates in any 90-day window730. Denser rules return 409 recurrence_density_limit.
Length of each date31 days
INTERVAL1 to 366
COUNT1 to 10,000
exdates / rdates366 (including cancelled dates) / 100
Individually edited dates per series730
mode=future edits per series64

A rule with no COUNT or UNTIL repeats indefinitely, which is fine.

Dated IDs

Each date of a series has its own ID: the series UUID, an underscore, and the date’s original start.

DateID
Timed: Wednesday Oct 7, 2026 at 09:007c4e9b2a-1f3d-4a8e-b6c5-2e9d0f1a8b47_t20261007T090000
All-day: Oct 7, 20267c4e9b2a-1f3d-4a8e-b6c5-2e9d0f1a8b47_d20261007

The time is the original wall-clock start in the series’ time zone, as the rule generated it. The ID stays the same after you move the date, so you can safely store it. Read it from event_id on agenda items, or build it yourself from a known date.

A dated event has kind: instance and three extra fields:

  • series_id: the series UUID.
  • original_start and original_start_at: when the rule placed the date, before any edit.
  • is_exception: true once the date has been edited.

List the dates

There are two ways to list dates:

  • GET /calendar/agenda lists every date of every event in a window. It includes recurring events’ dates only up to about 90 days from now.
  • GET /calendar/events/{series_id}/instances lists the dates of one series in a window. It computes them from the rule, so it works for any window up to 366 days, including far in the future.
from datetime import datetime, timezone
dates = client.inboxes.calendar.list_event_instances(
"scheduler@agentmail.to",
"7c4e9b2a-1f3d-4a8e-b6c5-2e9d0f1a8b47",
after=datetime(2026, 10, 5, tzinfo=timezone.utc),
before=datetime(2026, 10, 10, tzinfo=timezone.utc),
)
for date in dates.events:
print(date.event_id, date.start_at)

Both lists apply edits to each date and leave out cancelled dates. Get a date by its dated ID for its full details, including description and attendees.

Change one date

PATCH a dated ID to change only that date. To make the change conditional, send the date’s own etag (from Get Event, which looks like "occ-0-0-0-none-none") in If-Match; dated events have no resource_revision.

inbox_id = "scheduler@agentmail.to"
date_id = "7c4e9b2a-1f3d-4a8e-b6c5-2e9d0f1a8b47_t20261007T090000"
moved = client.inboxes.calendar.update_event(
inbox_id,
date_id,
start="2026-10-07T10:00:00",
end="2026-10-07T10:15:00",
)
print(moved.event.is_exception) # True

You can change title, description, location, metadata, status, start, end, duration_mode and attendees for a date. Unlike a series or one-off event, a date accepts end without start. all_day, timezone and recurrence belong to the series and are rejected for a date.

An edited date keeps the fields you changed for it. Its other fields keep following the series, so renaming the series later also renames the moved date. The first edit to a date sends calendar.event.created for that date; later edits send calendar.event.updated.

The same timing rules as one-off events apply to each date: once a date has started its start is fixed, and once it has ended only its descriptive fields can change. See Events that have started or ended.

Change this date and every later date

Add mode=future to apply a change from one date onward, for example to rename a meeting from next week or make it longer:

date_id = "7c4e9b2a-1f3d-4a8e-b6c5-2e9d0f1a8b47_t20261012T090000"
client.inboxes.calendar.update_event(
inbox_id,
date_id,
mode="future",
title="Team sync",
end="2026-10-12T09:30:00",
)

A mode=future change cannot include start; it returns 409 future_edit_start_move_not_supported. To move the time of every remaining date, update the series itself. Single-date and future edits can coexist at the same date. Fields explicitly changed with mode=single keep their single-date values; other fields follow the applicable future edit.

Cancel dates

DELETE a dated ID to cancel dates without deleting the series:

  • mode=single (default) cancels that one date by adding its original start to the series’ recurrence.exdates. The response shows the date with status: cancelled; afterwards it no longer appears in lists and gets no start or end webhooks. A date that is already running still gets its calendar.event.ending. The date is now removed from the series, so reading its dated ID can return 404 or 410. A date within about 90 days reads back as cancelled once the series is regenerated, for 30 days after the cancel. To confirm a cancel, check the series’ recurrence.exdates.
  • mode=future cancels that date and every later one, ending the series. The series’ recurrence.truncate_before records where it now stops.
  • mode=future from the first date deletes the whole series and returns a deletion_id, like deleting the series UUID.
date_id = "7c4e9b2a-1f3d-4a8e-b6c5-2e9d0f1a8b47_t20261009T090000"
client.inboxes.calendar.delete_event(inbox_id, date_id, mode="single")

Each sends calendar.event.updated with the cancelled date (deleting the whole series from its first date sends calendar.event.deleted). Add send_invites=true to email the cancellation to attendees.

Change the whole series

PATCH the series UUID to change every date. A change to the schedule (start, end, timezone, duration_mode, recurrence or status) regenerates the series’ dates, and start and end webhooks follow the new schedule. A date that is already in progress still gets its calendar.event.ending.

While dates are regenerated, which takes a few seconds, the agenda view can briefly leave out some of the series’ dates. Dates that have already started or ended keep reading back as they ran, in the agenda and by their dated ID, even if the new schedule no longer produces them. Changing such a date returns 410 event_expired.

To turn a series into a one-off event, set recurrence to null. This only works while none of its dates has been edited; otherwise it returns 409 event_in_progress, and you should create a new one-off event and delete the series instead.

Time zones and daylight saving

Rules are evaluated in wall-clock time in the series’ timezone, so each date keeps its local start time when clocks change. When a date’s local start falls in a gap (for example 02:30 on the night clocks spring forward), the date still occurs, moved later by the length of the gap: 02:30 becomes 03:30. Its dated ID and original_start keep 02:30. When the local start repeats (when clocks fall back), the earlier of the two times is used.

duration_mode decides the end of a date that spans a change: nominal (the default for series) keeps the local end time, and exact keeps the elapsed length.