# app.zeens.event

> Published by [zeens.app](https://lexicon.garden/identity/did:plc:pwpuqwge6g7xrjmfbw73uycw)

✓ This is the authoritative definition for this NSID.

## Links

- [View on Lexicon Garden](https://lexicon.garden/lexicon/did:plc:pwpuqwge6g7xrjmfbw73uycw/app.zeens.event)
- [Documentation](https://lexicon.garden/lexicon/did:plc:pwpuqwge6g7xrjmfbw73uycw/app.zeens.event/docs)
- [Examples](https://lexicon.garden/lexicon/did:plc:pwpuqwge6g7xrjmfbw73uycw/app.zeens.event/examples)

## Definitions

### `app.zeens.event#itineraryItem`

**Type**: `object`

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `location` | `ref` → `#location` | No | Optional location for this item. Only meaningful for events with mode 'in_person' or 'hybrid'. Items with lat/lng are plotted on the event map. |
| `text` | `string` | Yes | What happens at this point in the event (e.g. 'Meet at the park entrance', 'Darkroom demo', 'Q&A with the curator'). |
| `time` | `string` | No | Free-text time label for this item (e.g. '9:00 AM', 'Lunch'). Stored as text, not a datetime, so it renders verbatim without timezone handling. |

### `app.zeens.event#link`

**Type**: `object`

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `label` | `string` | No |  |
| `uri` | `string` (uri) | Yes |  |

### `app.zeens.event#location`

**Type**: `object`

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `address` | `string` | No | Human-readable street address. |
| `lat` | `integer` | No | Latitude in microdegrees (degrees x 1e7). ATProto rejects non-integer numbers, so coordinates are scaled to integers. |
| `lng` | `integer` | No | Longitude in microdegrees (degrees x 1e7). |
| `name` | `string` | No | Venue / place name. |

### `app.zeens.event`

**Type**: `record`

An event: a photowalk, workshop, meetup, exhibition or talk. Written to the organiser's own PDS. Others RSVP via app.zeens.rsvp (ATProto handle) or via an email address tracked off-protocol in PocketBase. The app does NOT collect payment: paid events carry free-text paymentInstructions and any URL/link in that field is rejected at write time. Carries the app.zeens.v marker so firehose + Lua filters recognise it. NOTE: the record id stays 'app.zeens.event' and the app.zeens.* namespace is network plumbing — it is not renamed when the product is. Two orthogonal axes describe an event: `mode` (WHERE it happens — in person, online, or both) and `categories` (WHAT it is — one or more of photowalk/workshop/meetup/exhibition/talk/other). The `hostedBy` field is DEPRECATED (the organiser is the record owner); kept for back-compat with old records.

**Key**: `tid`

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `app.zeens.v` | `integer` | Yes | Zeens version marker (always 1). |
| `approvalRequired` | `boolean` | No | When true, an RSVP is pending until the organiser approves it; only approved RSVPs count toward the cap and the attendee list. Approval state is tracked off-protocol (the organiser cannot mutate another user's rsvp record). Default false. |
| `capacityMode` | `string` | No | Behaviour when maxParticipants is reached. 'hard' = block new going RSVPs. 'waitlist' = overflow goes on a waitlist, promoted in RSVP order. Only meaningful when maxParticipants is set. Default 'hard'. |
| `categories` | `array` | Yes | WHAT the event is. One or more (max 3) — an event can be both a photowalk and a meetup. Drives browse/filter, which OR-matches any element. Orthogonal to `mode`. The legacy value 'online_meetup' is tolerated on read and means mode 'online' + category 'meetup'. |
| `coverImage` | `blob` | No | Wide cover image shown on the event header and share previews. |
| `createdAt` | `string` (datetime) | Yes |  |
| `createdBy` | `string` | No | Display text for the event creator/organiser (the account that owns this record). |
| `description` | `string` | No | Long-form event description. |
| `endsAt` | `string` (datetime) | No | Optional event end instant. |
| `facets` | `array` | No | Rich-text facets (links, mentions) over the description, reusing the Bluesky richtext lexicon. |
| `hostedBy` | `string` | No | DEPRECATED. Display text for who is hosting. The organiser is the record owner, so this is no longer written; kept so old records still read. |
| `itinerary` | `array` | No | Ordered agenda for the event: a list of items, each with optional time, descriptive text, and an optional location. Works for any event type — a photowalk's route, a workshop's schedule, an exhibition's programme. Items that carry coordinates are drawn on the event map as numbered pins in order; an event whose items have no coordinates simply renders the list with no map. |
| `links` | `array` | No | Social / website links for the event. |
| `location` | `ref` → `#location` | No | Physical location. Expected for mode 'in_person' and 'hybrid'; absent for 'online'. |
| `maxParticipants` | `integer` | No | Optional cap on confirmed (going) participants. Absent = unlimited. |
| `mode` | `string` | Yes | WHERE the event happens, and one of exactly three values. 'in_person' needs a location; 'online' needs a virtualUrl and has no location; 'hybrid' needs both. Orthogonal to `categories` — an online workshop is mode 'online' + categories ['workshop']. Records written before this field carried meaning default to 'in_person' on read. The legacy value 'virtual' is tolerated on read and means 'online'. |
| `paid` | `boolean` | No | Whether the event costs money. The app never processes payment. |
| `paymentInstructions` | `string` | No | Free-text explanation of how the organiser collects payment out of band. MUST NOT contain a URL/link; rejected at write time client- and server-side. |
| `photoCategories` | `array` | No | Photography genre tags (Street, Landscape, Portrait, ...). Up to 3, optional. Drawn from the shared category list used by posts. Distinct from `categories`, which is the event type. |
| `rsvpPolicy` | `string` | Yes | Who may RSVP. 'atmosphere' = any ATProto account. 'photosky' = members only. 'anyone_email' = anyone, including non-ATProto people via an email address. Enforcement is soft (obscurity-level), like collection visibility. |
| `startsAt` | `string` (datetime) | Yes | Event start, as an absolute instant. Render in `timezone`. |
| `timezone` | `string` | Yes | IANA timezone name (e.g. 'America/New_York'). Stored separately from startsAt so calendar export (.ics DTSTART;TZID) and the displayed local time are correct. |
| `title` | `string` | Yes | Event title. |
| `virtualUrl` | `string` (uri) | No | Join link for events with mode 'online' or 'hybrid'. Required for those modes. Shown only to RSVP'd users in the client. |

## Raw Schema

```json
{
  "$type": "com.atproto.lexicon.schema",
  "defs": {
    "itineraryItem": {
      "properties": {
        "location": {
          "description": "Optional location for this item. Only meaningful for events with mode 'in_person' or 'hybrid'. Items with lat/lng are plotted on the event map.",
          "ref": "#location",
          "type": "ref"
        },
        "text": {
          "description": "What happens at this point in the event (e.g. 'Meet at the park entrance', 'Darkroom demo', 'Q&A with the curator').",
          "maxLength": 500,
          "type": "string"
        },
        "time": {
          "description": "Free-text time label for this item (e.g. '9:00 AM', 'Lunch'). Stored as text, not a datetime, so it renders verbatim without timezone handling.",
          "maxLength": 50,
          "type": "string"
        }
      },
      "required": [
        "text"
      ],
      "type": "object"
    },
    "link": {
      "properties": {
        "label": {
          "maxLength": 100,
          "type": "string"
        },
        "uri": {
          "format": "uri",
          "type": "string"
        }
      },
      "required": [
        "uri"
      ],
      "type": "object"
    },
    "location": {
      "properties": {
        "address": {
          "description": "Human-readable street address.",
          "maxLength": 500,
          "type": "string"
        },
        "lat": {
          "description": "Latitude in microdegrees (degrees x 1e7). ATProto rejects non-integer numbers, so coordinates are scaled to integers.",
          "type": "integer"
        },
        "lng": {
          "description": "Longitude in microdegrees (degrees x 1e7).",
          "type": "integer"
        },
        "name": {
          "description": "Venue / place name.",
          "maxLength": 200,
          "type": "string"
        }
      },
      "type": "object"
    },
    "main": {
      "description": "An event: a photowalk, workshop, meetup, exhibition or talk. Written to the organiser's own PDS. Others RSVP via app.zeens.rsvp (ATProto handle) or via an email address tracked off-protocol in PocketBase. The app does NOT collect payment: paid events carry free-text paymentInstructions and any URL/link in that field is rejected at write time. Carries the app.zeens.v marker so firehose + Lua filters recognise it. NOTE: the record id stays 'app.zeens.event' and the app.zeens.* namespace is network plumbing — it is not renamed when the product is. Two orthogonal axes describe an event: `mode` (WHERE it happens — in person, online, or both) and `categories` (WHAT it is — one or more of photowalk/workshop/meetup/exhibition/talk/other). The `hostedBy` field is DEPRECATED (the organiser is the record owner); kept for back-compat with old records.",
      "key": "tid",
      "record": {
        "properties": {
          "app.zeens.v": {
            "description": "Zeens version marker (always 1).",
            "type": "integer"
          },
          "approvalRequired": {
            "description": "When true, an RSVP is pending until the organiser approves it; only approved RSVPs count toward the cap and the attendee list. Approval state is tracked off-protocol (the organiser cannot mutate another user's rsvp record). Default false.",
            "type": "boolean"
          },
          "capacityMode": {
            "description": "Behaviour when maxParticipants is reached. 'hard' = block new going RSVPs. 'waitlist' = overflow goes on a waitlist, promoted in RSVP order. Only meaningful when maxParticipants is set. Default 'hard'.",
            "knownValues": [
              "hard",
              "waitlist"
            ],
            "type": "string"
          },
          "categories": {
            "description": "WHAT the event is. One or more (max 3) — an event can be both a photowalk and a meetup. Drives browse/filter, which OR-matches any element. Orthogonal to `mode`. The legacy value 'online_meetup' is tolerated on read and means mode 'online' + category 'meetup'.",
            "items": {
              "knownValues": [
                "photowalk",
                "workshop",
                "meetup",
                "exhibition",
                "talk",
                "other"
              ],
              "type": "string"
            },
            "maxLength": 3,
            "minLength": 1,
            "type": "array"
          },
          "coverImage": {
            "accept": [
              "image/jpeg",
              "image/png",
              "image/webp"
            ],
            "description": "Wide cover image shown on the event header and share previews.",
            "maxSize": 4000000,
            "type": "blob"
          },
          "createdAt": {
            "format": "datetime",
            "type": "string"
          },
          "createdBy": {
            "description": "Display text for the event creator/organiser (the account that owns this record).",
            "maxLength": 200,
            "type": "string"
          },
          "description": {
            "description": "Long-form event description.",
            "maxLength": 3000,
            "type": "string"
          },
          "endsAt": {
            "description": "Optional event end instant.",
            "format": "datetime",
            "type": "string"
          },
          "facets": {
            "description": "Rich-text facets (links, mentions) over the description, reusing the Bluesky richtext lexicon.",
            "items": {
              "ref": "app.bsky.richtext.facet",
              "type": "ref"
            },
            "type": "array"
          },
          "hostedBy": {
            "description": "DEPRECATED. Display text for who is hosting. The organiser is the record owner, so this is no longer written; kept so old records still read.",
            "maxLength": 200,
            "type": "string"
          },
          "itinerary": {
            "description": "Ordered agenda for the event: a list of items, each with optional time, descriptive text, and an optional location. Works for any event type — a photowalk's route, a workshop's schedule, an exhibition's programme. Items that carry coordinates are drawn on the event map as numbered pins in order; an event whose items have no coordinates simply renders the list with no map.",
            "items": {
              "ref": "#itineraryItem",
              "type": "ref"
            },
            "maxLength": 30,
            "type": "array"
          },
          "links": {
            "description": "Social / website links for the event.",
            "items": {
              "ref": "#link",
              "type": "ref"
            },
            "maxLength": 5,
            "type": "array"
          },
          "location": {
            "description": "Physical location. Expected for mode 'in_person' and 'hybrid'; absent for 'online'.",
            "ref": "#location",
            "type": "ref"
          },
          "maxParticipants": {
            "description": "Optional cap on confirmed (going) participants. Absent = unlimited.",
            "minimum": 1,
            "type": "integer"
          },
          "mode": {
            "description": "WHERE the event happens, and one of exactly three values. 'in_person' needs a location; 'online' needs a virtualUrl and has no location; 'hybrid' needs both. Orthogonal to `categories` — an online workshop is mode 'online' + categories ['workshop']. Records written before this field carried meaning default to 'in_person' on read. The legacy value 'virtual' is tolerated on read and means 'online'.",
            "knownValues": [
              "in_person",
              "online",
              "hybrid"
            ],
            "type": "string"
          },
          "paid": {
            "description": "Whether the event costs money. The app never processes payment.",
            "type": "boolean"
          },
          "paymentInstructions": {
            "description": "Free-text explanation of how the organiser collects payment out of band. MUST NOT contain a URL/link; rejected at write time client- and server-side.",
            "maxLength": 1000,
            "type": "string"
          },
          "photoCategories": {
            "description": "Photography genre tags (Street, Landscape, Portrait, ...). Up to 3, optional. Drawn from the shared category list used by posts. Distinct from `categories`, which is the event type.",
            "items": {
              "maxLength": 100,
              "type": "string"
            },
            "maxLength": 3,
            "type": "array"
          },
          "rsvpPolicy": {
            "description": "Who may RSVP. 'atmosphere' = any ATProto account. 'photosky' = members only. 'anyone_email' = anyone, including non-ATProto people via an email address. Enforcement is soft (obscurity-level), like collection visibility.",
            "knownValues": [
              "atmosphere",
              "photosky",
              "anyone_email"
            ],
            "type": "string"
          },
          "startsAt": {
            "description": "Event start, as an absolute instant. Render in `timezone`.",
            "format": "datetime",
            "type": "string"
          },
          "timezone": {
            "description": "IANA timezone name (e.g. 'America/New_York'). Stored separately from startsAt so calendar export (.ics DTSTART;TZID) and the displayed local time are correct.",
            "maxLength": 100,
            "type": "string"
          },
          "title": {
            "description": "Event title.",
            "maxGraphemes": 150,
            "maxLength": 300,
            "type": "string"
          },
          "virtualUrl": {
            "description": "Join link for events with mode 'online' or 'hybrid'. Required for those modes. Shown only to RSVP'd users in the client.",
            "format": "uri",
            "type": "string"
          }
        },
        "required": [
          "title",
          "startsAt",
          "timezone",
          "mode",
          "rsvpPolicy",
          "categories",
          "createdAt",
          "app.zeens.v"
        ],
        "type": "object"
      },
      "type": "record"
    }
  },
  "id": "app.zeens.event",
  "lexicon": 1
}
```
