# Events

Events are what people plan together in a circle: a meeting, a work day, a party. These endpoints move the events of
the old platform in, with their original dates and organisers, and let you correct or remove what you imported. Read
[Start here](start.md) first: its rules apply to everything on this page. Events work like [Posts](posts.md), and
this page says where they differ.

## What the API has, and what it does not

- Events with a start. An event whose date is still being voted on (a "find a time" poll without a chosen date) is not
  in the API: it is not listed, and `GET`, `PATCH` and `DELETE` answer `404 not_found` for it.
- Cancelled events are listed and can be read, with `"cancelled": true`.
- **Not in the API:** who said they are going, and any other RSVP. An import brings the event, never the answers. Nobody
  is marked as going, so the event starts with no one to remind.
- Comments on events are not in this version either.

## The event object

```json
{
  "id": 9101,
  "external_id": "fb-event-7001",
  "circle_ids": [412],
  "author": {"id": "EveABqLyto9L", "name": "Karen Holm"},
  "title": "Seed swap in the garden",
  "starts_at": "2026-11-04T19:00:00+01:00",
  "ends_at": "2026-11-04T21:00:00+01:00",
  "all_day": false,
  "time_zone": "Europe/Copenhagen",
  "location": "The garden hut, Elm Street 4",
  "meeting_url": null,
  "description": "Bring seeds, cuttings and a thermos.",
  "rsvp_enabled": true,
  "max_participants": null,
  "cancelled": false,
  "images": [],
  "created_at": "2026-10-20T10:00:00.000Z",
  "edited_at": null
}
```

| Field | Type | Meaning |
|---|---|---|
| `id` | number | The event's id. Use it in `/events/:id`. |
| `external_id` | text or `null` | Your id from the old platform, when the event was created through the API with one. `null` for every other event. |
| `circle_ids` | list of numbers | The circles it is in. Only this community's circles are listed. |
| `author` | object | The organiser, in the same two shapes as a post's author (below). |
| `title` | text | The name of the event. |
| `starts_at` | time | When it starts, in the event's own time zone: `2026-11-04T19:00:00+01:00`. |
| `ends_at` | time or `null` | When it ends. `null` for a timed event with no end. An all-day event always has one: the end of its **last day**, `23:59:59` on that day (the end of its first day when no end was given). |
| `all_day` | true or false | Whether the event takes whole days. |
| `time_zone` | text | The event's time zone, such as `Europe/Copenhagen`. |
| `location` | text or `null` | Where it is held. |
| `meeting_url` | text or `null` | An online meeting address. |
| `description` | text | The description, as plain text. Empty when it has none. |
| `rsvp_enabled` | true or false | Whether people can say whether they are going. |
| `max_participants` | number or `null` | The limit on how many can come, or `null` for no limit. |
| `cancelled` | true or false | Whether the event has been cancelled. A cancelled event is still listed. |
| `images` | list | Its pictures, as `{"id", "filename"}`. There is no picture address: the pictures belong to the circle's members, and the API does not hand them out. |
| `created_at` | time | When it was made: the original date for an imported event. |
| `edited_at` | time or `null` | When a person last edited it in the app. Changes through the API never set it. |

**`author`** is the organiser, and is one of two shapes:

- A member: `{"id": "EveABqLyto9L", "name": "Karen Holm"}`. The `id` is the member's id from [Members](members.md).
- A name from the old platform, for someone who has no Zen Social account (yet):
  `{"id": null, "name": "Ole Berg", "external_id": "fb-user-1002"}`. See [Authors and claims](authors.md).

There are no email addresses, no picture addresses, no list of who is going, and nothing about a "find a time" poll.

## List them: `GET /events`

```sh
curl "$API/events" -H "Authorization: Bearer $KEY"
```

Answers `200 OK` with `{"data": [<event>, …], "next_page": null}`, 100 per page (`?page=2` for the next). The newest
**start** comes first, not the newest `created_at`: an imported event carries its original creation date, and the start
is how an event is found.

The list shows exactly what the admin would see in the app, and nothing more:

- only events in circles the admin is a member of, including the community's own feed;
- in a curated circle, a plain member sees only their own events and those published in an edition, and so does the
  admin unless they are that circle's owner, admin or editor;
