Specification 3.2.0 Stable Additive-only stability CC BY 4.0
Neighborhood Commons
Open neighborhood data infrastructure

An open layer for everything that happens in a neighborhood.

Build the neighborhood app you've always wanted to use. The Commons is the typed substrate underneath — open, verified, free to read, designed to compose into anything a neighborhood needs to know about itself.

Imagine building

Five typed atoms: events · organizations · places · broadcasts · lists. Schema.org-aligned. Read free, no key required.

Currently serving 6,434 events across 381 organizations and 257 places in Philadelphia.6,434 public-facts · 0 first-party · 0 verified businesses. First-party tier is bootstrapping — early apps welcome.
Live API No auth required
$ curl "https://api.neighborhood-commons.org/api/v1/events?near=39.97,-75.14&radius_km=2" # Verified organizations near a point $ curl "https://api.neighborhood-commons.org/api/v1/organizations?verified=true&near=39.97,-75.14&radius_km=2" # What's being broadcast right now $ curl "https://api.neighborhood-commons.org/api/v1/broadcasts?near=39.97,-75.14&radius_km=1"

The complete integration guide is below. Every type, every endpoint, the verification system, webhooks, error codes — one page, top to bottom. Reach the API with curl, fetch, the typed TypeScript SDK, or your AI assistant pointed at the spec.

Quick Start Get a developer account
01 — Open

Read free.

Every read is open. No API key, no rate sheet, no portal. The 3.0 spec commits to additive-only stability — code you write today still works in 18 months.

02 — Composable

Apps as surfaces.

The Commons isn't a destination. Each app composes its own slice through opt-in filters: by verifier, by contributor, by proximity, by type. Same data, different editorial.

03 — Layered

Authority follows the entity.

A chess club self-asserts: it exists because someone declares it does, on an app that lets them. A music venue's show schedule arrives as public facts proxied from their calendar page — and the venue can level up by claiming and verifying, enriching those facts with first-party detail that pays back as richer presentation across every participating app. A poster on a corner is witnessed — observed by a person, recorded with documentary evidence. Each method tells consumer apps something about authority; apps filter to the tier they trust.

The deal

Cooperate on data. Compete on experience.

The Commons is the public floor — typed atoms, verified first-party authority, faithful attribution. That floor is shared, free to read, and durable. Your app is what stands on it.

Apps don't fragment the data; they enrich it. You compete on editorial — what to surface, how to curate, who you build for, what users can do once they're inside your app. You don't compete on owning the record of what a neighborhood is, or who the verified actors in it are.

The deepest move here is to write, not just read. Surfacing a yoga teacher's class schedule in your app is already a service to that teacher. Writing the schedule back to the Commons puts it in every participating app from then on — same effort, multiplicative reach. Data your users produce on your surfaces gains durability and value when other apps can also surface it. The self-interest is straightforward: you build a better relationship with the people you're documenting when you make their information as useful as it can possibly be. Build to write, and the work compounds.

This is infrastructure for an ecosystem, not a platform with a captive market. Every additional app makes the data more useful. Every verification one app does makes every other app's job a little easier. Every write expands the floor everyone stands on. The growth dynamic is shared rather than zero-sum.


1Quick Start

You can read every endpoint without authentication. Start here.

Read events near a point

$ curl "https://api.neighborhood-commons.org/api/v1/events?near=39.97,-75.14&radius_km=2&limit=5"

Returns up to five published events within 2 km of the supplied coordinates. Response shape: { meta, events }. Every event is self-contained — no further joins required to render it.

Filter by verification

$ curl "https://api.neighborhood-commons.org/api/v1/organizations?verified=true&near=39.97,-75.14&radius_km=2"

Returns organizations within 2 km of the supplied coordinates that have at least one active verification.

Filter events by authority tier

$ curl "https://api.neighborhood-commons.org/api/v1/events?first_party=true"

Returns only events posted by the verified organization itself — the authority tier. Filter to first_party=false for events aggregated from public sources.

Read what's broadcasting right now

$ curl "https://api.neighborhood-commons.org/api/v1/broadcasts?near=39.97,-75.14&radius_km=1"

Active broadcasts only — expired and retracted broadcasts are filtered server-side. Response includes the embedded organization and location for each broadcast.

Calendar feed

https://api.neighborhood-commons.org/api/v1/events.ics

Standards-compliant iCalendar feed. Drop the URL into Google Calendar / Apple Calendar / any RFC 5545 reader. RSS available at /api/v1/events.rss.

Writing data? Skip to Writing data. You can self-issue a service key in pending status and build your full integration immediately — only the live-write step requires a one-time review.

2Types

