REST API Reference
Eventonomy is 100% REST: there is no admin-ajax.php usage anywhere. Every UI surface (blocks, admin console, CLI) hits the same endpoints. Third-party code uses the same contract.
This is the canonical REST reference. It is generated from and verified against the controllers in
includes/REST/Controller/*.php(Free) andeventonomy-pro/includes/REST/*.php(Pro). The olderdocs/REST-API.mdin the plugin root is deprecated and now just points here.
What You Will Learn
- Base URL, namespace, and authentication strategies
- The canonical list-response envelope
- All resources: Events, Occurrences, RSVPs, Tickets, Orders, Venues, Organizers, Media, Import, Moderation, Unsubscribe, ICS, App Config, Current User, Notifications, Push Devices, Event Reports, Bulk
- The ban gate on the write surface, and why the plugin owns no CORS handling
- Where Pro adds routes (refund, follows, discovery, checkin, calendar-sync feeds, payment webhooks, coupons, Stripe Connect onboarding, organizer earnings)
- Pagination strategies (cursor vs. offset)
- The error contract
Conventions
| Concern | Value |
|---|---|
| REST namespace | eventonomy/v1 |
| Base URL | /wp-json/eventonomy/v1/ |
| HTTP methods | GET (read), POST (create), PATCH (partial update), DELETE (delete). No PUT. |
| Machine-readable spec | docs/api/openapi.json (OpenAPI 3.1, generated from the live route registrations) |
The generated
openapi.jsonlistsputalongsidepostandpatchon update routes. That is an artifact ofWP_REST_Server::EDITABLEbeing the literal string'POST, PUT, PATCH'- the spec records what the server will accept. The contract is unchanged: use PATCH for partial updates.
| Timestamps | ISO 8601 UTC. Never a field named date. |
Authentication
First-Party (Cookie + Nonce)
Used by blocks, the frontend SPA, and in-browser clients.
fetch('/wp-json/eventonomy/v1/events', {
headers: { 'X-WP-Nonce': eventonomyData.nonce },
credentials: 'same-origin'
});
Headless (Application Passwords)
Used by external integrations, mobile apps, and server-to-server calls. Requires HTTPS.
curl -u "jane:abcd EFGH ijkl MNOP qrst UVWX" \
https://example.com/wp-json/eventonomy/v1/events
There is no first-party JWT and no session-exchange route: the application password is the credential on every request. WordPress core will not offer Application Passwords on a site it does not consider secure, so HTTPS is effectively mandatory - that is core behaviour, and Eventonomy cannot relax it.
The Ban Gate on Writes
Because core authenticates an application password on rest_authentication_errors before any plugin code runs, a banned member holding a valid application password would otherwise still be able to write. BanGateProvider closes that on rest_pre_dispatch: every non-GET / HEAD / OPTIONS request to a /eventonomy/v1 route from a banned user is refused with 403 evnm_forbidden. Reads stay open.
The signal is the filterable evnm_user_is_banned( $user_id ), which honours a truthy evnm_banned user meta out of the box (set from the user profile screen by anyone with evnm_manage_settings) and can be pointed at any external moderation system:
add_filter( 'evnm_user_is_banned', function ( bool $banned, int $user_id ): bool {
return $banned || my_community_is_suspended( $user_id );
}, 10, 2 );
Guest writes (guest RSVP, guest order, magic-link requests) run as user 0 and are covered by the spam guard and rate limiter instead, not by this gate.
CORS
Eventonomy ships no CORS handling. It emits no Access-Control-Allow-* header and hooks neither rest_pre_serve_request nor send_headers; requests are served with whatever headers WordPress core and your web server produce. Native mobile clients are unaffected (they are not browsers and perform no preflight). A browser-hosted client on a different origin - a web build or a development tunnel - needs the origin allowlisted at the server, proxy, or in a site-level snippet. There is no plugin setting for it and one is not planned.
Public Endpoints
No auth required: GET /events (published/public rows only), GET /events/{id} (published), POST /events/bulk (batch read of public rows), GET /occurrences, GET /events/{id}/tickets, GET /venues, GET /organizers, GET /events/{id}.ics, GET /calendar.ics, GET /settings/app-config, GET /unsubscribe, POST /events/{id}/rsvp (guest RSVP), POST /rsvp/magic-link, and GET /rsvp/manage (the token is the credential).
The List Envelope
Every list endpoint returns the same uniform 7-key envelope, built by BaseController::respond_list() / respond_result():
{
"items": [ /* resource objects */ ],
"total": 124,
"page": 2,
"per_page": 12,
"pages": 11,
"has_more": true,
"next_cursor": null
}
has_more = (offset + count(items)) < total.- Offset endpoints fill
page/per_pageand leavenext_cursornull. - Cursor endpoints fill
next_cursorand leavepage/per_pagenull.
A repository
query()result on its own carries only{ items, total, pages, has_more }; thepage,per_page, andnext_cursorkeys are added byrespond_list()/respond_result(). A third-party endpoint that calls a repository directly must run the result through those helpers (or add the keys itself) to emit the full envelope.
Resources
Events: /events
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /events |
Public (published/public rows) | List events. Offset pagination. |
| GET | /events/{id} |
Public (published) | Single event detail. |
| POST | /events |
evnm_create_events (plus the creator_role setting) |
Create an event. |
| PATCH | /events/{id} |
Owner or evnm_edit_others_events |
Partial update. |
| DELETE | /events/{id} |
Owner or evnm_edit_others_events |
Permanently delete the event. There is no trash and no soft-delete: the row is removed and evnm_after_delete_event cascades to its occurrences, RSVPs, tickets, orders and meta. |
| POST | /events/bulk-action |
can_bulk - logged in and evnm_edit_others_events; per-row ownership re-checked inside |
Apply one action across a selection of events. |
| POST | /events/{id}/duplicate |
can_duplicate - owner or evnm_edit_others_events, and the create-events gate |
(1.1.0) Clone an event into a new draft. |
| POST | /events/{id}/blast |
can_blast_event (event manager) |
(1.1.0) Send an email blast to the event's attendees. |
| POST | /events/{id}/report |
Any logged-in user (rate-limited) | Report an event for moderation. |
POST /events/bulk-action is the write counterpart to POST /events/bulk (which is a public batch read - see below). The two are different routes with different gates; do not confuse them.
Body: bulk_action (required - publish, draft, cancel or delete; anything else returns 422 evnm_invalid_action) and ids (required, a non-empty array; a non-array or empty array returns 422 evnm_invalid_ids, and more than 50 ids returns 422 evnm_too_many). The whole selection is hydrated in one query, then each row is re-checked with evnm_user_can_manage_event() - delete for the delete action, edit otherwise - so the route can never widen what the caller may touch. publish / draft / cancel map to the published / draft / cancelled statuses and run through EventService::update(); delete runs through EventService::delete(), so the usual cascade fires.
The response is a per-selection report, not the list envelope:
{
"updated": 3,
"updated_ids": [ 12, 15, 19 ],
"skipped": 2,
"reasons": { "forbidden": 1, "already": 1 }
}
reasons keys are not_found, forbidden, already (the row is already in the target status) and error.
Capabilities are meta-caps that map onto capabilities your roles already have. Every
evnm_*capability named in this reference resolves throughCapabilities::map(); the authoritative table of what each one maps to, and which default roles hold it, is the capability map. Two things to keep in mind when reading the Auth column: an event's own author passes the ownership branch ofuser_can_manage_event()(so a member-organizer with no WordPress editing capabilities still manages their own event, its attendees, and its orders), and "owner or X" always means X is only needed for events you do not own.
GET /events real list parameters (EventsController::index): author_id (author user id - there is no author alias and no me keyword), include[] (narrow to a set of ids - include[]=1&include[]=2 or include=1,2; the status gate still applies so it only ever narrows visibility), space_id, organizer_ids (list form, same shape as include[] - narrow to events by a set of catalog organizer ids), city, featured, search, category (slug), tag (slug), status (gated - non-public values require evnm_edit_others_events, or an author querying their own author_id scope), cursor, page, per_page, and fields. The event object also exposes a public url (single-event permalink).
tagis a sibling ofcategoryand takes a term slug, not a name or id. Both resolve against the native WordPress taxonomies bound to theevnm_eventobject type.fields=cardis the only recognised value and it is a response-shaping flag, not a filter. It enriches each item with the display-ready fields the events-list template computes server-side - date chip, going count, price badge, category - resolved through batched queries so an appended page never triggers an N+1. It exists so the "Load more" JS append path renders cards that are visually identical to the server-rendered ones. Any other value is ignored and you get the standard event object.fields=cardalso carries the viewer's own state. Each item gainsviewer_rsvp(the caller's RSVP status on that event, ornull),viewer_rsvp_id(so a client can PATCH or DELETE the RSVP without a second lookup),has_checked_in, andcapacity_remaining. The three per-user fields are resolved in one batched query for the whole page (RsvpRepository::viewer_status_for_events()), never per row.capacity_remainingis not per-user - it is the event's going headcount against its capacity, andnullmeans unlimited (capacity0), not unknown. The per-user fields are emitted asnull/falsefor a guest rather than omitted, so the response shape never changes; a guest card page therefore stays cacheable, while an authenticated one is sent withCache-Control: no-store, private.Unless
include[]is set, the list is forward-looking, and that is driven by settings rather than by a parameter. Withshow_past_eventsoff (the default) anafter_utc = nowfloor is applied, soGET /eventsnever returns finished events. With it on,past_events_days(default0= no floor at all) sets how far back the window reaches. There is no request parameter that overrides either.include[]is the deliberate exception - it skips the floor entirely, so a saved/bookmarked list still resolves events that have ended.space_id- scope the list to a single space (community-group) id.space_idis a first-class indexed column on the events table (KEY space_status (space_id, status)), so this filter is cheap at scale; it powers the BuddyPress group Events tab (Pro). Pass?space_id=<id>to return only events linked to that space. Verified inincludes/REST/Controller/EventsController.php.
Event status / time fields. The canonical event status values are draft | pending | published | cancelled | private; the visible/public value is published (not WordPress's publish). Event start/end are the columns start_utc / end_utc (not starts_at / ends_at).
GET /events/{id} social-proof and viewer fields. The detail payload carries the same viewer_rsvp / viewer_rsvp_id / has_checked_in / capacity_remaining set as fields=card, plus:
going- the confirmed headcount.going_label- the same count pre-formatted server-side ("12 going"), correctly pluralized and translated, so a client renders it verbatim instead of carrying its own plural rules.attendees_preview- up to 5 entries of{ user_id, display_name }. Never an email address: this is the avatar stack a visitor already sees on the public event page, and the full roster stays behind the manager-onlyGET /events/{id}/attendees. Guests who registered without an account are counted ingoingbut never listed here.
As on the card list, an authenticated detail response is sent with Cache-Control: no-store, private.
The registration block is returned on both the card and the detail shape, and tells a client whether to offer registration at all:
{
"registration": {
"mode": "external",
"questions": [
{ "id": "dietary", "label": "Dietary needs", "type": "select", "required": false, "options": [ "None", "Vegetarian", "Vegan" ] }
],
"external_url": "https://example.org/tickets/summer-social"
}
}
modeis one ofrsvp(people register here),external(registration lives on another site) ornone(no registration needed). An unrecognised stored value resolves torsvp.questionsare resolved definitions, not raw ids. The question bank is a site-wide setting and an event stores only a selection, so ids alone would force every client to fetch and join the bank itself. Only the fields an attendee is asked to fill are exposed - never the whole event settings blob.external_urlis present only whenmodeisexternal, so a stale URL left behind by a mode change can never be mistaken for an active external flow.
Building a mobile or headless client? The Mobile API Contract collects these fields, the app bootstrap block, the ban gate and the caching rules in the order a client needs them.
POST /events writable fields. In addition to the usual event fields, space_id (int) is a writable foreign key: a neutral space link, not BuddyPress-specific. Passing space_id in the create body scopes the new event to that space; the value is threaded straight into the insert by EventsController::collect_input and EventService::create (no post-create second write). A community layer (Pro's BuddyPress bridge) validates group membership at create time via the evnm_before_create_event filter before the row is written. Verified in includes/REST/Controller/EventsController.php + includes/Services/EventService.php.
Pro (BuddyPress). With Eventonomy Pro's BuddyPress integration active, a member's events are exposed per bucket through
GET eventonomy/v1/member-events?user_id={owner}&bucket={organizing|going|interested|maybe}(the profile Events tab). The endpoint enforces visitor-privacy server-side: a non-owner viewer only ever receives the Organizing and Going buckets; Interested/Maybe are never serialized for anyone but the owner. It returns the standard list envelope. Verified ineventonomy-pro/includes/Integrations/BuddyPress/Rest/MemberEventsController.php.
Occurrences: /occurrences
Calendar query endpoint. Returns occurrence rows joined to a compact event summary.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /occurrences |
Public | Calendar list. Cursor pagination on (id). |
| GET | /events/{id}/occurrences |
Public | Occurrences for one event. |
| PATCH | /occurrences/{id} |
Event owner or evnm_edit_others_events |
Update a single occurrence. |
| DELETE | /occurrences/{id} |
Event owner or evnm_edit_others_events |
Soft-cancel an occurrence. |
Real parameters (OccurrencesController::get_occurrences): from, to, event_id, status, cursor, cursor_start, cursor_id, per_page.
from/tobound the window.fromdefaults to now andtoto now +recurrence_horizon_months(the setting, clamped 1-60, default 12 months), so an unparameterised call returns the next year, not everything.event_idnarrows to one event.per_pagedefaults to 12 and is clamped byevnm_rest_max_per_page.statusmaps to the event's status, not the occurrence's - occurrence-level status stays the repository default (active), so cancelled occurrences are always excluded. It runs through the samegate_public_status()gate asGET /events: a non-manager getspublished+ public only.cursoris the opaque token echoed back in the response'snext_cursor; it decodes to the(start_utc, id)keyset.cursor_start/cursor_idare the explicit back-compat form and take precedence overcursorwhen both are supplied.
There is no
categoryorvenuefilter on this route - neither name is read by the handler, so passing them is silently ignored and you get the unfiltered window back. Filter by category onGET /eventsinstead, or narrow withevent_id.
RSVPs: /rsvps
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /events/{id}/rsvp |
Logged-in or guest (name+email) | Create/update RSVP. Idempotent per identity. Pass occurrence_id in the body to scope to one occurrence of a recurring event. |
| PATCH | /rsvps/{id} |
can_manage_rsvp (RSVP owner, event manager, or magic-link token) |
Update an RSVP (status, guests). |
| DELETE | /rsvps/{id} |
can_manage_rsvp |
Cancel an RSVP. Triggers waitlist promotion. |
| POST | /rsvps/bulk |
evnm_manage_rsvps (manage_options) - site-wide, no ownership branch |
(1.1.0) Bulk RSVP action: going / cancelled / resend. |
| GET | /rsvps/mine |
Any logged-in user (always self-scoped) | The caller's own RSVPs. status=going|past, offset pagination. |
| GET | /events/{id}/attendees |
can_manage_event_rsvps - event owner, or evnm_manage_rsvps (manage_options) for events you do not own |
Attendee list. Offset pagination. |
| POST | /rsvp/magic-link |
Public (rate-limited) | Request an account-less management link by email. |
| GET | /rsvp/manage |
Public (the magic-link token is the credential) | Fetch the RSVP a token authorizes (the manage flow). |
POST /rsvps/bulk is site-wide, not event-scoped. It spans events, so it is gated on the site-manager capability evnm_manage_rsvps (manage_options) through Capabilities::user_can() directly - there is no ownership branch. An event owner without manage_options is refused (403) and keeps the per-row PATCH / DELETE /rsvps/{id} routes, whose own checks already cover their attendees.
GET /events/{id}/attendees changed in 1.3.0. Reading the roster of an event you do not own now requires evnm_manage_rsvps (manage_options); it previously fell through to edit_others_posts, which let any Editor read every organizer's attendee names and emails. Event owners are unaffected - they pass on ownership.
GET /rsvps/mine parameters: status (going for upcoming, past; default going), page, per_page (default 20, max 100). Always scoped to the authenticated user - there is no user parameter, so it cannot be pointed at anyone else. Returns the standard list envelope.
RSVP statuses: going, maybe, no, waitlist. There is no separate checked_in status; check-in is a checked_in_at timestamp column exposed as the derived checked_in flag.
There is no separate token-verify endpoint and no PATCH variant of the manage route. A guest presents the magic-link token directly to
GET /rsvp/manage(read) or toPATCH/DELETE /rsvps/{id}(write): the token itself is the credential; there is no verify/session-token exchange. Occurrence-scoped RSVPs are created throughPOST /events/{id}/rsvpwith anoccurrence_idbody param; there is no occurrence-nested RSVP route, and no DELETE-on-event cancel route (cancel isDELETE /rsvps/{id}).
Tickets: /events/{id}/tickets, /tickets/{id}
| Method | Path | Auth |
|---|---|---|
| GET | /events/{id}/tickets |
Public |
| POST | /events/{id}/tickets |
can_manage_event |
| PATCH | /tickets/{id} |
can_manage_ticket |
| DELETE | /tickets/{id} |
can_manage_ticket |
Update and delete address the ticket by its own id (/tickets/{id}), not nested under the event.
Ticket types: free, donation, paid. Defining paid tickets is allowed in Free; selling them returns 402 evnm_pro_required.
Orders: /orders
| Method | Path | Auth |
|---|---|---|
| POST | /orders |
Logged-in or guest |
| GET | /orders/{id} |
Order owner or evnm_manage_orders |
| GET | /orders |
evnm_manage_orders (event-scoped for organizers) |
| PATCH | /orders/{id} |
update_order_permissions_check (order owner's event manager / admin) |
| POST | /orders/bulk |
bulk_orders_permissions_check (event manager) |
Order statuses: pending, paid, cancelled, refunded.
PATCH /orders/{id} (1.1.0) applies a single status transition and only writes status; the existing evnm_after_update_order listeners do the materialization/reversal. Writable transitions are pending → paid and pending|paid → cancelled only. refunded is not writable through this route (Pro's refund flow owns it); an invalid or already-applied transition returns 409 evnm_order_state, and any target other than paid/cancelled returns 422.
POST /orders/bulk (1.1.0) runs the same cancel transition across many orders through the single canonical path.
Free completes only $0 orders. Orders with total > 0 return 402 evnm_pro_required.
Refunding an order (Pro)
| Method | Path | Auth |
|---|---|---|
| POST | /orders/{id}/refund |
Event organizer or manage_options (manage_orders cap) |
Registered by Pro (eventonomy-pro/includes/REST/RefundController.php) in Free's eventonomy/v1 namespace. The route takes no body - the only registered argument is the id in the path, and a refund Eventonomy issues is always the full order. Partial refunds are deliberately not offered here: issue those in the gateway's own dashboard, where they are recorded against the order through RefundService::reconcile() but leave it paid, the tickets valid, and organizer earnings not reversed proportionally. The buyer who owns the order cannot refund it; refunding is gated on the ability to manage the order's event. All logic runs through Pro's idempotent RefundService; the response is { status, refund_txn_id, amount }.
Venues & Organizers: /venues, /organizers
The shared, dedupable directory of reusable venue and organizer records (Free). Events link to them by id while keeping a JSON snapshot for display. Both resources expose the same route shape:
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /venues · /organizers |
Public | Search/list. ?search= filters by name; unified list envelope. |
| POST | /venues · /organizers |
Create-capable users | Create a record (server dedups by normalized name + city). |
| GET | /venues/{id} · /organizers/{id} |
Public | Single record. |
| PATCH | /venues/{id} · /organizers/{id} |
Editors | Update a record. |
| DELETE | /venues/{id} · /organizers/{id} |
Managers | Delete a record. |
| POST | /venues/{id}/merge · /organizers/{id}/merge |
Managers | Merge this record into another (re-points every event that used it). |
Media: /media/cover
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /media/cover |
upload_permission |
(1.1.0) Upload an event cover image. Size ceiling is filterable via evnm_cover_upload_max_bytes (default 5 MB); allowed MIME types via evnm_cover_upload_mimes. |
Import: /import/job
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /import/job |
evnm_manage_settings (401 for anon) |
(1.1.0) Poll the status/progress of the background migration import job. |
| POST | /import/job/resume |
evnm_manage_settings (401 for anon) |
Pick a stopped import back up from its persisted cursor. |
Both routes take no parameters. GET /import/job doubles as the stall watchdog: reading progress reschedules an interrupted job, so a client that polls it never has to ask for a resume. Both return { "job": <status payload> }, and GET returns { "job": null } when no job has ever run.
POST /import/job/resume is the explicit escape hatch for a chain that stopped for good. The job was always resumable - the cursor is persisted and dedup keys on import_source_ref, so re-running a row that already landed skips it - there was simply no way to ask for it once the chain stopped. It returns a WP_Error when there is nothing to resume.
Moderation: /moderation/reports/resolve
The admin counterpart to POST /events/{id}/report: it actions a report that a member filed.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /moderation/reports/resolve |
moderate_permissions_check - evnm_manage_settings (401 anon, 403 non-manager) |
Mark a report resolved, or re-open it. |
Body: event_id (required, integer), report_key (required, string) and resolved (boolean, default true). Pass resolved=false to re-open.
report_key is the stored evnm_meta key of the report, and it is validated against the exact shape this feature writes - report:{digits}:{digits}. Anything else returns 422 evnm_invalid_report, so no other meta row can be reached through this route. An unknown key returns 404 evnm_not_found.
Resolving stamps resolved_at (ISO 8601 UTC) and resolved_by (the acting user id) onto the stored report; re-opening removes both. Either way the write fires evnm_report_resolution_changed ($event_id, $report_key, $record) so an external moderation system can stay in step. The response is { event_id, report_key, resolved, resolved_at }.
Unsubscribe: /unsubscribe
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /unsubscribe |
permit_public_click (public; signed HMAC token is the credential) |
(1.1.0) One-click email unsubscribe / resubscribe landing. Markup is overridable via evnm_unsubscribe_page_html. |
ICS Feeds
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /events/{id}.ics |
Public (published/public rows) | Single-event VCALENDAR download. |
| GET | /calendar.ics |
Public | Subscribable VCALENDAR feed of upcoming public events. |
Neither route takes any parameters. IcsController::register_routes() registers methods, callback and permission_callback and no args key at all, and neither handler reads a query parameter - get_calendar_ics() never calls $request->get_param() once. There is no from, to, category, organizer or venue filter, no ?token= personal feed, and no ?occurrence_id= on the per-event route. Anything you append to the URL is silently ignored; the feed you get back is the same for every caller.
If you need a filtered or per-member feed, build it as your own route and reuse IcsService - do not expect these two to honour a query string.
What the two routes actually do:
- Both are gated on ICS being switched on - the
icsfeature flag and theics_feed_enabledsetting. With either off, both return404(evnm_not_found), not an empty calendar. /calendar.icsis hard-capped: onestatus=publishedquery atper_page=100, then up to 100 occurrences per event. There is no pagination and no filter to raise it, so a site with more than 100 published events does not get all of them in the feed./calendar.icsis forward-looking. Occurrences that have already started are dropped, and an event whose occurrences are all in the past is excluded entirely, so a subscriber's calendar does not fill with ended events. The per-event route has no such filter - it exports every active occurrence of that event.- Cancelled occurrences are excluded from both (
status=active). - A recurring event is emitted as ONE
VEVENTcarrying anRRULE, anchored to its first occurrence - not oneVEVENTper expanded occurrence, which would make the series unreadable to calendar clients. This is unconditional: there is no filter to turnRRULEemission off and fall back to expanded rows. - Both emit raw
text/calendarand exit; neither returns the JSON list envelope.
App Config
GET /settings/app-config # public bootstrap - features, currency, timezone, is_pro_active
Public and cacheable: it carries only site-level config (formatting, feature flags, and the app block). Per-user data deliberately lives on /me instead, so a CDN or REST cache can never hand one member's identity to another.
The app block is the mobile bootstrap, built by SettingsController::app_bootstrap():
{
"app": {
"app_enabled": true,
"min_app_version": "1.0.0",
"branding": {
"app_name": "Riverside Events",
"logo_url": "https://example.com/wp-content/uploads/site-icon.png",
"login_bg_url": "",
"accent_color": "#4d7c0f"
},
"legal": { "privacy_url": "", "terms_url": "" },
"push": { "enabled": true }
}
}
app_enabledis a two-key gate. Free sets it totrueonly when Pro is active and the owner'sapp_enabledsetting is on; Pro then ANDs a valid license on top throughevnm_rest_app_config. It is fail-closed, so a Free-only site always reportsfalseand a lapsed license flips it tofalseat the next connect.branding.app_namefalls back to the site title,logo_urlto the WordPress site icon, andaccent_colorto#4d7c0f, so none of the three is ever empty by accident.legal.privacy_urlandlegal.terms_urlare both empty by default. The privacy URL comes fromget_privacy_policy_url(), which returns nothing until the WordPress privacy page is published - and WordPress creates that page as a draft - and the terms URL comes from theapp_terms_urlsetting, which ships blank. Treat both as optional strings.- Extend the payload with
evnm_rest_app_config. Anything added here is public and cacheable, so never put per-user data on this route.
Full client-side detail: Mobile API Contract.
Current User - /me
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /me |
Any logged-in user | The caller's identity block. Sent with Cache-Control: no-store, private. |
| DELETE | /me |
Any logged-in user (self only) | Self-serve account deletion. |
GET /me returns id, display_name, username, email, avatar_url, roles, and can_create_events (the same resolved gate the create-event UI uses, so a client never offers an action the API will refuse). Extend the payload with the evnm_rest_me filter:
add_filter( 'evnm_rest_me', function ( array $data, \WP_User $user ) {
$data['my_addon_badges'] = my_addon_badges_for( $user->ID );
return $data;
}, 10, 2 );
DELETE /me is the App Store / Play Store account-deletion requirement. It always acts on the caller (there is no id parameter) and takes a required confirm_username body param that must equal the caller's exact user_login - a mismatch returns 422 evnm_confirm_mismatch. Administrators and super-admins cannot self-delete through the API and receive 403 evnm_admin_no_self_delete. On success the member's RSVPs and orders are anonymized through the existing privacy eraser, the WordPress user is deleted, and the response is { "deleted": true }. Events they authored remain as public records.
Billing Details - /me/billing
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /me/billing |
Any logged-in user | The caller's saved billing/invoice details. |
| POST · PUT · PATCH | /me/billing |
Any logged-in user (self only) | Save or update them. |
Billing identity lives on the user, not the order, and is stored on the same
billing_* user-meta keys WooCommerce uses - so one saved address serves every
purchase, and a site already running Woo inherits what the customer has. A paid
order snapshots these values at purchase time, so editing them later never rewrites
a historical invoice.
Always self-scoped: there is no id parameter, and the caller can only ever read or write their own record.
Notifications - /notifications
The in-app notification feed. Every route is auth-only and permanently scoped to the current user; reads carry Cache-Control: no-store, private.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /notifications |
Any logged-in user | The caller's feed. page, per_page (default 20, max 100). Standard list envelope. |
| GET | /notifications/unread-count |
Any logged-in user | { "unread": 3 } - the badge count. |
| POST | /notifications/{id}/read |
Any logged-in user | Mark one notification read. Returns { "read": true }. |
| POST | /notifications/read-all |
Any logged-in user | Mark every notification read. Returns { "updated": <count> }. |
Marking read is scoped to the caller server-side, so passing another member's notification id simply matches nothing and returns { "read": false }.
Push Devices - /push/devices
Device-token registration for mobile push. Tokens are stored per user (one member can have several devices) in the evnm_push_tokens user meta - no new table. Delivery itself is Pro's job.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /push/devices |
Any logged-in user | Register or refresh a token. Body: token (required), platform (ios / android, optional). Returns { "registered": true }. |
| DELETE | /push/devices |
Any logged-in user | Remove a token on logout or uninstall. Body: token (required). Returns { "removed": true }. |
Re-posting an existing token refreshes its updated_at rather than duplicating it, so a client can register on every launch. DELETE is idempotent - an unknown token still returns { "removed": true }.
Event Reports - /events/{id}/report
Lets a member flag an event for moderation (a store-compliance requirement for user-generated content).
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /events/{id}/report |
Any logged-in user | Report an event. Returns { "reported": true }. |
Body: reason (required - spam, inappropriate, scam, misinformation, other) and note (optional free text). Reporters must be logged in, and are capped at 5 reports per hour (429 evnm_too_many_requests). Only publicly viewable events can be reported; anything else returns 404. Each report is recorded in evnm_meta (reports never overwrite each other) and fires evnm_event_reported so an add-on can build a moderation queue:
add_action( 'evnm_event_reported', function ( int $event_id, array $report ) {
// $report = [ 'reason', 'note', 'user_id', 'reported_at' ]
my_addon_queue_for_review( $event_id, $report );
}, 10, 2 );
Bulk Read: /events/bulk
POST /events/bulk is a public batch read, not an authed mutation. It is the batch counterpart to GET /events/{id}: fetch many events by id in one round trip (e.g. a member's saved/bookmarked list). It has permission_callback => __return_true and takes no action.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /events/bulk |
Public | Fetch events by id. Body: { "ids": [1, 2, 3] }. |
idsmust be an array (non-array →422 evnm_invalid_ids); more than 50 ids →422 evnm_too_many.- Rows the caller may not publicly view are silently dropped from the result, so the response only ever contains publicly-viewable events.
- Returns the standard list envelope, preserving the requested id order.
There are no publish / draft / trash / feature bulk-mutation actions on this route.
Pro-Only Resources
With Eventonomy Pro active, additional routes register in the same eventonomy/v1 namespace (controllers under eventonomy-pro/includes/REST/):
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /coupons/validate |
nonce_required (a valid X-WP-Nonce for wp_rest) |
Validate a coupon code against a subtotal. Body: code (required), subtotal (required, number). |
| GET | /discovery |
Public (read-only browse over published events) | Discovery event feed. Registered only while the discovery feature flag is on. |
| POST | /checkin |
can_check_in |
Check an attendee in (QR scan). Body: token (required), event_id (optional). |
| GET | /checkin/stats |
can_read_stats |
Live check-in counts for an event. event_id required. |
| POST | /orders/{id}/refund |
can_refund |
Refund an order (Pro's RefundService). |
| GET · POST · PUT/PATCH · DELETE | /follows |
auth_required |
Follow / unfollow organizers and events. |
| GET | /member-events |
Public | A member's events per bucket (BuddyPress profile tab). Non-owner viewers receive only the organizing and going buckets; see the callout above. |
| ALLMETHODS | /payments/{gateway}/webhook |
Public (gateway signature verified in-handler) | Inbound gateway webhook (Stripe/PayPal/etc.). |
| GET | /payments/return |
Public | Buyer return/landing after an off-site payment. |
| GET | /pro/earnings/me |
Any logged-in user (always self-scoped) | The caller's own earnings summary plus recent payouts. |
| GET | /pro/earnings/me/events |
Any logged-in user (always self-scoped) | The caller's own per-event earnings. page, per_page (default 20, max 100). |
| GET | /pro/connect/authorize |
Any logged-in user | Mint a single-use OAuth state token and return the Stripe authorize URL. |
| GET | /pro/connect/return |
Public (the single-use state token is the credential) |
The OAuth redirect target registered with Stripe. |
| POST | /pro/connect/webhook |
Public (Connect webhook signature verified in-handler) | Inbound Stripe Connect account webhook. |
| GET | /pro/connect/status |
Any logged-in user | The caller's own Connect connection state. |
| DELETE | /pro/connect |
Any logged-in user | Forget the caller's Connect linkage on our side. |
| GET · POST | /pro/admin/payouts |
manage_options |
List organizers with an outstanding balance; POST records a payout against one (the PayoutService chokepoint). |
| GET | /pro/admin/payouts/history |
manage_options |
Recent payouts, newest first. |
Check-in auth is event-scoped, not a role. There is no "staff" capability: both check-in routes require a logged-in user (401 evnm_pro_not_logged_in) and then resolve the event and call evnm_user_can_manage_event( $user, $event, 'manage_rsvps' ) - so the event's own organizer passes on ownership, and everyone else needs evnm_manage_rsvps (manage_options). Anything else is 403 evnm_pro_forbidden. POST /checkin will resolve the event from the checkin_token when event_id is omitted, so the gate still applies to the right event.
GET /discovery is feature-gated at registration time. Its provider only calls register_routes() when evnm_feature_enabled( 'discovery' ) is true, so with discovery switched off the path is not registered at all and returns 404 rest_no_route rather than an empty feed. Parameters: city, category, search, cursor, page (default 1), per_page (default 20, max 100). It is deliberately public - the service forces status=published and every row goes through evnm_rest_prepare_event, the same public surface as Free's GET /events.
Earnings is self-scoped by construction. organizer_id is always get_current_user_id() and is never a request parameter, so one organizer can never read another's ledger. There is no admin-facing earnings route; the site owner's view is /pro/admin/payouts.
Connect is per-member Stripe onboarding. GET /pro/connect/authorize mints a single-use state token mapped to the caller and hands back the Stripe authorize URL. GET /pro/connect/return is public because Stripe redirects the member's browser to it with no WordPress nonce - the single-use state token is the credential, consumed once, exactly the trust model the payment webhook uses. POST /pro/connect/webhook is public for the same reason a gateway webhook is: Stripe posts server-to-server and the signature is verified in the handler.
POST /orders/{id}/refund takes no body: a refund Eventonomy issues is always the FULL order. Partial refunds are not offered through the API or the admin UI - issue those in the gateway's own dashboard, where they are recorded against the order but leave it paid, the tickets valid, and organizer earnings NOT reversed proportionally. The buyer who owns the order cannot refund it; refunding is gated on the ability to manage the order's event.
Calendar sync feeds (Pro)
Pro adds calendar-sync subscriptions in two scopes, deliberately split into two route trees with two different gates rather than one route with a mode flag.
Member scope - /pro/calendar-feeds (eventonomy-pro/includes/REST/CalendarFeedController.php). A member connects their own remote calendar from the frontend dashboard, with no wp-admin access. Gated on evnm_user_can_create_events() - the same gate as POST /events, so whoever may submit an event may sync one.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /pro/calendar-feeds |
Create-capable users | The caller's own feeds. |
| POST | /pro/calendar-feeds |
Create-capable users | Connect a feed. |
| POST | /pro/calendar-feeds/scan |
Create-capable users | Dry-run preview: { "count": 42 }. Imports nothing. |
| POST | /pro/calendar-feeds/{id}/sync |
Feed owner | Run this feed now. |
| DELETE | /pro/calendar-feeds/{id} |
Feed owner | Remove the subscription. |
Admin scope - /pro/admin/calendar-feeds (AdminCalendarFeedController.php). The site owner's counterpart, backing the Sync Sources admin page. Every route is gated on manage_options. It lists every feed (site-wide feeds plus members' feeds) with page / per_page (default 20, max 100), and can run or remove any of them.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /pro/admin/calendar-feeds |
manage_options |
Every feed on the site. |
| POST | /pro/admin/calendar-feeds |
manage_options |
Add a site-wide feed (owner_id 0). |
| POST | /pro/admin/calendar-feeds/scan |
manage_options |
Dry-run preview: { "count": 42 }. |
| POST | /pro/admin/calendar-feeds/{id}/sync |
manage_options |
Run any feed now. |
| DELETE | /pro/admin/calendar-feeds/{id} |
manage_options |
Remove any feed. |
Write body for both (POST): source_type (remote-ics default, or eventbrite), url (required for remote-ics; must be a valid http/https address), and cadence (hourly / daily / weekly / monthly, default daily). The member route also takes token - the member's own Eventbrite private token, stored encrypted; the admin route instead uses the site-wide token from Settings → Integrations. Members are capped at 20 feeds, filterable via evnm_pro_member_feed_cap.
On the {id} routes the two scopes differ exactly where you would expect: the member route resolves the feed's owner and returns 403 evnm_pro_feed_forbidden for someone else's feed (an administrator still passes), while the admin route reaches any feed. Both return 404 evnm_pro_feed_missing for an unknown id. DELETE removes only the subscription - events already imported stay put, authored by whoever imported them.
Pagination
| Endpoint | Strategy |
|---|---|
GET /events |
Offset (?page=); ?cursor= accepted as a keyset fallback |
GET /occurrences |
Cursor |
GET /events/{id}/attendees |
Offset (next_cursor is always null) |
GET /events/{id}/tickets |
Offset |
GET /orders |
Offset |
Cursor parameters: ?cursor=<opaque_id>, ?per_page=. Offset parameters: ?page=, ?per_page=. Default per_page = 12, max = 100. The per-page ceiling is filterable via evnm_rest_max_per_page (default 100); the max page number via evnm_rest_max_page.
Error Contract
All errors use WP_Error with an evnm_ code prefix:
{
"code": "evnm_not_found",
"message": "Event not found.",
"data": { "status": 404 }
}
Validation errors (422) include per-field detail under data.errors.
Common codes: evnm_unauthorized (401), evnm_forbidden (403), evnm_not_found (404), evnm_validation_failed (422), evnm_rsvp_closed (409), evnm_capacity_full (409), evnm_order_state (409, invalid order transition), evnm_pro_required (402), evnm_rate_limited (429), evnm_too_many (422, more than 50 ids in /events/bulk).
Adding Your Own Endpoints
Register in any namespace and reuse Eventonomy services via evnm():
add_action( 'rest_api_init', function () {
register_rest_route( 'my-addon/v1', '/events/(?P<id>\d+)/my-data', [
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => function ( $req ) {
$event = evnm( \Eventonomy\Contracts\EventRepositoryInterface::class )
->get( (int) $req['id'] );
return rest_ensure_response( [ 'event_id' => $event['id'] ] );
},
] );
} );
What's Next?
Browse the full hook and filter catalog.