Events lifecycle¶
Events are scheduled, live learning — workshops, all-hands, office hours. Unlike on-demand content, people don't get assigned to an event; they enroll. This guide walks the full arc: define a reusable series, schedule a concrete event, enroll attendees, and check them in on the day.
How the pieces fit¶
graph LR
ES[Event series<br/>template] -->|defaults flow into| EV[Event<br/>scheduled occurrence]
EV -->|has one or more| TS[Timeslots]
U[User] -->|enrolls in| EV
EN[Enrollment] -->|check in on the day| CI[checkin_datetime]
- An event series is an optional template carrying default settings — location, capacity, in-person/online modes. Create it once, reuse it for every occurrence of a recurring event.
- An event is a concrete scheduled occurrence with one or more timeslots (the actual sessions). An event can belong to a series (inheriting its defaults) or stand alone.
- An enrollment links a user to an event, with an attendance method and check-in state.
1. (Optional) Create an event series¶
If this is a one-off, skip to step 2. For anything recurring, define the template first so every event shares the same defaults:
curl -X POST https://acme.plusplus.app/api/v2/event-series/ \
-H "Authorization: Bearer your_token_here" \
-H "Content-Type: application/json" \
-d '{
"name": "Weekly Engineering Sync",
"duration": 60,
"location_id": 42,
"is_local": true,
"is_online": true,
"enrollment_limit": 50,
"online_enrollment_limit": 200
}'
{
"public_id": "6ba7b810-…",
"name": "Weekly Engineering Sync",
"is_local": true,
"is_online": true,
"enrollment_limit": 50,
"online_enrollment_limit": 200,
"location": { "id": 42, "name": "San Francisco HQ", "timezone": "America/Los_Angeles" }
}
location_id is the numeric id of a location (the one place in the API you reference something by a numeric id rather than a public_id). Capacity is per occurrence: enrollment_limit / wait_list_limit govern in-person seats, and the online_* variants govern online seats. 0 means unlimited.
2. Schedule an event¶
An event needs at least one timeslot. Link it to the series with event_series_id (or omit it for a standalone event):
curl -X POST https://acme.plusplus.app/api/v2/events/ \
-H "Authorization: Bearer your_token_here" \
-H "Content-Type: application/json" \
-d '{
"name": "Weekly Engineering Sync — Mar 18",
"event_series_id": "6ba7b810-…",
"timeslots": [
{
"starts_at": "2026-03-18T17:00:00Z",
"duration": "PT1H",
"rooms_info": "Room 3A, Building 2",
"watch_link": "https://meet.acme.com/eng-sync"
}
]
}'
{
"public_id": "c8a7f1e2-…",
"name": "Weekly Engineering Sync — Mar 18",
"starts_at": "2026-03-18T17:00:00Z",
"ends_at": "2026-03-18T18:00:00Z",
"event_series": { "public_id": "6ba7b810-…", "name": "Weekly Engineering Sync" },
"timeslots": [
{
"id": 42,
"starts_at": "2026-03-18T17:00:00Z",
"ends_at": "2026-03-18T18:00:00Z",
"duration": "PT1H",
"rooms_info": "Room 3A, Building 2",
"watch_link": "https://meet.acme.com/eng-sync"
}
]
}
A few timeslot details:
durationis an ISO-8601 duration string:PT1H,PT1H30M,PT90M.starts_at— send the local wall-clock time the session begins, labeled as UTC (Z). The event's location supplies the real timezone. See the events module conventions in the API reference for the full rationale.- The event's top-level
starts_at/ends_atare computed from its timeslots — you don't set them directly. - To create the event with its own self check-in or enrollment window, add a
settingsobject. See Setting the windows when you create the event. - When the workspace syncs events to Google Calendar through user calendars, the calendar event for a timeslot you create is placed on the calendar of the user who owns the API token, the same as creating it in the web app. Pick a token owner who should host the invitation.
What the event inherits from its series¶
Everything you leave out of the create request is taken from the series, so the request above needs only a name and a timeslot to produce an event that matches one scheduled from the series in the web app:
Fields you can override by sending them:
| Inherited | Notes |
|---|---|
body_markdown |
|
tags |
Send "tags": [] to create the event without any. |
location_id |
Send "location_id": null to create the event without a location. |
enrollment_limit, wait_list_limit, online_enrollment_limit, online_wait_list_limit |
|
is_local, is_online, is_hidden |
|
external_link |
|
The series' duration |
Applied to any timeslot that doesn't set its own duration. |
Sending any of these overrides the series — including sending null, which is read as a decision rather than an omission.
Inherited with no way to override, because this endpoint has no field for them:
| Inherited | Notes |
|---|---|
cover image |
The series is the only way to give an event a cover image. |
| Facilitators | Organizer, co-organizers and presenters are copied onto the event. Adjust afterwards via the facilitators sub-resource. |
| Channel | The event lands in the series' channel, which also governs who can see it. |
| Survey relationships | Copied as new rows; their schedule is recomputed from the event's own timeslots. |
external_link_description |
Inheritance happens once, at creation: later edits to the series don't reach events already scheduled from it.
:::note
Inheritance only applies to POST /events/. PATCH /events/{id}/ changes exactly the fields you send, so moving an existing event into a series does not backfill anything.
:::
To list the events generated from a series, filter by it:
curl "https://acme.plusplus.app/api/v2/events/?event_series_id=6ba7b810-…&ordering=created" \
-H "Authorization: Bearer your_token_here"
ordering supports name, created, and modified (prefix - for descending). To present events chronologically, sort by each event's computed starts_at client-side.
3. Enroll attendees¶
Enroll a user by referencing the event and an attendance method:
curl -X POST https://acme.plusplus.app/api/v2/enrollments/ \
-H "Authorization: Bearer your_token_here" \
-H "Content-Type: application/json" \
-d '{
"user_id": "8f3a…",
"event_id": "c8a7f1e2-…",
"attendance_method": "in_person",
"accept_waitlist": true
}'
{
"public_id": "d4e5f6a7-…",
"user": { "public_id": "8f3a…", "name": "Jane Doe", "email": "jane.doe@acme.com" },
"event": { "public_id": "c8a7f1e2-…", "name": "Weekly Engineering Sync — Mar 18" },
"status": "enrolled",
"attendance_method": "in_person",
"checkin_datetime": null
}
Behavior worth knowing:
attendance_methodisin_personoronline; capacity is tracked separately for each.accept_waitlist— when the chosen method is full, set thistrueto take a waitlist spot (status: "waitlisted") instead of getting a409.- Create-or-get:
201for a new enrollment,200if an identical active enrollment already exists.
A 409 carries a code naming what blocked it. Branch on the code — the remediation differs:
error.code |
What happened | What to do |
|---|---|---|
enrollment_not_open |
The enrollment window hasn't opened, and your token doesn't manage the event's roster | Retry after enrollment_opens_at, or enroll with an admin or organizer token |
enrollment_closed |
The enrollment window has shut, and your token doesn't manage the event's roster | Don't retry; an admin or organizer can still enroll people |
enrollment_not_enrollable |
The attendance method has no spots left | Retry with accept_waitlist: true |
event_not_enrollable |
The event itself can't take enrollments | Check the event still exists and isn't archived |
enrollment_method_conflict |
An active enrollment exists with a different method | Drop the existing enrollment first, or accept the one you have |
The first two are about time, the third about space — accept_waitlist only helps with the third, and never gets you past a shut window.
To enroll a whole group at once, use the bulk endpoint — see Bulk operations & async jobs.
The enrollment window¶
Enrollment opens and closes on a schedule of the event's own. The event reports both bounds as resolved timestamps, in the event's location timezone like starts_at:
Out of the box enrollment opens 180 days before the event and closes exactly at its start. Both are offsets from starts_at, so they move when the event is rescheduled, and either can be null — no opening date, or enrollment that never closes. They are also null when the event has no timeslots, because the offset has nothing to anchor to.
Who the window binds. The enrollment window applies to self-service enrollment. A token belonging to someone who manages the event's roster — an admin, organizer or facilitator — can enroll people before it opens or after it closes, and POST /enrollments/ never answers enrollment_not_open or enrollment_closed for them. Any other caller is refused with those codes outside the window. To move the bounds, change the enrollment_opens_offset and enrollment_closes_offset settings — see Event settings below.
4. Check in on the day¶
When the attendee shows up, record it:
curl -X POST https://acme.plusplus.app/api/v2/enrollments/d4e5f6a7-…/checkin/ \
-H "Authorization: Bearer your_token_here"
Check-in is idempotent — checking in someone already checked in returns 200, not an error — so it's safe to retry or wire to a "scan badge" button. It returns 409 only if the enrollment was dropped.
The self check-in window¶
Every event carries a window during which attendees may check themselves in. The event reports it as resolved values, alongside the enrollment window:
{
"public_id": "c8a7f1e2-…",
"starts_at": "2026-03-18T17:00:00Z",
"self_checkin_enabled": true,
"self_checkin_opens_at": "2026-03-18T16:00:00Z",
"self_checkin_closes_at": "2026-03-18T18:00:00Z"
}
The ..._at fields are absolute timestamps in the event's location timezone, the same as starts_at and ends_at. Read those to decide whether check-in is open right now. They are null when that bound is unset, and also null when the event has no timeslots, because the offset has nothing to anchor to. By default the window opens an hour before the event starts and closes an hour after.
Two consequences worth designing around:
- Who the window binds. An admin, organizer, facilitator, or the attendee's manager can check someone in at any time — before the window opens, long after it closes, even with
self_checkin_enabled: false. The window only constrains the attendees themselves. So a badge-scanning kiosk running on an organizer's token ignores it, while an attendee-facing app using each attendee's own token gets a409naming the reason:self_checkin_disabled,checkin_not_openorcheckin_closed, with the relevant time in the message. - No-shows. Once
self_checkin_closes_atpasses, an enrollment that was never checked in counts as a no-show in PlusPlus reporting, instead of staying enrolled. Anullclose bound means check-in never closes, so those enrollments are never reported as no-shows. If you sync attendance to another system, treatself_checkin_closes_atas the moment the roster becomes final.
To change the window, change the settings behind it.
5. Event settings¶
The windows above are resolved from settings, and settings cascade: the event's own value wins, then its event series', then its channel's, then the workspace default. The event resource only shows you the answer. The settings sub-resource shows you why, and is where you change it. Events carry more settings than the windows (calendar invites, AI summaries, recording downloads and more), all in the shape Cascaded settings describes; this section covers the windows:
curl https://acme.plusplus.app/api/v2/events/c8a7f1e2-…/settings/ \
-H "Authorization: Bearer pp_your_token_here"
{
"self_checkin_enabled": { "value": true, "inherited": true, "source": "workspace", "parent_value": true, "default": true },
"self_checkin_opens_offset": { "value": "-PT1H", "inherited": true, "source": "workspace", "parent_value": "-PT1H", "default": "-PT1H" },
"self_checkin_closes_offset": { "value": "PT2H", "inherited": false, "source": "event", "parent_value": "PT1H", "default": "PT1H" },
"enrollment_opens_offset": { "value": "-P180D", "inherited": true, "source": "workspace", "parent_value": "-P180D", "default": "-P180D" },
"enrollment_closes_offset": { "value": "-P1D", "inherited": true, "source": "event_series", "parent_value": "-P1D", "default": "PT0S" }
}
Every setting has the same shape:
| Key | What it tells you |
|---|---|
value |
The resolved value. For the offsets, a signed ISO 8601 duration from the event's start, or null for no bound. |
inherited |
false when the event holds its own value. |
source |
Which level supplied it: event, event_series, channel or workspace. |
parent_value |
What value would become if the event dropped its own value: the value the next level up supplies. |
default |
The platform default, before any level overrides it. |
inherited is the field to pay attention to when automating. An event with "inherited": false has its own value, so changing the event series will not affect it — the override wins until you clear it. In the response above, editing the series would move when check-in opens but not when it closes, and clearing the event's own close offset would bring it back to parent_value, an hour after the start.
Changing a setting¶
Writes go to the same URL, in the shape you read less source, parent_value and default. Send {"value": ...} for the value you want the event to have:
curl -X PATCH https://acme.plusplus.app/api/v2/events/c8a7f1e2-…/settings/ \
-H "Authorization: Bearer pp_your_token_here" \
-H "Content-Type: application/json" \
-d '{
"self_checkin_enabled": { "value": true },
"self_checkin_opens_offset": { "value": "-PT30M" },
"self_checkin_closes_offset": { "value": "PT2H" },
"enrollment_closes_offset": { "value": "-PT24H" }
}'
That opens self check-in 30 minutes before the event starts, closes it two hours after, and closes enrollment a day before. The response is the full settings resource, so you can confirm each one now reads "inherited": false, "source": "event". Only the settings you send are touched. The sign carries the direction, so either bound can sit on either side of the start:
| Send | Means |
|---|---|
{"value": "-PT1H"} |
One hour before the event starts |
{"value": "PT30M"} |
30 minutes after the event starts |
{"value": "-P180D"} |
180 days before the event starts |
{"value": "PT0S"} |
Exactly at the event's start |
{"value": null} |
No bound on that side |
{"inherited": true} |
Drop this event's own value and follow the series, channel and workspace again |
| omitted | Leave the setting exactly as it is |
self_checkin_enabled takes {"value": true}, {"value": false} or {"inherited": true} the same way, but its value cannot be null. You may also send back "inherited": false alongside a value, exactly as a read reports it; source, parent_value and default are read-only and are refused.
Offsets read back normalized: whole days as days, the rest as hours, minutes and seconds. The "-PT24H" sent above reads back as "-P1D", so compare parsed durations rather than strings.
Offsets rather than timestamps is deliberate: the window is anchored to the event's start, so rescheduling the event moves the window with it. There is no way to pin check-in to a fixed wall-clock time independent of the event.
Setting the windows when you create the event¶
As a convenience, POST /events/ accepts the same object under settings, so a new event starts life with its windows in place instead of taking the inherited defaults and being corrected by a second request:
curl -X POST https://acme.plusplus.app/api/v2/events/ \
-H "Authorization: Bearer pp_your_token_here" \
-H "Content-Type: application/json" \
-d '{
"name": "Weekly Engineering Sync — Mar 18",
"event_series_id": "6ba7b810-…",
"timeslots": [{ "starts_at": "2026-03-18T17:00:00Z", "duration": "PT1H" }],
"settings": {
"self_checkin_opens_offset": { "value": "-PT30M" },
"enrollment_closes_offset": { "value": "-P1D" }
}
}'
The event is saved with those settings in the same write, so the event.created webhook already carries the final windows. Settings you leave out follow the series, channel and workspace as usual.
This is for creation only. To change an existing event's settings, use PATCH /events/{public_id}/settings/; PATCH /events/{public_id}/ refuses a settings key with a 422 that points you there.
Adjusting a bound¶
Because reads and writes speak the same units, changing a bound is duration arithmetic — no need to work back from the event's start time:
// GET /events/c8a7f1e2-…/settings/
// → "self_checkin_opens_offset": { "value": "-PT1H", "inherited": true, "source": "workspace" }
// PATCH /events/c8a7f1e2-…/settings/
// { "self_checkin_opens_offset": { "value": "-PT1H30M" } }
Note what that "inherited": true was telling you: the event had no value of its own, so this PATCH creates an override and stops the event following its series. Send {"inherited": true} to undo that:
curl -X PATCH https://acme.plusplus.app/api/v2/events/c8a7f1e2-…/settings/ \
-H "Authorization: Bearer pp_your_token_here" \
-H "Content-Type: application/json" \
-d '{"self_checkin_opens_offset": {"inherited": true}}'
Clearing is not the same as sending {"value": null}. A null value stores a real decision — this bound is not enforced — and is still an override. {"inherited": true} removes the event's value entirely, so the event tracks its series again, including later edits to it.
The settings resource carries its own ETag. Send it back as If-Match on the PATCH to be refused with 412 if someone changed the settings — or the series they inherit from — in between.
6. Handle drops and cancellations¶
curl -X POST https://acme.plusplus.app/api/v2/enrollments/d4e5f6a7-…/drop/ \
-H "Authorization: Bearer your_token_here"
drop sets status: "dropped". It returns 409 if the attendee has already been checked in — you can't un-attend someone. When a waitlisted spot opens up because of a drop, promote a waitlisted enrollment as your business rules require.
7. After the event: recordings¶
Captured the session? Attach the recording to the series so it plays back for everyone, via the Mux upload flow. That's its own walkthrough — see Uploading recordings.
Common pitfalls¶
- Trying to assign an event. Events use enrollments, not assignments. There's no assignment endpoint for events.
- Setting
starts_aton the event. It's derived from timeslots — schedule the timeslot and the event's start/end follow. - Forgetting a timeslot. An event create requires at least one timeslot; an event with no session can't be scheduled.
- Mismatched online/in-person limits. In-person and online seats are independent. A full room doesn't block online enrollment, and vice versa.
- Deleting an event referenced by a track.
DELETEreturns409if a track references the event. Remove it from the track first.