Developers
Calendar API
The Calendar API lets you read the events in a workspace calendar, with recurring events expanded into real dates, create new events, change them and delete them. Use it to build agendas, reminders, or to sync events from another tool.
Before you start
- Base URL:
https://app.tylode.com/api/v1 - Send your token in the
Authorization: Bearer tylode_pat_xxxheader. Personal access tokens need the Pro plan. See Authentication. - Get your workspace ID with
GET /api/v1/workspaces, or in the app under Settings → Workspace → Workspace details → Workspace ID → Copy. - Errors any endpoint can return (
401,403,429) are covered in Errors and conventions. - The JavaScript examples use the
tylode()helper from the Pages API.
The API works with the workspace calendar. Your personal calendar isn't available through the API. See Calendar for how both work in the app.
Endpoints
| Method | Path | What it does | Scope |
|---|---|---|---|
| GET | /api/v1/calendar/events | List events, optionally in a date range | calendar:read |
| POST | /api/v1/calendar/events | Create an event | calendar:write |
| PATCH | /api/v1/calendar/events/:id | Change an event | calendar:write |
| DELETE | /api/v1/calendar/events/:id | Delete an event | calendar:delete |
The event object
| Field | Type | Description |
|---|---|---|
id | string | Event ID. Occurrences of a recurring event share the ID of the original event. |
calendar_id | string | Calendar the event belongs to. |
workspace_id | string | Workspace of that calendar. |
title | string | Event title. |
description | string | Description (can be empty). |
start_at | string | Start, as an ISO 8601 timestamp in UTC. |
end_at | string or null | End, or null if the event has no end time (all-day events usually don't). |
all_day | boolean | Whether it's an all-day event. |
recurrence_rule | string or null | Repeat rule for recurring events, null for one-off events. See Recurring events. |
excluded_at | array or null | Start times of single occurrences that were deleted from a recurring event. |
List events
GET /api/v1/calendar/events
Returns the workspace calendar's events sorted by start_at. With a date range, recurring events come back as one item per occurrence.
Scope: calendar:read
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
workspace_id | query | string | Yes | The workspace whose calendar you want. |
from | query | string (ISO 8601) | No | Start of the range. |
to | query | string (ISO 8601) | No | End of the range. |
How from and to work:
| You send | One-off events | Recurring events |
|---|---|---|
| Neither | All of them. | Once each, as the original event with its recurrence_rule (not expanded). |
from and to | Those with from ≤ start_at < to. | Every occurrence starting between from and to (both included). |
Only from | Those with start_at ≥ from. | Occurrences from from to 365 days later. |
Only to | Those with start_at < to, however old. | Occurrences from now until to. |
For day views, send both bounds. There's no pagination.
URL-encode the timestamps: a + in an offset like +02:00 turns into a space if you paste it raw into a URL. With curl, -G plus --data-urlencode does it for you:
curl -G https://app.tylode.com/api/v1/calendar/events \
-H "Authorization: Bearer tylode_pat_xxx" \
--data-urlencode "workspace_id=WORKSPACE_ID" \
--data-urlencode "from=2026-09-28T00:00:00+02:00" \
--data-urlencode "to=2026-10-05T00:00:00+02:00"const params = new URLSearchParams({
workspace_id: WORKSPACE_ID,
from: "2026-09-28T00:00:00+02:00",
to: "2026-10-05T00:00:00+02:00",
});
const events = await tylode(`/calendar/events?${params}`);Response 200
A one-off event followed by two occurrences of a weekly meeting. The occurrences share the same id.
{
"data": [
{
"id": "a3c6f9b2-5e8d-4a1c-b4f7-0d3a6c9e2b58",
"calendar_id": "f1e4b7a0-3c6f-4d9a-8e2b-5a8d1c4f7e03",
"workspace_id": "7c1e2a4b-3f5d-4e8a-9b21-6d0f5c8e1a37",
"title": "Dentist",
"description": "",
"start_at": "2026-09-28T07:30:00+00:00",
"end_at": "2026-09-28T08:15:00+00:00",
"all_day": false,
"recurrence_rule": null,
"excluded_at": null
},
{
"id": "b8e1d4a7-0f3c-4e6b-9a2d-5c8f1b4e7a30",
"calendar_id": "f1e4b7a0-3c6f-4d9a-8e2b-5a8d1c4f7e03",
"workspace_id": "7c1e2a4b-3f5d-4e8a-9b21-6d0f5c8e1a37",
"title": "Team sync",
"description": "Weekly check-in.",
"start_at": "2026-09-28T08:00:00.000Z",
"end_at": "2026-09-28T08:30:00.000Z",
"all_day": false,
"recurrence_rule": "FREQ=WEEKLY;BYDAY=MO,TH,FR",
"excluded_at": ["2026-10-01T08:00:00+00:00"]
},
{
"id": "b8e1d4a7-0f3c-4e6b-9a2d-5c8f1b4e7a30",
"calendar_id": "f1e4b7a0-3c6f-4d9a-8e2b-5a8d1c4f7e03",
"workspace_id": "7c1e2a4b-3f5d-4e8a-9b21-6d0f5c8e1a37",
"title": "Team sync",
"description": "Weekly check-in.",
"start_at": "2026-10-02T08:00:00.000Z",
"end_at": "2026-10-02T08:30:00.000Z",
"all_day": false,
"recurrence_rule": "FREQ=WEEKLY;BYDAY=MO,TH,FR",
"excluded_at": ["2026-10-01T08:00:00+00:00"]
}
]
}Here the Thursday 1 October occurrence was deleted in the app, so it's listed in excluded_at and left out of the results.
Errors
| Status | When |
|---|---|
400 | workspace_id is missing. |
Create an event
POST /api/v1/calendar/events
Creates a one-off event in the workspace calendar.
Scope: calendar:write
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
workspace_id | body | string | Yes | Workspace whose calendar gets the event. |
title | body | string | Yes | Event title. |
start_at | body | string (ISO 8601) | Yes | Start, with an offset or Z. |
end_at | body | string (ISO 8601) | No | End. Defaults to no end time. |
all_day | body | boolean | No | All-day event. Defaults to false. |
description | body | string | No | Description. Defaults to empty. |
The API can't create recurring events or add participants; do that in the app. For an all-day event, do what the app does: send midnight of that day in your time zone as start_at, set all_day to true and leave out end_at.
curl -X POST https://app.tylode.com/api/v1/calendar/events \
-H "Authorization: Bearer tylode_pat_xxx" \
-H "Content-Type: application/json" \
-d '{
"workspace_id": "WORKSPACE_ID",
"title": "Quarterly review",
"start_at": "2026-10-06T10:00:00+02:00",
"end_at": "2026-10-06T11:30:00+02:00",
"description": "Room 2 and video call."
}'const event = await tylode("/calendar/events", {
method: "POST",
body: {
workspace_id: WORKSPACE_ID,
title: "Quarterly review",
start_at: "2026-10-06T10:00:00+02:00",
end_at: "2026-10-06T11:30:00+02:00",
description: "Room 2 and video call.",
},
});Response 201
The response doesn't include recurrence_rule or excluded_at, since events created through the API never repeat.
{
"data": {
"id": "c9f2e5b8-1a4d-4f7c-b0e3-6d9a2c5f8b41",
"calendar_id": "f1e4b7a0-3c6f-4d9a-8e2b-5a8d1c4f7e03",
"workspace_id": "7c1e2a4b-3f5d-4e8a-9b21-6d0f5c8e1a37",
"title": "Quarterly review",
"description": "Room 2 and video call.",
"start_at": "2026-10-06T08:00:00+00:00",
"end_at": "2026-10-06T09:30:00+00:00",
"all_day": false
}
}Errors
| Status | When |
|---|---|
400 | workspace_id, title or start_at is missing. |
400 | The workspace doesn't have a calendar yet. |
400 | The event couldn't be created (for example, a timestamp isn't valid or you can't add events in that workspace). |
Change an event
PATCH /api/v1/calendar/events/:id
Changes the title, description, start, end, or whether it's an all-day event. Only what you send changes: if someone else changes another field in the meantime, their change stays.
Scope: calendar:write
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | Yes | Event ID, from List events. |
title | body | string | No | New title. It can't be blank. |
description | body | string | No | New description. "" leaves it empty. |
start_at | body | string (ISO 8601) | No | New start, with a UTC offset or Z. |
end_at | body | string (ISO 8601) or null | No | New end, or null to remove the end time. |
all_day | body | boolean | No | Whether it's an all-day event. |
Send at least one of the body fields. The end can't end up before the start; if you only change one of them, it's compared with the one the event already had.
On a recurring event, the change applies to all its occurrences, because they all come from the same event (its start_at is the first one's). The recurrence rule can't be changed through the API.
curl -X PATCH https://app.tylode.com/api/v1/calendar/events/EVENT_ID \
-H "Authorization: Bearer tylode_pat_xxx" \
-H "Content-Type: application/json" \
-d '{
"start_at": "2026-10-06T11:00:00+02:00",
"end_at": "2026-10-06T12:30:00+02:00"
}'// Push the quarterly review back an hour
const event = await tylode(`/calendar/events/${EVENT_ID}`, {
method: "PATCH",
body: {
start_at: "2026-10-06T11:00:00+02:00",
end_at: "2026-10-06T12:30:00+02:00",
},
});Response 200
The event as it ends up. It includes recurrence_rule, but not excluded_at.
{
"data": {
"id": "c9f2e5b8-1a4d-4f7c-b0e3-6d9a2c5f8b41",
"calendar_id": "f1e4b7a0-3c6f-4d9a-8e2b-5a8d1c4f7e03",
"workspace_id": "7c1e2a4b-3f5d-4e8a-9b21-6d0f5c8e1a37",
"title": "Quarterly review",
"description": "Room 2 and video call.",
"start_at": "2026-10-06T09:00:00+00:00",
"end_at": "2026-10-06T10:30:00+00:00",
"all_day": false,
"recurrence_rule": null
}
}Errors
| Status | When |
|---|---|
400 | The event ID isn't valid. |
400 | You didn't send anything to change, or title is blank. |
400 | start_at or end_at isn't a valid ISO 8601 date (end_at also accepts null). |
400 | end_at would end up before start_at. |
404 | The event doesn't exist or you can't change it. |
Delete an event
DELETE /api/v1/calendar/events/:id
Deletes an event. If it repeats, all its occurrences are deleted, because they all come from the same event. Events have no trash: it can't be recovered.
Scope: calendar:delete
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | Yes | Event ID. |
curl -X DELETE https://app.tylode.com/api/v1/calendar/events/EVENT_ID \
-H "Authorization: Bearer tylode_pat_xxx"Response 200
{ "data": { "id": "c9f2e5b8-1a4d-4f7c-b0e3-6d9a2c5f8b41", "papelera": false } }To remove a single occurrence of a recurring event, do it in the app. What all DELETE endpoints have in common is in Deleting: trash or permanent.
Errors
| Status | When |
|---|---|
400 | The ID isn't valid. |
404 | The event doesn't exist or you can't delete it. |
Recurring events
Recurring events are created in the app (see Calendar). The API gives you two ways to read them.
With a range (from and/or to), the API expands them for you: each occurrence is a separate item with its own start_at and end_at (in UTC, like 2026-10-02T08:00:00.000Z) and everything else copied from the original event, including its id. Use id plus start_at as a unique key. Deleted occurrences are already left out.
Without a range, you get each recurring event once, with its first start_at and these two fields to expand it yourself:
recurrence_rule: an iCalendar RRULE, without theRRULE:prefix. The first occurrence is the event'sstart_at. The app writes rules like these:Rule Meaning FREQ=DAILYEvery day, forever. FREQ=WEEKLY;BYDAY=MO,WE,FREvery Monday, Wednesday and Friday. FREQ=WEEKLY;INTERVAL=2Every two weeks, on the same weekday as the start. FREQ=MONTHLY;COUNT=6Monthly, six times. FREQ=YEARLY;UNTIL=20301231T235959ZYearly until the end of 2030. excluded_at: start times of occurrences that were deleted one by one. Skip them. Compare them as instants, not as text: the same moment can be written as+00:00orZ, with or without milliseconds.
A sketch with the rrule package, which expands rules the same way:
import { RRule } from "rrule";
function occurrences(event, from, to) {
const rule = new RRule({
...RRule.parseString(event.recurrence_rule),
dtstart: new Date(event.start_at),
});
const excluded = new Set((event.excluded_at ?? []).map((d) => new Date(d).getTime()));
return rule.between(from, to, true).filter((d) => !excluded.has(d.getTime()));
}If a rule can't be read, the event comes back as a single item, unexpanded, even when you send a range.
Time zones
- Every timestamp is an instant. When you send one (
start_at,end_at,from,to), include an offset (2026-10-06T10:00:00+02:00) orZ(2026-10-06T08:00:00Z), so there's no doubt about which moment you mean. - Responses are always in UTC. Stored events look like
2026-10-06T08:00:00+00:00; expanded occurrences look like2026-10-06T08:00:00.000Z. Both parse withnew Date(). Convert to the viewer's time zone when you display them. - For "today" or "this week", compute the start and end of the day in your time zone and send them as
fromandto.
Example: a daily agenda script
This Node.js script prints today's events in your time zone. Run it with TZ set so "today" means your day, for example TZ=Europe/Madrid node agenda.mjs.
// agenda.mjs (paste the tylode() helper from the Pages API above this line)
const WORKSPACE_ID = process.env.TYLODE_WORKSPACE_ID;
const start = new Date();
start.setHours(0, 0, 0, 0); // local midnight
const end = new Date(start);
end.setDate(end.getDate() + 1); // next local midnight
const params = new URLSearchParams({
workspace_id: WORKSPACE_ID,
from: start.toISOString(),
to: end.toISOString(),
});
const events = (await tylode(`/calendar/events?${params}`))
// occurrences include the end bound; keep strictly today's
.filter((e) => new Date(e.start_at) < end);
const hhmm = (iso) =>
new Date(iso).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
console.log(`Agenda for ${start.toDateString()}`);
if (events.length === 0) console.log("Nothing scheduled.");
for (const e of events) {
const when = e.all_day ? "All day" : e.end_at ? `${hhmm(e.start_at)}–${hhmm(e.end_at)}` : hhmm(e.start_at);
console.log(`${when} ${e.title}`);
}Agenda for Mon Sep 28 2026
09:30–10:15 Dentist
10:00–10:30 Team syncTo send it to Slack every morning, together with the cards due today, see the daily agenda recipe.
Related
- Calendar in the app
- Boards API, for cards with due dates
- MCP: the
list_calendar_eventstool uses this same endpoint, so an assistant sees recurring events on the right dates