docs/enterprise/event-handler-api.md
New in reflex-enterprise v0.7.1.
rxe.EventHandlerAPIPlugin exposes your app's registered event handlers as
HTTP POST endpoints and auto-generates an OpenAPI 3 specification for them.
This turns any Reflex app into a machine-driveable API without writing a single
route by hand — great for CLI scripts, end-to-end tests, or external
integrations that need to drive the same logic the frontend uses.
# Requires `reflex >= 0.9.0` and `reflex-enterprise`. The plugin only works with `rxe.App`.
# Authentication changed in reflex-enterprise v0.9.4.
Endpoints now require a token the **app issued** — a caller-invented UUID is no longer accepted. Fetch one from `POST /_reflex/auth/token` (see [Authentication](#authentication)). Calls are also rate limited per token, and framework/auth event handlers are no longer exposed.
For driving the app from an LLM agent, Auto MCP publishes the same handlers over the Model Context Protocol instead of REST. The two plugins share one implementation — the same handler filtering, naming, authentication, and rate limits — so they can be enabled together and will agree.
When the plugin is enabled, the following routes are added to the backend:
| Path | Purpose |
|---|---|
POST /_reflex/auth/token | Issues a bearer token bound to a fresh, server-generated session. Rate limited per client IP. |
POST /_reflex/event/<state_name>/<handler_name> | One endpoint per @rx.event handler on every application state class. Streams state deltas as newline-delimited JSON. |
POST /_reflex/retrieve_state | Returns the full root state .dict() for the token's session without re-hydrating client storage. |
GET /_reflex/events/openapi.yaml | Auto-generated OpenAPI 3 specification describing every endpoint above. |
GET,HEAD /.well-known/api-catalog | RFC 9727 API catalog pointing at the OpenAPI spec (RFC 9264 Linkset). |
Handler argument names and type annotations are introspected to build each
requestBody schema, and the docstring's first line becomes the endpoint
summary. Handlers registered as page on_load triggers are listed in the
description field of the spec so API consumers can tell which endpoint is
invoked when a given page is "visited".
Add the plugin to the plugins list of rxe.Config in rxconfig.py:
import reflex as rx
import reflex_enterprise as rxe
config = rxe.Config(
app_name="my_app",
plugins=[
rxe.EventHandlerAPIPlugin(
# Every argument is optional.
api_version="1.0.0",
contact={"name": "Ops", "email": "[email protected]"},
license_info={
"name": "Apache 2.0",
"url": "https://opensource.org/licenses/Apache-2.0",
},
)
],
)
Your app must use rxe.App() (not rx.App()):
import reflex_enterprise as rxe
app = rxe.App()
| Option | Default | Purpose |
|---|---|---|
api_version | "1.0.0" | info.version in the generated spec. |
contact / license_info | None | info.contact / info.license objects. |
call_rate_limit / call_rate_window | 60 / 60.0 | Per-session-token cap on API calls across all endpoints. Per-handler override via rxe.event(rate_limit=...). |
token_rate_limit / token_rate_window | 10 / 60.0 | Per-client-IP cap on POST /_reflex/auth/token grants. |
anonymous_sessions | True | Whether this plugin wires the anonymous token endpoint. |
anonymous_session_ttl | 3600 | Anonymous token lifetime in seconds. There is no refresh — an expired token means a fresh session. |
trusted_proxy_hops | 0 | Number of trusted reverse proxies, used to resolve the real client IP for the token endpoint's per-IP limit. 0 ignores X-Forwarded-For. |
token_store | auto | Token storage. Defaults to Redis when the app is configured for Redis, otherwise in-process. |
Setting a rate limit to 0 disables it, which is not recommended in
production.
# The backend serves the API on the Reflex backend port (default `http://localhost:8000` in dev, or the `deploy_url` in production). If you're running production with `--single-port`, the API is instead reachable on the frontend port (default `http://localhost:3000`).
Every endpoint requires an app-issued bearer token in the Authorization
header:
Authorization: Bearer <access_token>
The token identifies a client session, and that session is generated by the server — a caller can neither pick nor see the underlying Reflex client token, so a credential can only ever address its own session, never a browser session or another client's.
POST /_reflex/auth/token mints one, bound to a fresh, empty session:
curl -X POST http://localhost:8000/_reflex/auth/token
{
"access_token": "…",
"token_type": "Bearer",
"expires_in": 3600,
"session": "anonymous"
}
Reuse the same token across calls if you want subsequent requests to see the effects of earlier ones (e.g. create a ticket, then list tickets). Request a new one to get a fresh, independent session — anonymous tokens have no refresh, so an expired token simply means a new session.
The endpoint is rate limited per client IP (token_rate_limit, default 10 per
minute), because every grant seeds a server-side session that consumes memory.
Set anonymous_sessions=False so this plugin does not wire it.
# The token endpoint is shared with `MCPPlugin`.
Whichever plugin wires it first decides its settings (TTL, rate limit), and the route is served if *either* plugin enables it. `anonymous_sessions=False` here only stops *this* plugin from wiring it: any token the endpoint mints is still accepted on these REST endpoints, so set it on both plugins to stop issuing them at all. With no token source configured at all, the REST API is only reachable with an OAuth token — and unreachable if MCP OAuth is off too.
An anonymous session carries no user identity, so with an
AuthPlugin configured only auth=False
handlers and vars are reachable through one.
To act as a signed-in user, add rxe.MCPPlugin and
complete its OAuth 2.1 flow. The access
token it issues works on these REST endpoints as well, and the session it is
bound to gets the full enforcement stack: the per-event gate, callable auth=
checks, delta filtering, and AuthUserState.current().
Auth checks can tell the surfaces apart — ctx.surface is "event_api" for a
request that arrived here — and inspect the token's granted scopes through
ctx.token_scopes. See
surface-aware auth checks.
Every call is counted against the presenting session token: call_rate_limit
per call_rate_window (default 60 per minute), tracked per process. Exceeding
it returns 429 with a Retry-After header. Browser (websocket) events are
never rate limited by this mechanism.
Individual handlers can override their own budget:
class ReportState(rx.State):
@rxe.event(rate_limit=2, rate_limit_window=60.0)
def generate_expensive_report(self): ...
@rxe.event(rate_limit=0) # exempt from per-token limiting
def cheap_ping(self): ...
An overridden handler is counted in its own per-token bucket; everything else shares the token's default bucket.
Only your application's own event handlers get endpoints. Framework and auth
handlers — every OIDC provider (including your own OIDCAuthState
subclasses), the login/logout/callback dispatchers, the page guard,
AuthUserState, and other reflex / reflex_enterprise internals — are
withheld, along with generated set-var handlers. API clients authenticate
through the token endpoint or the OAuth flow instead of by posting to the login
handlers.
The same goes for the Pages section of the generated spec: it lists your
app's pages, not the auth machinery's (/login, /callback, /logout,
/forbidden, the OIDC popup pages, and the MCP consent page are all omitted,
along with their dynamic route variables).
The plugin publishes the OpenAPI spec at a well-known location per RFC 9727. Any compliant client can discover it from the catalog:
curl http://localhost:8000/.well-known/api-catalog
Response (RFC 9264 Linkset):
{
"linkset": [
{
"anchor": "http://localhost:8000/",
"service-desc": [
{
"href": "http://localhost:8000/_reflex/events/openapi.yaml",
"type": "application/vnd.oai.openapi"
}
]
}
]
}
Fetch the spec directly:
curl http://localhost:8000/_reflex/events/openapi.yaml
Browse it with any OpenAPI viewer (Swagger UI, Redoc, Scalar, the JetBrains
HTTP client, etc.) pointed at that URL. Its info.description documents the
token endpoint, a document-level BearerToken security requirement covers every
operation, and each operation documents its 401 and 429 responses.
Event handler endpoints return the state deltas produced by the handler as
newline-delimited JSON (application/x-ndjson). Each line is one
delta; the stream ends when the handler finishes:
{"reflex___state____state.tickets___tickets____ticket_state": {"tickets": [], "total_count": 3}}
{"reflex___state____state.tickets___tickets____ticket_state": {"open_count": 2}}
Var names come back clean: the framework's internal _rx_state_ field-marker
suffix is stripped from the streamed deltas, from /_reflex/retrieve_state, and
from the Auto MCP surfaces alike.
For one-shot clients that just want the final state, consume the stream to completion and then fetch the full state:
curl -X POST -H "Authorization: Bearer $TOKEN" \
http://localhost:8000/_reflex/retrieve_state
# Unlike the built-in `hydrate` event, `/_reflex/retrieve_state` does **not** reset client-storage vars (`rx.Cookie`, `rx.LocalStorage`, `rx.SessionStorage`). Use it whenever you want to read state without modifying it.
State reads redact the session's server-side client_token / session_id from
the returned router var, so the session token never reaches the client, and
framework/auth states are dropped from the returned dict.
The reflex-enterprise repository includes a ready-to-run IT-ticketing demo
under demos/tickets/ that exercises every feature of the plugin. Its
rxconfig.py is the minimal reference setup:
import reflex as rx
import reflex_enterprise as rxe
config = rxe.Config(
app_name="tickets",
async_db_url="sqlite+aiosqlite:///tickets.db",
db_url="sqlite:///tickets.db",
plugins=[
rxe.EventHandlerAPIPlugin(
contact={"name": "Reflex Maintainers", "email": "[email protected]"},
license_info={
"name": "Apache 2.0",
"url": "https://opensource.org/licenses/Apache-2.0",
},
)
],
disable_plugins=[rx.plugins.SitemapPlugin],
)
The state class exposes typical CRUD handlers — create_ticket,
update_ticket, set_status, delete_ticket, seed, clear_all, plus
list/filter/sort/pagination helpers and a load_tickets on-load handler.
Here's a trimmed excerpt:
class TicketState(rx.State):
tickets: list[TicketRecord] = []
total_count: int = 0
open_count: int = 0
@rx.event
async def create_ticket(
self,
title: str,
description: str = "",
priority: str = "medium",
assignee: str = "",
) -> None:
"""Create a new IT support ticket.
Args:
title: Short summary of the issue.
description: Optional long-form description.
priority: One of "low", "medium", "high".
assignee: Username of the person handling the ticket.
"""
await self._create_ticket_record(
title=title,
description=description,
priority=priority,
assignee=assignee,
)
await self._reload_from_db()
@rx.event
async def set_status(self, ticket_id: str, status: str) -> None:
"""Set the status of a ticket.
Args:
ticket_id: The id of the ticket.
status: One of "open", "in_progress", "closed".
"""
...
Because the state's full name is tickets___tickets____ticket_state, the
generated handler routes live at:
POST /_reflex/event/tickets___tickets____ticket_state/<handler_name>
The state name is built from the Python module path (dot separators become
___) followed by the class name — inspect the generated openapi.yaml if you
are unsure of the exact path for a given handler.
Assume a dev server running on http://localhost:8000:
TOKEN=$(curl -s -X POST http://localhost:8000/_reflex/auth/token \
| python -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')
BASE=http://localhost:8000
TICKET_STATE=$BASE/_reflex/event/tickets___tickets____ticket_state
Discover the API.
curl $BASE/.well-known/api-catalog
curl $BASE/_reflex/events/openapi.yaml
Retrieve the full state dict.
curl -X POST -H "Authorization: Bearer $TOKEN" \
$BASE/_reflex/retrieve_state
Seed some sample tickets.
curl -X POST -H "Authorization: Bearer $TOKEN" \
$TICKET_STATE/seed
Load the first page of tickets into the session. This mirrors the
on_load handler the frontend runs when a browser hits /:
curl -X POST -H "Authorization: Bearer $TOKEN" \
$TICKET_STATE/load_tickets
Create a ticket. title is required; description, priority, and
assignee are optional (the server applies the same defaults as in the
Python signature):
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"VPN is down","priority":"high","assignee":"alice"}' \
$TICKET_STATE/create_ticket
Change a ticket's status.
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"ticket_id":"<uuid>","status":"in_progress"}' \
$TICKET_STATE/set_status
Partial update. update_ticket treats empty strings as "leave
unchanged":
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"ticket_id":"<uuid>","assignee":"bob","priority":"low"}' \
$TICKET_STATE/update_ticket
Delete a ticket.
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"ticket_id":"<uuid>"}' \
$TICKET_STATE/delete_ticket
Clear the board.
curl -X POST -H "Authorization: Bearer $TOKEN" \
$TICKET_STATE/clear_all
The generated spec groups handlers under an OpenAPI tag matching the
state class name. Here's the entry for create_ticket:
/_reflex/event/tickets___tickets____ticket_state/create_ticket:
post:
summary: Create a new IT support ticket.
description: |
title: Short summary of the issue.
description: Optional long-form description.
priority: One of "low", "medium", "high".
assignee: Username of the person handling the ticket.
operationId: TicketState_create_ticket
tags: [TicketState]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [title]
properties:
title: {type: string}
description: {type: string, default: ""}
priority: {type: string, default: medium}
assignee: {type: string, default: ""}
responses:
"200": {$ref: "#/components/responses/StreamedDelta"}
"401": {$ref: "#/components/responses/Unauthorized"}
"429": {$ref: "#/components/responses/RateLimited"}
Because the OpenAPI spec is self-describing (summaries, parameter types, defaults, on-load references, and how to obtain a token), most LLM agents with HTTP tool access can drive a Reflex app end-to-end without any extra glue code. Give them the spec URL and a natural-language task:
Use the API exposed at
http://localhost:8000/_reflex/events/openapi.yamlto drive the application.Create a new ticket assigned to Masen for investigating RegistrationContext issues in reflex CI.
A well-equipped agent will:
GET /_reflex/events/openapi.yaml and parse the operations.POST /_reflex/auth/token for a session bearer credential.POST /_reflex/event/.../create_ticket with a body like
{"title": "Investigate RegistrationContext issues in Reflex CI", "assignee": "Masen", "priority": "medium"}./_reflex/retrieve_state to confirm the ticket landed.Other prompts that work well with the tickets demo:
Using the Reflex API at
http://localhost:8000, seed the database, then close every ticket currently assigned tobob.
Via
http://localhost:8000/_reflex/events/openapi.yaml, page through every ticket and summarize which assignees have the largest open backlog.
Using the Reflex API at
http://localhost:8000, create three high-priority tickets for the following issues, then show me the resulting state: <list of issues>
# For MCP-capable agents, [Auto MCP](/docs/enterprise/mcp/) is the better fit.
It publishes the same handlers as MCP tools with a searchable catalog, live state resources, and an OAuth flow the client can complete on its own — no spec-reading or token plumbing in the prompt.
If any of your pages use dynamic route segments (e.g. /tickets/[ticket_id]),
the plugin surfaces those as optional query parameters on every
endpoint so the state can read them via self.router:
POST /_reflex/event/.../load_ticket_detail?ticket_id=<uuid>
They appear under components.parameters.route_<name> in the OpenAPI spec
and are referenced from every operation's parameters list.
rxe.event(auth=...) checks —
which can also require an OAuth scope or reject the "event_api" surface
outright — or run the plugin only where API access is appropriate.call_rate_limit and token_rate_limit
accordingly.rx.redirect(...) work over the API, but the
redirect is emitted as a state delta rather than an HTTP 3xx — the client
sees the URL change, not a browser redirect. This is usually what you
want for programmatic clients.AuthPlugin
and its secure-by-default enforcement.