Mobile API Contract
This page is the plugin side of the contract a companion mobile app consumes. Everything here is a normal part of the eventonomy/v1 REST surface documented in the REST API Reference - there is no separate "app API", no second namespace, and no app-only authentication scheme. This page collects the routes and fields an app actually needs, in the order an app needs them, and states what the plugin does not own.
The app itself is a separate product and is not shipped with the plugin. Nothing on this page installs, builds, or configures an app - it documents what the site serves.
What You Will Learn
- How an app authenticates, and why there is no first-party token exchange
- The connect handshake: the public bootstrap block and the per-user identity block
- The event read contract: which viewer-relative fields the list and the detail carry, and which of them are cacheable
- The registration contract that tells an app whether to render a register button at all
- Where the write surface is closed to banned accounts
- What Eventonomy does not own: CORS, transport security, and app packaging
Contract Map
Each capability below is one row of the contract. The identifiers are the ones the app-side contract document uses, so the two can be read side by side.
| Id | Capability | Where it lives |
|---|---|---|
| C1a | Public bootstrap (branding, gating, legal, minimum version) | GET /settings/app-config -> the app block |
| C1b | Per-user identity | GET /me |
| C1c | License gate on the app | Pro filters evnm_rest_app_config |
| C2 | Ban gate on writes | rest_pre_dispatch, evnm_user_is_banned |
| C3 | Native push | POST / DELETE /push/devices (Free registers, Pro delivers) |
| C4 | Viewer-relative event state | GET /events?fields=card and GET /events/{id} |
| C5 | Check-in token for free registrations | Minted inline when an RSVP is created |
| C6 | In-app notification feed | /notifications routes |
| C7 | Self-serve account deletion | DELETE /me |
| C8 | Report an event | POST /events/{id}/report |
| C9 | My registrations (upcoming and past) | GET /rsvps/mine |
Authentication
Auth is WordPress's own Application Passwords over HTTP Basic. There is no first-party JWT and no refresh-token flow - the app stores an application password and sends it on every request.
There are three ways the app can come to hold one, and the site's own config says which are open (see the auth block below):
- The site's connect bridge, when one is advertised. On a site running BuddyNext alongside Eventonomy, BuddyNext owns site auth and its bridge carries the site's social providers and two-factor; Eventonomy registers
eventonomy://in its allowlist and stands its own bridge down. One door per site. - WordPress core's authorize screen (
wp-admin/authorize-application.php), the standalone door. Eventonomy keeps it usable: the app's deep-link scheme survivesesc_url()there, and a WooCommerce-style wp-admin block is exempted for that one screen so an attendee can actually reach it. POST /auth/app-password, which trades a member's ordinary WordPress username and password for an application password. This exists because core will not: its Basic auth accepts application passwords only, so the core route that mints them already requires one, and every core path to a member's first credential runs through wp-admin. It is still Application Passwords over Basic - this endpoint only issues the credential, it does not invent a session.
The exchange authenticates through wp_authenticate(), so every authenticate filter on the site still runs, and it answers 409 rather than pretending a password was wrong when the site wants an interactive second factor. It is TLS-gated, rate-limited per IP and per username, uniform in its failure message so it cannot enumerate accounts, and the site owner can switch it off (password_login below).
curl -u "jane:abcd EFGH ijkl MNOP qrst UVWX" \
https://example.com/wp-json/eventonomy/v1/me
Two consequences an app must plan for:
- HTTPS is mandatory in production. WordPress core disables Application Passwords on a site it does not consider secure, so the credential screen is simply absent over plain HTTP. This is core behaviour, not an Eventonomy setting, and Eventonomy cannot relax it.
- An application password bypasses any plugin-level login gate, because core authenticates it on
rest_authentication_errorsbefore any Eventonomy code runs. That is exactly why the write surface has its own gate - see below.
The same reasoning applies to POST /auth/app-password itself, in the one way the ban gate cannot cover: that caller is anonymous by definition - it is asking for its first credential - so rest_pre_dispatch sees user 0 and lets it past. The exchange therefore re-checks the ban explicitly, right after wp_authenticate() identifies who is asking, through the same evnm_user_is_banned signal. A banned member cannot type their password and walk away with a working credential.
Cookie plus X-WP-Nonce still works for in-browser clients and is unchanged.
The Ban Gate on Writes
includes/Providers/BanGateProvider.php hooks rest_pre_dispatch and rejects a banned member on every non-read request whose route starts with /eventonomy/v1. GET, HEAD and OPTIONS stay open, so a banned member can still browse.
add_filter( 'evnm_user_is_banned', function ( bool $banned, int $user_id ): bool {
return $banned || my_community_is_suspended( $user_id );
}, 10, 2 );
Out of the box the signal reads a truthy evnm_banned user meta, which an administrator sets from the user profile screen. Guest writes (guest RSVP, guest order, magic-link requests) run as user 0 and are not covered by this gate - they are covered by the spam guard and rate limiter instead.
A blocked request returns 403 evnm_forbidden.
Connect Handshake
1. Public bootstrap - GET /settings/app-config
The whole payload is site-level and cacheable. The app-specific part is the app key, 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
}
},
"auth": {
"social_providers": [],
"twofactor": false,
"register": true,
"app_passwords_available": true,
"connect_url": "https://example.com/login/connect-app/",
"connect_schemes": ["eventonomy"]
},
"password_login": true
}
The auth block is a shared Wbcom shape, published in the same form by every Wbcom product, so one reader in the app serves all of them. It tells the app which of the three doors above are open on THIS site:
| Key | Source | Notes |
|---|---|---|
app_enabled |
Pro active and the app_enabled setting |
Pro then ANDs a valid license on top. Fail-closed. |
min_app_version |
app_min_version setting |
Defaults to 1.0.0. The app decides what to do with a build below it. |
branding.app_name |
app_name setting, else the site title |
Never empty. |
branding.logo_url |
app_logo setting, else the WordPress site icon |
Empty when neither is set. |
branding.login_bg_url |
app_login_bg setting |
Empty by default. |
branding.accent_color |
app_accent setting, else #4d7c0f |
Never empty. |
legal.privacy_url |
get_privacy_policy_url() |
Empty unless the WordPress privacy page is published. |
legal.terms_url |
app_terms_url setting |
Empty by default. |
push.enabled |
app_push_enabled setting |
Whether the app should register the device at all. |
auth.connect_url |
The site's connect bridge | BuddyNext's bridge when BuddyNext is active; empty otherwise - Eventonomy ships no bridge of its own, so an empty string means "use core's authorize screen". Never guess this path. |
auth.connect_schemes |
evnm_app_connect_schemes |
Deep-link schemes this site may hand a credential to. eventonomy is ours; siblings join through the filter. |
auth.app_passwords_available |
wp_is_application_passwords_available() |
False on an insecure site, where no door can work. |
auth.register |
users_can_register |
Whether to offer "Create account" (opens the site's own registration page in the browser, so the owner's signup flow stays intact). |
auth.social_providers / auth.twofactor |
Always [] / false here |
Eventonomy has no social login of its own, and the exchange cannot complete an inline second factor. On a combined site the bridge carries both behind BuddyNext's login page. |
password_login |
app_password_login setting, filter evnm_app_password_login_enabled |
Whether POST /auth/app-password is open. Default on; an owner running 2FA turns it off so every member takes the interactive flow. The app must read this before rendering the control, so it never offers a path this site will refuse. |
Both legal URLs are empty on a fresh install, and the privacy one stays empty even on a site that has a privacy page, because WordPress creates that page as a draft and get_privacy_policy_url() returns nothing for an unpublished page. An app must treat both as optional strings and hide the link rather than render a dead one. The owner-facing fix is documented in Mobile App.
app_enabled is a two-key gate, and Free provides only the first key:
// Free: provisional.
'app_enabled' => defined( 'EVENTONOMY_PRO_VERSION' ) && (bool) Settings::get( 'app_enabled', true ),
// Pro, on evnm_rest_app_config (priority 20): AND a valid license.
$config['app']['app_enabled'] = ! empty( $config['app']['app_enabled'] ) && License::is_valid();
So a Free-only site always reports app_enabled: false and the app cannot be enabled there at all, and a Pro site whose license lapses starts reporting false at the next connect.
Extend the payload with evnm_rest_app_config. Anything you add here is public and cacheable - never put per-user data on this route.
2. Per-user identity - GET /me
Auth-only, and always sent with Cache-Control: no-store, private so a shared cache can never hand one member's identity to another. Returns id, display_name, username, email, avatar_url, roles, and can_create_events - the resolved gate, so the app never offers a create action the API will refuse. Extend it with evnm_rest_me.
The Event Read Contract
Two shapes, one preparer. GET /events?fields=card returns the lean card payload; GET /events/{id} returns the detail payload. Both carry the viewer-relative fields below.
On the card list - GET /events?fields=card
| Field | Type | Meaning |
|---|---|---|
viewer_rsvp |
string or null | The signed-in member's own RSVP status on this event, or null. |
viewer_rsvp_id |
int or null | The id of that RSVP, so the app can PATCH or DELETE it without a lookup. |
has_checked_in |
bool | Whether the member has already been checked in. |
capacity_remaining |
int or null | Places left. null means unlimited (capacity 0), not "unknown". |
The three viewer_* / has_checked_in fields are resolved in one batched call for the whole page (RsvpRepository::viewer_status_for_events()), never per row. capacity_remaining is not per-user - it is the event's headcount against its capacity - so it is attached for guests too.
Caching is split deliberately. The per-user fields are emitted as null / false rather than omitted, so the response shape is identical for a guest and a member. A guest card page carries no per-user data and stays cacheable; once the request is authenticated the response is sent with Cache-Control: no-store, private.
GET /wp-json/eventonomy/v1/events?fields=card&per_page=20
fields=card is response-shaping, not a filter. It also attaches the display-ready card fields (date, month, day, going, going_label, category, price_label, hue, tags) through batched queries, so an appended page never triggers an N+1.
On the detail - GET /events/{id}
Everything from the card contract, plus:
| Field | Type | Meaning |
|---|---|---|
going |
int | Confirmed headcount. |
going_label |
string | The same count pre-formatted server-side ("12 going"), correctly pluralized and translated. Empty string when nobody is going. |
attendees_preview |
array | Up to 5 entries of { user_id, display_name }. |
registration |
object | The registration contract - see below. |
attendees_preview carries user_id and display_name only, and never an email address. It is the avatar stack a visitor already sees on the public event page; the full attendee roster stays behind GET /events/{id}/attendees, which is manager-only. Guests who registered without an account are counted in going but are never listed.
going_label is formatted on the server on purpose: the app renders it verbatim and does not need its own plural rules or translation catalogue for the count.
The registration contract
{
"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 unknown stored value resolves torsvp. Read this before rendering anything: an app that always shows a register button will offer registration on an event that refuses it.questionsare resolved definitions, not raw ids. The question bank is a site-wide setting and an event stores only a selection, so returning ids alone would force every client to fetch and join the bank itself. Each entry isid,label,type,required,options. Only the fields an attendee is asked to fill are exposed - never the whole event settings blob.external_urlis present only whenmodeisexternal. It is omitted otherwise, so a stale URL left over from a mode change can never be mistaken for an active external flow.
The registration block is attached for both the card and the detail shape, so a list screen can already tell which cards should show a register affordance.
Virtual events
virtual.join_url and virtual.instructions are emitted as empty strings to anyone who is not a registered attendee of that event. Do not cache an authenticated event detail response into a shared store.
The Rest of the App Surface
These routes are documented in full in the REST API Reference; this is the app-relevant summary.
| Need | Route | Notes |
|---|---|---|
| My registrations | GET /rsvps/mine?status=going|past |
Always self-scoped; there is no user parameter. |
| Notification feed | GET /notifications, GET /notifications/unread-count, POST /notifications/{id}/read, POST /notifications/read-all |
Auth-only, self-scoped, no-store, private. |
| Push registration | POST /push/devices, DELETE /push/devices |
Tokens live in the evnm_push_tokens user meta. Re-posting a token refreshes it; DELETE is idempotent. Registration is Free; delivery is Pro. |
| Report an event | POST /events/{id}/report |
Logged-in only, 5 per hour. Store-compliance requirement. |
| Delete my account | DELETE /me |
Requires an exact confirm_username; administrators are refused. |
What Eventonomy Does Not Own
CORS
The plugin ships no CORS handling. There is no Access-Control-Allow-* header emitted anywhere, and nothing hooks rest_pre_serve_request or send_headers to add one. Requests to the REST API are served with whatever headers WordPress core and the web server produce.
What that means in practice:
- A native app build is unaffected. Native HTTP clients on iOS and Android are not browsers and do not perform a CORS preflight, so nothing needs allowlisting.
- A browser-hosted build does need allowlisting - a web build, a development tunnel, or anything running the app inside a browser origin that differs from the site's origin. That is a server or site-level concern: configure it in the web server, a reverse proxy, or a small site snippet on
rest_pre_serve_request. It is deliberately not an Eventonomy setting, because a plugin that guesses an origin allowlist is a plugin that eventually opens one it should not have.
Do not look for a CORS option in the plugin settings; there is none, and adding one is not on the contract.
Transport security
HTTPS is a hosting concern. Eventonomy does not enforce it, but Application Passwords will not be offered by WordPress on an insecure site, so an app cannot practically authenticate against one.
App packaging
Store builds, native push credentials, deep links, and the app's own error copy live in the app project, not here. Nothing in this repository configures them.
What's Next?
Read the full endpoint listing, including every parameter and error code.