The substrate is five typed atoms. Each maps to a Schema.org concept. Every response shape is self-contained — no implicit knowledge, no extra joins required to interpret a record. Field names follow Schema.org canonical (camelCase) on output; database columns underlying them are snake_case.

TypeSchema.orgWhat it represents
PlacePlacePhysical location — venue, park, address.
OrganizationOrganization · LocalBusinessThe unified entity primitive — business, community group, nonprofit, collective, solo operator.
EventEventActivity at a specific time, organized by an Organization, at a Place.
Broadcast(novel)Ephemeral signal from an Organization, pinned to a Place, max 24h lifetime.
ListItemListEditorial selection of events, organizations, or places.

Place

Schema.org Place. Physical locations, deduplicated by Google Places ID where one is available. The canonical record for an address; events and organizations link to it by id.

FieldTypeNotes
iduuidInternal Place ID. Used as FK from Events, Organizations, etc.
namestringCanonical name.
addressPostalAddressSchema.org PostalAddress object: streetAddress, addressLocality, addressRegion, postalCode, addressCountry.
geoGeoCoordinates{ latitude, longitude }. Always present.
identifierPropertyValue[]External IDs. Common entry: { propertyID: "googlePlaceId", value: "ChIJ..." }.
{
  "id": "f1b2c3d4-...",
  "name": "Espresso Co",
  "address": {
    "streetAddress": "1234 Main St",
    "addressLocality": "Philadelphia",
    "addressRegion": "PA",
    "postalCode": "19125",
    "addressCountry": "US"
  },
  "geo": { "latitude": 39.97, "longitude": -75.13 },
  "identifier": [
    { "propertyID": "googlePlaceId", "value": "ChIJ..." }
  ]
}

Two place IDs in the system. Place.id is the internal Commons UUID. Place.identifier[].value with propertyID: "googlePlaceId" is the Google Places API ID. Events still expose a top-level place_id field that contains the Google ID for backward compatibility — do not confuse with Place.id.

Organization

Schema.org Organization. The unified entity primitive — anything that publishes or organizes is an organization, regardless of how many humans operate it. A touring DJ is an organization-of-one; a chess club is an organization-of-many. Same primitive.

FieldTypeNotes
id, slug, namerequiredIdentity.
legalNameoptionalRegistered legal name for businesses, nonprofits.
description, url, logo, image, telephone, emailoptionalPublic-facing profile data.
sameAsstring[]Canonical URLs (Wikipedia, Wikidata, social profiles).
keywordsstring[]Searchable terms.
tagsstring[]Descriptive labels. Free-form within rules; a recommended starter vocabulary is published at /v1/meta/tags.
commercialboolean | nullTrue for for-profit; false for non-profit/community; null for unspecified.
openingHoursSpecificationobject[]Schema.org OpeningHoursSpecification array.
locationPlace | nullPrimary place reference, fully hydrated.
verified, verificationboolean, objectIf verified: { method, verifiedAt, verifiedByApp }.
{
  "id": "...",
  "slug": "bubs",
  "name": "Bubs",
  "description": "Community board game shop",
  "url": "https://bubs.example",
  "logo": "https://...",
  "telephone": "+12155550100",
  "email": "[email protected]",
  "sameAs": ["https://instagram.com/bubs"],
  "keywords": ["board games", "community"],
  "tags": ["retail", "games", "all-ages"],
  "commercial": true,
  "openingHoursSpecification": [
    { "dayOfWeek": "Monday", "opens": "12:00", "closes": "22:00" }
  ],
  "location": { "id": "...", "name": "Bubs", "address": {...}, "geo": {...} },
  "verified": true,
  "verification": {
    "method": "domain_email_loop",
    "verifiedAt": "...",
    "verifiedByApp": "Holler"
  }
}

An Organization may have a single location (the primary place) and additional places via the organization_places link table — chains, pop-ups, shared spaces.

No kind discriminator. Earlier versions had a kind enum (local_business, community_group, curator, etc.). That field is gone — the values mixed structural facts with vibes with legal status, and forced false choices that narrowed reach. Use tags for descriptive classification and commercial for the for-profit / non-profit signal. Consumer apps derive richer classification from structural signals: the org's place (with OSM categories), events it has posted over time, tags, and description text.

Event

Schema.org Event. Activity at a specific time, at a Place, organized by an Organization. Events are the most-used type in the substrate and have the longest list of properties — the table below covers the core; the full schema is in openapi.json.

