Grav API Plugin

August 5, 2026 · View on GitHub

A RESTful API for Grav CMS that provides full headless access to your site's content, media, configuration, users, and system management.

Built for the AI-native era — designed to work seamlessly with AI agents, MCP servers, CLI tools, mobile apps, and custom frontends.

Requirements

  • Grav CMS 2.0+
  • PHP 8.3+
  • Login Plugin 3.8+

Installation

GPM (preferred)

bin/grav install api

Manual

  1. Download or clone this repository into user/plugins/api
  2. Run composer install in the plugin directory
  3. Enable the plugin in Admin or via user/config/plugins/api.yaml

Quick Start

1. Enable the Plugin

# user/config/plugins/api.yaml
enabled: true

2. Generate an API Key

Via CLI (recommended for initial setup):

bin/plugin api keys:generate --user=admin --name="My First Key"

Via Admin Panel: Go to a user's profile — the API Keys section lets you generate, view, and revoke keys with optional expiry dates.

The generated key is shown once — save it immediately.

3. Make Your First Request

curl https://yoursite.com/api/v1/pages \
  -H "X-API-Key: grav_abc123..."

Environments

Grav supports multiple environments (e.g., localhost, staging.mysite.com, mysite.com) with per-environment config overrides stored in user/env/{environment}/config/. The API respects this system via the optional X-Grav-Environment header.

# Explicitly target an environment
curl -H "X-Grav-Environment: mysite.com" -H "X-API-Key: ..." https://yoursite.com/api/v1/pages

If the header is omitted, the API defaults to Grav's auto-detected environment (derived from the hostname). When the header specifies a different environment, Grav reinitializes its config and cache context for that environment before processing the request.

Discover available environments:

curl -H "X-API-Key: ..." https://yoursite.com/api/v1/system/environments

Returns the current environment and all environment-specific overrides found in user/env/:

{
  "data": {
    "current": "localhost",
    "environments": [
      {"name": "default", "active": true},
      {"name": "mysite.com", "active": false}
    ]
  }
}

Authentication

The API supports three authentication methods. All three provide the same level of access — the authenticated user's permissions apply regardless of which method is used. When a request is received, the API tries each method in order until one succeeds.

Long-lived credentials ideal for server-to-server integrations, CLI tools, MCP servers, and CI/CD pipelines. Keys don't expire by default (optional expiry can be set), and persist until explicitly revoked.

# Via header (recommended)
curl -H "X-API-Key: grav_abc123..." https://yoursite.com/api/v1/pages

# Via query parameter (useful for quick debugging — less secure, visible in logs)
curl https://yoursite.com/api/v1/pages?api_key=grav_abc123...

Keys are stored as bcrypt hashes in user/data/api-keys.yaml. Each key is associated with a user, can be named, given an optional expiry, and independently revoked. Generate keys via CLI (bin/plugin api keys:generate) or the admin panel.

Short-lived credentials ideal for SPAs, mobile apps, and any client-side application where long-lived secrets shouldn't be stored. Access tokens expire after 1 hour (configurable), and refresh tokens allow obtaining new access tokens without re-entering credentials.

Step 1 — Login (exchange credentials for tokens):

curl -X POST https://yoursite.com/api/v1/auth/token \
  -H "Content-Type: application/json" \
  -d '{"username": "admin", "password": "your-password"}'

Response:

{
  "data": {
    "access_token": "eyJ...",
    "refresh_token": "eyJ...",
    "token_type": "Bearer",
    "expires_in": 3600
  }
}

Step 2 — Use the access token for API calls:

# Recommended: X-API-Token custom header (survives FPM/FastCGI header stripping)
curl -H "X-API-Token: eyJ..." https://yoursite.com/api/v1/pages

# Also accepted: standard Authorization: Bearer
# (works on most hosts; may be stripped by Apache mod_fastcgi / CGI on MAMP
#  and similar setups — if that happens, use X-API-Token instead)
curl -H "Authorization: Bearer eyJ..." https://yoursite.com/api/v1/pages

Why X-API-Token? PHP running under FastCGI / CGI / PHP-FPM can silently strip the Authorization header before it reaches the application. A custom X-* header bypasses this entirely. The server accepts either; pick the one that works for your host.

Step 3 — Refresh before the access token expires (the old refresh token is automatically revoked — token rotation):

curl -X POST https://yoursite.com/api/v1/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refresh_token": "eyJ..."}'

Step 4 — Revoke when the user logs out (returns 204 No Content):

curl -X POST https://yoursite.com/api/v1/auth/revoke \
  -H "Content-Type: application/json" \
  -d '{"refresh_token": "eyJ..."}'

Session Passthrough (for admin panel integration)

If a user has an active Grav admin session, the API recognizes it automatically. This enables the current admin UI (or a future SPA admin) to call the API from the browser without separate authentication — no API key or JWT needed.

Which method should I use?

Use CaseMethodWhy
CLI tools, scriptsAPI KeySimple, long-lived, no token management
MCP servers, AI agentsAPI KeyPersistent, no expiry concerns
Server-to-serverAPI KeyStatic credential, easy to rotate
Mobile appJWTShort-lived, secure for client-side storage
SPA / browser frontendJWTTokens in memory, refresh flow handles expiry
Admin panel extensionsSessionSeamless — already logged in

API Endpoints

All endpoints are prefixed with /api/v1. All responses use a standard JSON envelope.

Pages

MethodEndpointDescription
GET/pagesList pages (filterable, sortable, paginated)
GET/pages/{route}Get a single page
POST/pagesCreate a new page
PATCH/pages/{route}Update a page (partial)
DELETE/pages/{route}Delete a page
POST/pages/{route}/moveMove a page
POST/pages/{route}/copyCopy a page
POST/pages/{route}/reorderReorder child pages
POST/pages/batchBatch operations on multiple pages
GET/taxonomyList all taxonomy types and values

