---
title: Events
url: 'https://learn.getgrav.org/2/api/events'
markdown: 'https://learn.getgrav.org/2/api/events.md'
date: '2026-09-21'
description: 'API Events The API fires events before and after every write operation, and uses events to let plugins extend the Admin2 UI (sidebars, menubars, widgets, context panels, settings panels, custom field components, custom reports). Plugins react to these events to validate, modify data, cancel operati…'
taxonomy:
  category:
    - docs
---

# API Events

The API fires events before and after every write operation, and uses events to let plugins extend the Admin2 UI (sidebars, menubars, widgets, context panels, settings panels, custom field components, custom reports). Plugins react to these events to validate, modify data, cancel operations, or compose UI.

## Page Events

| Event | When | Data |
|---|---|---|
| `onApiBeforePageCreate` | Before page creation (mutable) | `route`, `header`, `content`, `template`, `lang` |
| `onApiPageCreated` | After page created (also after a copy or a batch copy) | `page`, `route`, `lang`; a copy sends `page`, `route`, `source_route` and `method` (`copy` or `batch`) instead of `lang` |
| `onApiBeforePageUpdate` | Before page update (mutable) | `page`, `data` (request body); `method` is `batch` for a batch update |
| `onApiPageUpdated` | After page updated | `page`; `previous_template` when the template changed; a batch update sends `page`, `route` and `method` (`batch`) |
| `onApiBeforePageDelete` | Before page deletion | `page`, plus `lang` when a single translation is deleted, or `method` (`batch`) for a batch delete |
| `onApiPageDeleted` | After page deleted | `route`, plus `lang` when a single translation is deleted, or `method` (`batch`) for a batch delete |
| `onApiPageMoved` | After page moved (also once per moved page after a reorganize) | `page`, `old_route`, `new_route`; `method` is `reorganize` when it comes from `POST /pages/reorganize` |
| `onApiBeforePageTranslate` | Before translation created (mutable) | `page`, `lang`, `header`, `content` |
| `onApiPageTranslated` | After translation created | `page`, `route`, `lang` |
| `onApiBeforePageAdoptLanguage` | Before adopting a base file as a specific language | `page`, `route`, `lang`, `from_file`, `to_file` |
| `onApiPageLanguageAdopted` | After base file renamed to language-specific filename | `page`, `route`, `lang` |
| `onApiBeforePageSync` | Before a translation is overwritten from another language (mutable) | `page`, `source_lang`, `target_lang`, `header`, `content` |
| `onApiPageSynced` | After translation overwritten | `page`, `route`, `source_lang`, `target_lang` |
| `onApiBeforePagesReorder` | Before children reordering | `parent`, `order` |
| `onApiPagesReordered` | After children reordered | `parent`, `order` |
| `onApiBeforePagesReorganize` | Before bulk move/reorder | `operations` (resolved list) |
| `onApiPagesReorganized` | After bulk move/reorder | `operations` |

## Media Events

| Event | When | Data |
|---|---|---|
| `onApiBeforeMediaUpload` | Before each file upload | `page`, `filename`, `type`, `size`; site media adds `path` |
| `onApiMediaUploaded` | After upload completes | `page`, `filenames`; site media adds `path` |
| `onApiBeforeMediaDelete` | Before media deletion | `page`, `filename`; site media adds `path` |
| `onApiMediaDeleted` | After media deleted | `page`, `filename`; site media adds `path` |
| `onApiMediaMetadataUpdated` | After a media file's metadata is saved | `page`, `filename` for page media; `path`, `filename` for site media |
| `onApiMediaMetadataDeleted` | After a media file's metadata is removed | `page`, `filename` for page media; `path`, `filename` for site media |

For site media (`/media` endpoints) there is no page, so `page` is `null` and `path` holds the file or folder path relative to `user://media`. A listener that reads `page` without a null check needs to handle this.

## Config Events

| Event | When | Data |
|---|---|---|
| `onApiConfigUpdated` | After config saved | `scope`, `data` |

## Auth & User Events