FieldNotes
id, name, descriptionStandard.
start, end, timezoneISO 8601 with timezone offset. Field name is start (not startDate) per the upstream Neighborhood API spec.
locationEmbedded Place reference.
organizerEmbedded Organization reference (always an organization).
performer[]Array of performers, each a { organization?, performerName?, role? }. Free-form performerName allowed for performers without a Commons organization. Optional.
category, tagsCategorization.
cost, url, imagesPractical fields.
recurrence, series_id, series_instance_number, series_instance_countRRULE-based recurrence; series link.
open_window, capacity, rsvp, wheelchair_accessibleVisibility and access metadata.
first_partyBoolean. True iff the organizer had an active verification at the time of writing — the authority signal. Server-computed; not a caller input.
sourceProvenance under the four-role frame: { method, url, contributor, collected_at, license }. method is one of self_asserted, proxied, witnessed. url is non-null only for proxied events. The "who is this from?" role is filled by organizer.name, not by a publisher field. See docs/four-roles.md.

Broadcast

Ephemeral signal from an Organization, pinned to a Place. Maximum 24h lifetime. No direct Schema.org analog; SpecialAnnouncement was checked and rejected as a fit for ephemeral commercial signals — conventions for datePosted and expires borrowed from there. Broadcast creation does not require organizer verification; consumer apps use verified=true filters to scope editorially.

{
  "id": "...",
  "message": "Half off all sandwiches until we sell out",
  "datePosted": "2026-05-05T14:00:00-04:00",
  "expires": "2026-05-05T18:00:00-04:00",
  "status": "active",
  "organization": { "id": "...", "name": "Bubs", "verified": true },
  "location": { "id": "...", "name": "Bubs", "geo": {...} },
  "method": "self_asserted",
  "source": { "method": "self_asserted", "url": null, "contributor": { "name": "Holler", "url": null }, "collected_at": "..." }
}

Broadcast creation is not gated on the organization being verified — apps decide whether to surface unverified broadcasts in their feeds. Use verified=true filters on GET /v1/broadcasts to scope editorially.

List

Schema.org ItemList. Editorial selections by an Organization. Items are polymorphic — a single list can hold events, organizations, places, or any mix. Items reference existing primitives; lists do not create primitives.

{
  "id": "...",
  "slug": "this-weekends-picks",
  "name": "This Weekend's Picks",
  "description": "Five things worth your Saturday",
  "curator": { "@type": "Organization", "id": "...", "name": "Olive's Picks", "tags": ["curator"] },
  "itemListOrder": "Ascending",
  "numberOfItems": 5,
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "item": { "@type": "Event", "id": "...", "name": "..." },
      "curatorNote": "If you only see one thing, see this."
    }
  ]
}

Lists are editorial overlays — a curator-organization selects events, organizations, or places that already exist in the Commons. Lists are not a publishing path; they reference primitives, they don't invent them.

3Reading data

All read endpoints are unauthenticated. Optional X-API-Key header bumps rate limits; otherwise requests are limited per IP.

MethodPathReturns
GET/v1/eventsPaginated event list.
GET/v1/events/:idSingle event by ID.
GET/v1/events.icsiCalendar feed.
GET/v1/events.rssRSS 2.0 feed.
GET/v1/placesPaginated place list.
GET/v1/places/:idSingle place.
GET/v1/organizationsPaginated organization list.
GET/v1/organizations/:idOrSlugSingle organization by id or slug.
GET/v1/publishersPaginated list of organizations that publish (have at least one event or broadcast).
GET/v1/publishers/:idOrSlugSingle publisher record.
GET/v1/broadcastsActive broadcasts only.
GET/v1/broadcasts/:idSingle broadcast.
GET/v1/listsPaginated list of lists.
GET/v1/lists/:idOrSlugSingle list with hydrated items.
GET/v1/metaSpec metadata, regions, categories, stats.
GET/v1/meta/tagsRecommended organization tag vocabulary.

Filter composition

Filters are applied at the URL query level. Compose them freely; all are optional.

FilterApplies toEffect
first_party=trueeventsOnly events posted by the verified organization itself — the authority tier.
first_party=falseeventsOnly events aggregated from public sources (scrapers, feeds, witnessed-with-evidence).
verified=true / verified=falseorganizations, publishersFilter by verification status.
tag=slugorganizations, publishers, eventsOne or more tag matches. Multiple tags use AND semantics.
commercial=true / falseorganizations, publishersFilter by for-profit / non-profit signal.
place_category=slugorganizations, publishers, placesFilter by OSM-sourced place categorization (e.g., cafe, live_music_venue).
created_by_contributor=AppNamebroadcasts, eventsFilters to records whose source.contributor matches.
near=lat,lngevents, places, organizations, broadcastsCoordinates as "39.97,-75.13". Pair with radius_km.
radius_km=Nevents, places, organizations, broadcastsProximity radius. Required when using near.
q=textevents, places, organizations, listsText search across name and description.
category=slugeventsSingle category filter.
start_after, start_beforeeventsISO 8601 date or datetime.
collapse_series=trueeventsDeduplicates recurring events to the next upcoming instance.
series_id=uuideventsFilter to events in a specific series.