- events by people the admin has blocked, or who have blocked the admin, are left out;
- deleted events and "find a time" polls without a chosen date are left out;
- **cancelled events are in the list**, with `"cancelled": true`.

One circle only: add `?circle_id=`. The community's own feed is a circle here too: its id is the `id` from
`GET /organization`.

```sh
curl "$API/events?circle_id=412" -H "Authorization: Bearer $KEY"
```

`404 not_found` when that circle is not one of this community's; `403 forbidden` when the admin is not a member of it.

## Read one: `GET /events/:id`

```sh
curl "$API/events/9101" -H "Authorization: Bearer $KEY"
```

Answers `200 OK` with the event, also when it is cancelled. `404 not_found` when it is not in one of this community's
circles, has been deleted, or has no date yet; `403 forbidden` when the admin could not see it in the app.

## Create one: `POST /events`

The first example is a timed event under a member. The second is an all-day event under a name from the old platform:

```sh
curl "$API/events" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"external_id": "fb-event-7001", "circle_ids": [412], "author_id": "EveABqLyto9L", "created_at": "2026-10-20T10:00:00Z", "title": "Seed swap in the garden", "starts_at": "2026-11-04T19:00:00+01:00", "ends_at": "2026-11-04T21:00:00+01:00", "time_zone": "Europe/Copenhagen", "location": "The garden hut, Elm Street 4", "description": "Bring seeds, cuttings and a thermos."}'
```

```sh
curl "$API/events" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"external_id": "fb-event-7002", "circle_ids": [412], "author": {"external_id": "fb-user-1002", "name": "Ole Berg"}, "created_at": "2023-06-01T08:00:00Z", "title": "Midsummer weekend", "all_day": true, "starts_at": "2023-06-24", "ends_at": "2023-06-25", "time_zone": "Europe/Copenhagen", "description": "We tidy the grounds on Saturday and eat together on Sunday."}'
```