| Event | When | Data |
|---|---|---|
| `onApiUserLogin` | After a successful password, 2FA or SSO login | `user`, `method` (`password`/`2fa`/`sso`), `ip`, `request`; SSO adds `provider` |
| `onApiUserLoginFailure` | After any failed login attempt | `username`, `reason` (`password`/`2fa`/`disabled`/`no_api_access`), `ip` |
| `onApiUserLogout` | After `/auth/revoke` with a valid token | `user`, `ip`, `request` |
| `onApiPasswordReset` | After successful `/auth/reset-password` | `user`, `ip` |
| `onApiUserCreated` | After user created (via `/users`, `/auth/setup` or an accepted invitation) | `user` |
| `onApiUserUpdated` | After user updated | `user` |
| `onApiBeforeUserDelete` | Before user deletion | `user` |
| `onApiUserDeleted` | After user deleted | `username` |
| `onApiUser2faEnabled` | After 2FA turned on | `user` |
| `onApiUser2faDisabled` | After 2FA turned off | `user`, `forced_by_admin` (bool) |
| `onApiSetupComplete` | After first-run setup creates the initial super-admin | `user` |
| `onApiInvitationAccepted` | After an invitation is accepted and its account created | `user`, `invitation` |
| `onApiGroupCreated` | After a user group is created | `groupname`, `group` |
| `onApiGroupUpdated` | After a user group is updated | `groupname`, `group` |
| `onApiGroupDeleted` | After a user group is deleted | `groupname` |

### Single sign-on events

These let a login plugin add a provider to the Admin2 login screen. The API owns the routes and the session handling; the plugin only talks to the provider.

| Event | Endpoint | Data |
|---|---|---|
| `onApiLoginProviders` | `GET /auth/sso/providers` | `providers` (mutable list) |
| `onApiLoginStart` | `GET /auth/sso/{provider}/start` | `provider`, `request`, `callback_url`, `return_to`, `redirect` (set to the provider's authorization URL), `error` |
| `onApiLoginCallback` | `GET /auth/sso/{provider}/callback` | `provider`, `request`, `callback_url`, `user` (set to the signed-in Grav user), `error` |

After `onApiLoginCallback` returns a user, the API runs the same API-access, disabled-account and 2FA checks as a password login, then fires `onApiUserLogin` with `method` set to `sso`.

## GPM Events

| Event | When | Data |
|---|---|---|
| `onApiBeforePackageInstall` | Before install | `package`, `type` (`plugin`/`theme`) |
| `onApiPackageInstalled` | After install | `package`, `type`, `dependencies` |
| `onApiBeforePackageRemove` | Before removal | `package`, `type` |
| `onApiPackageRemoved` | After removal | `package`, `type` |
| `onApiBeforePackageUpdate` | Before single package update | `package`, `type` |
| `onApiPackageUpdated` | After package update | `package`, `type`, `dependencies` |
| `onApiBeforeGravUpgrade` | Before Grav core upgrade | `current_version`, `available_version` |
| `onApiGravUpgraded` | After Grav core upgrade | `previous_version`, `new_version` |

## Blueprint Events

| Event | When | Data |
|---|---|---|
| `onApiBlueprintResolved` | After a blueprint is serialized for Admin2 (mutable `fields`) | `context`, `fields`, `user`, plus `template`, `plugin`, `theme` or `page_id` depending on the blueprint |
| `onAdminPageTypes` / `onAdminModularPageTypes` | While `GET /blueprints/pages` builds the page type list | `types` (by reference) |

`context` tells a listener which blueprint it is looking at: `page` (with `template`), `plugin` (with `plugin`), `theme` (with `theme`), `account`, `group` or `group_new` (with `template`), `config` (only for the accounts configuration, with `template` set to `accounts`) or `plugin-page` (with `plugin` and `page_id`). Check it before changing fields so a listener meant for page blueprints leaves the others alone.

## Admin2 Integration Events

These are called while Admin2 composes the UI. Plugins append items/widgets/panels to the event data to register their integrations. The current user is passed in so listeners can gate registration on permissions, except for `onApiGenerateReports` and `onApiLogFiles`, which carry no `user`.

| Event | Endpoint | Data |
|---|---|---|
| `onApiSidebarItems` | `GET /sidebar/items` | `items`, `user` |
| `onApiMenubarItems` | `GET /menubar/items` | `items`, `user` |
| `onApiMenubarAction` | `POST /menubar/actions/{plugin}/{action}` | `plugin`, `action`, `body`, `user`, `result` (mutable) |
| `onApiFloatingWidgets` | `GET /floating-widgets` | `widgets`, `user` |
| `onApiContextPanels` | `GET /context-panels` | `panels`, `user` |
| `onApiAdminSettingsPanels` | `GET /settings/panels` | `panels`, `user` |
| `onApiPluginPageInfo` | `GET /gpm/plugins/{slug}/page` | `plugin`, `definition` (mutable), `user` |
| `onApiGenerateReports` | `GET /reports` | `reports` |
| `onApiDashboardNotifications` | `GET /dashboard/notifications` | `notifications` (mutable, grouped by location), `user`, `force` |
| `onApiDashboardWidgets` | `GET /dashboard/widgets` | `widgets`, `user` |
| `onApiMarkdownEditorButtons` | `GET /editor/toolbar-buttons` | `buttons`, `user` |
| `onApiLogFiles` | `GET /system/logs/files` | `files` (mutable list of `{file, label}`) |
| `onApiUserListFilters` | `GET /users/filters` | `filters`, `defaultFilter`, `showAll`, `user` |
| `onApiUserListFilter` | `GET /users?filter={id}` | `filter`, `collection` (mutable), `query`, `user` |
| `onApiUserListColumns` | `GET /users/columns` | `columns`, `user` |
| `onApiUserListColumnData` | `GET /users` | `usernames`, `data` (mutable), `user` |
| `onApiUserListRowActions` | `GET /users/row-actions` | `actions`, `user` |
| `onApiUserListRowAction` | `POST /users/{username}/row-action` | `id`, `plugin`, `action`, `username`, `user`, `result` (mutable) |

### Users list filter tabs

These two events let a plugin add a tab to the Users list that shows a filtered set of accounts — the Admin Next equivalent of admin-classic's Users-page tabs.

`onApiUserListFilters` collects the tabs for the nav row. Append a tab descriptor to the `filters` array; the core endpoint prepends the built-in `all` ("All Users") tab and drops any tab the caller isn't authorized for. The event also carries two page-level policies — `defaultFilter` (the tab the client opens on when the URL has no `filter`) and `showAll` (set it to `false` to suppress the "All Users" tab when showing every account isn't a useful landing view):

