REST API v1 and v2: one extension
This guide explains how Exponential 6.0.15 serves its REST content API: version 1 and version 2 come from one
extension, ezprestapi (1.2.6 or later). It shows which URL answers at which version, how a client authenticates,
how to call the API with curl, which settings drive it, and how to move an installation off the deprecated
ezprestapiprovider extension.
It is written for two readers. Developers who write clients or extend the API read sections 1 to 5. Site administrators who install, upgrade or check an installation read sections 5 to 8.
Every statement below was checked on the demonstration server (Apache with PHP-FPM, and Velocity) on 15 October 2026.
Host names, identifiers and secrets are replaced by documentation values (example.com, <client id>, expk_...).
Guides · Related: Personal API keys · ezprestapi · ezprestapiprovider (deprecated) · Remote services and apps
In short
- One extension, one activation:
ActiveExtensions[]=ezprestapi. It registers one provider class,ezp7xRestApiProvider, for the prefixesezpandezpl, with one route table and one controller,ezp7xRestContentController. - Reads answer at v1 and v2 (
/api/ezp/v1/...,/api/ezp/v2/...); writes (create, update, delete) answer at v2 only. - Every read and write checks the user's policies (
content/read,content/create,content/edit,content/remove), whatever the request was authenticated with. The check is the kernel'sexpRestContentPermissionand needs Exponential 6.0.15. - Authentication: an OAuth2 token from
POST /api/auth/v1/oauth/token(grantsauthorization_codeandrefresh_token), or a personal API key sent asAuthorization: Bearer expk_.... ezprestapiprovideris deprecated. Its final release, 6.0.4, is a compatibility shim on top ofezprestapi. Activateezprestapiand removeezprestapiproviderfromActiveExtensions(section 6).- Nothing changes for clients: the v1 and v2 answers are byte for byte the same as before, except that
OutputFormat=xhtmlfillsrenderedOutputagain.
Contents
- What serves what
- The routes
- Authentication
- Examples with curl
- Configuration
- Migrating from ezprestapiprovider
- Activation combinations, before and since
- Known issue: listAtom
- Migrations
1. What serves what
| Part | Where it lives | What it does |
|---|---|---|
| REST framework | the kernel (kernel/private/rest/), settings/rest.ini | prefix filter, routing, versions, authentication, answer cache, output renderers |
Auth provider ezpRestAuthProvider | the kernel, prefix auth | the OAuth2 token endpoint and the authentication routes |
Content provider ezp7xRestApiProvider | ezprestapi, prefixes ezp and ezpl | the content routes of v1 and v2 |
Content controller ezp7xRestContentController | ezprestapi | every content read and write, with the permission checks |
| Create and delete backend | nxc_powercontent | creates and removes content for the controller |
Permission helper expRestContentPermission | the kernel (6.0.15) | asks the user's policies the way the content module does |
ezprestapiprovider 6.0.4 | deprecated shim | ezpRestApiProvider and ezpRestContentController extend the ezprestapi classes; maps ezp and ezpl to ezp7xRestApiProvider |
ezpl is an alias prefix of ezp: the same routes, the same answers.
2. The routes
All paths below are relative to /api/ezp/<version> (or /api/ezpl/<version>).
| Method | Path | v1 | v2 | What it does |
|---|---|---|---|---|
GET | /content/node/<id> | yes | yes | one node (ResponseGroups=Metadata,Locations,Fields, OutputFormat=xhtml) |
GET | /content/node/<id>/list(/offset/<n>)(/limit/<n>)(/sort/<key>(/asc|desc)) | yes | yes | the children |
GET | /content/node/<id>/childrenCount | yes | yes | the number of children |
GET | /content/node/<id>/fields | yes | yes | every field of the node's object |
GET | /content/node/<id>/field/<identifier> | yes | yes | one field |
GET | /content/object/<id>, .../fields, .../field/<identifier> | yes | yes | the same by object id |
GET | /content/node/<id>/listAtom | yes | yes | Atom feed of the children; answers 500, see section 8 |
POST | /content/node/create | no | yes | create and publish below parentNodeID |
POST | /content/node/<id> | no | yes | update: checks content/edit, then answers 501 (not implemented yet) |
DELETE or POST | /content/node/delete/<id> | no | yes | remove the node's object with all its locations and what is below them |
A write sent to v1 does not exist there and is refused. The links inside a v1 answer stay in v1, so existing clients keep following them.
The answers and the policies each call checks are described in
Personal API keys, section 3 and
Permissions of the REST interface. In short: 200, 201 for a
create, 400 for a missing or unknown field, 401 without a valid token or key, 403 access_denied when the user
may not, 404 for an unknown node.
3. Authentication
rest.ini [Authentication] RequireAuthentication=enabled is the default: every content route needs a user. Two
credentials are accepted.
3.1 OAuth2 tokens
For an application that lets other users sign in and act for them.
- An administrator registers the application at Setup > oAuth admin (
/oauthadmin/list). It gets a client identifier, a secret and the redirect address of the application. - The application sends the user to the authorize page of the site:
https://example.com/oauth/authorize?client_id=<client id>&redirect_uri=<redirect uri>&response_type=code&state=<random>, with PKCE also&code_challenge=<challenge>&code_challenge_method=S256. The user signs in and allows the application; the site redirects back withcode=<code>and thestateof the request, unchanged (the application checks it). The user needs the policy OAuth / Authorize (oauth/authorize); without it the site redirects back witherror=access_denied(upgrade note). - The application exchanges the code for a token at the token endpoint,
POST /api/auth/v1/oauth/token, withgrant_type=authorization_code,client_id,client_secret,codeandredirect_uri, pluscode_verifierwhen it sent a challenge. A public client (stored without a secret, such as a mobile app) sends noclient_secretand must use PKCE (PKCE and state). - It sends the token with every request (
Authorization: Bearer <token>orAuthorization: OAuth <token>; theoauth_tokenparameter of a POST form body as well, unlessrest.ini [OAuthSettings] AllowTokenInBody=disabled; theoauth_tokenquery parameter only withAllowTokenInQuery=enabled, off by default). - When the token has expired (
rest.ini [OAuthSettings] TokenTTL, one hour by default), the application asks for a new one withgrant_type=refresh_tokenandrefresh_token=<refresh token>. A refresh token is good forRefreshTokenTTL(30 days by default,0= no expiry) from the refresh that issued it.
A token, a code or a refresh token stops working as soon as its user is disabled or locked by failed logins (OAuth token hardening).
The token endpoint supports the grants authorization_code and refresh_token.
A page in a browser on another host (a single page application) can call the API only when CORS is switched on
for its origin, rest.ini [CorsSettings]; it is off by default (CORS for the REST API).
3.2 Personal API keys
For a script of the user's own. The user makes a key on the site (My account > API access) and sends it as
Authorization: Bearer expk_.... The request runs as the key's owner, limited to the key's scopes, and never beyond
the owner's policies. The whole story is in Personal API keys.
4. Examples with curl
export EXP_API_KEY='expk_...' # a personal API key; an OAuth token works the same way with "OAuth <token>"
one node, v1 (what existing clients call)
curl -s -H "Authorization: Bearer $EXP_API_KEY" https://example.com/api/ezp/v1/content/node/2
the first 20 children with their fields, v1 (the call of the mobile app)
curl -s -H "Authorization: Bearer $EXP_API_KEY" \
"https://example.com/api/ezp/v1/content/node/<node id>/list/offset/0/limit/20?ResponseGroups=Fields"
the same node at v2, with the rendered XHTML of its view
curl -s -H "Authorization: Bearer $EXP_API_KEY" \
"https://example.com/api/ezp/v2/content/node/2?OutputFormat=xhtml"
one field by object id
curl -s -H "Authorization: Bearer $EXP_API_KEY" https://example.com/api/ezp/v2/content/object/<object id>/field/title
create a folder below a node (v2 only)
curl -s -H "Authorization: Bearer $EXP_API_KEY" https://example.com/api/ezp/v2/content/node/create \
-d parentNodeID=<parent node id> -d classIdentifier=folder -d languageLocale=eng-US -d name='From a script'
# {"message":"Created","objectId":<object id>,"nodeId":<node id>}
remove it again (v2 only)
curl -s -X DELETE -H "Authorization: Bearer $EXP_API_KEY" https://example.com/api/ezp/v2/content/node/delete/<node id>
# {"message":"Removed","nodeId":<node id>,"objectId":<object id>}Exchanging an authorization code for an OAuth token:
curl -s https://example.com/api/auth/v1/oauth/token \
-d grant_type=authorization_code -d client_id='<client id>' -d client_secret='<client secret>' \
-d code='<code>' -d redirect_uri='<redirect uri>'Keep keys, secrets and tokens in environment variables or a secret store, never in a script, a repository or shell history on a shared machine.
5. Configuration
The kernel's settings/rest.ini holds the defaults; ezprestapi adds its provider and output section in
extension/ezprestapi/settings/rest.ini.append.php. Override in settings/override/rest.ini.append.php.
[System]
# the path below which the REST interface answers
ApiPrefix=/api
[ApiProvider]
# set by ezprestapi (and, identically, by the ezprestapiprovider 6.0.4 shim)
ProviderClass[ezp]=ezp7xRestApiProvider
ProviderClass[ezpl]=ezp7xRestApiProvider
[Authentication]
RequireAuthentication=enabled
AuthenticationStyle=ezpRestOauthAuthenticationStyle
#AuthenticationStyle=ezpRestBasicAuthStyle
RequireHTTPS=disabled
DefaultUserID=
[OAuthSettings]
TokenTTL=3600
RefreshTokenTTL=2592000
AllowTokenInQuery=disabled
AllowTokenInBody=enabled
# the template of OutputFormat=xhtml for the controller of ezprestapi (ezprestapi 1.2.6, kernel 6.0.15)
[ezp7xRestContentController_viewContent_OutputSettings]
Template=rest_pagelayout.tpl| Setting | Meaning |
|---|---|
[System] ApiPrefix | the URL path the REST interface answers below (/api) |
[ApiProvider] ProviderClass[<prefix>] | the provider class of a prefix: /api/<prefix>/<version>/.... ezp and ezpl are ezp7xRestApiProvider; auth is the kernel's ezpRestAuthProvider |
[Authentication] RequireAuthentication | disabled lets every request run as DefaultUserID (or the anonymous user); the permission checks still apply |
[Authentication] AuthenticationStyle | OAuth (default) or HTTP basic authentication; personal API keys are accepted by both |
[Authentication] RequireHTTPS | refuse plain HTTP |
[<Controller>_<action>_OutputSettings] Template | the page layout template of the XHTML renderer for that controller action |
[ApiKeySettings] | the personal API keys, see Personal API keys, section 13 |
The output section is named after the controller. Up to ezprestapi 1.2.5 only
[ezpRestContentController_viewContent_OutputSettings] existed, the name of the old v1 controller, so a read with
OutputFormat=xhtml through ezp7xRestContentController found no template and answered an empty renderedOutput.
1.2.6 and the kernel of 6.0.15 add the section for ezp7xRestContentController.
After a change to rest.ini, clear the INI cache (php bin/php/ezcache.php --clear-tag=ini --allow-root-user),
reload PHP-FPM and restart Velocity.
6. Migrating from ezprestapiprovider
ezprestapiprovider 6.0.4 (15 October 2026) is its final release. It keeps old installations working as a shim:
ezpRestApiProvider extends ezp7xRestApiProvider, ezpRestContentController extends
ezp7xRestContentController, its rest.ini maps ezp and ezpl to ezp7xRestApiProvider, and its Composer
package requires se7enxweb/ezprestapi ~1.2.6. It no longer ships the five classes it duplicated
(ezpRestAtomController, ezpRestAtomView, ezpRestAtomDecorator, ezpRestContentModel,
ezpRestApiViewController); ezprestapi ships them.
To move an existing installation:
Update the packages:
se7enxweb/ezprestapito 1.2.6 or later (andse7enxweb/ezprestapiproviderto 6.0.4 while it is still installed). The Exponential 6.0.15 rootcomposer.jsonrequiresse7enxweb/ezprestapi ~1.2.6and no longer requiresezprestapiprovider.In
settings/override/site.ini.append.php, keep or add the one line and remove the other:[ExtensionSettings] ActiveExtensions[]=ezprestapi # remove: ActiveExtensions[]=ezprestapiproviderClear the INI cache, regenerate the extension autoloads, and reload the web runtime:
php bin/php/ezcache.php --clear-tag=ini --allow-root-user php bin/php/ezpgenerateautoloads.php -e systemctl reload <your PHP-FPM service> ./console exp:velocity restart --allow-root-user # when Velocity serves the siteCheck:
curl -s -H "Authorization: Bearer $EXP_API_KEY" https://example.com/api/ezp/v1/content/node/2and the same at/api/ezp/v2/answer200.
Things that keep working:
- A
rest.inioverride that saysProviderClass[ezp]=ezpRestApiProviderworks through the shim whileezprestapiprovideris installed. Change it toezp7xRestApiProvider. - Code that extends
ezpRestContentControllerworks while the shim is installed. Extendezp7xRestContentControllerinstead. - Nothing changes for OAuth clients, their tokens, personal API keys or the v1 URLs.
New installations: the setup wizard (eZStepCreateSites::restApiExtension() in
kernel/setup/steps/ezstep_create_sites.php) activates ezprestapi, or ezprestapiprovider only when ezprestapi
is not on disk, never both. The installers of the legacy site packages (ezwebin_site, ezflow_site,
ezdemo_site and their _clean variants) activate ezprestapi from the 6.0.15 packages of 15 October 2026 on; the
sevenx and cjw site packages already did.
7. Activation combinations, before and since
Both extensions set rest.ini [ApiProvider] ProviderClass[ezp]. Extension settings are prepended per
ActiveExtensions entry, so the extension listed earlier in ActiveExtensions is read last and its value wins.
Before (ezprestapi up to 1.2.5, ezprestapiprovider up to 6.0.3):
| Active | ProviderClass[ezp] | Result |
|---|---|---|
ezprestapi alone | ezp7xRestApiProvider | v1 reads and v2 reads and writes, all checked |
both, ezprestapi listed first | ezp7xRestApiProvider | the same; the provider extension contributed nothing |
both, ezprestapiprovider listed first | ezpRestApiProvider | every /api/ezp/v2/... answered 404, create and delete too; v1 reads ran in ezpRestContentController without the permission checks; ezpl still served v2 through ezprestapi |
ezprestapiprovider alone (as the setup wizard used to activate it) | ezpRestApiProvider | v1 reads only, unchecked; v2 404; the prefix ezpl did not exist |
With both installed, the five duplicate class names also made the autoload generator warn and keep the copies of
ezprestapi.
Since (ezprestapi 1.2.6, ezprestapiprovider 6.0.4):
| Active | ProviderClass[ezp] and [ezpl] | Result |
|---|---|---|
ezprestapi alone | ezp7xRestApiProvider | v1 and v2, every content route in ezp7xRestContentController, all checked |
| both, in either order | ezp7xRestApiProvider | the same |
ezprestapiprovider alone (with ezprestapi installed as its dependency) | ezp7xRestApiProvider | the same |
This was checked by building the route table for each combination. The answers were compared with fixtures of 33
URLs recorded on Apache and on Velocity before and after the change: the mobile app's
GET /api/ezp/v1/content/node/<id>/list/offset/0/limit/20?ResponseGroups=Fields and every other v1 and v2 read are
byte for byte identical. The only difference is OutputFormat=xhtml, whose renderedOutput is filled again.
8. Known issue: listAtom
GET /content/node/<id>/listAtom answers 500 at v1 and v2, before and after this change. Two things are missing:
the route's URL generation, and the feed view handler of the Zeta Components MVC feed tie-in
(ezcMvcFeedViewHandler), a package the distribution does not install and that is not among the
expcomponents forks. Use the list route, or the site's RSS export,
until it is fixed.
Demo mode. On a public demo (environment variable EXP_DEMO_MODE=1) the web services refuse to change or reset the password of an administrator: expaccount.reset, expaccount.changePassword and expuser.setPassword answer HTTP 403 with the reason, and expaccount.forgotRequest sends no key and gives the same answer as for any address. Demo mode.
9. Migrations
The state of the migrations and their runs, read only, under /api/migration/v1 (the kernel's
expMigrationRestProvider, rest.ini [ApiProvider] ProviderClass[migration]):
| Method | Path | What it answers |
|---|---|---|
GET | /api/migration/v1/status | summary (pending, failed, orphans, total, ready), environment, engine, lock, every migration with its state (migrations), orphans, warnings, errors |
GET | /api/migration/v1/runs?limit=20&offset=0 | the latest runs, newest first (at most 100 at once) |
GET | /api/migration/v1/runs/<run id> | one run and the migrations it wrote; 404 for an unknown run |
GET | /api/migration/v1/snapshot?compare=classes,roles&subtree=<node remote id>&limit=500 | fingerprints (SHA-1) of the classes, roles, sections, object state groups, languages, settings of settings/override (secrets left out) and a content subtree, never a value: what exp:migration:generate --from-diff=<address> of another installation compares with (generators); 400 for an unknown kind |
The user needs the policy migration/read (403 access_denied without it). A personal API key needs the scope
migration:read ("Read the state of the migrations"); an OAuth token reads as its user. The answers are never kept
in the REST application cache. Nothing runs or is undone through the API: a run and a rollback stay with
./console exp:migration:migrate and Setup > Migrations. The same data is in GraphQL as migrationStatus,
migrations, migrationRuns and migrationRun (the GraphQL API).
curl -s -H "Authorization: Bearer expk_..." https://www.example.com/api/migration/v1/status
curl -s -H "Authorization: Bearer expk_..." "https://www.example.com/api/migration/v1/runs?limit=5"Related pages
- Migrations: writing and running them
- Personal API keys: making, using and revoking a key; the permission table of the REST interface
- ezprestapi and its release notes
- ezprestapiprovider and its release notes
- nxc_powercontent
- Behavior changes of the extensions
- Remote services and apps
Become an Exponential Partner
Partners share their information, provide funding, contribute back and develop the products with us, and that is how Exponential grows. A partner website and dashboard are planned for mid-2027: please become a partner today to speed up our growth rate and land new features faster.