Skip to content

Recurring & repeating tasks

Bordio has two different ways a task can repeat. They are mutually exclusive — pick one:

  • On a schedule (recurrence) — the task/event repeats on fixed dates, e.g. every Monday. Uses an iCalendar RRULE. Works for tasks and events. Covered first, below.
  • After completion (repeat_on_completion) — a fresh copy is created only when you close the task, with dates shifted forward. Good for “do this again N days after I finish it”. Tasks only. See Repeat after completion.

A task or event can repeat on a schedule — a daily task, a weekly standup, a monthly review. You create a repeating series by adding a recurrence object to the normal create request. The result is a series (one template you address by its id), not a pile of copies.

Recurrence uses the iCalendar RRULE format (RFC 5545) — the same rule syntax as Google Calendar and CalDAV. If you have ever built a recurrence with a calendar library, you already know it.

Add recurrence to POST /scheduled-events. The series is anchored at start_at.

Terminal window
curl -X POST "https://api.bordio.com/public/v1/scheduled-events" \
-H "Authorization: Bearer brd_sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"title": "Team standup",
"start_at": "2026-07-06T09:00:00Z",
"end_at": "2026-07-06T09:15:00Z",
"recurrence": {
"rule": "RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR",
"until": "2026-12-31"
}
}'

This creates a standup every Monday, Wednesday and Friday from 06 Jul 2026 until the end of the year.

Add recurrence to POST /tasks. The series is anchored by the task’s date (the day the task is scheduled) — you must include date, otherwise the request is rejected with 422.

Terminal window
curl -X POST "https://api.bordio.com/public/v1/tasks" \
-H "Authorization: Bearer brd_sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"title": "Water the plants",
"project_id": "proj_6594a1b2c3d4e5f6a7b8c9d0",
"date": "2026-07-06",
"recurrence": {
"rule": "RRULE:FREQ=DAILY",
"until": "2026-08-31"
}
}'
Field Required Description
rule yes A bare iCalendar RRULE string, e.g. RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR.
until no ISO-8601 date/datetime — the inclusive end of the series.

The rule is a standard RRULE line. The most common parts:

  • FREQ= — how often: DAILY, WEEKLY, MONTHLY or YEARLY.
  • INTERVAL= — every N periods (e.g. INTERVAL=2 with FREQ=WEEKLY = every two weeks). Defaults to 1.
  • BYDAY= — days of the week: MO,TU,WE,TH,FR,SA,SU (use with FREQ=WEEKLY).
  • BYMONTHDAY= — day of the month, e.g. BYMONTHDAY=1 (use with FREQ=MONTHLY).

Examples:

Goal rule
Every day RRULE:FREQ=DAILY
Every weekday RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR
Every other Monday RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO
First of every month RRULE:FREQ=MONTHLY;BYMONTHDAY=1
Every year RRULE:FREQ=YEARLY

To keep series valid and safe, the API is strict about the rule:

  • FREQ is required and must be one of DAILY, WEEKLY, MONTHLY, YEARLY. Sub-daily frequencies (HOURLY, MINUTELY, SECONDLY) are rejected.
  • Do not include DTSTART. The start is derived from the resource (start_at for events, date for tasks). A DTSTART in the rule is rejected.
  • Do not use COUNT. Bound the series with the until field instead.
  • The series cannot start more than 730 days in the past (start_at for events, date for tasks). Older history is imported as one-off records or as a series anchored at a recent date; existing series are not affected.
  • INTERVAL, if present, must be a positive integer (≤ 1000).
  • The rule must be a single RRULE: line (no RDATE/EXDATE, no line breaks).

Anything else returns 422 validation_error with code invalid_recurrence_rule.

Use the until field for the end date. If you omit until, the series is open-ended and repeats indefinitely.

Recurring resources include a recurrence object in responses; single (non-recurring) ones return recurrence: null.

{
"data": {
"id": "event_6594a1b2c3d4e5f6a7b8c9d0",
"title": "Team standup",
"start_at": "2026-07-06T09:00:00.000Z",
"recurrence": {
"rule": "RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR",
"until": "2026-12-31T00:00:00.000Z"
}
}
}

The returned rule is normalized to the RRULE: line — the internal DTSTART is not exposed.

Instead of a fixed schedule, a task can spawn a new copy of itself when you complete it (move it to a closed status). This is a different mechanism from recurrence above — there is no RRULE, and nothing is created until the task is actually closed. Add repeat_on_completion to POST /tasks:

Terminal window
curl -X POST "https://api.bordio.com/public/v1/tasks" \
-H "Authorization: Bearer brd_sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"title": "Change the water filter",
"project_id": "proj_6594a1b2c3d4e5f6a7b8c9d0",
"estimated_time_seconds": 1800,
"repeat_on_completion": {
"start_date_shift_days": 30,
"due_date_shift_days": 2,
"until": "2027-12-31"
}
}'

When this task is closed, Bordio creates a fresh copy: same title, project, assignees, priority and subtasks, with its start date set 30 days after the completion date and its due date 2 days after that. The copy repeats the same way, until until passes.