```php
public function onApiUserListFilters(Event $event): void
{
    // Event has no by-reference offsetGet — append via read-modify-write,
    // the same idiom as onApiSidebarItems / onApiMenubarItems.
    $filters = $event['filters'];
    $filters[] = [
        'id'        => 'active',           // selected via ?filter=active; 'all' is reserved
        'plugin'    => 'my-plugin',
        'label'     => 'Active',           // plain text, not a translation key
        'icon'      => 'fa-bolt',          // optional
        'priority'  => 10,                 // optional, higher sorts earlier
        'authorize' => 'api.users.read',   // optional, string or any-of array
    ];
    $event['filters'] = $filters;

    // Optional page-level policy.
    $event['defaultFilter'] = 'active';    // land here with no ?filter in the URL
    $event['showAll'] = false;             // hide the built-in "All Users" tab
}
```

The `icon` field accepts the shared `IconSpec` format: a Font Awesome shorthand (`fa-bolt`, `fa-regular:clock`, `fa-brands:github`), any icon CSS classes Admin2 already loads (`class:ti ti-user`), or a safe structured SVG object (`['type' => 'svg', 'path' => '…']`). Raw SVG markup is not accepted — SVG icons are rebuilt from whitelisted tags and attributes.

`defaultFilter` is honoured only when it maps to a tab the caller can actually see, otherwise it falls back to the first tab in the row. `showAll => false` drops "All Users" only once at least one of the plugin's own tabs survives authorization, so the row can never end up empty.

`onApiUserListFilter` runs while `GET /users?filter={id}` builds the listing — after search and sort, but **before** permission/group filtering and pagination. The plugin owning the active `filter` id narrows the collection and assigns it back; the core endpoint still applies the caller's access scope and paginates, so a tab can only narrow what's already visible, never widen it:

```php
public function onApiUserListFilter(Event $event): void
{
    if ($event['filter'] !== 'active') {
        return;
    }
    // filter() works on both account backends; see below.
    $event['collection'] = $event['collection']->filter(
        fn ($user) => $user->get('state', 'enabled') === 'enabled'
    );
}
```

Filter tabs work with both account backends, and the event carries the same keys on each. On the Flex-accounts backend (the Grav default) `collection` is a `FlexCollectionInterface`, and the listener must assign back a Flex collection or its result is ignored. On the filesystem account store `collection` is a Grav `ArrayCollection` of users keyed by username, which supports `filter()`, `matching()` and iteration but has no Flex methods such as `filterBy()`; the listener may assign back any iterable of users, and only accounts already in the list are kept. `filter()` with a closure works on both. To use a Flex-only method, check the type first:

```php
use Grav\Framework\Flex\Interfaces\FlexCollectionInterface;

$collection = $event['collection'];
$event['collection'] = $collection instanceof FlexCollectionInterface
    ? $collection->filterBy(['state' => 'enabled'])
    : $collection->filter(fn ($user) => $user->get('state', 'enabled') === 'enabled');
```

### Users list columns

These two events let a plugin add its own columns to the Users list — a WhatsApp link, a subscription expiry date, a login count — without touching the table markup. The contract is deliberately narrow: a plugin *declares* a column and *supplies scalar values*, but Admin2 owns rendering, layout, refreshes and list state. There is no path for a plugin to inject markup or a renderer.

`onApiUserListColumns` collects the extra columns, exposed as `GET /users/columns` (mirroring `/users/filters`, gated by `api.users.read`). Append a column descriptor to the `columns` array:

```php
public function onApiUserListColumns(Event $event): void
{
    // Event has no by-reference offsetGet — append via read-modify-write,
    // the same idiom as onApiUserListFilters / onApiSidebarItems.
    $columns = $event['columns'];
    $columns[] = [
        'id'        => 'my-plugin-valid-till', // required, unique
        'plugin'    => 'my-plugin',
        'label'     => 'Valid until',          // plain text, not a translation key
        'field'     => 'subscription.valid_till', // key into each user's data map
        'formatter' => 'datetime',             // one of the built-in formatters below
        'sortable'  => false,                  // optional; client-side, current page only
        'priority'  => 50,                     // optional, higher sorts earlier
        'authorize' => 'api.users.read',       // optional, string or any-of array
    ];
    $event['columns'] = $columns;
}
```

The `formatter` is validated against a fixed whitelist — `text`, `link`, `date`, `datetime`, `boolean`, `number`, `badge` — and an unknown value falls back to `text`. The server only ever names a formatter; the client renders it, so no renderer function or HTML crosses the wire. A `link` value is href-validated on the client (relative or `http(s)` only; anything else renders as inert text). The `authorize` check is re-run server-side and the field is stripped from the response.

`onApiUserListColumnData` supplies the per-user values. It fires **once per list response**, and its `usernames` list contains only the accounts already selected after search, filter, permission scoping and pagination — so a plugin resolves data for the current page, never for every account. Return a `username => [ field => value ]` map:

```php
public function onApiUserListColumnData(Event $event): void
{
    $data = $event['data'];
    foreach ($event['usernames'] as $username) {   // current page only
        $data[$username] = [
            'subscription.valid_till' => $this->validTill($username),
        ];
    }
    $event['data'] = $data;
}
```

Those values are merged into each serialized user under an `extra` map on `GET /users`, keyed by the column's `field`, so there is no second endpoint to call and no client-side join. Only scalars (and null) survive the merge — arrays, objects and oversized values are dropped — and the event is isolated, so a listener that throws degrades to missing values rather than breaking the listing. Columns work with both the Flex-accounts backend and the filesystem account store.

### Users list row actions