Filter composition is the editorial primitive. App A surfaces only first-party events; App B surfaces everything but visually differentiates by authority tier. Both express that as URL parameters. There is no default firehose — apps construct their own slice.

The Commons holds two authority tiers on the same data substrate:

Apps choose: maximum coverage (take everything), maximum authority (filter to first_party=true), or composed (surface both with visual differentiation).

Pagination & rate limits

List endpoints accept limit (1–200, default 50) and offset (default 0). Response shape:

{
  "meta": { "total": 142, "limit": 50, "offset": 0, "spec": "neighborhood-api-v0.2", "license": "CC-BY-4.0" },
  "events": [ ... ]
}

Rate limit: 1,000 requests/hour per IP — or per API key when an X-API-Key header is sent. Limits are returned in standard RateLimit-* headers. 429 response indicates the current window is exhausted; backoff and retry after the window rolls over.

Cache hints: read responses include Cache-Control: public, max-age=60 for events, organizations, places, and lists. Broadcasts use a shorter max-age=15 because they're ephemeral. Honor these on the consumer side to keep both your costs and Commons load down.

4Writing data

Writes go through a service key. You can self-issue a key and build your full integration before talking to anyone — auth, request shapes, verification flows, webhook signatures. The only thing gated is actually delivering data to the Commons. Keys ship pending and stay there until a one-time review activates writes. Reads are unrestricted either way.

Get a key

Two-step OTP flow against the public API. No human in the loop until activation.

Step 1 — request a verification code:

$ curl -X POST "https://api.neighborhood-commons.org/api/v1/service/register/send-otp" \
  -H "Content-Type: application/json" \
  -d '{ "email": "[email protected]" }'

You'll receive an 8-digit code by email. It expires in 10 minutes.

Step 2 — submit the code along with your application:

$ curl -X POST "https://api.neighborhood-commons.org/api/v1/service/register/verify-otp" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "token": "12345678",
    "app_name": "Your App",
    "app_url": "https://yourapp.com",
    "what_youre_building": "One paragraph: who the user is, what the integration looks like.",
    "verification_process": "How you verify organizations you onboard — in person, video call, domain email loop, manual review."
  }'

The response includes api_key.raw_key — the credential, returned once. Use it as X-API-Key: nc_... from then on. The key lands with status: "pending_activation" — reads work immediately; writes return 403 KEY_PENDING until activation.

What pending lets you do

A pending key authenticates everywhere. You can:

What pending blocks: any write that mutates Commons state (POST/PATCH/DELETE on /v1/service/*). These return 403 KEY_PENDING. Nothing else changes between pending and active.

Activate writes

Once your build is ready, email [email protected] from the same address you registered with, including a link to your app. The review focuses on what your verification process looks like in practice — verifying organizations is the part of the flow with downstream consequences for other apps reading the Commons. Activation flips a single column on your key; no rotation, no code changes on your side.

Brand identity on verification emails

Verification emails ship under your app's display name (YourApp <[email protected]>) — most email clients show the display name first and prominently, so this reads as your app to the user. If you'd prefer they come from your own domain ([email protected]), mention it during activation. It's a config field, not a code change.

Scoping rules

Three authority paths

The Commons accepts writes from publishers with authority over what they publish. Three valid authority shapes:

Places — register a venue

Idempotent on googlePlaceId. If a Place with the supplied Google Place ID already exists, the response is 200 with the existing record. Otherwise 201 with the new one.

$ curl -X POST "https://api.neighborhood-commons.org/api/v1/service/places" \
  -H "X-API-Key: $NC_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Bubs",
    "googlePlaceId": "ChIJ...",
    "address": {
      "streetAddress": "1234 Main St",
      "addressLocality": "Philadelphia",
      "addressRegion": "PA",
      "postalCode": "19125"
    },
    "geo": { "latitude": 39.97, "longitude": -75.13 }
  }'

Organizations — create, link, update

Three relevant endpoints: create a new Organization (auto-links the calling key), link your key to an existing Organization, or update an Organization you're already linked to.

$ curl -X POST "https://api.neighborhood-commons.org/api/v1/service/organizations" \
  -H "X-API-Key: $NC_KEY" -H "Content-Type: application/json" \
  -d '{
    "name": "Bubs",
    "description": "Community board game shop",
    "url": "https://bubs.example",
    "telephone": "+12155550100",
    "email": "[email protected]",
    "tags": ["retail", "games", "all-ages"],
    "commercial": true,
    "primaryPlaceId": "PLACE_UUID"
  }'

# Link to an existing Organization (if another app created it first)
$ curl -X POST "https://api.neighborhood-commons.org/api/v1/service/organizations/link" \
  -H "X-API-Key: $NC_KEY" -H "Content-Type: application/json" \
  -d '{ "organizationId": "ORG_UUID" }'

# Update fields on an Organization linked to this key
$ curl -X PATCH "https://api.neighborhood-commons.org/api/v1/service/organizations/ORG_UUID" \
  -H "X-API-Key: $NC_KEY" -H "Content-Type: application/json" \
  -d '{ "description": "Updated bio.", "telephone": "+12155550101" }'

Image uploads (logo, hero image) are JSON-base64: POST /v1/service/organizations/:id/logo and POST /v1/service/organizations/:id/image with body { "image": "<base64>" }. Magic-byte check, Sharp re-encoding, R2 upload — same pipeline as event images.

Events — using the organizer model

Event creation requires organizerOrganizationId. The calling service key must be linked to that organization, OR have witness_authority=true with source_method='witnessed'.

$ curl -X POST "https://api.neighborhood-commons.org/api/v1/service/events" \
  -H "X-API-Key: $NC_KEY" -H "Content-Type: application/json" \
  -d '{
    "organizerOrganizationId": "ORG_UUID",
    "name": "Thursday Jazz Night",
    "start": "2026-05-07T19:00:00-04:00",
    "end": "2026-05-07T22:00:00-04:00",
    "timezone": "America/New_York",
    "category": "live-music",
    "location": {
      "name": "Bubs",
      "address": "1234 Main St, Philadelphia PA",
      "place_id": "ChIJ..."
    },
    "description": "Weekly jazz trio. BYOB.",
    "cost": "Free"
  }'

# Reassign an event's organizer
$ curl -X PATCH "https://api.neighborhood-commons.org/api/v1/service/events/EVENT_UUID/organizer" \
  -H "X-API-Key: $NC_KEY" -H "Content-Type: application/json" \
  -d '{ "organizerOrganizationId": "ORG_UUID" }'

Broadcasts — ephemeral signals

Maximum 24h lifetime. Body: organizationId, placeId, message (≤280 chars), expires (ISO 8601, must be future, <=24h ahead).

$ curl -X POST "https://api.neighborhood-commons.org/api/v1/service/broadcasts" \
  -H "X-API-Key: $NC_KEY" -H "Content-Type: application/json" \
  -d '{
    "organizationId": "ORG_UUID",
    "placeId": "PLACE_UUID",
    "message": "Half off sandwiches until we sell out",
    "expires": "2026-05-05T18:00:00-04:00"
  }'

# Retract early
$ curl -X POST "https://api.neighborhood-commons.org/api/v1/service/broadcasts/BROADCAST_UUID/retract" \
  -H "X-API-Key: $NC_KEY"

Verification gate: broadcast creation does not require the organization to be verified. Verification is a read-time consumer-app filter. Apps editorialize.

Lists — editorial overlays

Lists are editorial overlays over existing primitives. List items can only reference event/organization/place records that already exist; lists do not create new primitives.

# Create the list (curator is an Organization)
$ curl -X POST "https://api.neighborhood-commons.org/api/v1/service/lists" \
  -H "X-API-Key: $NC_KEY" -H "Content-Type: application/json" \
  -d '{
    "name": "This Weekend'\''s Picks",
    "description": "Five things worth your Saturday",
    "curatorOrganizationId": "ORG_UUID"
  }'

# Add an existing event to the list by position
$ curl -X POST "https://api.neighborhood-commons.org/api/v1/service/lists/LIST_UUID/items" \
  -H "X-API-Key: $NC_KEY" -H "Content-Type: application/json" \
  -d '{
    "position": 1,
    "itemType": "event",
    "itemId": "EVENT_UUID",
    "curatorNote": "If you only see one thing, see this."
  }'

# Remove an item at position
$ curl -X DELETE "https://api.neighborhood-commons.org/api/v1/service/lists/LIST_UUID/items/3" \
  -H "X-API-Key: $NC_KEY"

Disputes

Used to flag a verified organization as misattributed. Records the dispute for human review.

$ curl -X POST "https://api.neighborhood-commons.org/api/v1/service/disputes" \
  -H "X-API-Key: $NC_KEY" -H "Content-Type: application/json" \
  -d '{
    "organizationId": "ORG_UUID",
    "reason": "Imposter — verified email [email protected] does not match the actual venue.",
    "submitterContact": "[email protected]"
  }'

5Verification

Verification has one job: anchor first-party authority for an organization's profile data (Type A). Confirm that an organization claiming to be a real-world entity actually corresponds to it. That's the entirety of the load.

What verification is not: not a cross-app reputation graph; not an identifier portability mechanism; not a network-effect prize. The anti-extraction work is done by CC-BY plus many readers — not by verification machinery.

Until an organization is verified, the Commons can still hold information about them — happy hours scraped from a flyer, events lifted from a venue's website, hours pulled from a public listing. That's the public-facts tier. Apps consume both tiers and filter via first_party=true|false:

# Everything the Commons knows about events near a point
$ curl "https://api.neighborhood-commons.org/api/v1/events?near=39.97,-75.14&radius_km=2"

# Only events posted by the verified organization itself
$ curl "https://api.neighborhood-commons.org/api/v1/events?near=39.97,-75.14&radius_km=2&first_party=true"

# Only events aggregated from public sources (scrapers, feeds, witnessed)
$ curl "https://api.neighborhood-commons.org/api/v1/events?near=39.97,-75.14&radius_km=2&first_party=false"

The Commons orchestrates the verification process; apps surface the UI; the verified organization becomes the only valid editor of its Type A profile data.

Any app can verify any organization, through whichever method fits the relationship — domain email loop, manual document review, in-person attestation, stewardship vouches. The Commons records the attestation chain (which app verified, what method, when) but doesn't prescribe how an app does the work. Apps that verify carelessly hurt themselves: bad verifications surface as bad data downstream, and the ecosystem picks up their contributions less. The stakes are direct and self-enforcing — it's why the Commons doesn't need a central referee.

The flow

Step 1 — Discover the path. Given an organization and identifier, the Commons returns which submission endpoint to call:

$ curl "https://api.neighborhood-commons.org/api/v1/service/verifications/path?\
organization_id=ORG_UUID&\
identifier_type=email&[email protected]" \
  -H "X-API-Key: $NC_KEY"

Three possible response shapes:

// Business-domain email
{
  "alreadyVerified": false,
  "requiredMethod": "domain_email_loop",
  "endpoint": "/v1/service/verifications/challenges"
}

// Personal-domain email — needs evidence-based review
{
  "alreadyVerified": false,
  "requiredMethod": "manual_review",
  "endpoint": "/v1/service/verifications/manual"
}

// Already verified — your UI should skip this and confirm the existing record
{
  "alreadyVerified": true,
  "existingVerification": {
    "method": "domain_email_loop",
    "verifiedAt": "...",
    "verifiedByApp": "Merrie"
  }
}

Step 2a — Auto-track (challenge / confirm). Used when the path returned domain_email_loop. Issue a code; the Commons emails it from your brand_config sender:

$ curl -X POST "https://api.neighborhood-commons.org/api/v1/service/verifications/challenges" \
  -H "X-API-Key: $NC_KEY" -H "Content-Type: application/json" \
  -d '{
    "organizationId": "ORG_UUID",
    "identifierType": "email",
    "identifierValue": "[email protected]"
  }'

# Response: { "challengeId": "...", "expiresAt": "..." }

# User pastes the code in your UI; you submit
$ curl -X POST "https://api.neighborhood-commons.org/api/v1/service/verifications/challenges/CHALLENGE_ID/confirm" \
  -H "X-API-Key: $NC_KEY" -H "Content-Type: application/json" \
  -d '{ "code": "123456" }'

# Response on match:   { "status": "verified", "verifiedAt": "...", "method": "domain_email_loop" }
# Response on mismatch: { "status": "rejected", "reason": "invalid_code" }

Codes are six digits. Challenges expire after 30 minutes. Five wrong attempts on the same challenge return 429 RATE_LIMIT.

Step 2b — Manual track (structured evidence). Used when the path returned manual_review — for organizations without clean email-loop access (community groups, neighborhood associations, civic bodies). Submit evidence; if your key has verification_authority covering the method, it auto-approves; otherwise it queues for admin review:

$ curl -X POST "https://api.neighborhood-commons.org/api/v1/service/verifications/manual" \
  -H "X-API-Key: $NC_KEY" -H "Content-Type: application/json" \
  -d '{
    "organizationId": "ORG_UUID",
    "identifierType": "email",
    "identifierValue": "[email protected]",
    "evidence": {
      "verifiedVia": "documentation_review",
      "reviewerAttestation": "Reviewed bylaws and meeting minutes; identifier matches secretary listed in 2024 filing.",
      "reviewerAccountId": "internal-reviewer-id"
    }
  }'

# With verification_authority: { "status": "verified", "verifiedAt": "...", "method": "manual_review" }
# Without:                     { "status": "pending", "reviewId": "..." }

Acceptable evidence types depend on the organization. For community groups: bylaws, meeting minutes, listed contact in long-standing public materials. For businesses: business license, address verification, in-person visit. The reviewer attestation captures the reasoning for audit.

verification_authority

A JSON array on each service key listing the verification methods the key may auto-approve. Set during activation, based on the verification process you described. Apps without authority can still submit manual-review evidence — those submissions just queue. Typical turnaround for admin review is one business day.

6Webhooks

Subscribe to push notifications instead of polling. Each delivery is HMAC-SHA256 signed. Failed deliveries are retried with exponential backoff; subscriptions that fail repeatedly are auto-disabled.

Setup

Subscribe with a service-tier API key:

$ curl -X POST "https://api.neighborhood-commons.org/api/v1/webhooks" \
  -H "X-API-Key: $NC_KEY" -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.example/webhooks/commons",
    "event_types": ["event.created", "event.updated", "event.deleted"]
  }'

# Response includes a signing_secret. Save it — it cannot be recovered.
# All deliveries to this subscription are signed with this secret.

# Test the endpoint with a synthetic delivery
$ curl -X POST "https://api.neighborhood-commons.org/api/v1/webhooks/SUBSCRIPTION_ID/test" \
  -H "X-API-Key: $NC_KEY"

# Inspect recent delivery attempts (debugging)
$ curl "https://api.neighborhood-commons.org/api/v1/webhooks/SUBSCRIPTION_ID/deliveries" \
  -H "X-API-Key: $NC_KEY"

# Unsubscribe
$ curl -X DELETE "https://api.neighborhood-commons.org/api/v1/webhooks/SUBSCRIPTION_ID" \
  -H "X-API-Key: $NC_KEY"

Event types

3.0 surfaces the following event types. Additional types land additively as the substrate grows; consumers can subscribe to a subset or pass ["*"] to receive all current and future types.

TypeFires on
event.createdNew event row published.
event.updatedEvent mutation (PATCH).
event.deletedEvent hard-deleted.
event.series_createdRecurring series instantiated.

Payload shape

Every delivery is a POST to your subscribed URL with a JSON body:

{
  "type": "event.created",
  "delivery_id": "...",
  "subscription_id": "...",
  "occurred_at": "2026-05-05T15:00:00Z",
  "data": { /* the resource — Event, Organization, etc. — in spec shape */ }
}

