# Recurring events

> How a weekly or monthly series is represented and how its next date is computed.

Source: https://api.tailend.dev/docs/recurring-events

This page is for anyone building a calendar, feed, or newsletter view: it
explains how a weekly storytime or a monthly meetup shows up in the API —
one listing per series, not one row per Saturday — and what happens once a
series has run its course.

```bash
curl -sS -H "X-API-Key: $TAILEND_API_KEY" --get https://api.tailend.dev/v1/philadelphia/events \
  --data-urlencode "recurring=true" \
  --data-urlencode "limit=1"
```

```javascript
const res = await fetch(
  "https://api.tailend.dev/v1/philadelphia/events?recurring=true&limit=1",
  { headers: { "X-API-Key": process.env.TAILEND_API_KEY } },
);
const { data } = await res.json();
console.log(data[0].name, data[0].rrule, data[0].occurrence);
```

```json
{
  "data": [
    {
      "id": "2f3c9d4e-3d2b-4b7d-9a3e-1b2c3d4e5f60",
      "name": "Storytime in the Park",
      "is_recurring": true,
      "rrule": "FREQ=WEEKLY;BYDAY=SA",
      "recurrence_description": "Every Saturday through October",
      "starts_at": "2026-08-01T14:00:00Z",
      "occurrence": {
        "next_starts_at": "2026-09-19T14:00:00Z",
        "next_starts_local": "2026-09-19T10:00:00"
      }
    }
  ]
}
```

## One listing per series

A recurring event is a single row, not one row per occurrence. `is_recurring`
tells you it repeats, `rrule` gives the machine-readable recurrence rule
(RFC 5545), and `recurrence_description` gives the human-readable version
(`"Every Saturday through October"`). There's no separate endpoint or
per-occurrence resource — a weekly storytime is one `id`, one `slug`, one
permalink, for the life of the series.

## How the next occurrence works in list results

`starts_at`/`starts_local` on a recurring row are the series' original
anchor — when it first started, not necessarily anything upcoming. The
`occurrence` block carries the one that matters for "what's happening":

| Field | Meaning |
| --- | --- |
| `occurrence.next_starts_at` | UTC instant of the next live occurrence, or `null` when the series has ended or no occurrence is known |
| `occurrence.next_starts_local` | The same instant, as local wall-clock time |

List results (`GET /v1/{metro}/events`) order and window recurring rows by
this next occurrence rather than by the original anchor date — so a series
that started in January and still meets every Saturday shows up in a
`from`/`to` window that covers next Saturday, not just a window that covers
January. A one-off (non-recurring) event keeps working exactly as before,
windowed and ordered on its own `starts_at`.

`occurrence` is additive: check for its presence rather than assuming it's
always there, and always check `has_date`/`starts_at` first for whether an
event has a date at all (see the [API reference](https://api.tailend.dev/docs/api.md)).

## Finished series

Once a recurring series has no more live occurrences,
`occurrence.next_starts_at` is `null` and the row drops out of ordinary
list results — there's nothing left to window it against. The listing
itself isn't deleted: `GET /v1/events/{id}` and the slug route still
resolve it (so a permalink you've already published or indexed keeps
working), it's just not returned by a plain `from`/`to` list call once its
last occurrence has passed.

## What the API doesn't carry: per-occurrence exceptions

There's no structured list of skipped or moved dates (a `2026-09-27`
occurrence cancelled, or moved to `2026-09-28`) — the API carries the
series' current `rrule` and `next_starts_at`/`next_starts_local`, which
always reflect the source's current schedule, not a history of individual
changes to it. If a specific occurrence date changed, that shows up as a
new `occurrence.next_starts_at` value the next time you read the row — not
as an exception record attached to the old date.

If you need to know *when* a listing's dates or price changed, rather than
just what's current, `GET /v1/listings/{id}/versions` gives you that
history — each entry has a `changed_at`, a `kind` (`date_changed` is the one
you want), and the names of the fields that changed. See the
[API reference](https://api.tailend.dev/docs/api.md) for the full shape, and
[Pagination](https://api.tailend.dev/docs/pagination.md) for walking it.

## What to do

- Filter with `recurring=true`/`recurring=false` when you only want one kind.
- Treat `occurrence.next_starts_at: null` as "nothing upcoming," not as an
  error — keep the listing's `id`/`slug` around if you've already published
  a link to it.
- Don't try to reconstruct a full occurrence calendar from `rrule` and call
  it authoritative — `occurrence.next_starts_at` is the one the API itself
  computed and stands behind; a client-side RRULE expansion can drift from
  it (holidays, one-off cancellations, schedule changes).
