Skip to main content

API endpoints

Status: Scaffolded 2026-09-24, ingest endpoint and public reads added 2026-09-29 per the ingest contract; place lists, reviews, check-ins, and POST /api/reports implemented 2026-10-24

All routes live under /api on https://api.vegangrove.org; health is /healthz. Auth is Authorization: Bearer <session token>. Every error is { error: { code, message } } (error handling). Every list is { items, nextCursor } with an opaque cursor over _id; there is no page or skip, and a bad cursor is a 400 invalid_cursor.

Auth levels: public (no session), member (any session), organizer (grove organizer or organization admin for the host), admin (role: admin, checked in the database per request), ingest (the X-Ingest-Key header, compared in constant time against INGEST_KEY; it writes only through /api/ingest/* and reads nothing).

Scaffold status: auth, me, places (including reviews, check-ins, and owner-only lists), admin place approval, admin review hide and restore, POST /api/reports, healthz, stats, the public reads for events, organizations, groves, media, and guides, and the ingest endpoint are implemented with tests. Every other route is mounted, validates its input, and returns 501 { error: { code: 'not_implemented' } } until its milestone (milestones). The admin report queue is still 501.

Auth and account (implemented)​

RouteAuthNote
POST /api/auth/register { email, password, handle }publicreturns { token, user }, rate limited
POST /api/auth/login { email, password }publicrate limited
POST /api/auth/magic-link { email }publicalways 202, no account enumeration
POST /api/auth/magic-link/verify { token }publicsingle use, 15 minute TTL
POST /api/auth/apple { identityToken, nonce }publicemail from the token only, 501 in scaffold
POST /api/auth/google { idToken }public501 in scaffold
POST /api/auth/logoutmemberdeletes the session
GET, PATCH, DELETE /api/mememberdelete is the hard delete from ADR 0010
GET /api/me/sessions, DELETE /api/me/sessions/:idmember
GET /api/me/notification-preferences, PUTmember501
POST, DELETE /api/push-tokensmember501

Health and stats (implemented)​

RouteAuthNote
GET /healthzpublic{ ok: true } after a DB ping, else 503
GET /api/statspublicaggregate counters, cached 5 minutes

Places (implemented)​

Place and event responses carry location as { lng, lat }. GeoJSON ({ type: 'Point', coordinates: [lng, lat] }) exists only inside the database for the 2dsphere index; clients never see or send it. Bounding boxes go in as bbox=west,south,east,north. The web smoke test renders a marker from a fixture in exactly this shape so the two repos cannot drift apart silently again.

RouteAuthNote
GET /api/places?bbox=w,s,e,n&type=&veganLevel=&includeChains=&q=&cursor=&limit=publicapproved places only; veganLevel defaults to full, chains hidden unless includeChains=true, fully vegan places sort first
GET /api/places/map-pins?bbox=w,s,e,n&type=&veganLevel=&includeChains=publicpins only (slug, name, type, veganLevel, chain, location), no cursor, capped at 1,000 with truncated: true past the cap
GET /api/places/:slugpublicthe full record with phone, postcode, hours, tags, the source attribution, and the check-in aggregate (checkInCount, latestCheckInMonth, verification). This read does not load place_checkins
POST /api/placesmembercreated as pending
GET /api/places/:id/reviewspublic; session optional{ items, nextCursor } of active reviews on an approved place. 404 when the place is missing or not approved. mine is present only for a session, and the response is then private, no-store
POST /api/places/:id/reviews { rating, content?, visitedMonth?, showHandle? }member201 { review }. 409 review_exists when that member already has an active review of the place
GET /api/places/:id/check-inspublic{ checkInCount, latestCheckInMonth, verification }
POST /api/places/:id/check-ins { veganLevel, rating? }member201 with the same aggregate. 409 already_checked_in for a second check-in at that place in the current UTC month. The server assigns the month. The optional rating is stored and never returned
GET /api/place-listsmember{ items, nextCursor } of the caller's lists. Cache-Control: private, no-store
POST /api/place-lists { name, placeIds?, isPublic? }member201 { list }
GET /api/place-lists/:idmember{ list }, or 404 when the list is missing or belongs to someone else
PATCH /api/place-lists/:idmember{ list }, or 404 on the same rule
DELETE /api/place-lists/:idmember204, or 404
POST /api/place-lists/:id/places { placeId }member{ list }. Idempotent. 404 when the list is not the caller's or the place does not exist. 400 list_full past 500 places
DELETE /api/place-lists/:id/places/:placeIdmember{ list }. Idempotent

A list is { id, name, isPublic, placeIds, places, createdAt, updatedAt }. places embeds the public place shape, in list order, including a pending place the owner saved. There is no user id on the list. isPublic is stored for future friend sharing and is not published yet: there is no public list route, and another member's list is 404 even when the flag is true.

A public review is { id, placeId, rating, content, visitedMonth, showHandle, handle, createdAt, mine? }. handle is set only when showHandle was true and the account still exists. No user id, no email, and no follower count.

verification is verified when a check-in in the last 180 days agreed with the place's vegan level, otherwise unverified. The check-in response has no items, no user ids, and no handles. Disputed is not a value the API returns yet (verification).

Events, groves, organizations (public reads implemented)​

RouteAuthNote
GET /api/events?from=&to=&area=&type=&groveId=&cursor=&limit=publicpublished only, visibility-filtered by caller, upcoming first
GET /api/events/:slugpublic
POST /api/events, PATCH /api/events/:idorganizer
POST, DELETE /api/events/:id/rsvpmember
GET /api/events/:id/attendeesorganizereveryone else sees counts only
GET /api/groves, GET /api/groves/:slugpublicthe ten regional Groves, member counts only
POST /api/groves/:id/join, DELETE /api/groves/:id/leavemember
GET /api/organizations?type=&area=&q=&cursor=&limit=, GET /api/organizations/:slugpublicverified first; adminUserIds never in the response

Friends, feed, posts​

RouteAuthNote
GET /api/friends, GET /api/friends/requestsmember
POST /api/friends/invitesmember{ code, expiresAt }
POST /api/friends/invites/:code/acceptmember
DELETE /api/friends/:userIdmember
GET /api/feed?scope=friends|grove:<id>|public&cursor=member
POST /api/posts, GET, DELETE /api/posts/:idmembera post the caller may not see is 404
POST, DELETE /api/posts/:id/reactionsmember
GET, POST /api/posts/:id/commentsmember
POST, DELETE /api/posts/:id/savemember
GET /api/handles/:handle/postspublicpublic posts only, per ADR 0004
POST /api/reports { targetType, targetId, reason }memberstores a report. The reporter is the session. The response is { report: { id, targetType, targetId, reason, status, createdAt } } with no reportedBy. A review target must be an active review (404 otherwise). Reporting your own review is 403. A second open report of the same target is 409 already_reported. A place target must exist. Posts, comments, users, and messages are stored without a lookup, so this route cannot probe private rows

Uploads, messages, media, guides, actions, companion​

RouteAuthNote
POST /api/uploads/image/presign { contentType, purpose }member{ url, key }, S3 PUT
POST /api/uploads/video/create { title }member{ videoId, tusEndpoint, authorization }, Bunny
GET, POST /api/conversationsmember
GET, POST /api/conversations/:id/messagesmemberencrypted at rest, 90 day TTL
Socket.IO /messagesmemberrooms user:<id>, conversation:<id>
GET /api/media/homepublic{ hero, rows }: featured titles in a deterministic daily order, editor shelves, automatic rows; cached 5 minutes; implemented
GET /api/media?q=&kind=&tag=&year=&free=&maxRuntime=&sort=&cursor=&limit=publicpublished only; sort is featured (default), release, title, rating or runtime; keyset cursor; implemented
GET /api/media/collections, GET /api/media/collections/:slugpublicpublished shelves, four preview items on the list; implemented
GET /api/media/:slugpublic, session optional{ media }, plus viewer { saved, reactions } with a session (then private, no-store); implemented
GET /api/media/:slug/relatedpublicup to 12 titles sharing a topic or genre; implemented
POST and DELETE /api/media/:id/savememberidempotent watchlist toggle; GET /api/me/watchlist lists it; implemented
POST /api/media/:id/reactions { type }, DELETE /api/media/:id/reactions/:typemembermoved or acted; answers { stats, viewer }; implemented
GET /api/guides?category=&cursor=&limit=, GET /api/guides/:slugpublicpublished only; implemented
GET, POST /api/actions, DELETE /api/actions/:idmemberprivate log
POST /api/companion/chat { conversationId?, message }memberSSE stream, rate limited
GET /api/companion/conversations, POST .../:id/pin, DELETE .../:idmember

Ingest (implemented 2026-09-29)​

RouteAuthNote
POST /api/ingest/:resource { source, items[] }ingest or adminresource is places, events, organizations, media, or guides; at most 200 items; upsert on (source, sourceId)

Responses: 200 { inserted, updated, unchanged, rejected: [] } when every item was written, 207 with rejected: [{ index, sourceId, errors }] when some were not, 400 validation_error for a bad envelope, 401 unauthorized for a wrong key, 429 rate_limited past 60 calls per 15 minutes per key, 503 not_configured until INGEST_KEY is set. The item schemas and the moderation rules are in Data ingest.

List query parameters​

Every list takes cursor and limit (1 to 100, default 50). The rest are per resource.

ListFiltersDefaults
/api/placesbbox (required), type, veganLevel (full, options, all), includeChains, qveganLevel=full, includeChains=false, full sorts first
/api/places/map-pinsas places, without q and without a cursorcapped at 1,000
/api/eventsfrom, to, area, type, groveIdupcoming, published, visible to the caller
/api/organizationstype, area, qverified first
/api/grovesnoneall
/api/mediaq, kind, tag, year, free, maxRuntime, sortpublished; five sorts, all keyset
/api/guidescategorypublished

Admin​

RouteAuthNote
GET /api/admin/places/pending, PUT /api/admin/places/:id/approve, .../rejectadminimplemented
GET /api/admin/reviews?status=admin{ items, nextCursor }, including removed reviews. Implemented
PUT /api/admin/reviews/:id { status }adminstatus is active or removed. { review }. Restoring is 409 review_exists when that member already has another active review. Recomputes ratingAvg and reviewCount. Implemented
GET, POST /api/admin/media, PATCH, DELETE /api/admin/media/:idadminevery status; a hand-set field is locked against ingest; implemented
GET, POST /api/admin/media/collections, PATCH /api/admin/media/collections/:idadminshelves with an ordered itemIds; implemented
CRUD /api/admin/guidesadmin501
GET /api/admin/reports, PUT /api/admin/reports/:idadmin501. Filing a report is implemented; the queue is not