Signature verification

Each delivery carries an X-NC-Signature header containing the HMAC-SHA256 of the raw request body, keyed by your subscription's signing_secret. Verify before trusting any delivery.

// Node.js example — verify before processing
import { createHmac, timingSafeEqual } from 'crypto';

function verifyWebhook(rawBody: Buffer, signatureHeader: string, secret: string): boolean {
  // Header format: "sha256=<hex>"
  const [algo, sig] = signatureHeader.split('=');
  if (algo !== 'sha256' || !sig) return false;

  const expected = createHmac('sha256', secret).update(rawBody).digest('hex');

  // Constant-time compare; both buffers must be the same length
  const sigBuf = Buffer.from(sig, 'hex');
  const expectedBuf = Buffer.from(expected, 'hex');
  if (sigBuf.length !== expectedBuf.length) return false;
  return timingSafeEqual(sigBuf, expectedBuf);
}

// Express handler
app.post('/webhooks/commons', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['x-nc-signature'] as string;
  if (!verifyWebhook(req.body, sig, process.env.COMMONS_WEBHOOK_SECRET!)) {
    return res.status(401).end();
  }
  const payload = JSON.parse(req.body.toString());
  // ... process the delivery
  res.status(204).end();
});

Verify against the raw request body, not a re-stringified version — different JSON serializers produce different byte sequences and the signature won't match.