Field Required Description
start_date_shift_days no Days after completion the next copy starts. 0 = same day. Omit or pass null to leave the copy unscheduled — it lands in the waiting list.
due_date_shift_days no Days after the copy’s start that its due date falls. Omit for no due date. Ignored when the copy has no start date.
until no Stop repeating after this date (ISO-8601). Omit to repeat indefinitely.
initial_task_status_id no Status each new copy starts in (task_status_...). Defaults to the task’s status — set it so the copy begins as e.g. “To do” instead of the closed status that triggered it.

Notes:

  • Only when closed. The copy is created when the task moves to a status whose state is closed — not on every edit.
  • Fields are inherited. Title, description, project, assignees, reporters, priority, subtasks and the time estimate carry over to each copy automatically; you only specify the date shifts.
  • Waiting-list repeats. Without start_date_shift_days the copy has no start date, so it waits in the waiting list until someone schedules it. Because the due date is computed from the start date, due_date_shift_days has no effect in this mode.
  • Mutually exclusive with recurrence. Sending both on the same task returns 422.

Responses include a repeat_on_completion object (or null):

{
"data": {
"id": "task_6594a1b2c3d4e5f6a7b8c9d0",
"repeat_on_completion": {
"start_date_shift_days": 30,
"due_date_shift_days": 2,
"until": "2027-12-31T00:00:00.000Z"
}
}
}
  • The series (the repeating task itself) has a plain id — task_<id> / event_<id> — the id you get back from create. Reading, updating or deleting it applies to the whole series.
  • An individual occurrence has its own id that carries the occurrence date, e.g. task_<id>_20260113T090000Z (see Identifiers). Read it back from the API and use it verbatim to address that single occurrence — never build it yourself.

GET /tasks answers two different questions, and which one you get depends on the view parameter.

Without view the list contains your one-off tasks plus the individual occurrences that already exist in the system — the ones somebody has edited, completed or rescheduled. The repeating task itself is not in that list, and one series can appear as several rows. This is the historical behaviour and it does not change.

With view=by_task the list contains tasks: a repeating task is one row, carrying its rule and the occurrence to work on next. Its occurrences are not listed separately.

Terminal window
curl "https://api.bordio.com/public/v1/tasks?view=by_task&project_id=proj_6594a1b2c3d4e5f6a7b8c9d0" \
-H "Authorization: Bearer brd_sk_live_..."
{
"data": [
{
"id": "task_6594a1b2c3d4e5f6a7b8c9d0",
"title": "Team standup",
"kind": "recurring_task",
"series_id": "task_6594a1b2c3d4e5f6a7b8c9d0",
"series_chain_id": "task_6594a1b2c3d4e5f6a7b8c9d0",
"recurrence": { "rule": "RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR", "until": null },
"recurrence_summary": "every week on Monday, Wednesday, Friday",
"current_occurrence": {
"id": "task_6594a1b2c3d4e5f6a7b8c9d0_20260907T090000Z",
"kind": "occurrence",
"title": "Team standup",
"task_status_id": "task_status_6594a1b2c3d4e5f6a7b8c9d2"
}
}
]
}

These fields are present on every task the API returns, in any mode — they were added alongside the existing ones, so a client that does not read them sees no change:

Field Meaning
kind task (one-off), recurring_task (repeats on a rule), occurrence (one dated occurrence)
series_id The repeating task this record belongs to. A repeating task carries its own id here; a one-off task has null
series_chain_id Groups repeating tasks that came from one original series — see Splitting a series
recurrence_summary The same rule in words, e.g. every weekday
current_occurrence The occurrence to work on next, in full. null once a repeating task has no open occurrences left

current_occurrence is the earliest open occurrence, including overdue ones: an abandoned daily task shows the oldest thing you still owe, not tomorrow’s copy. It is the full task object, because an occurrence that somebody edited can differ from the series — its own title, assignees, status or due date — and you should not have to fetch it separately to find that out.

New integrations should always pass view explicitly.

Listing occurrences day by day (view=by_date)

Section titled “Listing occurrences day by day (view=by_date)”

The third question a listing can answer is “what happens between these dates”. Pass view=by_date with a date range, and every day the repeating task falls on becomes its own record — including the dates nobody has touched yet, which do not exist as separate records until you ask for them:

Terminal window
curl "$BASE/tasks?view=by_date&date_from=2026-09-01&date_to=2026-09-30" -H "$AUTH"
{
"data": [
{ "id": "task_..._20260901T090000Z", "kind": "occurrence", "date": "2026-09-01", "title": "Team standup" },
{ "id": "task_..._20260902T090000Z", "kind": "occurrence", "date": "2026-09-02", "title": "Team standup" }
],
"pagination": { "next_cursor": "…", "has_more": true }
}