These two events let a plugin add a per-user action button to the Users list — "Impersonate", "Send reset link", "Sync subscription" — that runs a server-side operation against one account. They replace the old workaround of injecting a button into the rendered table with a `MutationObserver`: Admin2 owns the table, the row identity and the button placement, and the plugin receives the target username server-side. The model mirrors [menubar items and actions](https://learn.getgrav.org/2/api/developer-guide#menubar-items-actions), split into a declaration event and an execution event.

`onApiUserListRowActions` collects the buttons, exposed as `GET /users/row-actions` (gated by `api.users.read`). Append an action descriptor to the `actions` array:

```php
public function onApiUserListRowActions(Event $event): void
{
    // Event has no by-reference offsetGet — append via read-modify-write.
    $actions = $event['actions'];
    $actions[] = [
        'id'        => 'impersonate-user',   // required, unique — the execution key
        'plugin'    => 'impersonate',
        'label'     => 'Impersonate',        // plain text, not a translation key
        'icon'      => 'fa-user-secret',     // optional Font Awesome class
        'action'    => 'start',              // optional verb passed back to the handler
        'confirm'   => 'Impersonate this user?', // optional client confirmation prompt
        'priority'  => 80,                   // optional, higher sorts earlier
        'authorize' => ['admin.impersonate', 'admin.super'], // optional, string or any-of array
    ];
    $event['actions'] = $actions;
}
```

Like columns, the descriptor is formatter-free: `authorize` is re-checked server-side and stripped from the response, the list is capped, and no HTML or renderer function crosses the wire. **`authorize` gates only which buttons render — it is not a security boundary.**

`onApiUserListRowAction` runs the action. Admin2 invokes it over `POST /users/{username}/row-action` with a `{ "id": "<action-id>" }` body; the API re-checks the declared action's `authorize` against the caller (an unknown or unauthorized id is an indistinguishable `404`), then fires the event with the resolved target username. Guard on `$event['plugin']` — every listener receives every call — re-authorize against the specific target, and hand back a result:

```php
public function onApiUserListRowAction(Event $event): void
{
    if (($event['plugin'] ?? '') !== 'impersonate') {
        return;
    }

    $username = (string) $event['username'];
    // Independently re-check permission against THIS target — the declaration
    // filter was only UX. Throw ForbiddenException to return a real 403.
    $url = $this->createImpersonationUrl($username);

    $event['result'] = [
        'status'  => 'success',                 // 'success' | 'error'
        'message' => "Impersonating {$username}", // shown as a toast
        'url'     => $url,                        // optional — opened in a new tab
    ];
}
```

The result is normalized to `{ status, message, url }`. **`url` is validated server-side before it reaches the client**: only a root-relative path (not the protocol-relative `//host` form) or a same-origin absolute URL survives — a `javascript:`/`data:` scheme or a cross-origin URL is dropped, so a "return a URL and go there" flow can't become an open redirect. Admin2 shows `message` as a toast and opens any surviving `url` in a new tab with `noopener`. A handler that throws degrades to an error toast rather than breaking the Users list; a thrown `ForbiddenException` propagates as a `403`. Row actions need `api.users.read`, and a caller who isn't a super user can't run one against a super user account (`403`). Like filter tabs and columns, they work with both account backends.

### Labels and translation

Item labels for the sidebar, menubar, filter tabs, columns and row actions may be either **plain display text** (`'Active'`) or a **translation key** (`'PLUGIN_MYPLUGIN.ACTIVE'`). When a label looks like a key, the API resolves it server-side against the **signed-in user's Admin Next language** and returns the translated string — so a plugin should register the raw key and **must not** call `Language::translate()` itself. Translating in the plugin uses the site's active *content* language, which produces a mixed-language admin (e.g. a Russian label inside an English UI) when the two differ. Register the key; let the API localize it.

## Webhook Event Mapping

The outgoing webhook dispatcher listens for a subset of the above events and translates them into webhook events with the following names:

| API Event | Webhook Event |
|---|---|
| `onApiPageCreated` | `page.created` |
| `onApiPageUpdated` | `page.updated` |
| `onApiPageDeleted` | `page.deleted` |
| `onApiPageMoved` | `page.moved` |
| `onApiPageTranslated` | `page.translated` |
| `onApiPagesReordered` | `pages.reordered` |
| `onApiMediaUploaded` | `media.uploaded` |
| `onApiMediaDeleted` | `media.deleted` |
| `onApiUserCreated` | `user.created` |
| `onApiUserUpdated` | `user.updated` |
| `onApiUserDeleted` | `user.deleted` |
| `onApiConfigUpdated` | `config.updated` |
| `onApiPackageInstalled` | `gpm.installed` |
| `onApiPackageRemoved` | `gpm.removed` |
| `onApiGravUpgraded` | `grav.upgraded` |

See the [Webhooks endpoints](https://learn.getgrav.org/2/api/endpoints/webhooks) for creating and managing webhook subscriptions. Webhooks fire *after* the API event, so listeners that mutate state via `onApi*` events will see their changes reflected in the outgoing payload.

## Admin-Compatible Events (`onAdmin*`)

The API also fires the same events that Grav's classic Admin plugin fires. This ensures third-party plugins that subscribe to admin events (SEO Magic, Auto Date, Mega Frontmatter, etc.) work correctly regardless of whether changes come from the classic admin UI or the API/Admin2.

Both event families fire for every operation — admin events first, then API events.

| Event | When | Data |
|---|---|---|
| `onAdminCreatePageFrontmatter` | Page create (before save) | `header`, `data` |
| `onAdminSave` | Before save (page, user, config) | `object` (by reference), `page` |
| `onAdminAfterSave` | After save | `object`, `page` |
| `onAdminAfterDelete` | After page deletion | `object`, `page` |
| `onAdminAfterSaveAs` | After page move/rename | `path` |
| `onAdminAfterAddMedia` | After media upload | `object`, `page` |
| `onAdminAfterDelMedia` | After media deletion | `object`, `page`, `media`, `filename` |

### Event Ordering Example

For a page create operation, events fire in this order:

1. `onApiBeforePageCreate` — API before event
2. `onAdminCreatePageFrontmatter` — admin frontmatter injection
3. `onAdminSave` — admin pre-save (plugins can modify the page)
4. `onAdminAfterSave` — admin post-save (indexing, notifications)
5. `onApiPageCreated` — API after event (triggers webhooks)

### Subscribing from a Plugin

Do **not** gate the subscription of these admin-compatible events behind an `isAdmin()` check in `onPluginsInitialized()`. When a save comes through the API (which is how Admin2 saves everything), the request is not handled by the classic Admin plugin, so `$grav['admin']` is registered later during request dispatch by the API's `AdminProxy`. At `onPluginsInitialized()` time it is not yet set, so `isAdmin()` still returns `false` and any handlers you register inside an `isAdmin()` block are never subscribed. The result is a plugin whose save/delete logic silently never runs under Admin2.

 Note

A common 1.7-era pattern was to register `onAdminAfterSave` only when `isAdmin()` was true. That no longer works for API/Admin2 writes. These events only fire during admin/API write operations anyway, so it is safe to subscribe to them unconditionally.

Register them directly in `getSubscribedEvents()`, or unconditionally in `onPluginsInitialized()`:

```php
public static function getSubscribedEvents(): array
{
    return [
        'onPluginsInitialized'    => ['onPluginsInitialized', 0],
        // Always registered — these only fire during admin/API writes:
        'onAdminAfterSave'        => ['onObjectSave', 0],
        'onAdminAfterDelete'      => ['onObjectDelete', 0],
        'onAdminAfterSaveAs'      => ['onObjectMove', 0],
        'onFlexObjectAfterSave'   => ['onObjectSave', 0],
        'onFlexObjectAfterDelete' => ['onObjectDelete', 0],
    ];
}
```

Note that Flex objects (and Flex Pages) fire `onFlexObjectAfterSave` / `onFlexObjectAfterDelete` — the `*After*` variants — not the bare `onFlexObjectSave` / `onFlexObjectDelete` names. Subscribe to the variant that matches the data you need.

## Route Registration

Plugins extend the API with custom endpoints through `onApiRegisterRoutes`. Two related events let a plugin open routes to the public and add MCP tools:

| Event | When | Data |
|---|---|---|
| `onApiRegisterRoutes` | During router initialization | `routes` (`ApiRouteCollector`) |
| `onApiCollectPublicRoutes` | Once per request, before authentication | `api_base`, `prefixes` (mutable), `exact` (mutable) |
| `onApiMcpTools` | While `GET /mcp/tools` collects MCP tool definitions | `tools` (an `McpToolCollector`; call `->add($plugin, [...])`) |

`onApiCollectPublicRoutes` lets a plugin mark its own routes as public, so they skip authentication. Append full paths (including `api_base`) to `prefixes` for prefix matches or to `exact` for exact matches. An entry can be limited to one method by writing it as `"GET /api/v1/my-plugin/feed"`, which keeps writes on the same path authenticated.

See the [Plugin API Integration](https://learn.getgrav.org/2/plugins/plugin-api-integration) guide for details.

## System Events

| Event | When | Data |
|---|---|---|
| `onApiLogCleared` | After a log file is cleared via `DELETE /system/logs` | `file`, `bytes`, `user` |
| `onApiDemoBaselineCaptured` | After a demo baseline is captured | `status` |
| `onApiDemoReset` | After demo content is reset to the baseline | `status` |

## Using Events in Your Plugin

```php
public static function getSubscribedEvents(): array
{
    return [
        'onApiPageCreated' => ['onPageChanged', 0],
        'onApiPageUpdated' => ['onPageChanged', 0],
        'onApiPageDeleted' => ['onPageChanged', 0],
    ];
}

public function onPageChanged(Event $event): void
{
    // Rebuild search index, clear CDN cache, etc.
    $page = $event['page'] ?? null;
    $route = $event['route'] ?? $page?->route();

    $this->rebuildIndex($route);
}
```

 [ Edit this page on Github ](https://github.com/getgrav/grav-learn/edit/develop/pages/2/07.api/04.events/doc.md) 

 © 2026 Grav Documentation. All rights reserved.

---

## Navigation

- Parent: [REST API](https://learn.getgrav.org/2/api.md)
- Previous: [Rest API Reference](https://learn.getgrav.org/2/api/endpoints.md)
- Next: [Developer Guide](https://learn.getgrav.org/2/api/developer-guide.md)