| Field | Rules |
|---|---|
| `circle_ids` | Required: a list of one or more circle ids of this community. The event appears in each of them. The admin must be able to post in every one (not just read it): `403 forbidden` otherwise, and `404 not_found` for a circle outside the community. |
| `title` | Required: text, not empty. |
| `starts_at` | Required: when it starts (below). |
| `time_zone` | Required: an IANA time zone name, such as `Europe/Copenhagen` or `America/New_York`. `Copenhagen` alone is refused. |
| `ends_at` | When it ends (below). Optional. |
| `all_day` | `true` or `false`. Default `false`. |
| `location` | Text, optional. |
| `meeting_url` | An absolute `http` or `https` address without spaces, at most 2048 characters. Optional. |
| `description` | Plain text, as for a post's `content`. Optional. |
| `rsvp_enabled` | `true` or `false`. Default `true`. |
| `max_participants` | A whole number from 1 to 1,000,000, or `null` for no limit. |
| `external_id` | Your id for this event on the old platform, as text or a number, at most 200 characters. Always send it: re-runs depend on it, and so does handing the event back to its organiser later (see [Authors and claims](authors.md)). |
| `author_id` | A member's `id` from [Members](members.md). The organiser, as if they had made the event in the app. They must be a member of every circle in `circle_ids`. |
| `author` | `{"external_id": "…", "name": "…"}` for an organiser who has not joined. The same rules as for a post: see [Who wrote it](posts.md#who-wrote-it-author_id-or-author). |
| `created_at` | The original date and time the event was made, ISO 8601, in the past and no earlier than 1 January 1990. As for a post, a time without an offset is read as UTC, and a date or offset that does not exist (30 February, beyond `±14:00`) is refused. Default: now. |
| `image_urls` | A list of picture addresses for Zen Social to fetch. The same rules as for a post: see [Pictures](posts.md#pictures-images-and-image_urls). |
| `images` | Picture files, in a multipart form. The same rules as for a post. |
| `notify` | `true` to tell people about the event the way the app would. Default `false`. See "Telling people". |

Fields the API does not know are ignored without an error. With neither `author_id` nor `author`, the admin who made
the key is the organiser.

Answers:

- `201 Created` with the new event.
- `200 OK`, `409 record_deleted`, `409 changed_since_import`, `409 conflict` or `403 forbidden` when the `external_id`
  was imported before: see "Re-runs" below.
- `403 forbidden`: the admin cannot post in one of the circles.
- `404 not_found`: one of the circles is not this community's.
- `422 invalid`: a value is not allowed; `details` names each field and why, for example
  `{"time_zone": ["must be an IANA time zone, such as Europe/Copenhagen"]}`. Nothing was made.
- `422 notify_needs_member_author` and `422 notify_needs_future_event`: see "Telling people". Nothing was made.

### The times: `starts_at`, `ends_at` and `time_zone`

`time_zone` says where the event happens, and the event is shown in it. Always send it.

`starts_at` and `ends_at` are ISO 8601 with an offset from UTC, or `Z` for UTC: `2026-11-04T19:00:00+01:00`,
`2026-11-04T18:00:00Z`. The seconds are optional (`2026-11-04T19:00+01:00` works). The offset only says which moment is
meant; the answer shows it in the event's own `time_zone`.

- A time **without** an offset is refused, as is any date that does not exist (30 February) and any offset beyond
  `±14:00`. The answer is `422` with the reason under `details.starts_at` or `details.ends_at`. Do not guess an offset:
  ask the admin which time zone the old platform used.
- `ends_at` must not be before `starts_at`. It is optional.
- **All-day events:** send `"all_day": true`. Then `starts_at` and `ends_at` may be a plain date, `2023-06-24`, read in
  the event's `time_zone`. A plain date for an event that is not all-day is refused.
- **For an all-day event, `ends_at` is the last day it takes place, inclusive.** A one-day event on 24 June has
  `ends_at` of `2023-06-24` (or no `ends_at`); a weekend on 24 and 25 June, like the second example above, has
  `2023-06-25`. Calendar exports (iCalendar `DTEND`) usually give the day **after** the last day for an all-day event,
  so that weekend arrives as `2023-06-26`: subtract one day before you send it.

### The date of an old event

An imported event may start as early as **1 January 1990** and as late as **50 years from now**, and may be in the past or the future. (Events made in the
app keep their own limit, 1 January 2020. An old event you imported stays editable in the app all the same.) A
`starts_at` before 1990 or more than 50 years ahead is `422` with `details.starts_at`.

### The text: `description`

Plain text, with the same rules as a post's `content` (see [The text](posts.md#the-text-content)). It may be empty or
left out. A circle can have a word limit; imports do not have to keep to it, since they were written elsewhere. An event
sent with `"notify": true` does, as in the app (`422`, with the reason under `details.description`).

### Past and future events, and reminders

Nothing an import does tells anyone about the event (see [Silent by default](start.md#silent-by-default)).

- **A past event is silent.** It sends no reminder, to anyone, ever.
- **A future event keeps its normal reminder:** 24 hours before a timed event, and at 18:00 the evening before an
  all-day event (in the event's own time zone). It goes to the people who answered going or maybe, not the organiser,
  each according to their own notification settings. An import has no such people, since RSVPs are not part of the
  API, so in practice nobody is reminded until members answer in the app.
- **An event imported less than a day before it starts gets no reminder**, because that moment has already passed.

Moving the start of a future event through a `PATCH` moves the reminder with it.

### Telling people: `notify`

`"notify": true` announces the event exactly as the app does when a member makes one: notifications, push messages
and lines in daily summaries, for the circle's members, each according to their own notification settings. Send it only
when the admin has asked for people to be told, and never for imported history.

It needs two things, or the answer is a `422` and nothing is made:

- **A member as the organiser** (`author_id`, or neither field). A name from the old platform cannot invite anyone:
  `422 {"error":"notify_needs_member_author"}`.
- **An event that has not happened yet.** A timed event has happened once it has started; an all-day event, when its
  day is over in its own time zone. `422 {"error":"notify_needs_future_event"}` otherwise.

An event with `notify` also keeps to the circle's word limit (`422 invalid` otherwise).

## Re-runs

Send a create again with the same `external_id`, and the event is not made twice. The answers are those of
[Re-runs and `external_id`](start.md#re-runs-and-external_id), the same as for posts:

| Answer | Meaning |
|---|---|
| `200 OK` | The event was updated and keeps its id. |
| `409 record_deleted` | The event was deleted (in the app or through the API), or turned into a post in the app. It is never brought back. Skip it and report it. |
| `409 changed_since_import` | Someone edited or **cancelled** the event in the app since your last write. It was not overwritten. Skip it and report it. Someone saying they are going does not count as an edit. |
| `409 conflict` | The same create arrived twice at once. Send it again, once. |
| `403 forbidden` | The admin can no longer post in one of the event's circles. Skip it and report it. |
| `422 invalid` | A value sent is not allowed (see "Create one"); `details` says why. Nothing was changed. |

There is no `409 in_edition` for events: a newsletter edition does not lock an event, so a re-run and a `PATCH` still
work.

A re-run changes only what a `PATCH` changes, and only the fields you send: `title`, `starts_at`, `ends_at`, `all_day`,
`time_zone`, `location`, `meeting_url`, `description`, `rsvp_enabled` and `max_participants`. The circles, the
organiser, `created_at` and the pictures stay as first imported, and a re-run does not even check them. `notify` is
ignored too: a re-run never tells anyone. To change any of those, delete the event (with the admin's yes) and create it
under a new `external_id`.

## Change one: `PATCH /events/:id`

Send the fields to change, with the same rules as for a create. Fields you leave out stay as they are; other fields
(circles, organiser, `created_at`, pictures, `notify`) are ignored. A plain date sent for an all-day event is read in
the `time_zone` sent, or else the event's own. A new `time_zone` sent without `starts_at` or `ends_at` keeps an all-day
event on the same days (24 and 25 June stay 24 and 25 June) and a timed event at the same moment.

```sh
curl -X PATCH "$API/events/9101" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"starts_at": "2026-11-04T18:30:00+01:00", "location": "The garden hut (the red door)"}'
```

**Before a `PATCH` of an event whose organiser is now a member** (its `author` has an `id`), **or whose `edited_at` is
not `null`**, show the admin the change (the event as it is and as it will be) and wait for their yes. Those may be a
person's own plans, made or corrected by them.

Answers `200 OK` with the event, `422 invalid` with the reasons, `403 forbidden` when the admin may not change this
event (see "Who may change which event"), or `404 not_found`.

A change through the API is not an edit by a person: the event shows no "edited" mark, and nobody is told, not even
when the time moves. A cancelled event can be changed, and stays cancelled; the API does not cancel or restore events.
An event someone edited or cancelled in the app keeps that mark after your `PATCH`, so a later re-run still answers
`409 changed_since_import`.

An event created through the API with an `external_id` need not keep to its circles' word limit when you change its
description, and its start may be moved back to 1990; any other event must keep to the app's rules (2020 and the word
limit).

## Delete one: `DELETE /events/:id`

> **Warning: this deletes the event for everyone.** Its comments and RSVPs go with it and its pictures are removed.
> There is no undo, and a re-run never brings it back.

**Never send a DELETE without the admin's clear yes.** Tell them which event it is (its title, date and organiser) and
wait for their answer. If the event was cancelled, or has to be cancelled, that is done in the app: the API only
deletes.

```sh
curl -X DELETE "$API/events/9101" -H "Authorization: Bearer $KEY"
```

Answers `204 No Content`. `403 forbidden` when the admin may not delete it, `404 not_found` when it is not this
community's or is already deleted. A later create with its `external_id` answers `409 record_deleted`. Nobody is
told, and a deleted event sends no reminder.

## Who may change which event

**Events you imported:** an event created through this API **with an `external_id`** may be re-run, changed, deleted
and handed to its organiser through the API, whoever its organiser is now, as long as the admin can still post in
every one of its circles. That holds for any admin's key in this community, and after a claim has made a member its
organiser. This is the steward right: whoever moves the content in can still correct it.

**Every other event** follows the app's rules, as if the admin did it in the app: only its organiser can change it
(for an event with no organiser, an owner or admin of one of its circles), and its organiser or an owner or admin of
one of its circles can delete it. An event created without an
`external_id` under **someone else's member id** cannot be changed through the API afterwards, because the API cannot
tell it apart from that member's own. An event created without an `external_id` under a **name** has no organiser, so an
owner or admin of one of its circles may change it; it can never be claimed, though. Another reason to always send an
`external_id`.

The steward right exists only in this API. In the app, an imported event follows the usual rules.

## Nobody is told

Creating, changing and deleting events tells no member anything, unless a create sends `"notify": true`. Imported
events are also not announced later: a past event never sends a reminder, and a future one sends the usual reminder
(24 hours before a timed event, 18:00 the evening before an all-day one) only to people who answered going or maybe.
