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.
| Type | Schema.org | What it represents |
|---|---|---|
Place | Place | Physical location — venue, park, address. |
Organization | Organization · LocalBusiness | The unified entity primitive — business, community group, nonprofit, collective, solo operator. |
Event | Event | Activity 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. |
List | ItemList | Editorial 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.
| Field | Type | Notes |
|---|---|---|
id | uuid | Internal Place ID. Used as FK from Events, Organizations, etc. |
name | string | Canonical name. |
address | PostalAddress | Schema.org PostalAddress object: streetAddress, addressLocality, addressRegion, postalCode, addressCountry. |
geo | GeoCoordinates | { latitude, longitude }. Always present. |
identifier | PropertyValue[] | 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.
| Field | Type | Notes |
|---|---|---|
id, slug, name | required | Identity. |
legalName | optional | Registered legal name for businesses, nonprofits. |
description, url, logo, image, telephone, email | optional | Public-facing profile data. |
sameAs | string[] | Canonical URLs (Wikipedia, Wikidata, social profiles). |
keywords | string[] | Searchable terms. |
tags | string[] | Descriptive labels. Free-form within rules; a recommended starter vocabulary is published at /v1/meta/tags. |
commercial | boolean | null | True for for-profit; false for non-profit/community; null for unspecified. |
openingHoursSpecification | object[] | Schema.org OpeningHoursSpecification array. |
location | Place | null | Primary place reference, fully hydrated. |
verified, verification | boolean, object | If 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.
| Field | Notes |
|---|---|
id, name, description | Standard. |
start, end, timezone | ISO 8601 with timezone offset. Field name is start (not startDate) per the upstream Neighborhood API spec. |
location | Embedded Place reference. |
organizer | Embedded 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, tags | Categorization. |
cost, url, images | Practical fields. |
recurrence, series_id, series_instance_number, series_instance_count | RRULE-based recurrence; series link. |
open_window, capacity, rsvp, wheelchair_accessible | Visibility and access metadata. |
first_party | Boolean. True iff the organizer had an active verification at the time of writing — the authority signal. Server-computed; not a caller input. |
source | Provenance 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.
| Method | Path | Returns |
|---|---|---|
GET | /v1/events | Paginated event list. |
GET | /v1/events/:id | Single event by ID. |
GET | /v1/events.ics | iCalendar feed. |
GET | /v1/events.rss | RSS 2.0 feed. |
GET | /v1/places | Paginated place list. |
GET | /v1/places/:id | Single place. |
GET | /v1/organizations | Paginated organization list. |
GET | /v1/organizations/:idOrSlug | Single organization by id or slug. |
GET | /v1/publishers | Paginated list of organizations that publish (have at least one event or broadcast). |
GET | /v1/publishers/:idOrSlug | Single publisher record. |
GET | /v1/broadcasts | Active broadcasts only. |
GET | /v1/broadcasts/:id | Single broadcast. |
GET | /v1/lists | Paginated list of lists. |
GET | /v1/lists/:idOrSlug | Single list with hydrated items. |
GET | /v1/meta | Spec metadata, regions, categories, stats. |
GET | /v1/meta/tags | Recommended organization tag vocabulary. |
Filter composition
Filters are applied at the URL query level. Compose them freely; all are optional.
| Filter | Applies to | Effect |
|---|---|---|
first_party=true | events | Only events posted by the verified organization itself — the authority tier. |
first_party=false | events | Only events aggregated from public sources (scrapers, feeds, witnessed-with-evidence). |
verified=true / verified=false | organizations, publishers | Filter by verification status. |
tag=slug | organizations, publishers, events | One or more tag matches. Multiple tags use AND semantics. |
commercial=true / false | organizations, publishers | Filter by for-profit / non-profit signal. |
place_category=slug | organizations, publishers, places | Filter by OSM-sourced place categorization (e.g., cafe, live_music_venue). |
created_by_contributor=AppName | broadcasts, events | Filters to records whose source.contributor matches. |
near=lat,lng | events, places, organizations, broadcasts | Coordinates as "39.97,-75.13". Pair with radius_km. |
radius_km=N | events, places, organizations, broadcasts | Proximity radius. Required when using near. |
q=text | events, places, organizations, lists | Text search across name and description. |
category=slug | events | Single category filter. |
start_after, start_before | events | ISO 8601 date or datetime. |
collapse_series=true | events | Deduplicates recurring events to the next upcoming instance. |
series_id=uuid | events | Filter 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:
- Public facts (
first_party=false) — information about an organization, scraped from a venue's website, ingested from a partner feed, witnessed via OCR of a public flyer. Source-tracked, best-effort quality. - First-party (
first_party=true) — information from the organization, after they verify. Authoritative.
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:
- Read every public endpoint, with the higher service-tier rate limit.
- Hit
GET /v1/service/verifications/pathto exercise the routing-authority logic. - Dry-run write requests — schema validation runs, so you'll see your payload shape rejected the same way it would be on a live key.
- Build, deploy, and demo your whole integration end-to-end against production reads.
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
- Reads are unrestricted. Active service keys can read any public data; pending keys can too.
- Writes are scoped: an active service key can only create/update/delete data for organizations linked to it via
api_key_organization_links. - Cross-organization writes return
403 NOT_LINKED. - Admin keys (
is_admin=true, currently used only by Studio) bypass scoping. - Witness-authority keys (
witness_authority=true) can publish events withsource_method='witnessed'attributed to their app-collective organization. Granted at activation for specific use cases (e.g., Fiber's OCR contribution path).
Three authority paths
The Commons accepts writes from publishers with authority over what they publish. Three valid authority shapes:
- Entity-runs-it. A verified organization publishing about itself. The service key writing must be linked to that organization via
api_key_organization_links. - Pipeline-proxies-an-authoritative-source. An ingestion pipeline (typically Studio) writes records lifted from an authoritative external source — a venue's website, a partner feed.
source.contributorhonestly attributes the original source. - Witnessed-with-evidence. A service key with
witness_authority=truewrites records on behalf of a collective identity (e.g., "Fiber Community"), with evidence attached (e.g., a photo of a public flyer). Individual users are never identified.
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.
| Type | Fires on |
|---|---|
event.created | New event row published. |
event.updated | Event mutation (PATCH). |
event.deleted | Event hard-deleted. |
event.series_created | Recurring 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
- Version pinning.
[email protected]is a real artifact inpackage.json. Six months from now you know exactly which spec version your code targets. AI-generated code has no version semantics unless you write them down — the SDK writes them down for you. - CI determinism. Two engineers, same build. Your build today and your build in six months produce identical output if you don't bump the dep.
- A canonical reference your AI assistant can anchor on. "Match the SDK pattern" is a higher-quality prompt than "implement this from scratch." The SDK's value proposition has shifted — it's less an ergonomic wrapper, more a stable reference document for whatever's editing your code.
- An audit trail. Diffing two SDK versions shows exactly what moved in the API.
What it does not get you
- No abstractions beyond the spec. No retry logic, no caching, no auth dance, no smart defaults. The SDK and
curlhit the same endpoints with the same headers. - It is not required. For TypeScript prototypes, AI assistants pointed at
/openapi.jsongenerate working calls without the SDK in the loop. For production codebases — multiple contributors, CI, deliberate upgrade paths — the npm package earns its keep.
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.
| Method | Path |
|---|---|
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.
| Method | Path |
|---|---|
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:
| Status | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | Request body or query failed schema validation. |
| 400 | INVALID_URL · INVALID_SCHEME · BLOCKED_HOSTNAME · IP_LITERAL · URL_CREDENTIALS | URL field failed safety/normalcy checks. |
| 400 | DOMAIN_PENDING_REVIEW | URL's domain is not yet on the allowlist; queued for review. Soft/transient. |
| 401 | UNAUTHORIZED · API_KEY_REQUIRED · INVALID_API_KEY | Auth required or invalid. |
| 403 | KEY_PENDING | Service key is pending activation — reads work, writes are blocked. |
| 403 | NOT_LINKED | Service key is not linked to the target organization. |
| 403 | INSUFFICIENT_TIER | Endpoint requires admin or higher tier than the calling key has. |
| 404 | NOT_FOUND | Resource doesn't exist or isn't visible to the caller. |
| 409 | WRONG_METHOD | Verification submission posted to the wrong path. Call GET /v1/service/verifications/path first. |
| 409 | CONFLICT · DUPLICATE | State conflict, including slug collisions and duplicate external_id. |
| 409 | INSUFFICIENT_EVIDENCE · IDENTIFIER_DISPUTED · IMPOSTER_SIGNALS · OUT_OF_POLICY | Manual-review rejection reasons. |
| 413 | PAYLOAD_TOO_LARGE | Body exceeds the per-route limit. |
| 429 | RATE_LIMIT | Window exceeded. Back off; check RateLimit-* headers. |
| 500 | SERVER_ERROR | Internal 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.