Filtering pages:

GET /api/v1/pages?published=true&template=post&parent=blog

Lazy-loading children (for tree/miller column views):

# Direct children only (one level deep) — ideal for lazy-loading
GET /api/v1/pages?children_of=/blog

# Top-level pages only
GET /api/v1/pages?children_of=/

# Alternative: root-level pages
GET /api/v1/pages?root=true

Unlike parent (which returns all descendants), children_of returns only direct children — pages exactly one level below the given route. Combined with the has_children field in page responses, this enables efficient lazy-loading page trees and miller column interfaces.

Sorting:

GET /api/v1/pages?sort=date&order=desc

Allowed sort fields: date, title, slug, modified, order

Pagination:

GET /api/v1/pages?page=2&per_page=10

Getting rendered HTML:

GET /api/v1/pages/blog/my-post?render=true

Including children:

GET /api/v1/pages/blog?children=true&children_depth=2

Reordering children:

curl -X POST https://yoursite.com/api/v1/pages/blog/reorder \
  -H "X-API-Key: ..." -H "Content-Type: application/json" \
  -d '{"order": ["third-post", "first-post", "second-post"]}'

Batch operations (publish, unpublish, delete, copy — up to 50 items):

curl -X POST https://yoursite.com/api/v1/pages/batch \
  -H "X-API-Key: ..." -H "Content-Type: application/json" \
  -d '{"operation": "publish", "routes": ["/blog/draft-1", "/blog/draft-2"]}'

Multi-Language

All page endpoints support the ?lang=xx query parameter to target a specific language. Grav stores translations as separate files (e.g., default.en.md, default.fr.md).

MethodEndpointDescription
GET/languagesList configured site languages
GET/pages/{route}/languagesList available/missing translations for a page
POST/pages/{route}/translateCreate a new translation
# Get a page in French
GET /api/v1/pages/about?lang=fr

# List pages in German
GET /api/v1/pages?lang=de

# Create a French translation
curl -X POST https://yoursite.com/api/v1/pages/about/translate \
  -H "X-API-Key: ..." -H "Content-Type: application/json" \
  -d '{"lang": "fr", "title": "À propos", "content": "# Bienvenue"}'

# Delete only the French translation (keeps other languages)
DELETE /api/v1/pages/about?lang=fr

# Include translation info in page response
GET /api/v1/pages/about?translations=true

Media

MethodEndpointDescription
GET/pages/{route}/mediaList media for a page
POST/pages/{route}/mediaUpload media to a page
DELETE/pages/{route}/media/{filename}Delete page media
GET/mediaList site-level media
POST/mediaUpload site-level media
DELETE/media/{filename}Delete site-level media

Uploading:

curl -X POST https://yoursite.com/api/v1/pages/blog/my-post/media \
  -H "X-API-Key: grav_abc123..." \
  -F "file=@photo.jpg"

Configuration

MethodEndpointDescription
GET/config/{scope}Read configuration
PATCH/config/{scope}Update configuration
POST/config/{scope}/revertRevert overridden keys, or reset the whole scope

Scopes: system, site, plugins/{name}, themes/{name}, plus any site-authored custom scope (see Custom config scopes).

# Read site config
curl -H "X-API-Key: ..." https://yoursite.com/api/v1/config/site

# Update a plugin config
curl -X PATCH https://yoursite.com/api/v1/config/plugins/markdown \
  -H "X-API-Key: ..." \
  -H "Content-Type: application/json" \
  -d '{"extra": true}'

Differential saves. Config writes persist only the delta against the relevant parent yaml — system / site / media / security / scheduler / backups diff against system/config/<scope>.yaml (Grav core defaults), plugins/<name> diffs against user/plugins/<name>/<name>.yaml, and themes/<name> diffs against user/themes/<name>/<name>.yaml. Defaults come from the raw yaml on disk (not from blueprints, which describe the form and routinely diverge from runtime). Sequential arrays like languages.supported are treated atomically — any difference retains the whole new list, avoiding the classic admin-classic bug where shortening a list silently re-merged removed entries.

Targeting an environment for writes. The optional X-Config-Environment header points writes at an existing env folder under user/env/<name>/config/; an empty/missing value writes to base user/config/. Env folders are never created implicitly — clients must opt in via POST /system/environments. A non-empty header that doesn't match an existing folder returns a clear 400.

# Write only to the staging environment overrides
curl -X PATCH https://yoursite.com/api/v1/config/system \
  -H "X-API-Key: ..." \
  -H "X-Config-Environment: staging.example.com" \
  -H "Content-Type: application/json" \
  -d '{"languages": {"default_lang": "fr"}}'

X-Config-Environment is a write-target header (which env folder receives the change). It is distinct from X-Grav-Environment (which env to load for the request).

Override metadata. Every GET/PATCH config response carries a meta block describing which leaf keys the active layer's file actually overrides, and the value each would revert to:

{
  "data": { "debugger": { "enabled": true } },
  "meta": {
    "overrides": ["debugger.enabled"],
    "fallback": { "debugger.enabled": false }
  }
}

overrides is the set of dotted leaf paths the active file sets on top of the layer beneath it (the base user/config for an env overlay, or the raw on-disk defaults for the base layer). fallback maps each of those paths to the value it would return to. Admin2 uses this to draw the per-field revert indicators.

Reverting. POST /config/{scope}/revert removes overrides so the value beneath takes over, honoring the same If-Match ETag and X-Config-Environment write-target as PATCH:

# Drop specific overridden keys from the staging overlay
curl -X POST https://yoursite.com/api/v1/config/system/revert \
  -H "X-API-Key: ..." \
  -H "X-Config-Environment: staging.example.com" \
  -H "Content-Type: application/json" \
  -d '{"keys": ["debugger.enabled"]}'

