Eventonomy

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) and eventonomy-pro/includes/REST/*.php (Pro). The older docs/REST-API.md in 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.json lists put alongside post and patch on update routes. That is an artifact of WP_REST_Server::EDITABLE being 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_page and leave next_cursor null.
  • Cursor endpoints fill next_cursor and leave page / per_page null.

A repository query() result on its own carries only { items, total, pages, has_more }; the page, per_page, and next_cursor keys are added by respond_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 through Capabilities::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 of user_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).

  • tag is a sibling of category and takes a term slug, not a name or id. Both resolve against the native WordPress taxonomies bound to the evnm_event object type.

  • fields=card is 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=card also carries the viewer's own state. Each item gains viewer_rsvp (the caller's RSVP status on that event, or null), viewer_rsvp_id (so a client can PATCH or DELETE the RSVP without a second lookup), has_checked_in, and capacity_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_remaining is not per-user - it is the event's going headcount against its capacity, and null means unlimited (capacity 0), not unknown. The per-user fields are emitted as null / false for a guest rather than omitted, so the response shape never changes; a guest card page therefore stays cacheable, while an authenticated one is sent with Cache-Control: no-store, private.

  • Unless include[] is set, the list is forward-looking, and that is driven by settings rather than by a parameter. With show_past_events off (the default) an after_utc = now floor is applied, so GET /events never returns finished events. With it on, past_events_days (default 0 = 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_id is 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 in includes/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-only GET /events/{id}/attendees. Guests who registered without an account are counted in going but 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"
  }
}
  • mode is one of rsvp (people register here), external (registration lives on another site) or none (no registration needed). An unrecognised stored value resolves to rsvp.
  • questions are 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_url is present only when mode is external, 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 in eventonomy-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 / to bound the window. from defaults to now and to to now + recurrence_horizon_months (the setting, clamped 1-60, default 12 months), so an unparameterised call returns the next year, not everything.
  • event_id narrows to one event. per_page defaults to 12 and is clamped by evnm_rest_max_per_page.
  • status maps 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 same gate_public_status() gate as GET /events: a non-manager gets published + public only.
  • cursor is the opaque token echoed back in the response's next_cursor; it decodes to the (start_utc, id) keyset. cursor_start / cursor_id are the explicit back-compat form and take precedence over cursor when both are supplied.

There is no category or venue filter 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 on GET /events instead, or narrow with event_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 to PATCH / DELETE /rsvps/{id} (write): the token itself is the credential; there is no verify/session-token exchange. Occurrence-scoped RSVPs are created through POST /events/{id}/rsvp with an occurrence_id body param; there is no occurrence-nested RSVP route, and no DELETE-on-event cancel route (cancel is DELETE /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 ics feature flag and the ics_feed_enabled setting. With either off, both return 404 (evnm_not_found), not an empty calendar.
  • /calendar.ics is hard-capped: one status=published query at per_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.ics is 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 VEVENT carrying an RRULE, anchored to its first occurrence - not one VEVENT per expanded occurrence, which would make the series unreadable to calendar clients. This is unconditional: there is no filter to turn RRULE emission off and fall back to expanded rows.
  • Both emit raw text/calendar and 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_enabled is a two-key gate. Free sets it to true only when Pro is active and the owner's app_enabled setting is on; Pro then ANDs a valid license on top through evnm_rest_app_config. It is fail-closed, so a Free-only site always reports false and a lapsed license flips it to false at the next connect.
  • branding.app_name falls back to the site title, logo_url to the WordPress site icon, and accent_color to #4d7c0f, so none of the three is ever empty by accident.
  • legal.privacy_url and legal.terms_url are both empty by default. The privacy URL comes from get_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 the app_terms_url setting, 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] }.
  • ids must 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.

Hooks & Filters →