What to know about this view:

  • the range is required and cannot exceed 31 days. Future occurrences are generated from the rule, so an open-ended request has no end;
  • the range applies to the task’s date — the day it is scheduled for — not to its due date. due_date_from / due_date_to still work as ordinary filters inside the view;
  • one-off tasks scheduled in the range are listed alongside occurrences; a task with no date is not (there is no day to put it on);
  • an occurrence somebody edited appears once, as the record it actually is;
  • results are ordered from the start of the range, and pagination works as usual — next_cursor until has_more is false. Cursors are not interchangeable between views: a cursor from one listing is rejected with 400 by the other;
  • closed_since cannot be combined with it (400) — that filter orders by completion time, and a list cannot have two orders. Ask for completed work separately;
  • at most 500 repeating tasks are expanded in one request.

date_from / date_to also work without view, as a plain filter on the day a task is scheduled for.

Knowing the next occurrence right after you close one

Section titled “Knowing the next occurrence right after you close one”

PATCH accepts the same view parameter. With view=by_task, closing an occurrence comes back as the repeating task itself, with current_occurrence already pointing at the next one — no follow-up request needed:

Terminal window
curl -X PATCH "https://api.bordio.com/public/v1/tasks/task_..._20260907T090000Z?view=by_task" \
-H "Authorization: Bearer brd_sk_live_..." \
-H "Content-Type: application/json" \
-d '{"task_status_id": "task_status_..."}'

Without view the response is the record you changed, as before.

Editing “this and the following occurrences” leaves you with two repeating tasks — the part before the change and the part after it. This is how calendar apps behave, and it is why a project can contain two rows that look like the same standup.

series_chain_id tells you they belong together:

  • editing properties (title, assignee, description) keeps both halves in the same chain — same series_chain_id, different series_id;
  • editing the time or the repeat rule starts a genuinely new series, and the new half gets its own series_chain_id.

A half that has run out of occurrences keeps existing (its past occurrences are real history), but its current_occurrence is null.

In the by_task view, state asks whether there is work to do rather than looking at a status field:

Terminal window
curl "$BASE/tasks?view=by_task&state=open" -H "$AUTH"

A repeating task counts as open while it still has an open occurrence, and as closed once none are left — regardless of the status each occurrence starts in. Without view=by_task the filter works on the record’s own status, as it always has.

Recently completed work is a separate question with a separate answer — completed occurrences are individual records, so they are listed without view=by_task:

Terminal window
curl "$BASE/tasks?closed_since=2026-07-28" -H "$AUTH"

See Filtering tasks for both filters in context.

GET /scheduled-events takes the same view parameter, with the same meaning:

view One record is
omitted / by_date one occurrence — the default, unchanged behaviour
by_task one event; a repeating one carries its rule and, in current_occurrence, the next occurrence inside the requested range
Terminal window
curl "$BASE/scheduled-events?from=2026-09-01T00:00:00Z&to=2026-09-30T00:00:00Z&view=by_task" -H "$AUTH"

The from / to range stays required in both views. Events carry the same fields as tasks — kind (event, recurring_event, occurrence), series_id, series_chain_id, recurrence_summary, current_occurrence — so one piece of client code can read both.

  • Create a recurring task or event.
  • Read, update and delete both a series and an individual occurrence by its id.
  • List repeating tasks and events as tasks/events (view=by_task), with the next occurrence included.
  • List occurrences over a date range — view=by_date for tasks, and the default listing for events.
  • Choose what an edit affects with scope — see below.

Editing one occurrence, the rest, or all of them

Section titled “Editing one occurrence, the rest, or all of them”

PATCH and DELETE take a scope query parameter:

scope What it affects
omitted / only_this just the occurrence you addressed — the default, and the previous behaviour
this_and_following that occurrence and every later one; this splits the repeating task in two
all every occurrence, past ones included
Terminal window
# Move the rest of the series to a new assignee, leaving history alone
curl -X PATCH "https://api.bordio.com/public/v1/tasks/task_..._20260907T090000Z?scope=this_and_following" \
-H "Authorization: Bearer brd_sk_live_..." \
-H "Content-Type: application/json" \
-d '{"title": "Team standup (async)"}'

For a task that does not repeat the parameter is ignored.

Addressing the repeating task itself requires scope

Section titled “Addressing the repeating task itself requires scope”

If the id you PATCH or DELETE is the repeating task (the series_id, not an occurrence id) and you did not pass scope, the request is rejected with 400. Renaming or deleting an entire series by accident is not something you can undo, and the two ids look alike — so the API asks you to say what you meant: pass scope=all, or address a single occurrence by its own id.

scope=all writes the fields you send across the whole series and leaves everything else alone: if someone moved one Tuesday or reassigned a single date, that stays.

Some fields cannot be spread across a series and are rejected with 400 rather than applied halfway:

Field Why
assignee_ids, custom_fields they contain per-record identifiers, so copying them across occurrences would corrupt the records
tag_ids tags are applied through their own mechanism, with their own recurrence behaviour
due_date each occurrence derives its deadline from its own date — there is no series-wide value

Changing the schedule (the date or the repeat rule) is this_and_following, not all: it splits the series rather than rewriting its past.

DELETE ?scope=all removes the repeating task together with every occurrence of it.