# Neighborhood Commons — The Complete Guide > A shared, open database of neighborhood events. Not an app — plumbing. > Read for free. Contribute with a free API key. All data is CC BY 4.0. This document is everything you need to pull event data from the Neighborhood Commons, understand what you got, and contribute your own events back. It is designed to be read top-to-bottom by a developer, an LLM agent, or anyone who understands that a database is basically a giant spreadsheet. No prior knowledge assumed. No other documents required. Base URL: `https://api.neighborhood-commons.org` **This document is the Guide, part of the Commons Contract.** The Contract is three files together: - **The Spec** — [`/openapi.json`](https://api.neighborhood-commons.org/openapi.json) — machine-readable, authoritative. Generate your client from it. - **The Guide** — this file. Narrative companion. Explains *why* and *how*. - **The Log** — [`CHANGELOG.md`](https://github.com/joinfiber/neighborhood-commons/blob/master/CHANGELOG.md) — dated record of every change. Watch it. **Rule when they disagree: Spec wins. Guide explains. Log dates.** If something in this document contradicts the Spec on matters of fact, trust the Spec and open an issue. ### Design Principles The Commons is **thin**. It stores events and serves them. Curation, social features, recommendations, user accounts, ingestion pipelines — all of that belongs in the apps that build on top. The Commons is plumbing. The Commons is **durable**. The shapes in this document don't change with the winds. They change when the culture of events changes — a new category of gathering, a new dimension of accessibility. Those changes are rare and deliberate. If you build against this contract today, it should work next year. The Commons is **authoritative**. Every event response is self-contained: name, place, time, description, cost, category, image, recurrence. No implicit knowledge, no extra joins, no undocumented carry-forward. A developer who's been shipping for 20 years and another sitting down for a weekend project should both arrive at the same understanding. --- ## Part 1: Reading Events (No Setup Required) You can read every event in the Commons right now. No account. No API key. No authentication. Just make an HTTP request. ### Your First Request ```bash curl "https://api.neighborhood-commons.org/api/v1/events?limit=5" ``` That's it. You just pulled 5 events from the Commons. Here's what comes back: ```json { "meta": { "total": 142, "limit": 5, "offset": 0, "spec": "neighborhood-api-v0.2", "license": "CC-BY-4.0" }, "events": [ { "id": "a1b2c3d4-...", "name": "Punk Rock Karaoke", "start": "2026-04-15T20:00:00-04:00", "end": "2026-04-15T22:00:00-04:00", "timezone": "America/New_York", "description": "Weekly karaoke with a punk twist.", "category": ["karaoke"], "place_id": null, "location": { "name": "Tattooed Moms", "address": "530 South Street, Philadelphia PA 19147", "lat": 39.9428, "lng": -75.1534 }, "url": "https://example.com/event", "images": ["https://images.neighborhood-commons.org/portal-events/abc123/image"], "organizer": { "name": "Tattooed Moms", "phone": null }, "cost": "Free", "tags": ["all-ages", "free", "themed"], "series_id": "x9y8z7-...", "series_instance_number": 3, "series_instance_count": 12, "open_window": false, "capacity": null, "rsvp": null, "wheelchair_accessible": null, "recurrence": { "rrule": "FREQ=WEEKLY;COUNT=12" }, "source": { "publisher": "Tattooed Moms", "collected_at": "2026-03-30T17:00:00Z", "method": "portal", "contributor": null, "license": "CC BY 4.0" } } ] } ``` ### Filtering Events Add query parameters to narrow your results. All are optional. Combine freely. | Parameter | Example | What it does | |-----------|---------|-------------| | `limit` | `?limit=20` | How many events to return (1-200, default 50) | | `offset` | `?offset=50` | Skip this many results (for pagination) | | `start_after` | `?start_after=2026-04-01` | Only events starting after this date | | `start_before` | `?start_before=2026-05-01` | Only events starting before this date | | `category` | `?category=live-music` | Only events in this category (see category list below) | | `tag` | `?tag=free&tag=outdoor` | Only events with ALL of these tags | | `q` | `?q=karaoke` | Text search across event names and descriptions | | `near` | `?near=39.97,-75.13` | Events near these coordinates (latitude,longitude) | | `radius_km` | `?radius_km=5` | Search radius in km (default 10, max 100, requires `near`) | | `collapse_series` | `?collapse_series=true` | For recurring events, show only the next upcoming instance | | `series_id` | `?series_id=uuid` | All instances of one recurring series | | `group_id` | `?group_id=uuid` | Events from a specific group | | `recurring` | `?recurring=true` | Only recurring events (or `false` for one-offs only) | **Pagination example:** To get page 2 of 20-event pages: ```bash curl "https://api.neighborhood-commons.org/api/v1/events?limit=20&offset=20" ``` The `meta.total` field tells you how many total events match your filters. ### Other Read Endpoints | What | URL | Notes | |------|-----|-------| | Single event | `GET /api/v1/events/:id` | Returns `{ "event": { ... } }` | | iCal feed | `GET /api/v1/events.ics` | Subscribe in Google/Apple Calendar | | RSS feed | `GET /api/v1/events.rss` | For feed readers | | Search venues | `GET /api/v1/accounts?q=coffee` | Returns matching venue/business accounts | | Venues with events | `GET /api/v1/accounts?include=events` | Each venue includes `regular_programming` and `upcoming_events` | | Single venue | `GET /api/v1/accounts/:idOrSlug` | By UUID or slug (e.g., `johnny-brendas`) | | List groups | `GET /api/v1/groups` | Filter by `type`, `category`, `near`, `q` | | Single group | `GET /api/v1/groups/:id` | Group with venues and upcoming events | | Changes feed | `GET /api/events/changes?since=` | Incremental sync. NOTE: path lives OUTSIDE `/v1` — full URL is `https://api.neighborhood-commons.org/api/events/changes`. Rate limit 10/min per IP. | | Feed metadata | `GET /api/v1/meta` | License, stewards, spec version | | Live stats | `GET /api/v1/meta/stats` | Event count, venue count, region | | Regions | `GET /api/v1/meta/regions` | Geographic regions with timezones | | Categories | `GET /api/v1/meta/categories` | Categories with current event counts | **Venue slug algorithm:** Slugs are derived from the business name — lowercase, remove apostrophes, replace non-alphanumeric characters with hyphens. Example: `"Johnny Brenda's"` becomes `johnny-brendas`. ### Rate Limits | Endpoint | Limit | Key | |----------|-------|-----| | Events, accounts, meta | 1000/hr | API key ID or IP | | Groups | 30/min | IP | | Changes feed | 10/min | IP | | Contribute (pending tier) | 20/hr, 100/day | API key | | Contribute (verified) | 100/hr, 500/day | API key | | Contribute (trusted) | 500/hr, 2000/day | API key | With an API key (`X-API-Key` header), events and accounts endpoints give you a dedicated rate limit bucket. Without one, the limit is per IP address. Every response includes `RateLimit-*` headers so you can see where you stand. --- ## Part 2: Understanding the Data Think of the Commons as a spreadsheet where every row is an event. Here's what each column means. ### Every Event Has These Fields Every event response includes every field listed below. If a value doesn't apply, it's `null` (for single values) or `[]` (for lists) — never missing. You can always trust the shape. **The basics — what, when, where:** | Field | Type | Example | What it is | |-------|------|---------|-----------| | `id` | text | `"a1b2c3d4-..."` | Unique identifier (UUID). Use this to reference a specific event. | | `name` | text | `"Jazz Night"` | The event title. 1-200 characters. | | `start` | timestamp | `"2026-04-15T19:00:00-04:00"` | When it starts. ISO 8601 format with timezone offset. | | `end` | timestamp or null | `"2026-04-15T22:00:00-04:00"` | When it ends. Same format. Null if unknown. | | `timezone` | text | `"America/New_York"` | IANA timezone name. Use this for DST-correct local time display. | | `location` | object | `{ "name": "...", "address": "...", "lat": 39.97, "lng": -75.13 }` | Where it happens. `name` is always present. Others may be null. | | `category` | list | `["live-music"]` | Always a one-element list. Kebab-case slug. See category list below. | **Details — description, cost, links:** | Field | Type | Example | What it is | |-------|------|---------|-----------| | `description` | text or null | `"Live jazz quartet..."` | Event description. Max 2000 characters. | | `cost` | text or null | `"Free"`, `"$10"`, `"$5-15"` | Price as free-text. No structured pricing — just a human-readable string. | | `url` | text or null | `"https://example.com/event"` | Link to event page or tickets. | | `images` | list | `["https://r2.commons..."]` | Event cover image. Zero or one URL. All images are WebP, max 1200px wide, metadata stripped. | | `tags` | list | `["outdoor", "free", "all-ages"]` | Experience/access descriptors. May be empty. See tag list below. | | `wheelchair_accessible` | boolean or null | `true` | Accessibility flag. Null means not specified. | **The organizer:** | Field | Type | Example | What it is | |-------|------|---------|-----------| | `organizer` | object | `{ "name": "The Coffee Shop", "phone": null }` | Who's hosting. `phone` is always null (reserved for future use). | | `place_id` | text or null | `"ChIJ..."` | External place identifier (e.g., Google Places ID). | **Recurring events:** | Field | Type | Example | What it is | |-------|------|---------|-----------| | `series_id` | text or null | `"x9y8z7-..."` | If this event recurs, all instances share a series_id. Null for one-offs. | | `series_instance_number` | number or null | `3` | This is the 3rd occurrence in the series. | | `series_instance_count` | number or null | `12` | The series has 12 total instances. | | `recurrence` | object or null | `{ "rrule": "FREQ=WEEKLY;COUNT=12" }` | Recurrence rule in iCal RRULE format. Null for one-offs. | **Display behavior:** | Field | Type | What it is | |-------|------|-----------| | `open_window` | boolean | `false` (default): arrival at `start` is expected — the event disappears from feeds at start time. `true`: come-and-go window — the event stays visible until `end` (or start + 3 hours if no end). Use `true` for happy hours, open swims, markets, exhibits, drop-in office hours. | | `capacity` | integer or null | Informational max attendance. Commons does NOT track signups or enforce the cap. Use this to display "limited capacity" hints; do real ticketing in the consumer app via the `url` field. | | `rsvp` | `"recommended"`, `"required"`, or null | Whether RSVP is a thing for this event. Commons does not manage RSVPs. Use this to badge events ("RSVP required"); the actual signup flow lives in the consumer app or in `url`. | **Modeling film screenings.** Film is a first-class category (`category: "film"`). Each individual screening is one event row — multiple showtimes of the same film on the same day are multiple event rows, ideally sharing a `series_id` so a client can group them. Runtime is expressed by setting `end` explicitly at ingest (`end - start` = duration). Content rating (PG-13, R, etc.) is conveyed as a tag with a `rating:` prefix (e.g., `"tags": ["rating:r"]`). There are no Commons-specific runtime/rating/showtimes fields — film events use the same primitives as every other event type. **Clustering film screenings across theaters and dates.** When the same film plays at multiple theaters or across multiple days, set `tmdb_id` on every screening — the film's TMDB identifier from [themoviedb.org](https://www.themoviedb.org), the canonical external ID for theatrical releases (analogous to `place_id` from Google Places for venues). Consumers query `?category=film`, group results by `tmdb_id`, and render one card per film with the individual showtimes nested. For server-side filtering, `?tmdb_id={id}` returns every showing of a single film across the dataset — useful for "all showings of Anora this week" queries. Films not in TMDB (festival premieres, regional releases) leave `tmdb_id` null and appear as individual events. Consumers fetch additional film metadata (poster, synopsis, cast, runtime, rating) directly from TMDB using the ID — Commons stores no film metadata of its own. **Where it came from:** | Field | Type | Example | What it is | |-------|------|---------|-----------| | `source` | object | See below | Provenance: who published, how, and which app contributed. | `source.method` is one of: `portal` (venue submitted via web portal), `import` (bulk imported by admin), `api` (submitted via the Contribute API). `source.publisher` is always the venue/organizer name. `source.contributor` is the app or tool that contributed the data to the commons. It's an object with `name` and `url`, or `null` for portal-submitted events (the venue posted directly): ```json "source": { "publisher": "Johnny Brenda's", "method": "api", "contributor": { "name": "Playdate", "url": "https://playdate.com" }, "license": "CC BY 4.0" } ``` When `contributor` is present, consumers can display "Event data contributed by [Playdate](https://playdate.com)" — giving credit to the app that brought this data to the commons. ### Understanding Timestamps and Timezones This is the trickiest part. Here's what you need to know: **The `start` and `end` fields include a timezone offset.** The `-04:00` in `"2026-04-15T19:00:00-04:00"` means "4 hours behind UTC." This is Eastern Daylight Time (EDT). In winter, the same venue would show `-05:00` (Eastern Standard Time). **The offset is already correct for that specific event date.** You don't need to compute DST yourself. If you're just displaying the time, parse the ISO 8601 string as-is. **The `timezone` field is the IANA timezone name** (like `America/New_York`). Use this if you need to do date math, convert to other timezones, or determine the event's local time independently. It's the authoritative source for DST rules. **Same venue, different seasons:** ``` March event: "start": "2026-03-14T19:00:00-04:00" (EDT, offset -04:00) January event: "start": "2026-01-15T19:00:00-05:00" (EST, offset -05:00) ``` Both are 7:00 PM local time in Philadelphia. The offset changes because of Daylight Saving Time. ### Understanding Recurring Events When someone creates a recurring event (e.g., "Trivia Night, every Tuesday for 12 weeks"), the Commons generates 12 individual event rows — one for each occurrence. Each row is self-contained with its own `id`, `start`, `end`, and all other fields. **You never need to "expand" a series.** Every instance is already a complete event. You can display it, filter it, and link to it independently. The `series_id` groups them. The `series_instance_number` tells you which one it is (1, 2, 3...). The `recurrence.rrule` tells you the pattern in iCal format. **Tip:** Use `?collapse_series=true` if you want to show "Trivia Night (every Tuesday)" as a single item instead of 12 separate listings. The API returns only the nearest upcoming instance per series. **Note:** When `collapse_series=true`, pagination is approximate — `meta.total` is `null` because deduplication happens post-fetch. Use this for browse feeds that show "what's coming up," not for iterating the complete dataset. For exhaustive iteration, use `collapse_series=false` (the default). **Bounded vs. unbounded series.** `series_instance_number` distinguishes the two shapes: - `series_instance_number = 0` — ongoing/unbounded series (submitted without `COUNT=` or `UNTIL=`). Commons materializes a rolling 6-week window of instances and extends the horizon automatically — contributors submit once and never need to re-post. - `series_instance_number >= 1` — bounded series. `series_instance_count` tells you the total. If you're building a UI that shows "the series" as a single thing, the canonical representative is the instance with the lowest `series_instance_number` (0 for ongoing, 1 for bounded). ### Merging Commons Data with Your Own If you're building an app that has its own events AND wants to include Commons events, here's the practical approach: **Step 1: Pull events from the Commons.** ```bash curl "https://api.neighborhood-commons.org/api/v1/events?limit=200" ``` **Step 2: Map Commons fields to your own fields.** Here's a typical mapping: | Your field | Commons field | Notes | |-----------|--------------|-------| | title | `name` | Direct copy | | date/time | `start` | Parse as ISO 8601 datetime | | end date/time | `end` | May be null | | venue | `location.name` | Always present | | address | `location.address` | May be null | | lat/lng | `location.lat`, `location.lng` | May be null | | description | `description` | May be null | | price | `cost` | Free-text string, may be null | | link | `url` | May be null | | image | `images[0]` | First element of array, or null if array is empty | | type/category | `category[0]` | First element of array. Kebab-case slug like `live-music` | **Step 3: Store the Commons `id` alongside your own data.** This lets you deduplicate — if the same event exists in both your system and the Commons, you can match on `id` and avoid showing it twice. **Step 4: Refresh periodically.** Events get updated (times change, descriptions are edited, events are cancelled). Pull fresh data every few hours, or use webhooks for real-time updates (see Part 4). **Step 5: Attribution.** The data is CC BY 4.0. Credit "Neighborhood Commons" somewhere in your app — a footer link, an about page, a data source label. That's the only requirement. ### Keeping in Sync For apps that cache events locally (mobile apps, databases, static sites), use the changes feed: ```bash # First sync — get everything curl "https://api.neighborhood-commons.org/api/events/changes?since=2020-01-01T00:00:00Z&limit=50" ``` Response: ```json { "events": [ ...updated or new events... ], "deleted_ids": ["uuid-1", "uuid-2"], "sync_cursor": "2026-03-11T15:30:00Z", "has_more": false } ``` 1. On first sync, use a very old `since` date to get everything. 2. Save the `sync_cursor` from the response. 3. Next time, pass `since=`. 4. If `has_more` is `true`, keep requesting with the new cursor until it's `false`. 5. `deleted_ids` tells you which events to remove from your cache. Rate limit: 10 requests/minute on this endpoint. ### Visibility Rules Published events appear in feeds according to these rules. Knowing them up front saves debugging "where did my event go?": 1. **`status = 'published'`** — drafts, `pending_review`, and `unpublished` events are excluded from all public reads. Service API callers with the right scoping can read non-published rows. 2. **Account is not suspended** — events from suspended accounts return `404`, not `403`. The Commons does not disclose whether a suspended account exists. 3. **Time gate** — controlled by `open_window`: - `open_window = false` (default): visible until `start`, hidden after. - `open_window = true`: visible until `end`, or `start + 3 hours` if no end time. 4. **Region filter** — when a consumer provides coordinates (`near=lat,lng` + `radius_km`), results are filtered to events whose resolved `region_id` matches the provided location. --- ## Part 3: Becoming a Contributor Contributing means pushing events INTO the Commons so that every app connected to it can show them. There are two ways to contribute: 1. **Upload a CSV** — Sign in to the contributor portal, upload a spreadsheet, map your columns, and submit. Best for one-time contributions or if you're not a developer. 2. **Use the Contribute API** — Get a free API key, send JSON. Best for ongoing programmatic integration. Both are free. Both go through the same review process (new contributors start with manual review, then get auto-publish after demonstrating quality). ### Option A: Upload a CSV (No Code Required) Go to [api.neighborhood-commons.org](https://api.neighborhood-commons.org) and click "Contributor sign in." Register with your email, then use the **Upload** screen to: 1. **Upload** — Drop a CSV file. The system detects your columns automatically. 2. **Map** — Confirm which CSV columns map to Commons fields (name, date, venue, category, etc.). Common headers like "title", "event_name", "location" are auto-detected. 3. **Preview** — See how your rows will look as events. Rows with errors are flagged. Unrecognized category terms can be mapped to Commons categories. 4. **Submit** — Selected rows are created as events. New contributors' events go through admin review first. **Your CSV needs at minimum:** an event name column and a date column. Everything else (venue, time, category, description, price, URL, coordinates) is optional but makes the data more useful. **Category mapping:** If your CSV uses different category terms (e.g., "concert" instead of "live-music"), the system learns your vocabulary over time. The first time you upload "concert", you map it to `live-music`. Next time, it auto-maps. **Supported date formats:** `YYYY-MM-DD`, `MM/DD/YYYY`, `M/D/YYYY`, ISO 8601 datetimes. **Time formats:** `HH:MM` (24-hour), `h:mm AM/PM`. ### Option B: Use the Contribute API For programmatic integration, get a free API key. #### Step 1: Get Your API Key (2 minutes) Send your email: ```bash curl -X POST "https://api.neighborhood-commons.org/api/v1/developers/register/send-otp" \ -H "Content-Type: application/json" \ -d '{ "email": "you@example.com" }' ``` Check your email for a one-time code. Then verify and get your key: ```bash curl -X POST "https://api.neighborhood-commons.org/api/v1/developers/register/verify-otp" \ -H "Content-Type: application/json" \ -d '{ "email": "you@example.com", "token": "12345678", "name": "My Event App", "url": "https://myeventapp.com" }' ``` The response includes your `raw_key` — it looks like `nc_a1b2c3d4e5f6...`. **Save it immediately.** It is shown exactly once and cannot be recovered. From now on, include it in every write request: ``` X-API-Key: nc_a1b2c3d4e5f6... ``` ### Step 2: Submit Your First Event The minimum viable event needs 5 things: a name, a start time, a timezone, a category, and a location name. ```bash curl -X POST "https://api.neighborhood-commons.org/api/v1/contribute" \ -H "X-API-Key: nc_yourkey..." \ -H "Content-Type: application/json" \ -d '{ "name": "Open Mic Night", "start": "2026-04-15T19:00:00-04:00", "timezone": "America/New_York", "category": "open-mic", "location": { "name": "The Coffee Shop", "address": "123 Main St, Philadelphia PA 19125" } }' ``` Response: ```json { "event": { "id": "new-uuid-here", "status": "pending_review", "source": { "publisher": "My Event App", "method": "api" } } } ``` That's it. Your event is in the Commons. ### Step 3: Understand What Happens After Submission **New API keys start at `pending` tier.** Your events go into a review queue and someone approves them before they're published. Once you've demonstrated quality submissions, your key gets upgraded: | Tier | What happens to your events | Rate limit | |------|---------------------------|-----------| | `pending` (new keys) | Enter review queue, published after approval | 20/hour, 100/day | | `verified` | Auto-published immediately | 100/hour, 500/day | | `trusted` | Auto-published immediately | 500/hour, 2000/day | | `service` | Auto-published. Issued to trusted consumer apps (Merrie, Studio). | 2000/hour, 20000/day | Tier upgrades are manual. Submit good data consistently and it happens. ### The Full Contribute Event Schema **Required fields:** | Field | Type | Rules | Example | |-------|------|-------|---------| | `name` | text | 1-200 characters | `"Jazz Night"` | | `start` | timestamp | ISO 8601 with timezone offset | `"2026-04-15T19:00:00-04:00"` | | `timezone` | text | IANA timezone name | `"America/New_York"` | | `category` | text | One of the 20 category slugs (see below) | `"live-music"` | | `location.name` | text | 1-200 characters | `"South Restaurant"` | **Optional fields:** | Field | Type | Rules | Example | |-------|------|-------|---------| | `end` | timestamp | ISO 8601 with offset | `"2026-04-15T22:00:00-04:00"` | | `description` | text | Max 2000 characters | `"Weekly jazz quartet."` | | `cost` | text | Max 100 characters | `"Free"`, `"$10"`, `"$5-15"` | | `url` | URL | Must pass URL normalcy checks; non-allowlisted domains queue for review (see below) | `"https://example.com/event"` | | `image_url` | URL | The Commons downloads, re-encodes, and hosts it | `"https://example.com/photo.jpg"` | | `location.address` | text | Max 500 chars. Providing this enables geocoding. | `"600 N Broad St, Philadelphia PA"` | | `location.lat` | number | -90 to 90 | `39.9654` | | `location.lng` | number | -180 to 180 | `-75.1527` | | `location.place_id` | text | External place ID | `"ChIJ..."` | | `tags` | list of text | Max 15 tags. See tag list below. | `["outdoor", "free"]` | | `wheelchair_accessible` | boolean | | `true` | | `open_window` | boolean | Default `false`. `true` for come-and-go events (happy hour, open swim, market). Affects feed visibility. | `true` | | `capacity` | integer or null | Informational max attendance. Commons does NOT enforce or track signups. Ticketing lives in `url`. | `50` | | `rsvp` | string or null | `"recommended"`, `"required"`, or null. Signal only — Commons does not manage RSVPs. | `"required"` | | `external_id` | text | Your system's ID for this event. Used for dedup. | `"my-system-12345"` | | `venue_id` | UUID | Link to a Commons venue account | `"uuid-here"` | | `recurrence` | text | RRULE format (see below) | `"FREQ=WEEKLY;COUNT=12"` | | `instance_count` | number | 0-52, how many instances to generate | `12` | | `custom_category` | text | Max 50 chars, for niche sub-categories | `"sound bath"` | ### Submitting Recurring Events Use RRULE format (the same standard used by iCalendar). The Commons expands your pattern into individual event instances. **Bounded vs. unbounded.** If you include `COUNT=` or `UNTIL=` the Commons creates exactly that many instances (capped at 52). If you omit both (e.g. `FREQ=WEEKLY;INTERVAL=2`), the Commons treats the series as **ongoing** — it materializes a rolling 6-week horizon and extends automatically via a daily cron. **Submit once, never re-post.** Re-submitting an unbounded recurrence (via your resync logic) will create a duplicate series unless you also send `external_id`. | Pattern | RRULE | What it means | |---------|-------|--------------| | Every day | `FREQ=DAILY` | Daily, uses default count | | Every week | `FREQ=WEEKLY` | Same day each week | | Every 2 weeks | `FREQ=WEEKLY;INTERVAL=2` | Biweekly | | Every month | `FREQ=MONTHLY` | Same date each month | | 2nd Friday of each month | `FREQ=MONTHLY;BYDAY=2FR` | Ordinal weekday | | Mon/Wed/Fri | `FREQ=WEEKLY;BYDAY=MO,WE,FR` | Multiple days per week | | 12 weeks then stop | `FREQ=WEEKLY;COUNT=12` | Bounded series | Example — trivia every Tuesday for 8 weeks: ```json { "name": "Tuesday Trivia", "start": "2026-04-07T19:00:00-04:00", "timezone": "America/New_York", "category": "trivia-games", "location": { "name": "The Pub" }, "recurrence": "FREQ=WEEKLY;COUNT=8" } ``` Response: ```json { "event": { "series_id": "new-series-uuid", "instance_count": 8, "instance_ids": ["id-1", "id-2", "...", "id-8"], "status": "pending_review", "source": { "publisher": "My App", "method": "api" } } } ``` ### Submitting Multiple Events at Once ```bash curl -X POST "https://api.neighborhood-commons.org/api/v1/contribute/batch" \ -H "X-API-Key: nc_yourkey..." \ -H "Content-Type: application/json" \ -d '{ "events": [ { "name": "Event 1", "start": "...", "timezone": "...", "category": "...", "location": { "name": "..." } }, { "name": "Event 2", "start": "...", "timezone": "...", "category": "...", "location": { "name": "..." } } ] }' ``` Up to 50 events per batch. Response codes: - `201` — all events accepted - `207` — some succeeded, some failed (check per-event results) - `400` — all failed Each event in the batch counts individually against your rate limit. ### Managing Your Events ```bash # List events you submitted curl "https://api.neighborhood-commons.org/api/v1/contribute/mine?status=published" \ -H "X-API-Key: nc_yourkey..." # Edit a single event you submitted (one instance only; does NOT affect siblings in a series) curl -X PATCH "https://api.neighborhood-commons.org/api/v1/contribute/:id" \ -H "X-API-Key: nc_yourkey..." \ -H "Content-Type: application/json" \ -d '{ "description": "Updated description" }' # Edit a recurring event — applies to every future instance AND to the series template, # so instances the auto-extend cron materializes later also inherit the update. # Past instances are preserved. Use this (not PATCH /:id) when a user edits "the weekly event." curl -X PATCH "https://api.neighborhood-commons.org/api/v1/contribute/series/:seriesId" \ -H "X-API-Key: nc_yourkey..." \ -H "Content-Type: application/json" \ -d '{ "name": "New title", "cost": "$10" }' # Delete an event you submitted curl -X DELETE "https://api.neighborhood-commons.org/api/v1/contribute/:id" \ -H "X-API-Key: nc_yourkey..." # Link an event to a group curl -X PATCH "https://api.neighborhood-commons.org/api/v1/contribute/events/:id/group" \ -H "X-API-Key: nc_yourkey..." \ -H "Content-Type: application/json" \ -d '{ "group_id": "uuid" }' ``` You can only edit and delete events that belong to **the account your API key is linked to** — not just events the same key created. Ownership is by account, not by key UUID. This means rotating an API key (issue a new one linked to the same account, then revoke the old one) preserves all editorial control. It also means a team with multiple keys (dev + prod) under the same account share an event pool — both can edit each other's events. If a key has no linked account, every PATCH/DELETE returns `403 KEY_NOT_LINKED`. Contact an operator to issue properly-linked keys via `POST /service/api-keys` with an `account_id`. ### Creating Venues and Groups **Venues** represent physical locations (bars, coffee shops, parks): ```bash curl -X POST "https://api.neighborhood-commons.org/api/v1/contribute/venues" \ -H "X-API-Key: nc_yourkey..." \ -H "Content-Type: application/json" \ -d '{ "name": "The Coffee Shop", "address": "123 Main St, Philadelphia PA 19125" }' ``` If a venue with the same slug already exists, the existing venue is returned (200) instead of creating a duplicate (201). **Groups** represent organizations (community groups, nonprofits, collectives, curators): ```bash curl -X POST "https://api.neighborhood-commons.org/api/v1/contribute/groups" \ -H "X-API-Key: nc_yourkey..." \ -H "Content-Type: application/json" \ -d '{ "name": "Philly Bike Train", "slug": "philly-bike-train", "type": "community_group", "category_tags": ["fitness", "outdoors"], "neighborhood": "Fishtown", "city": "Philadelphia" }' ``` Group types: `business`, `community_group`, `nonprofit`, `collective`, `curator`. ### Important Behaviors - **Geocoding:** If you provide `location.address` but no coordinates, the Commons geocodes it automatically (via OpenStreetMap/Nominatim). The resulting lat/lng are stored on the event. - **Region resolution:** Coordinates are used to assign the event to a geographic region. Events without a region won't appear in location-filtered feeds — so always provide an address or coordinates. - **Image processing:** If you provide `image_url`, the Commons downloads the image, strips metadata, re-encodes it as WebP (max 1200px wide), and stores it on Cloudflare R2. The response contains the final hosted URL. Processing is asynchronous — POST/PATCH returns before the image is fetched. To know when async processing finishes (or permanently fails), subscribe to the `event.image_processed` webhook (see Part 4) — it fires once per event for both succeeded and failed outcomes, so consumers polling `images[]` get a stop signal in either direction. - **URL validation:** The `url` field is checked for normalcy and against an operator-managed allowlist of domains. Tracking parameters (`utm_*`, `fbclid`, etc.) are stripped automatically and `http://` is coerced to `https://`. The following return a `400` and are never accepted: - `INVALID_URL` — malformed URL - `INVALID_SCHEME` — scheme other than http/https - `URL_CREDENTIALS` — embedded `user:pass@` - `IP_LITERAL` — hostname is an IPv4 or IPv6 literal - `BLOCKED_HOSTNAME` — `localhost`, `*.local`, `*.internal`, `*.localhost` A non-allowlisted but otherwise valid domain returns `400 DOMAIN_PENDING_REVIEW` with the offending domain in `error.domain`. The Commons logs a review request automatically — operators review the queue and approve domains as appropriate. Once approved, contributors can resubmit the event and it will be accepted. Treat this as a soft, transient failure (similar to a moderation hold), not a permanent rejection. - **Capacity and RSVP:** Commons stores `capacity` (informational max attendance) and `rsvp` (`null` / `"recommended"` / `"required"`) as signals. **Commons does not manage attendance, count signups, or enforce caps.** Consumer apps display these to set expectations; the actual ticketing or signup flow lives in `url`. Use `link_url` for Eventbrite, Partiful, a venue's reservation page, etc. If you need an attendee management system, you're outside Commons' scope — that's app territory. - **Deduplication:** If you provide `external_id`, the Commons enforces uniqueness per API key. Submitting the same `external_id` twice returns `409 CONFLICT`. - **Tag validation:** Tags are checked against the category's allowed set. Invalid tags for that category are silently removed. You may get fewer tags back than you sent. Custom tags (not in the prescribed list) are accepted as-is. ### Checking Your Key Status ```bash # See your key info, tier, and usage curl "https://api.neighborhood-commons.org/api/v1/developers/me" \ -H "X-API-Key: nc_yourkey..." # Rotate your key (requires email re-verification) curl -X POST "https://api.neighborhood-commons.org/api/v1/developers/keys/rotate" \ -H "X-API-Key: nc_yourkey..." \ -H "Content-Type: application/json" \ -d '{ "email": "you@example.com" }' ``` --- ## Part 4: Real-Time Updates via Webhooks Instead of polling for changes, you can subscribe to real-time notifications. When an event is created, updated, or deleted, the Commons sends an HTTPS POST to your endpoint. ### Setting Up a Webhook ```bash curl -X POST "https://api.neighborhood-commons.org/api/v1/webhooks" \ -H "X-API-Key: nc_yourkey..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://yourapp.com/webhooks/commons", "event_types": ["event.created", "event.updated", "event.deleted"] }' ``` The response includes a `signing_secret` — **save it immediately.** It is shown exactly once. ### Event Types | Type | When it fires | |------|--------------| | `event.created` | A new event is published | | `event.updated` | An existing event is modified | | `event.deleted` | An event is removed | | `event.series_created` | A new recurring series is created (one webhook for the whole series, includes all instance IDs) | | `event.image_processed` | The async image download + R2 re-encode finished for an event (success or permanent failure). Opt-in: not included in the default `event_types` because the payload shape is different from the others (see below). | **`event.image_processed` payload** — distinct from the standard `{ event_type, event, timestamp, delivery_id }` shape. Fires once per event regardless of outcome: ```json { "event_type": "event.image_processed", "event_id": "4d1ecb4c-ee6e-4199-9ad0-587efbd9c65b", "status": "succeeded", "image_url": "https://images.neighborhood-commons.org/portal-events/4d1.../image", "error_code": null, "timestamp": "2026-04-23T14:22:01Z", "delivery_id": "8421" } ``` On failure: `status: "failed"`, `image_url: null`, and `error_code` is one of `URL_BLOCKED` (SSRF/validation rejected the URL), `DOWNLOAD_FAILED` (HTTP error or fetch threw), `INVALID_FORMAT` (not JPEG/PNG/WebP, or file truncated), `ENCODE_FAILED` (Sharp re-encode threw), or `UPLOAD_FAILED` (R2 rejected the upload). The full event row is not included — fetch `GET /events/{event_id}` if you need the rest. ### Verifying Webhook Signatures Every delivery includes an `X-NC-Signature` header. Verify it to confirm the request came from the Commons: ```javascript const crypto = require('crypto'); function verifySignature(rawBody, signatureHeader, secret) { const expected = 'sha256=' + crypto.createHmac('sha256', secret) .update(rawBody) .digest('hex'); return crypto.timingSafeEqual( Buffer.from(signatureHeader), Buffer.from(expected) ); } ``` ### Reliability - Failed deliveries retry 3 times: after 1 minute, 5 minutes, 25 minutes. - 10 consecutive failures automatically disables your subscription. - Re-enable with `PATCH /api/v1/webhooks/:id` setting `{ "status": "active" }`. - Max 5 subscriptions per API key. - Webhook URLs must be HTTPS. Private IPs and cloud metadata endpoints are blocked. ### Managing Webhooks ```bash # List your subscriptions curl "https://api.neighborhood-commons.org/api/v1/webhooks" \ -H "X-API-Key: nc_yourkey..." # Test a webhook (sends a test delivery) curl -X POST "https://api.neighborhood-commons.org/api/v1/webhooks/:id/test" \ -H "X-API-Key: nc_yourkey..." # View delivery history curl "https://api.neighborhood-commons.org/api/v1/webhooks/:id/deliveries" \ -H "X-API-Key: nc_yourkey..." # Confirm a specific event reached this subscriber curl "https://api.neighborhood-commons.org/api/v1/webhooks/:id/deliveries?event_id=4d1ecb4c-ee6e-4199-9ad0-587efbd9c65b&status=delivered" \ -H "X-API-Key: nc_yourkey..." # Update a webhook curl -X PATCH "https://api.neighborhood-commons.org/api/v1/webhooks/:id" \ -H "X-API-Key: nc_yourkey..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://newurl.com/hook", "status": "active" }' # Delete a webhook curl -X DELETE "https://api.neighborhood-commons.org/api/v1/webhooks/:id" \ -H "X-API-Key: nc_yourkey..." ``` --- ## Reference: Categories 20 categories. Use the slug (left column) in API requests. Both kebab-case (`live-music`) and underscore (`live_music`) are accepted on input. Responses always use kebab-case. | Slug | Label | Group | |------|-------|-------| | `live-music` | Live Music | Performance | | `dj-dance` | DJ & Dance | Performance | | `comedy` | Comedy | Performance | | `theatre` | Theatre | Performance | | `open-mic` | Open Mic | Performance | | `karaoke` | Karaoke | Performance | | `art-exhibit` | Art & Exhibits | Arts & Culture | | `film` | Film | Arts & Culture | | `literary` | Literary | Arts & Culture | | `tour` | Tour | Arts & Culture | | `happy-hour` | Happy Hour | Food & Drink | | `market` | Market & Pop-up | Food & Drink | | `fitness` | Fitness | Active | | `sports` | Sports & Rec | Active | | `outdoors` | Outdoors & Nature | Active | | `class` | Class & Workshop | Learning & Social | | `trivia-games` | Trivia & Games | Learning & Social | | `kids-family` | Kids & Family | Learning & Social | | `community` | Community | Civic | | `spectator` | Spectator | Civic | ## Reference: Tags 30 prescribed tags plus any custom tags you want. Tags describe the experience of attending — "Can I go? What's the space like? What's the energy?" **Access — "Can I go?"** `all-ages`, `18-plus`, `21-plus`, `family-friendly`, `free`, `cover-charge`, `donation-based`, `na-friendly`, `byob`, `dog-friendly`, `cash-only` **Logistics — "How do I attend?"** `registration-required`, `drop-in`, `limited-spots`, `solo-friendly`, `bring-gear` **Setting — "What's the space like?"** `outdoor`, `rooftop`, `seated` **Vibe — "What's the energy?"** `chill`, `high-energy`, `late-night`, `beginner-friendly`, `themed`, `competitive` **Format — "What happens there?"** `hands-on`, `tasting`, `acoustic`, `participatory`, `volunteer` **Rules:** - Age tags (`all-ages`, `18-plus`, `21-plus`) are mutually exclusive. If you submit more than one, only the first is kept. - Max 15 tags per event. - Custom tags are accepted alongside prescribed ones. Must be lowercase kebab-case, max 100 characters. - Each category has an approved subset of prescribed tags. Tags not approved for the category are silently removed. ## Reference: Errors Every error response has this shape: ```json { "error": { "code": "VALIDATION_ERROR", "message": "Human-readable explanation of what went wrong" } } ``` | Code | HTTP Status | What it means | |------|-------------|--------------| | `VALIDATION_ERROR` | 400 | Your request body failed validation. The message says which field. | | `INVALID_URL` | 400 | A URL in the payload is malformed. | | `INVALID_SCHEME` | 400 | URL scheme is not http or https. | | `URL_CREDENTIALS` | 400 | URL contains embedded `user:pass@` credentials. | | `IP_LITERAL` | 400 | URL hostname is an IPv4 or IPv6 literal. | | `BLOCKED_HOSTNAME` | 400 | URL hostname is `localhost`, `*.local`, `*.internal`, or `*.localhost`. | | `DOMAIN_PENDING_REVIEW` | 400 | The URL's domain is not yet on the allowlist. A review request was logged automatically; response includes `error.domain`. Soft/transient — retry after approval. | | `DOMAIN_NOT_APPROVED` | 400 | Legacy error code kept for compatibility. New integrations should expect `DOMAIN_PENDING_REVIEW`. | | `UNAUTHORIZED` | 401 | Missing or invalid API key. | | `FORBIDDEN` | 403 | Your key doesn't have permission for this action. | | `NOT_FOUND` | 404 | That event/account/group doesn't exist. | | `NOT_LINKED` | 403 | Your service key is not linked to the target portal account. | | `KEY_NOT_LINKED` | 403 | Your contribute API key has no linked owning account. The key cannot create or edit events until an operator links it. | | `ACCOUNT_REQUIRED` | 400 | `POST /service/api-keys` was called for a non-service tier without `account_id`. Required to prevent KEY_NOT_LINKED at use time. | | `NO_OWNER` | 403 | Operation requires an owning account and none is set (admin access required). | | `CONFLICT` | 409 | State conflict — e.g. a domain approval request is already reviewed. | | `DUPLICATE` | 409 | You already submitted an event with this `external_id`. | | `PAYLOAD_TOO_LARGE` | 413 | Request body exceeds the per-route limit. | | `RATE_LIMIT` | 429 | Too many requests. Check `RateLimit-*` headers for when to retry. | | `SERVER_ERROR` | 500 | Something broke on our end. No internal details exposed. | ## Reference: Regions Every event belongs to a geographic region (a city or neighborhood). When you submit an event: 1. If you provide coordinates (`location.lat` + `location.lng`) — the Commons resolves the containing region automatically. 2. If you provide only an address — the Commons geocodes it first, then resolves the region. 3. If neither is provided — the event gets the default region. Events without a proper region won't appear in location-filtered feeds. Always provide an address or coordinates. --- ## Part 5: Service API (Building Consumer Apps) If you're building an app that manages events on behalf of venue operators — a CMS, a scheduling tool, a venue management platform — you need the Service API. This is the layer that lets your app authenticate its own users, link them to Commons portal accounts, and manage their data via REST. ### The Model Your app owns authentication. The Commons owns event data. The link between them is a portal account, identified by email. 1. A venue operator signs up on **your** app (your auth, your database) 2. Your app calls the Commons to establish the link 3. Your app creates/updates/deletes events on their behalf 4. The data flows to every other app connected to the Commons ### Getting a Service Key Service keys are issued by the Commons operator. Contact hi@neighborhood-commons.org with: - Your app name and URL - What you're building and how you'll use the data - Contact email for your team Service keys are scoped: you can only modify data for accounts you've explicitly linked to your key. You cannot read, modify, or delete data belonging to other apps' accounts. ### Linking Accounts ```bash # Find-or-create a portal account and link it to your service key curl -X POST "https://api.neighborhood-commons.org/api/v1/service/accounts/link" \ -H "X-API-Key: nc_your_service_key" \ -H "Content-Type: application/json" \ -d '{ "email": "john@johnnysbar.com", "business_name": "Johnny'\''s Bar", "claimed_by": "your-app-name" }' ``` Response: ```json { "account": { "id": "uuid-of-portal-account", "email": "john@johnnysbar.com", "business_name": "Johnny's Bar", "status": "active", "claimed_at": "2026-04-08T...", "claimed_by": "your-app-name" }, "created": true, "linked": true } ``` If the account already exists (same email), it's returned as-is with `created: false`. The link to your service key is established either way. ### Looking Up Accounts ```bash # Check if an account exists before linking curl "https://api.neighborhood-commons.org/api/v1/service/accounts?email=john@johnnysbar.com" \ -H "X-API-Key: nc_your_service_key" ``` ### Creating Events for Linked Accounts Once linked, create events using the account's portal ID: ```bash curl -X POST "https://api.neighborhood-commons.org/api/v1/service/events" \ -H "X-API-Key: nc_your_service_key" \ -H "Content-Type: application/json" \ -d '{ "account_id": "uuid-from-link-response", "name": "Thursday Jazz Night", "start": "2026-04-17T19:00:00-04:00", "end": "2026-04-17T22:00:00-04:00", "timezone": "America/New_York", "category": "live_music", "location": { "name": "Johnny'\''s Bar", "address": "1234 Frankford Ave, Philadelphia PA" }, "description": "Weekly jazz trio. BYOB.", "cost": "Free" }' ``` The Service API accepts the same **friendly-shape** payload as the Contribute API and the public read schema — `name`, `start`, `timezone`, `location`, `url`, `cost`. `recurrence` is optional; omit it for one-off events. See [`ServiceEventInput`](https://api.neighborhood-commons.org/openapi.json) in the Spec for the authoritative field list. **Spec wins when this Guide and the Spec disagree.** ### Scoping Rules - **Reads are unrestricted** — any service key can read all public data (same as the public API) - **Writes are scoped** — you can only create/update/delete events for accounts linked to your key - **Cross-account writes return 403** — `"This API key is not linked to the target account"` - **Admin keys** bypass scoping (issued only to the platform operator) ### Service Endpoint Reference **The authoritative list of Service API endpoints lives in [`/openapi.json`](https://api.neighborhood-commons.org/openapi.json).** That spec is the contract — prefer it over this doc when they disagree. What follows is the common subset consumers reach for most often. | Method | Path | Scope | |--------|------|-------| | `POST` | `/service/accounts/link` | Find-or-create a portal account by email and link it to your key | | `GET` | `/service/accounts` | List/search linked accounts (with event counts) | | `GET` | `/service/accounts/:id` | Single account detail | | `POST` | `/service/accounts` | Create a new portal account | | `PATCH` | `/service/accounts/:id` | Update a linked account | | `DELETE` | `/service/accounts/:id` | Delete a linked account (and its events) | | `POST` | `/service/accounts/:id/cover-image` | Upload an account cover image | | `POST` | `/service/accounts/:id/logo` | Upload an account logo | | `GET` | `/service/events` | List events (supports `time`, `search`, `source_method`, `category`, `all_instances` filters) | | `GET` | `/service/events/:id` | Single event detail | | `POST` | `/service/events` | Create an event (or recurring series) for a linked account | | `PATCH` | `/service/events/:id` | Update a single event (one instance only, does NOT propagate within a series) | | `PATCH` | `/service/events/batch` | Batch-update a subset of fields across many events | | `PATCH` | `/service/events/series/:seriesId` | Update all future instances AND the series template (auto-extend inherits) | | `DELETE` | `/service/events/:id` | Delete a single event | | `DELETE` | `/service/events/series/:seriesId` | Delete every event in a series | | `POST` | `/service/events/:id/image` | Upload an event image (JSON base64, JSON image_url, or multipart) | | `PATCH` | `/service/events/:id/group` | Link an event to a group (or unlink with `{ group_id: null }`) | | `GET` | `/service/groups` | List groups | | `GET` | `/service/groups/:id` | Group detail | | `POST` | `/service/groups` | Create a group | | `PATCH` | `/service/groups/:id` | Update a group | | `DELETE` | `/service/groups/:id` | Delete a group | | `POST` | `/service/groups/:id/venues` | Add a venue to a group | | `DELETE` | `/service/groups/:groupId/venues/:venueId` | Remove a venue from a group | | `GET` | `/service/stats` | Platform stats (admin) | | `GET` | `/service/api-keys` | List all API keys (admin) | | `POST` | `/service/api-keys` | Issue a new API key, linked to an account. Returns the raw key once. (admin) | | `PATCH` | `/service/api-keys/:id` | Update an API key — name, status (revoked), tier, etc. (admin) | | `GET` | `/service/approved-domains` | List allowlisted contribute-URL domains (admin) | | `POST` | `/service/approved-domains` | Add a domain to the allowlist (admin) | | `DELETE` | `/service/approved-domains/:domain` | Remove a domain from the allowlist (admin) | | `GET` | `/service/domain-approval-requests?status=pending` | Review queue of pending domain requests (admin) | | `POST` | `/service/domain-approval-requests/:id/approve` | Approve a request and add its domain (admin) | | `POST` | `/service/domain-approval-requests/:id/reject` | Reject a pending request without allowlisting (admin) | Admin-only endpoints require a service key with `is_admin=true`. Non-admin service keys are scoped to linked accounts only. --- ## Stability The v1 API is stable. Breaking changes to `/api/v1/*` endpoints require at least 90 days notice. Response shapes, query parameters, and authentication requirements are locked. Extension APIs (`/portal/*`, `/service/*`) may evolve with shorter notice. If a breaking change is needed, v2 will be introduced and v1 will keep running. --- ## License All event data is published under **Creative Commons Attribution 4.0 International (CC BY 4.0)**. You can use it for any purpose — commercial or non-commercial — as long as you credit "Neighborhood Commons." Contact: hi@neighborhood-commons.org Spec: [Neighborhood API v0.2](https://github.com/The-Relational-Technology-Project/neighborhood-api)