# Reset the whole scope — unlink the active layer's file entirely
curl -X POST https://yoursite.com/api/v1/config/system/revert \
  -H "X-API-Key: ..." \
  -H "X-Config-Environment: staging.example.com" \
  -H "Content-Type: application/json" \
  -d '{"reset": true}'

A {"keys": [...]} payload drops just those paths; {"reset": true} removes the active layer's file outright. The response is the same shape as a read, reflecting the post-revert state.

Custom config scopes

Beyond the built-in scopes, a site can expose its own top-level config — the Grav cookbook "add a custom yaml file" recipe. Drop a blueprint at user/blueprints/config/<scope>.yaml (or under environment://blueprints/config/) paired with user/config/<scope>.yaml, and the generic config endpoints accept <scope> automatically — no plugin code required. Admin2 lists it as a config tab alongside System and Site.

A scope qualifies as custom when it is a flat slug (^[a-z0-9][a-z0-9_-]*$ — no slashes or dots, which also blocks path traversal), is not one of the built-in scopes, and has its blueprint under the user:// or environment:// stream. The user/environment requirement is deliberate: core ships its own blueprints under blueprints://config (e.g. streams.yaml) that must never become writable through the generic config permission.

Gotcha — use bare field keys. A custom config blueprint must name its fields with bare keys (company_name), exactly like the core blueprints (site.yaml, system.yaml). Do not prefix them with the scope name (custom.company_name): the form fields would render blank and saves would nest the data under a spurious custom: block instead of writing it flat. The scope is already the file; the field key is the path within it.

Users

MethodEndpointDescription
GET/usersList users (paginated)
POST/usersCreate a user
GET/users/{username}Get user details
PATCH/users/{username}Update a user
DELETE/users/{username}Delete a user
POST/users/{username}/avatarUpload user avatar
DELETE/users/{username}/avatarRemove user avatar
POST/users/{username}/2faGenerate 2FA secret + QR code
GET/users/{username}/api-keysList API keys
POST/users/{username}/api-keysGenerate an API key
DELETE/users/{username}/api-keys/{keyId}Revoke an API key

System

MethodEndpointDescription
GET/pingKeep-alive / health check
GET/system/environmentsList available environments (current host + user/env/* + legacy 1.6 layouts)
POST/system/environmentsCreate a new env folder under user/env/<name>/config/
GET/system/infoSystem information
DELETE/cacheClear cache
GET/system/logsRead logs
DELETE/system/logsClear a log file (super-admin only)
POST/system/backupCreate a backup
GET/system/backupsList backups

GPM (Package Manager)

MethodEndpointDescription
GET/gpm/pluginsList installed plugins (with update status)
GET/gpm/plugins/{slug}Get installed plugin details
GET/gpm/plugins/{slug}/readmeGet plugin README
GET/gpm/plugins/{slug}/changelogGet plugin changelog
GET/gpm/themesList installed themes (with thumbnails/screenshots)
GET/gpm/themes/{slug}Get installed theme details
GET/gpm/themes/{slug}/readmeGet theme README
GET/gpm/themes/{slug}/changelogGet theme changelog
GET/gpm/updatesCheck for available updates
POST/gpm/installInstall a plugin or theme
POST/gpm/removeRemove a plugin or theme
POST/gpm/updateUpdate a specific package
POST/gpm/update-allUpdate all packages
POST/gpm/upgradeSelf-upgrade Grav core
POST/gpm/direct-installInstall from URL or zip upload
GET/gpm/searchSearch repository (plugins + themes)
GET/gpm/repository/pluginsBrowse available plugins
GET/gpm/repository/themesBrowse available themes
GET/gpm/repository/{slug}Get repository package details

Installing a package:

curl -X POST https://yoursite.com/api/v1/gpm/install \
  -H "X-API-Key: ..." -H "Content-Type: application/json" \
  -d '{"package": "shortcode-core", "type": "plugin"}'

Installing a premium package (pass the license inline, or pre-register it via the license-manager plugin):

curl -X POST https://yoursite.com/api/v1/gpm/install \
  -H "X-API-Key: ..." -H "Content-Type: application/json" \
  -d '{"package": "typhoon", "type": "theme", "license": "A1B2C3D4-E5F6A7B8-C9D0E1F2-A3B4C5D6"}'

Searching the repository:

# Search across all plugins and themes
GET /api/v1/gpm/search?q=email

# Search plugins only
GET /api/v1/gpm/repository/plugins?q=form

# Search themes only
GET /api/v1/gpm/repository/themes?q=blog

Searches match against slug, name, description, author, and keywords. All repository endpoints support pagination (?page=2&per_page=50).

Scheduler

MethodEndpointDescription
GET/scheduler/jobsList all scheduler jobs with status
GET/scheduler/statusGet cron installation status
GET/scheduler/historyJob execution history (paginated)
POST/scheduler/runTrigger scheduler run manually

System Info & Reports

MethodEndpointDescription
GET/systeminfoSystem info overview (PHP, disk, cache, plugins)
GET/reportsPlugin-extensible diagnostic reports

Dashboard

MethodEndpointDescription
GET/dashboard/notificationsGet system notifications (v2 schema — see below)
POST/dashboard/notifications/{id}/hideDismiss a notification
GET/dashboard/feedGet getgrav.org news feed
GET/dashboard/statsDashboard statistics snapshot
GET/dashboard/popularityPage-view popularity series for chart widgets
GET/dashboard/widgetsResolved widget list + layouts for the current user
PATCH/dashboard/layoutSave the current user's dashboard layout
PATCH/dashboard/site-layoutSave the site-wide default layout (super-admin)

Customizable dashboard. GET /dashboard/widgets returns a merged widget list combining (1) a built-in core registry, (2) plugin contributions via the onApiDashboardWidgets event, (3) the site-default layout, and (4) the current user's overrides. Site-hidden widgets are dropped entirely from a user's view (cannot be re-enabled per-user); the user's overrides win for size/order on the rest. Each resolved widget carries its allowed sizes[], defaultSize, icon, and authorize permission so the client can render the customize-mode picker without a second round-trip.

Notifications schema (v2). Each notification has structured fields — type (info | notice | warning | promo), icon, title, message (markdown), link, image + accent (for promo cards), action: {label, url}, and dependencies — so clients can render natively rather than receive embedded HTML. The endpoint fetches from https://getgrav.org/notifications2.json and caches per-user under user/data/notifications/{md5}_v2.yaml.

Plugin-contributed widgets — listen for onApiDashboardWidgets and append to $event['widgets']. Each entry can declare an authorize permission so the resolver hides widgets the user lacks access to.

Webhooks

Webhooks send HTTP POST notifications to external URLs when content changes via the API.

MethodEndpointDescription
GET/webhooksList configured webhooks
POST/webhooksCreate a webhook
GET/webhooks/{id}Get webhook details
PATCH/webhooks/{id}Update a webhook
DELETE/webhooks/{id}Delete a webhook
GET/webhooks/{id}/deliveriesView delivery log (paginated)
POST/webhooks/{id}/testSend a test payload

Creating a webhook:

curl -X POST https://yoursite.com/api/v1/webhooks \
  -H "X-API-Key: ..." -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/webhook-receiver",
    "events": ["page.created", "page.updated", "page.deleted"],
    "enabled": true
  }'

The response includes a secret for verifying payload signatures. Use "events": ["*"] to subscribe to all events.

Available events:

EventTrigger
page.createdPage created
page.updatedPage updated
page.deletedPage deleted
page.movedPage moved
page.translatedTranslation created
pages.reorderedChildren reordered
media.uploadedMedia uploaded
media.deletedMedia deleted
user.createdUser created
user.updatedUser updated
user.deletedUser deleted
config.updatedConfig changed
gpm.installedPackage installed
gpm.removedPackage removed
grav.upgradedGrav core upgraded

Payload format:

{
  "event": "page.created",
  "timestamp": "2026-03-26T20:00:00+00:00",
  "webhook_id": "wh_abc123...",
  "data": {
    "page": {"route": "/blog/new-post", "title": "New Post", "slug": "new-post"},
    "route": "/blog/new-post"
  }
}

Security: Each delivery includes an X-Grav-Signature header containing an HMAC-SHA256 hash of the payload body, signed with the webhook's secret. Verify it in your receiver:

$signature = hash_hmac('sha256', $rawBody, $webhookSecret);
$valid = hash_equals($signature, $_SERVER['HTTP_X_GRAV_SIGNATURE']);

Reliability: Failed deliveries (5xx responses or timeouts) are retried up to 3 times with exponential backoff. After 5 consecutive failures, the webhook is automatically disabled. The failure count resets on any successful delivery.

Note: Webhooks fire only for changes made through the API. Changes via the admin panel or direct filesystem edits use different code paths and won't trigger webhooks.

Authentication

MethodEndpointDescription
POST/auth/tokenLogin (get JWT tokens)
POST/auth/refreshRefresh access token
POST/auth/revokeRevoke refresh token
GET/auth/setupFirst-run check (returns needs_setup plus the password policy)
POST/auth/setupCreate the very first super-admin user (only when no users exist)
GET/auth/password-policyStructured representation of system.pwd_regex for client-side strength meters

These endpoints do not require authentication.

Password policy. GET /auth/password-policy parses system.pwd_regex into { regex, min_length, rules[] } by recognizing the common lookahead form ((?=.*\d), (?=.*[a-z]), (?=.*[A-Z]), (?=.*\W), .{N,}) and mapping each to a human-readable rule label. Admins can override the auto-detected rules with an optional system.pwd_rules: [{id, label, pattern}, …] list for custom or localized messaging without touching pwd_regex. The same policy is piggybacked on GET /auth/setup so the first-run setup screen renders its strength meter without a second round-trip; POST /auth/setup enforces it server-side regardless of what the UI shows.

Translations

MethodEndpointDescription
GET/translations/{lang}Get all translation strings for a language
GET/thumbnails/{file}Serve a cached thumbnail image (public)

Translation and thumbnail endpoints do not require authentication.

Get all English translations:

curl -s "https://yoursite.com/api/v1/translations/en"

Filter by prefix (for faster partial loads):

curl -s "https://yoursite.com/api/v1/translations/en?prefix=PLUGIN_ADMIN"
{
  "data": {
    "lang": "en",
    "count": 1248,
    "checksum": "6101ee5fcfeabc085cea537e4583038f",
    "strings": {
      "PLUGIN_ADMIN.TITLE": "Title",
      "PLUGIN_ADMIN.CONTENT": "Content",
      "PLUGIN_ADMIN.OPTIONS": "Options",
      "PLUGIN_ADMIN.PUBLISHING": "Publishing",
      "..."
    }
  }
}

The checksum can be used for cache invalidation — only re-fetch when the checksum changes. The prefix parameter enables a two-phase loading strategy: fetch a small subset for immediate use, then load the full set in the background.

Blueprints

Blueprints provide form schema definitions used to render configuration and content editing interfaces. The API resolves blueprint inheritance (extends@, import@) and returns a normalized JSON structure suitable for client-side form rendering.

MethodEndpointDescriptionPermission
GET/blueprints/pagesList available page templatesapi.pages.read
GET/blueprints/pages/{template}Get resolved blueprint for a page templateapi.pages.read
GET/blueprints/plugins/{plugin}Get blueprint for a plugin's configurationapi.config.read
GET/blueprints/themes/{theme}Get blueprint for a theme's configurationapi.config.read
GET/blueprints/usersGet user account blueprintapi.users.read
GET/blueprints/users/permissionsGet all registered permission actionsapi.users.read
GET/blueprints/config/{scope}Get blueprint for system config (system, site, media)api.config.read
GET/data/resolveResolve blueprint data-options@ directivesapi.pages.read
POST/blueprint-uploadUpload a file targeted by a blueprint destination(scope-derived)
DELETE/blueprint-uploadRemove a previously-uploaded blueprint file (idempotent)(scope-derived)

List page templates:

curl -s "https://yoursite.com/api/v1/blueprints/pages" \
  -H "X-API-Key: YOUR_KEY"
{
  "data": [
    { "type": "default", "label": "Default" },
    { "type": "blog", "label": "Blog" },
    { "type": "item", "label": "Item" }
  ]
}

Get a page blueprint (resolved with inheritance):

curl -s "https://yoursite.com/api/v1/blueprints/pages/blog" \
  -H "X-API-Key: YOUR_KEY"
{
  "data": {
    "name": "blog",
    "title": "blog",
    "child_type": "item",
    "validation": "loose",
    "fields": [
      {
        "name": "tabs",
        "type": "tabs",
        "fields": [
          {
            "name": "content",
            "type": "tab",
            "title": "Content",
            "fields": [
              { "name": "header.title", "type": "text", "label": "Title" },
              { "name": "content", "type": "markdown" },
              { "name": "header.media_order", "type": "pagemedia", "label": "Page Media" }
            ]
          },
          {
            "name": "blog",
            "type": "tab",
            "title": "Blog Config",
            "fields": [
              { "name": "header.content.limit", "type": "text", "label": "Max Item Count", "validate": { "required": true, "type": "int" } },
              { "name": "header.content.order.by", "type": "select", "label": "Order By", "options": { "folder": "Folder", "title": "Title", "date": "Date" } },
              { "name": "header.content.pagination", "type": "toggle", "label": "Pagination" }
            ]
          }
        ]
      }
    ]
  }
}

The resolved blueprint includes all inherited fields from parent blueprints (e.g., a theme's blog.yaml extending the system default.yaml), with extends@ and import@ directives fully resolved.

Supported field types in the serialized output include: text, textarea, select, toggle, checkbox, radio, markdown, editor, filepicker, pagemedia, taxonomy, list, array, tabs, tab, section, fieldset, columns, column, spacer, display, hidden, and more. Unknown field types are passed through with their properties intact for client-side fallback rendering.

Get a plugin blueprint:

curl -s "https://yoursite.com/api/v1/blueprints/plugins/email" \
  -H "X-API-Key: YOUR_KEY"

Blueprint file uploads. POST /blueprint-upload mirrors admin-classic's taskFilesUpload for theme/plugin config forms. It accepts a blueprint destination (a Grav stream like theme://images/logo, user://assets, account://avatars; the self@:subpath form relative to a blueprint owner; or a plain user-rooted relative path) plus a scope (plugins/<slug>, themes/<slug>, pages/<route>, users/<username>) and writes the file to the right place.

Streams resolve through Grav's locator so symlinked theme/plugin folders (common in dev setups) work cleanly — the response returns a logical user-rooted path (user/themes/quark2/images/logo/foo.png) independent of realpath, so a subsequent DELETE /blueprint-upload round-trips through the symlink to remove the actual file. .. traversal and absolute paths are rejected, filenames are sanitized, and the dangerous-extension allowlist is checked. DELETE is idempotent — a missing file returns 204 No Content.

Response Format

Success

{
  "data": { ... },
  "meta": {
    "pagination": {
      "page": 1,
      "per_page": 20,
      "total": 47,
      "total_pages": 3
    }
  },
  "links": {
    "self": "/api/v1/pages?page=1&per_page=20",
    "next": "/api/v1/pages?page=2&per_page=20",
    "last": "/api/v1/pages?page=3&per_page=20"
  }
}

Non-paginated responses omit meta and links.

Page objects include a has_children boolean field indicating whether the page has child pages, enabling tree/column UIs to show expand indicators without loading children upfront.

Errors (RFC 7807)

{
  "status": 404,
  "title": "Not Found",
  "detail": "Page not found at route: /blog/missing-post"
}

Validation errors include field-level details:

{
  "status": 422,
  "title": "Unprocessable Entity",
  "detail": "Missing required fields: title, route",
  "errors": [
    {"field": "title", "message": "The 'title' field is required."},
    {"field": "route", "message": "The 'route' field is required."}
  ]
}

Concurrency Control

Write endpoints support optimistic concurrency via ETags:

  1. GET responses include an ETag header
  2. Send If-Match: "<etag>" with your PATCH/DELETE request
  3. If the resource changed since your last read, you get a 409 Conflict

This prevents accidental overwrites when multiple clients edit the same content.

Rate Limiting

Enabled by default: 120 requests per 60-second window, per authenticated user (or per IP for unauthenticated requests).

Response headers on every request:

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1711382460

Exceeding the limit returns 429 Too Many Requests.

Configure in api.yaml:

rate_limit:
  enabled: true
  requests: 120
  window: 60
  excluded_paths:
    - /sync/   # default — exempt collab endpoints from the per-user bucket

excluded_paths exempts matching path prefixes from the bucket entirely — useful for high-frequency authenticated traffic (e.g. the sync plugin's polling, which fires ~90 req/min per active editor and would otherwise trip the global anti-abuse limit). Auth and per-route permissions still apply, so the bypass is gated by normal authentication rather than being a free pass.

CORS

Enabled by default for all origins. Configure allowed origins, methods, and headers in the admin panel or api.yaml:

cors:
  enabled: true
  origins:
    - https://myapp.example.com
    - https://admin.example.com
  methods: [GET, POST, PATCH, DELETE, OPTIONS]
  headers: [Content-Type, X-API-Token, X-API-Key, Authorization, If-Match]
  credentials: false

HTTP Method Override

Some shared-hosting nginx configs reject DELETE, PATCH, and PUT at the edge with a 405 Method Not Allowed before the request ever reaches PHP. To work around this, the API accepts an X-HTTP-Method-Override header on POST requests that rewrites the verb before dispatch:

# Equivalent to DELETE /pages/blog/old-post
curl -X POST https://yoursite.com/api/v1/pages/blog/old-post \
  -H "X-API-Key: ..." \
  -H "X-HTTP-Method-Override: DELETE"

Only DELETE, PATCH, and PUT are honored (never GET), and the override is opt-in per request — clients that don't need it pay zero cost. Admin-next auto-detects the need on a failed mutation and caches the fallback decision in sessionStorage, so subsequent requests in the same session skip straight to the compatible path.

Permissions

The API uses Grav's built-in ACL system. Available permissions:

PermissionDescription
api.accessBasic API access (required for all authenticated requests)
api.pages.readRead pages and taxonomy
api.pages.writeCreate, update, delete, move, copy, reorder, batch pages
api.media.readRead/list media files
api.media.writeUpload and delete media files
api.config.readRead configuration
api.config.writeUpdate configuration
api.users.readRead user accounts
api.users.writeCreate, update, delete users
api.system.readRead system info, logs, dashboard, notifications, feed
api.system.writeClear cache, dismiss notifications
api.system.backupCreate, list, download, and delete site backups (the archive includes account hashes and config secrets)
api.gpm.readList packages, check updates, browse/search repository
api.gpm.writeInstall, remove, update packages
api.scheduler.readView scheduler jobs, status, history
api.scheduler.writeTrigger scheduler runs
api.webhooks.readView webhooks and delivery logs
api.webhooks.writeCreate, update, delete, test webhooks

Users with admin.super bypass all permission checks.

Page-level permissions

A page can carry its own rules in frontmatter, and they override the account-wide api.pages.* permissions for that page and everything below it:

---
title: Company Handbook
permissions:
    inherit: true            # default — fall back to the parent page's rules
    authors: [jane]          # who counts as an author of this page
    groups:
        editors: 'ud'        # or { update: true, delete: false }
        authors: 'crud'
        defaults: '-d'       # applies to every signed-in user
---

Letters map to create, read, update, delete, publish, list; a - applies to the letter right after it, so '-ud' denies update and still allows delete.

The rules work in both directions:

  • a grant lets a group act on that page without holding the site-wide permission — api.pages.read plus a page granting ud is enough to save and delete that page (and its children, unless they set inherit: false)
  • a deny stops someone who does hold api.pages.write, including on page media, batch operations and reorganize

Resolution follows Grav's Flex pages: a matching group that denies wins outright, otherwise a matching group that allows wins, otherwise the page has no opinion and the account permission decides — walking up to the parent unless inherit: false. Super admins are not affected by page rules, and a page grant never widens an API key beyond its own scopes or lifts the demo write-lock.

Every page record returned by the API carries a permissions object with the caller's effective create / read / update / delete / publish / list for that page, which is what Admin-Next uses to show or hide the Save, Copy and Delete buttons.

CLI Commands

Manage API keys from the command line:

# Generate a new key (interactive prompts if flags omitted)
bin/plugin api keys:generate --user=admin --name="My Key"

# Generate with expiry (30 days)
bin/plugin api keys:generate --user=admin --name="Temp Key" --expiry=30

# List all keys for a user
bin/plugin api keys:list --user=admin

# Revoke a key (interactive selection if key-id omitted)
bin/plugin api keys:revoke --user=admin [key-id]

Extending the API

Other Grav plugins can register their own API routes by listening to the onApiRegisterRoutes event:

// In your plugin class
public static function getSubscribedEvents(): array
{
    return [
        'onApiRegisterRoutes' => ['onApiRegisterRoutes', 0],
    ];
}

public function onApiRegisterRoutes(Event $event): void
{
    $routes = $event['routes'];

    $routes->get('/comments/{pageRoute:.+}', [CommentsApiController::class, 'index']);
    $routes->post('/comments/{pageRoute:.+}', [CommentsApiController::class, 'create']);

    // Group related routes
    $routes->group('/webhooks', function ($group) {
        $group->get('', [WebhookController::class, 'index']);
        $group->post('', [WebhookController::class, 'create']);
        $group->delete('/{id}', [WebhookController::class, 'delete']);
    });
}

Your controller should extend AbstractApiController to get access to all the standard helpers (auth, pagination, response building, etc).

Admin-Next Integration

Plugins can integrate with the admin-next UI by registering sidebar navigation items and providing page definitions. The API plugin provides dedicated events and endpoints that the admin-next frontend consumes to dynamically build plugin pages.

Endpoints:

MethodEndpointDescription
GET/sidebar/itemsCollect sidebar navigation items from all plugins
GET/gpm/plugins/{slug}/pageGet a plugin's page definition for admin-next
GET/gpm/plugins/{slug}/page-scriptServe a plugin's page web component JS
GET/gpm/plugins/{slug}/report-script/{reportId}Serve a plugin's report web component JS
GET/blueprints/plugins/{plugin}/pages/{pageId}Get a custom page blueprint for a plugin

Register a sidebar item via onApiSidebarItems:

public static function getSubscribedEvents(): array
{
    return [
        'onApiSidebarItems'  => ['onApiSidebarItems', 0],
        'onApiPluginPageInfo' => ['onApiPluginPageInfo', 0],
    ];
}

public function onApiSidebarItems(Event $event): void
{
    $items = $event['items'];

    $items[] = [
        'id'       => 'license-manager',
        'plugin'   => 'license-manager',
        'label'    => 'Licenses',
        'icon'     => 'fa-key',
        'route'    => '/plugin/license-manager',
        'priority' => 10,
        'badge'    => null,
    ];

    $event['items'] = $items;
}

The sidebar item structure:

FieldTypeDescription
idstringUnique identifier for the sidebar item
pluginstringPlugin slug that owns this item
labelstringDisplay text in the sidebar
iconstringFontAwesome icon class (e.g., fa-key)
routestringFrontend route the item navigates to
priorityintSort order (lower values appear first)
badgestring|nullOptional badge text (e.g., count or status)

Provide a page definition via onApiPluginPageInfo:

public function onApiPluginPageInfo(Event $event): void
{
    if ($event['plugin'] !== 'license-manager') {
        return;
    }

    $event['definition'] = [
        'id'            => 'license-manager',
        'plugin'        => 'license-manager',
        'title'         => 'License Manager',
        'icon'          => 'fa-key',
        'page_type'     => 'blueprint',  // or 'component'
        'blueprint'     => 'licenses',
        'data_endpoint' => '/licenses/form-data',
        'save_endpoint' => '/licenses',
        'actions'       => [
            ['id' => 'import', 'label' => 'Import', 'icon' => 'fa-upload', 'upload' => true, 'endpoint' => '/licenses/import'],
            ['id' => 'export', 'label' => 'Export', 'icon' => 'fa-download', 'download' => true, 'endpoint' => '/licenses/export'],
            ['id' => 'save', 'label' => 'Save', 'icon' => 'fa-check', 'primary' => true],
        ],
    ];
}

The page definition structure:

FieldTypeDescription
idstringUnique page identifier
pluginstringPlugin slug that owns this page
titlestringPage title displayed in the header
iconstringFontAwesome icon class
page_typestringRendering mode: blueprint (form from YAML) or component (custom web component)
blueprintstringBlueprint name to load (when page_type is blueprint)
data_endpointstringAPI path to fetch form data
save_endpointstringAPI path to save form data
actionsarrayToolbar action buttons (see below)

Each action in the actions array:

FieldTypeDescription
idstringAction identifier
labelstringButton text
iconstringFontAwesome icon class
primaryboolWhether this is the primary action (styled prominently)
uploadboolWhether this action opens a file upload dialog
downloadboolWhether this action triggers a file download
endpointstringAPI path for the action (required for upload/download actions)

Events

The API fires events before and after all write operations, allowing plugins to react, validate, modify data, or cancel operations.

Page Events

EventWhenEvent Data
onApiBeforePageCreateBefore a page is savedroute, header, content, template, lang (modifiable by reference)
onApiPageCreatedAfter page creationpage (PageInterface), route, lang
onApiBeforePageUpdateBefore a page is updatedpage (PageInterface), data (request body, modifiable by reference)
onApiPageUpdatedAfter page updatepage (PageInterface)
onApiBeforePageDeleteBefore a page is deletedpage (PageInterface), lang (if language-specific delete)
onApiPageDeletedAfter page deletionroute, lang (if language-specific delete)
onApiPageMovedAfter page movepage (PageInterface), old_route, new_route
onApiBeforePageTranslateBefore a translation is createdpage, lang, header, content (modifiable by reference)
onApiPageTranslatedAfter translation createdpage, route, lang
onApiBeforePagesReorderBefore children are reorderedparent (PageInterface), order (slug array)
onApiPagesReorderedAfter children reorderedparent (PageInterface), order

Media Events

EventWhenEvent Data
onApiBeforeMediaUploadBefore each file is savedpage, filename, type, size
onApiMediaUploadedAfter upload completespage, filenames (array)
onApiBeforeMediaDeleteBefore a media file is deletedpage, filename
onApiMediaDeletedAfter media deletionpage, filename

Config Events

EventWhenEvent Data
onApiConfigUpdatedAfter config is savedscope, data

User Events

EventWhenEvent Data
onApiUserCreatedAfter user creationuser (UserInterface)
onApiUserUpdatedAfter user updateuser (UserInterface)
onApiBeforeUserDeleteBefore user deletionuser (UserInterface)
onApiUserDeletedAfter user deletionusername

GPM Events

EventWhenEvent Data
onApiBeforePackageInstallBefore package installpackage, type
onApiPackageInstalledAfter package installedpackage, type
onApiBeforePackageRemoveBefore package removalpackage, type
onApiPackageRemovedAfter package removedpackage, type
onApiBeforeGravUpgradeBefore Grav upgradecurrent_version, available_version
onApiGravUpgradedAfter Grav upgradedprevious_version, new_version

Route Registration

EventWhenEvent Data
onApiRegisterRoutesDuring router initializationroutes (ApiRouteCollector)

Admin-Next Integration Events

EventWhenEvent Data
onApiSidebarItemsSidebar items are collected via GET /sidebar/itemsitems (array, modifiable), user (UserInterface)
onApiPluginPageInfoPlugin page definition requested via GET /gpm/plugins/{slug}/pageplugin (string), definition (array|null, modifiable), user (UserInterface)
onApiDashboardWidgetsWidget registry is collected via GET /dashboard/widgetswidgets (array, modifiable), user (UserInterface)

Using Events in Your Plugin

React to content changes (e.g., clear a search index when pages change):

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

public function onApiPageCreated(Event $event): void
{
    $page = $event['page'];
    $this->searchIndex->add($page);
}

public function onApiPageUpdated(Event $event): void
{
    $page = $event['page'];
    $this->searchIndex->update($page);
}

public function onApiPageDeleted(Event $event): void
{
    $route = $event['route'];
    $this->searchIndex->remove($route);
}

Validate or modify data before save (e.g., enforce content rules):

public function onApiBeforePageCreate(Event $event): void
{
    $header = &$event['header'];
    $content = &$event['content'];

    // Auto-add a timestamp
    $header['api_created'] = date('c');

    // Reject empty content
    if (empty(trim($content))) {
        throw new \RuntimeException('Page content cannot be empty.');
    }
}

Reject file uploads (e.g., enforce image-only policy):

public function onApiBeforeMediaUpload(Event $event): void
{
    $type = $event['type'];
    if (!str_starts_with($type, 'image/')) {
        throw new \RuntimeException('Only image uploads are allowed.');
    }
}

Admin-Compatible Events

In addition to the onApi* events above, the API plugin fires the same onAdmin* events that Grav's 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 admin UI or the API.

Both event families fire for every operation — onAdmin* events first, then onApi* events.

Events Fired

EventControllerMethodsEvent Data (matches admin plugin signatures)
onAdminCreatePageFrontmatterPagescreateheader (array, modifiable), data (request body)
onAdminSavePagescreate, update, translateobject (Page, by reference), page (Page, by reference)
onAdminAfterSavePagescreate, update, translateobject (Page), page (Page)
onAdminAfterDeletePagesdeleteobject (Page), page (Page)
onAdminAfterSaveAsPagesmovepath (new filesystem path)
onAdminAfterAddMediaMediauploadPageMediaobject (Page), page (Page)
onAdminAfterDelMediaMediadeletePageMediaobject (Page), page (Page), media (Media), filename (string)
onAdminSaveUserscreate, updateobject (User, by reference)
onAdminAfterSaveUserscreate, updateobject (User)
onAdminSaveConfigupdateobject (Data, by reference)
onAdminAfterSaveConfigupdateobject (Data)

Event Ordering

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)

Example: Existing Plugin Compatibility

Plugins that already listen for admin events will automatically work with the API — no code changes needed:

// This SEO Magic listener fires for both admin UI saves and API saves
public static function getSubscribedEvents(): array
{
    return [
        'onAdminAfterSave'   => ['onObjectSave', 0],
        'onAdminAfterDelete' => ['onObjectDelete', 0],
    ];
}

Configuration Reference

Full configuration in user/config/plugins/api.yaml:

enabled: true
route: /api
version_prefix: v1

auth:
  api_keys_enabled: true
  jwt_enabled: true
  jwt_secret: ''          # Auto-generated on first use
  jwt_algorithm: HS256
  jwt_expiry: 3600        # Access token lifetime (seconds)
  jwt_refresh_expiry: 604800  # Refresh token lifetime (seconds)
  session_enabled: true

cors:
  enabled: true
  origins: ['*']
  methods: [GET, POST, PATCH, DELETE, OPTIONS]
  headers: [Content-Type, X-API-Token, X-API-Key, Authorization, If-Match, If-None-Match]
  expose_headers: [ETag, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset]
  max_age: 86400
  credentials: false

rate_limit:
  enabled: true
  requests: 120
  window: 60

pagination:
  default_per_page: 20
  max_per_page: 100

OpenAPI Specification

A complete OpenAPI 3.0 specification is included at openapi.yaml. Import it into:

  • Grav Docs via the API Doc Import plugin
  • Postman for interactive testing
  • Swagger UI for browsable documentation
  • Any OpenAPI-compatible tool for client SDK generation

Development

Running Tests

composer install
composer test

Run the tests from inside a Grav install (the plugin's normal home at user/plugins/api). The bootstrap auto-detects the hosting Grav and loads its autoloader, which provides symfony/yaml — the plugin relies on Grav's copy rather than bundling its own (see the replace entry in composer.json).

Most unit tests fall back to lightweight stubs and run without a Grav installation, but the YAML-dependent tests (config diffing, page header merging, API key storage) need Grav reachable. If you're working from a standalone source clone that the bootstrap can't locate automatically, point it at your Grav root:

GRAV_ROOT=/path/to/grav composer test

For integration tests within a Grav instance:

vendor/bin/phpunit --group integration

Project Structure

grav-plugin-api/
├── api.php                          # Plugin entry point
├── api.yaml                         # Default configuration
├── blueprints.yaml                  # Admin UI configuration
├── permissions.yaml                 # ACL permission definitions
├── openapi.yaml                     # OpenAPI 3.0 specification
├── languages/en.yaml                # Translation strings
├── composer.json
├── classes/Api/
│   ├── ApiRouter.php                # FastRoute dispatcher + middleware chain
│   ├── ApiRouteCollector.php        # Plugin route registration helper
│   ├── Auth/
│   │   ├── AuthenticatorInterface.php
│   │   ├── ApiKeyAuthenticator.php
│   │   ├── JwtAuthenticator.php
│   │   ├── SessionAuthenticator.php
│   │   └── ApiKeyManager.php
│   ├── Controllers/
│   │   ├── AbstractApiController.php
│   │   ├── AuthController.php
│   │   ├── ConfigController.php
│   │   ├── DashboardController.php
│   │   ├── DashboardWidgetController.php
│   │   ├── GpmController.php
│   │   ├── MediaController.php
│   │   ├── PagesController.php
│   │   ├── SchedulerController.php
│   │   ├── SystemController.php
│   │   ├── UsersController.php
│   │   └── WebhookController.php
│   ├── Exceptions/
│   │   ├── ApiException.php
│   │   ├── ConflictException.php
│   │   ├── ForbiddenException.php
│   │   ├── NotFoundException.php
│   │   ├── UnauthorizedException.php
│   │   └── ValidationException.php
│   ├── Middleware/
│   │   ├── AuthMiddleware.php
│   │   ├── CorsMiddleware.php
│   │   ├── JsonBodyParserMiddleware.php
│   │   └── RateLimitMiddleware.php
│   ├── Response/
│   │   ├── ApiResponse.php
│   │   └── ErrorResponse.php
│   ├── Serializers/
│   │   ├── SerializerInterface.php
│   │   ├── PageSerializer.php
│   │   ├── MediaSerializer.php
│   │   ├── PackageSerializer.php
│   │   └── UserSerializer.php
│   └── Webhooks/
│       ├── WebhookManager.php
│       └── WebhookDispatcher.php
└── tests/
    ├── bootstrap.php
    ├── Stubs/
    └── Unit/

License

MIT License. See LICENSE for details.

Credits

Built by Team Grav for the Grav CMS community.