Events
Events are dated entries on a space's calendar — a session, a call, a party, or a date a space has promised to publish something. Only events a space published to everyone appear in this API; member-only events, drafts, and cancelled events are absent. Two endpoints cover them:
GET /v1/spaces/{space}/events— one space's public events in a time window.GET /v1/events/upcoming— public events across every publicly discoverable space.
Both return the same item shape, ordered earliest first and cursor-paginated.
The per-space endpoint takes any space identifier
and returns 404 Not Found when the space has no public profile. The
cross-space feed has no not-found: a space that is not publicly discoverable
simply never contributes to it, exactly as it never appears in
GET /v1/spaces.
Occurrences, not events
The unit on this wire is an occurrence. A recurring event is stored once and expanded on read, so a weekly series contributes one item per week inside the window you asked for. Three fields carry that identity:
id— the event. A recurring event returns the sameidon every item.seriesId— the series this occurrence belongs to. The same value asidtoday; the pair is what stays correct if occurrences ever gain identities of their own.recurrenceId— this occurrence's stable identity within its series: its local wall-clock time (2026-08-15T19:00:00) or, for an all-day event, its date (2026-08-15).
Address a specific occurrence by the (seriesId, recurrenceId) pair. Never
identify one by its resolved UTC instant — daylight-saving shifts move the
instant while the recurrence identity holds steady. These are the same
occurrence identities the space's own calendar uses.
Time windows
Both endpoints take an optional from and to (ISO 8601):
curl 'https://api.dfos.com/v1/spaces/field-notes/events?from=2026-09-01T00:00:00Z&to=2026-10-01T00:00:00Z'
fromdefaults to now.todefaults to 90 days afterfrom.- The widest window served is 366 days.
The window selects overlapping occurrences, not ones that start inside it — a multi-day festival already under way is still returned.
Windows are clamped, never rejected. A window longer than 366 days, or one that ends before it starts, silently becomes the widest window this API serves rather than returning an error. Read the times on the items you get back rather than assuming your requested window was honored verbatim.
Paging a window
Pagination follows the shared cursor contract —
pass nextCursor back as after, keep limit constant, treat cursors as
opaque. Hold from and to constant for the whole walk too: every page
re-expands the same window.
Two things differ from the row-backed listings. A cursor walk reaches at most
the first 500 occurrences of a window — narrow the window to see past them,
rather than paging further. And these listings do not compute totalCount, so
it is absent from the envelope.
Event fields
Each item carries:
space— the space the event belongs to:id,did,domain,displayName, andavatarUrl. Present on both endpoints, so a cross-space feed can attribute and link every item without a second request.title,description(plain text ornull), andlocation— free text exactly as the organizer wrote it.type—attend(people show up) orupdate(the space will publish something on this date). An open enum; treat an unrecognized value as a generic event.mode— how anattendevent happens:call,place, orchat. Presentational only, andnullon anupdateevent or when unspecified. Also an open enum.url— a link the organizer attached (a call link, a ticket page), ornull. This is arbitrary organizer-supplied content: treat it as untrusted.interestCount— how many members marked interest. This API is read-only; there is no anonymous RSVP, so a caller can never contribute to this number.cover— the image to render for this occurrence, when it has one: the occurrence's own cover art if the organizer set one, otherwise the event's. Always a public image with a permanent, unsigned URL — nourlExpiresAt, and safe to hotlink while the event references it. This endpoint never emits a signed or expiring URL.
All-day versus timed
isAllDay selects which of two mutually exclusive time shapes an item uses.
Every field of the other shape is null.
Timed (isAllDay: false):
startsAt/endsAt— this occurrence's resolved instants (ISO 8601 UTC).endsAtisnullfor an open-ended event and equal tostartsAtfor a point-in-time one.localStart— the start as local wall-clock time (2026-08-15T19:00:00) intimeZone. This is the time the organizer actually authored, and it equalsrecurrenceId.timeZone— the IANA zone the event was authored in, e.g.America/Chicago.
All-day (isAllDay: true):
startDate—YYYY-MM-DD.endDate— an exclusive end date, ornullfor a single day.
Render an all-day event from its dates, not by converting an instant: "7pm local, whatever UTC that lands on" is the real intent of a calendar entry, and the instant alone is lossy across a DST boundary.
What's not here
The recurrence rule itself, whether an occurrence has been fulfilled by a post, and the fulfilling post are all absent — an item describes one occurrence, not the series' schedule. Attendee lists and RSVP are member-only and never reach this API.
What to store
Store the (seriesId, recurrenceId) pair as an occurrence's durable key, and
the space's id or did for attribution. Re-fetch cover URLs rather than
archiving them (see Compatibility → Media URLs),
and never store a resolved UTC instant as an occurrence's identity.