Retry behavior

Deliveries that fail (network error, non-2xx response, timeout) retry with exponential backoff: ~30s, 2m, 10m, 1h, 6h. After five consecutive failures, the subscription is automatically disabled and stops receiving deliveries; re-enable it by PATCH-ing status back to active once your endpoint is healthy. Inspect GET /v1/webhooks/:id/deliveries for failure reasons.

Idempotency: Commons may retry a delivery that actually succeeded (e.g., your server returned 2xx but Commons didn't see the response). Use delivery_id as your idempotency key — store it server-side and skip processing on duplicates.

7SDK & spec

The official SDK is a typed TypeScript client generated from openapi.json. Install:

npm install neighborhood-commons

The SDK is intentionally thin: generated types and a typed fetch wrapper. ~50 lines of runtime, zero opinions beyond the spec. Anything more would be a second source of truth that drifts.

What it gets you

What it does not get you

Other languages

If you're not in TypeScript, generate your own client from the spec:

# TypeScript types only (matches what we publish)
$ npx openapi-typescript https://api.neighborhood-commons.org/openapi.json -o ./types.ts

# Full clients in any language
$ openapi-generator-cli generate -i https://api.neighborhood-commons.org/openapi.json -g python  -o ./client
$ openapi-generator-cli generate -i https://api.neighborhood-commons.org/openapi.json -g go      -o ./client
$ openapi-generator-cli generate -i https://api.neighborhood-commons.org/openapi.json -g rust    -o ./client

The spec is the contract. Whatever you generate from it is as canonical as what we publish.

8Reference

The complete endpoint list, request/response schemas, and error enum live in openapi.json. That document is authoritative — when this Guide and the spec disagree, trust the spec. The summary below is for orientation.

Endpoint summary

Read endpoints — no authentication required.

MethodPath
GET/v1/events · /v1/events/:id · /v1/events.ics · /v1/events.rss
GET/v1/places · /v1/places/:id
GET/v1/organizations · /v1/organizations/:idOrSlug
GET/v1/publishers · /v1/publishers/:idOrSlug
GET/v1/broadcasts · /v1/broadcasts/:id
GET/v1/lists · /v1/lists/:idOrSlug
GET/v1/meta · /v1/meta/regions · /v1/meta/categories · /v1/meta/tags

Service-tier write endpoints — require X-API-Key header with a service-tier key.

MethodPath
POST/v1/service/places
POST · PATCH/v1/service/organizations · /v1/service/organizations/:id
POST/v1/service/organizations/link
POST/v1/service/organizations/:id/logo · /v1/service/organizations/:id/image
POST/v1/service/broadcasts · /v1/service/broadcasts/:id/retract
POST · PATCH/v1/service/lists · /v1/service/lists/:id
POST · DELETE/v1/service/lists/:id/items · /v1/service/lists/:id/items/:position
POST · PATCH · DELETE/v1/service/events · /v1/service/events/:id
PATCH/v1/service/events/:id/organizer
POST/v1/service/events/:id/image
GET/v1/service/verifications/path
POST/v1/service/verifications/challenges · /v1/service/verifications/challenges/:id/confirm
POST/v1/service/verifications/manual
GET · POST/v1/service/verifications/pending · .../:id/approve · .../:id/reject (admin)
POST/v1/service/disputes
POST · GET · DELETE/v1/webhooks · /v1/webhooks/:id · /v1/webhooks/:id/test · /v1/webhooks/:id/deliveries

Error codes

Every error response has the same envelope:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Human-readable explanation."
  }
}

Codes you'll commonly see, grouped by the HTTP status they accompany:

StatusCodeMeaning
400VALIDATION_ERRORRequest body or query failed schema validation.
400INVALID_URL · INVALID_SCHEME · BLOCKED_HOSTNAME · IP_LITERAL · URL_CREDENTIALSURL field failed safety/normalcy checks.
400DOMAIN_PENDING_REVIEWURL's domain is not yet on the allowlist; queued for review. Soft/transient.
401UNAUTHORIZED · API_KEY_REQUIRED · INVALID_API_KEYAuth required or invalid.
403KEY_PENDINGService key is pending activation — reads work, writes are blocked.
403NOT_LINKEDService key is not linked to the target organization.
403INSUFFICIENT_TIEREndpoint requires admin or higher tier than the calling key has.
404NOT_FOUNDResource doesn't exist or isn't visible to the caller.
409WRONG_METHODVerification submission posted to the wrong path. Call GET /v1/service/verifications/path first.
409CONFLICT · DUPLICATEState conflict, including slug collisions and duplicate external_id.
409INSUFFICIENT_EVIDENCE · IDENTIFIER_DISPUTED · IMPOSTER_SIGNALS · OUT_OF_POLICYManual-review rejection reasons.
413PAYLOAD_TOO_LARGEBody exceeds the per-route limit.
429RATE_LIMITWindow exceeded. Back off; check RateLimit-* headers.
500SERVER_ERRORInternal failure. No internal details exposed.

The complete ErrorCode enum is in openapi.json. Generated SDK clients receive it as a discriminated union, so error handling can switch on the code with full type-checking.

Stability

The 3.2.0 spec is stable. Future minor versions add types, fields, and endpoints additively without breaking existing consumers. Removals or renames require a 4.0.0 release with strong justification — measured in years, not months. The explicit taxonomy of what counts as breaking vs. additive lives in docs/stability-promise.md. Watch the Changelog for every contract-affecting change.

If something in this Guide contradicts openapi.json on a matter of fact, trust the spec and file an issue. The Spec wins. The Guide explains. The Log dates.