Joomla Web Services API

This content was generated with the assistance of AI and should be verified against the official Joomla documentation before being relied on in production.

This sub-section’s Joomla bibliography lists the reference material consulted while preparing these pages.

Since Joomla 4.0 every Joomla site ships a second front door next to the website and the administrator: the API application. It answers HTTP requests under /api/index.php/v1/... with machine-readable JSON, so that a mobile app, a static-site generator, a build script or another server can read and change the site’s content without scraping HTML or driving the administrator forms. The same endpoints are what a headless front end uses (see Headless Joomla) and what Joomla.org uses to trigger Automated Core Updates on a site (see Updating Joomla and Automated Core Updates). This page explains the application, the request lifecycle, the JSON:API format, authentication with API tokens, the core endpoints, filtering and pagination, and the everyday operations on articles with curl. Adding endpoints for your own component is covered in Extending the Web Services API.

It was written against Joomla 6.2 (current; the 6.2.1 source tree was read, PHP 8.3 or newer) and checked against the Joomla Developer Manual and the source on 2026-10-30. Joomla 5.4 receives security fixes only, and 4.x and 3.x are end of life; the Web Services API does not exist in Joomla 3, and differences in 4.x and 5.x are mentioned where they matter. The examples use the fictitious Tallpine Hiking Club (package pkg_tallpine, component com_tallpine, plugin plg_webservices_tallpine) at https://tallpine.example.

What the API is for, and who uses it

  • A site builder or administrator decides who may use the API, issues tokens and enables or disables the authentication and webservices plugins.

  • A front-end developer reads articles, categories, tags and media from a separate application (Astro, Next.js, a mobile app) and sometimes writes back.

  • A developer of integrations automates content imports, publishes from a CI job or synchronises users.

  • An extension developer exposes the records of a component through the same mechanism.

The Joomla Developer Manual page "Web Services" is the official starting point, but it is short: it shows create, read, update and delete requests for articles (as Joomla Framework HTTP and PHP cURL code) and it does not document response formats, error codes or pagination, and its headings still say "Joomla 4.x". The wiki page J4.x:Joomla_Core_APIs on docs.joomla.org lists the endpoints, but that wiki is a legacy resource written for 4.x; use it only as an index, and check an endpoint against the source tree or against your own site. The authoritative list of routes is the code of the webservices plugins in plugins/webservices/* of the Joomla source tree. This page describes what those plugins and the application register in 6.2.

The API application

Joomla has four applications: site, administrator, console and API. The API application is the PHP class Joomla\CMS\Application\ApiApplication (client id 3), started by api/index.php. It reuses the same container, the same configuration, the same extensions and the same database as the website; it differs in what it does with a request:

Aspect API application behaviour

Entry point

api/index.php, which checks the PHP minimum (8.3 in Joomla 6) and loads api/includes/app.php. On Apache, the distributed htaccess.txt rewrites any request that starts with /api/ and is not a real file to api/index.php, so https://tallpine.example/api/v1/content/articles also works when URL rewriting is on. Without rewriting use /api/index.php/v1/....

Router

Joomla\CMS\Router\ApiRouter, not the SEF router. Routes are registered by plugins of the webservices group.

Document

Joomla\CMS\Document\JsonapiDocument with the MIME type application/vnd.api+json; the API never renders a template.

Authentication

Plugins of the api-authentication group instead of the authentication group. The Access checks use the global permission core.login.api.

Components

Each API-enabled component has an api/components/com_xxx folder with its own controllers, views and (optionally) serializers, namespaced Joomla\Component\Xxx\Api\....

The base URL is therefore https://tallpine.example/api/index.php/v1. The v1 segment is a version prefix owned by the route definitions; every core route starts with it. The API application also needs the site’s secret in configuration.php, because tokens are checked against it (see Authentication: API tokens).

If the site is served from the public folder introduced in Joomla 5.0, the API entry point belongs to the public folder as well; see Installation and the public folder. A reverse proxy or CDN must pass the Authorization and X-Joomla-Token headers and must not cache API responses that depend on them; see Web server, reverse proxy and HTTPS.

The request lifecycle

ApiApplication::doExecute() runs three steps: initialiseApp(), route() and dispatch(). The route() step is the one that differs from the other applications and is where authentication happens:

sequenceDiagram autonumber participant C as Client (curl, front end) participant A as ApiApplication participant W as webservices plugins participant R as ApiRouter participant T as api-authentication plugin (token) participant K as API controller (ApiController) participant V as JsonApiView C->>A: GET /api/index.php/v1/content/articles<br/>Accept: application/vnd.api+json<br/>X-Joomla-Token A->>W: import plugins, onBeforeApiRoute W->>R: createCRUDRoutes(v1/content/articles, ...) A->>R: parseApiRoute(method) R-->>A: controller, task, vars (component, public) A->>A: content negotiation (406 if no match) A->>T: login(core.login.api) then onUserAuthenticate T-->>A: user identity or 401 A->>K: dispatch: articles.displayList K->>K: filters, pagination, access checks K->>V: displayList() V-->>A: JSON:API document (data, links, meta) A-->>C: 200 application/vnd.api+json

In words:

  1. The application imports all enabled plugins of the webservices group and fires onBeforeApiRoute with the router. Each plugin registers the routes of one component with createCRUDRoutes() or individual Route objects. A component whose webservices plugin is disabled has no API.

  2. ApiRouter::parseApiRoute() finds the first route whose method and path pattern match. No match is a 404 (RouteNotFoundException).

  3. Content negotiation uses the Accept header. By default only application/vnd.api+json is offered (a route may offer others through a format variable). If nothing matches, the application throws NotAcceptable and answers 406. The negotiated type decides the document and view type: application/vnd.api+json maps to the jsonapi format and application/json to json.

  4. After onAfterApiRoute, any route that is not marked public calls login() silently with the action core.login.api. The api-authentication plugins run (see Authentication: API tokens); if no plugin authenticates the user, or the user lacks the permission, the application throws AuthenticationFailed and the response is 401.

  5. dispatch() loads the component of the route through ComponentHelper::renderComponent(). The router has set controller and task in the input, so com_content's articles controller runs displayList, displayItem, add, edit or delete.

  6. The controller prepares the model state (filters, pagination), checks access and calls the view. The view (JsonApiView) serialises items with a serializer and the document renders them as JSON.

The respond() step adds the header X-Powered-By: JoomlaAPI/1.0 and, when enabled, the CORS headers (see CORS for browser clients).

JSON:API format

Joomla follows the JSON:API specification, using the tobscure/json-api library. The required request header is Accept: application/vnd.api+json. Requests that carry a body use Content-Type: application/json; the controllers read the raw JSON body and an optional data key. A list response looks like this (shortened):

{
  "links": {
    "self": "https://tallpine.example/api/index.php/v1/content/articles",
    "next": "https://tallpine.example/api/index.php/v1/content/articles?page%5Boffset%5D=20",
    "last": "https://tallpine.example/api/index.php/v1/content/articles?page%5Boffset%5D=60"
  },
  "data": [
    {
      "type": "articles",
      "id": "17",
      "attributes": {
        "id": 17,
        "title": "Ridge walk to Lake Tallpine",
        "alias": "ridge-walk-to-lake-tallpine",
        "state": 1,
        "language": "*",
        "created": "2026-09-14 09:12:00",
        "modified": "2026-10-02 18:40:11",
        "distance-km": "12.5"
      },
      "relationships": {
        "category": { "data": { "type": "categories", "id": "8" } },
        "created_by": { "data": { "type": "users", "id": "42" } }
      }
    }
  ],
  "meta": { "total-pages": 4 }
}

Points to know:

  • Each resource has type, id, attributes and, for articles, relationships (category, created_by, tags; single items also modified_by, and languageAssociations when multilingual is enabled).

  • Custom fields appear as attributes named after the field (distance-km, elevation-gain, difficulty, season of the Tallpine articles), using the field’s API value when its plugin provides one.

  • The links object carries self, and for lists first, previous, next and last. The meta object has total-pages. There is no total-items count; compute the totals from the number of pages if you need them.

  • The article text attribute is the introtext and the fulltext joined by a space, and the content plugins have run on it (onContentPrepare) before it is serialised. Article images are resolved to absolute URLs.

  • The fields[type]=a,b and include= parameters of the JSON:API specification are not generally supported. Joomla decides which attributes and relationships to render in the view (fieldsToRenderItem, fieldsToRenderList, relationship) and lets plugins change that with the onApiGetFields event (see Extending the Web Services API). Do not expect sparse fieldsets to reduce the payload.

Errors are JSON:API error documents with an HTTP status: 401 for failed authentication, 403 when the user may log in to the API but not perform the action, 404 for an unknown route or record, 406 for a bad Accept header, 409 for a record that must be trashed before deletion; invalid data and save failures use the status of the exception raised (for example 422 for invalid parameters) and unexpected failures give 500. Error bodies of the JSON:API handlers have an errors array whose entries contain title (the message) and code (the status); the 404 and 409 bodies of a delete are plain JSON objects with status, code and message.

Authentication: API tokens

Anonymous access exists only for routes explicitly marked public (for example the Joomla Update notification routes used by Automated Core Updates, which carry their own signature). Everything else needs an identity. Joomla ships two plugins in the api-authentication group:

Plugin (name in the Plugin Manager) Fresh 6.2 install Credential

API Authentication - Web Services Joomla Token (plg_api-authentication_token)

enabled

An API token in X-Joomla-Token or Authorization: Bearer.

API Authentication - Web Services Basic Auth (plg_api-authentication_basic)

disabled

The username and password of a Joomla user sent by HTTP Basic authentication.

A second plugin manages the tokens themselves: User - Joomla API Token (plg_user_token, group user, enabled by default). It adds the Joomla API Token tab to the user edit form and to the user’s own profile. Without it nobody can create a token, and the token authentication plugin has nothing to check.

Creating a token

  1. In the administrator open System → Plugins and check that User - Joomla API Token and API Authentication - Web Services Joomla Token are enabled. Open User - Joomla API Token and review Allowed User Groups. The default is only the Super Users group (id 8). Add a group to let its members use tokens; clearing the field allows every group.

  2. Give the user (or the group) the permission Web Services Login (core.login.api) in System → Global Configuration → Permissions, or on the group’s row in Users → Groups. The permission is inherited like all global permissions, so allow it on the group that holds your API accounts and nowhere else. The official Manual says an API token is used with the permissions of a Super User or of an account that has core.login.api and core.login.site; the token plugin itself checks the user’s group and the core.login.api action when it calls login().

  3. Edit the user and open the Joomla API Token tab. If the user has no token yet, save the profile once: the plugin generates 32 random bytes and stores them. After saving, reopen the tab to see the Token field with a Copy button. The Active switch temporarily disables the token, and Reset (set it to Yes and save) replaces it with a new one.

  4. Store the token in a secret manager or in an environment variable of the job that calls the API, never in the page source, a repository or a front-end bundle.

A user cannot see someone else’s token. An administrator with permission can enable, disable or reset another user’s token, for example when a leak is suspected; that is the incident-response path.

What the token is, and what is stored

The database does not contain the token you copy. The #__user_profiles table holds two rows for the user, keyed joomlatoken.token and joomlatoken.enabled. The first row is the seed: the 32 random bytes, base64-encoded. The displayed token is computed from the seed and the site’s secret when the form is rendered:

token = base64( algorithm ":" userId ":" HMAC(algorithm, base64decode(seed), siteSecret) )

The algorithm is sha256 by default (sha512 is also accepted). On each request the token authentication plugin decodes the token, checks the algorithm, rebuilds the HMAC from the stored seed and the secret of configuration.php, and compares the two values with hash_equals(). It also checks that the token is active, that the user belongs to an allowed group, and that the user is not blocked, unactivated or marked "require password reset". All conditions are evaluated before the plugin decides, so the response time does not reveal which one failed.

Consequences:

  • A database leak does not reveal usable tokens without the site secret; keep both protected, and never commit configuration.php.

  • Changing the secret in configuration.php invalidates every token because the HMAC changes. The tokens look unchanged in the user profiles but stop working until users copy the new value. A site restored from a backup onto a new configuration.php has the same effect.

  • An empty secret makes the plugin refuse every token.

  • Resetting a user’s token changes the seed and so invalidates the old value at once.

  • A token is the user’s identity: every ACL rule, workflow and check-in applies to that user.

Sending the token

The plugin looks first for Authorization: Bearer <token> (the word Bearer is case-sensitive) and then for the X-Joomla-Token header, which is the form used in the Manual and most examples. Both carry the same value. The .htaccess distributed with Joomla contains the rewrite that copies the Authorization header to HTTP_AUTHORIZATION for PHP FastCGI; without that rule Apache drops the header and only X-Joomla-Token works. If you use another header mechanism, check that the web server passes the header to PHP.

# The token lives in the environment of the shell or CI job, not in the command.
export JOOMLA_API_TOKEN="$(cat /run/secrets/tallpine_api_token)"
export BASE="https://tallpine.example/api/index.php/v1"

curl --silent --show-error \
  --header "Accept: application/vnd.api+json" \
  --header "X-Joomla-Token: ${JOOMLA_API_TOKEN}" \
  "${BASE}/content/articles?page%5Blimit%5D=1"

The Manual advises against environment variables for secrets and suggests a password manager or secret vault; in a CI job use the platform’s secret store, which exposes the value to the process as an environment variable only for the duration of the job, and keep it out of shell history and logs (set +x).

Basic Auth is disabled by default

The API Authentication - Web Services Basic Auth plugin lets a client authenticate with a username and password through the standard Authorization: Basic ... header (read by PHP as PHP_AUTH_USER and PHP_AUTH_PW). It is installed but disabled on a fresh 6.2 site, while the token plugin is enabled. Keep it that way:

  • The request carries a reusable password on every call; the API login is a silent login, so there is no interactive step where a second factor could be asked for.

  • Passwords end up in client configuration, logs and proxies.

  • A token can be disabled or reset without changing the user’s password and does not give access to the administrator.

The user still needs the core.login.api permission when Basic Auth is used. Enable the plugin only for a trusted environment where tokens are impossible, and only over HTTPS. Older tutorials and books enable Basic Auth first because it is shorter to demonstrate; Developing Extensions for Joomla! 5 (2023) describes tokens, and the 4.x-era material that tells you to switch the Basic Auth plugin on should be read with this default in mind.

Permissions on top of authentication

Authentication proves who the caller is; the controllers still apply the ACL of the component. For com_content:

  • listing and reading follow the visibility the user would have in the administrator list (the model applies the access-level and category rules of the user’s groups);

  • POST requires core.create on the component or the target category, via allowAdd();

  • PATCH requires core.manage on the component, and core.edit on the record or core.edit.own on a record the user created, via allowEdit();

  • DELETE requires the matching delete permission and fails with 409 when the record cannot be deleted (for example it is not trashed).

Give an integration account the smallest role that works, for instance a custom API Publisher group that may create articles in the Trails category and cannot reach the administrator. See Users, groups, access levels and ACL for creating groups and actions, and Security hardening, MFA and passkeys for the rest of the hardening checklist.

The core endpoints

Each core webservices plugin registers the routes of one component. All routes are below /api/index.php/v1. createCRUDRoutes() produces five routes: GET list, GET item (/:id, digits only), POST add, PATCH edit (/:id) and DELETE (/:id). Note that updates use PATCH, not PUT; the router only registers the verbs listed.

Plugin Endpoints Notes

webservices/content

content/articles, content/categories, fields/content/articles, fields/content/categories, fields/groups/content/articles, fields/groups/content/categories, and content/articles/:id/contenthistory (list, PATCH .../keep, DELETE)

Articles are the most complete resource: filters, custom fields, tags and Schema.org data.

webservices/contact

contacts, contacts/categories, contacts/form/:id, fields/contacts/contact, fields/contacts/mail, fields/contacts/categories, field groups, contacts/:id/contenthistory

contacts/form/:id submits the contact form of a contact.

webservices/banners

banners, banners/clients, banners/categories, banners/:id/contenthistory

webservices/newsfeeds

newsfeeds/feeds, newsfeeds/categories

webservices/tags

tags

webservices/users

users, users/groups, users/levels, fields/users, fields/groups/users

users filters on search, state, active and groupid.

webservices/menus

menus/site, menus/administrator, menus/site/items, menus/administrator/items, and .../items/types

The items endpoints filter on menutype.

webservices/modules

modules/site, modules/administrator, modules/types/site, modules/types/administrator

webservices/templates

templates/styles/site, templates/styles/administrator

webservices/plugins

GET plugins, GET plugins/:id, PATCH plugins/:id

Filters: search, status, type, element. Plugins can be enabled, disabled and reordered, not created.

webservices/languages

GET languages, POST languages, languages/content, languages/overrides/site, languages/overrides/administrator, overrides search

webservices/config

config/application, config/:component_name (GET and PATCH)

Reads and writes Global Configuration and component options. Treat it as administrator-level.

webservices/media

media/adapters, media/files

List and read adapters and files; create, change and delete files; see Media.

webservices/messages

messages

Private messages.

webservices/redirect

redirects

webservices/privacy

privacy/requests, privacy/requests/:id, privacy/requests/export/:id, privacy/consents, privacy/consents/:id

Personal data, so keep it restricted.

webservices/installer

GET extensions

webservices/joomlaupdate

joomlaupdate/getUpdate, healthcheck, prepareUpdate, finalizeUpdate, notificationSuccess, notificationFailed

The endpoints used by Automated Core Updates (5.4/6.0 and later). See Updating Joomla and Automated Core Updates.

The set of endpoints depends on the installed version and on which webservices plugins are enabled (the Menus, Redirect, Templates, Messages, Privacy, Languages, Config, Plugins, Installer and Joomla Update plugins are all separate extensions). To list what a site really offers, open System → Plugins, filter by the webservices type, and read the route registration in each plugin class. A site without a plugin has no route and answers 404. A custom component’s endpoints are added the same way by plg_webservices_tallpine; see Extending the Web Services API.

The security consequence is that the API is a second administration interface. An account that may call config, plugins, users or extensions is effectively an administrator. Disable the webservices plugins that your integrations do not use; this removes the endpoints entirely.

Reading articles

The articles list endpoint is GET /v1/content/articles. It accepts these filters (all optional), taken from the ArticlesController:

Parameter Meaning

filter[author]

User id of the author (created_by).

filter[category]

Category id.

filter[search]

Free-text search of the article list.

filter[state]

Publication state: 1 published, 0 unpublished, 2 archived, -2 trashed.

filter[featured]

1 for featured articles.

filter[tag]

Tag id.

filter[language]

Language tag such as en-GB, de-DE or *.

filter[modified_start] and filter[modified_end]

Only articles modified on or after, or before, the given date and time (2026-10-01 00:00:00). Present in the 6.2 controller and introduced in Joomla 6.0; check the controller of an older site before relying on them. They are the basis of incremental synchronisation (below).

filter[checked_out]

User id that has the article checked out.

filter[stage]

Workflow stage id, when workflows are enabled.

list[ordering] and list[direction]

A valid list column (for example a.modified, a.created, a.title) and asc or desc. An invalid column is ignored and an invalid direction becomes asc.

Square brackets in a query string must be percent-encoded for strict clients; curl with --globoff accepts them as they are. Joomla itself writes %5B and %5D in the links it returns.

# Published articles of the category "Alpine" (id 8) modified since 1 October, newest first.
curl --silent --globoff \
  --header "Accept: application/vnd.api+json" \
  --header "X-Joomla-Token: ${JOOMLA_API_TOKEN}" \
  "${BASE}/content/articles?filter[category]=8&filter[state]=1&filter[modified_start]=2026-10-01%2000:00:00&list[ordering]=a.modified&list[direction]=desc"

The modified_start and modified_end filters compare against the stored modification time in UTC. For a nightly export, remember the largest modified value of the previous run and ask for everything after it; the trashed and unpublished articles are returned only if you ask for their state, so a synchroniser must query the states it cares about. Deleted records disappear without a trace, so a mirror needs a periodic full comparison as well.

A single article is GET /v1/content/articles/17. A missing id gives 404.

Pagination

Lists use offset pagination with the JSON:API parameters page[offset] and page[limit]. The default page size is 20 items (itemsPerPage of ApiController), and the view defaults to offset=0, limit=20. Each response includes:

  • links.self, and links.first and links.previous when the offset is greater than zero,

  • links.next and links.last when more pages exist,

  • meta.total-pages.

An offset greater than the total number of items gives 404 instead of an empty page. Follow the next link until it disappears, or compute the offsets from total-pages. Do not hard-code a large limit hoping to fetch everything: a large limit loads and serialises every item (including their fulltext and custom fields) in one request.

# Walk all published articles, 50 at a time, following the "next" link.
url="${BASE}/content/articles?filter[state]=1&page[limit]=50"
while [ -n "$url" ]; do
  page="$(curl --silent --globoff \
    --header "Accept: application/vnd.api+json" \
    --header "X-Joomla-Token: ${JOOMLA_API_TOKEN}" "$url")"
  echo "$page" | jq -r '.data[] | "\(.id)\t\(.attributes.title)"'
  url="$(echo "$page" | jq -r '.links.next // empty')"
done

Creating, updating and deleting articles

The write operations take the article’s form fields as a JSON object. The controller validates the data with the same form (com_content's article form) and the same model as the administrator, so required fields, filters and access rules are identical. catid, title, alias (optional), articletext (or introtext and fulltext), state, access, language and the custom fields are accepted; tags is an array of tag ids. Custom fields are sent as top-level properties named after the field, and the controller moves them into com_fields for the model.

Create

curl --silent --show-error --request POST \
  --header "Accept: application/vnd.api+json" \
  --header "Content-Type: application/json" \
  --header "X-Joomla-Token: ${JOOMLA_API_TOKEN}" \
  --data '{
    "title": "Ridge walk to Lake Tallpine",
    "alias": "ridge-walk-to-lake-tallpine",
    "catid": 8,
    "articletext": "<p>A 12.5 km loop with 640 m of ascent.</p>",
    "state": 1,
    "access": 1,
    "language": "*",
    "tags": [3, 4],
    "distance-km": "12.5",
    "elevation-gain": "640",
    "difficulty": "moderate"
  }' \
  "${BASE}/content/articles"

On success the response is the JSON:API document of the new article (the controller saves the record, checks it in and displays it), with its generated id. Clients should read that id rather than guess it. The Manual’s article example uses the Uncategorized category, which has id 2 on a default install; real sites use their own category ids, here 8 for Trails › Alpine. The Manual also says that the article text must be split into introtext and fulltext when you want a "Read more" break; the combined articletext property puts the break where the <hr id="system-readmore"> is in the HTML. If the article has a featured or publishing window, send featured, publish_up and publish_down.

Typical failures are a missing catid or title (422 with the validation message), a category the user may not create in (403), a duplicated alias in the category (422/500 with the save error), an invalid access or language, and a custom field value that does not match the field type. The error message tells which; at most three validation messages are returned.

Update with PATCH

PATCH sends only the properties that change. For the properties you leave out, the controller loads the stored record and merges its values before validation, so omitting state does not unpublish the article. Tags are kept when tags is not sent.

curl --silent --show-error --request PATCH \
  --header "Accept: application/vnd.api+json" \
  --header "Content-Type: application/json" \
  --header "X-Joomla-Token: ${JOOMLA_API_TOKEN}" \
  --data '{ "title": "Ridge walk to Lake Tallpine (reopened)", "difficulty": "easy" }' \
  "${BASE}/content/articles/17"

The edit checks the record out and in again, so an article that another user holds checked out fails with a check-in/check-out error. The code also keeps stored UTC dates unchanged when the request does not mention them. There is no PUT route.

Delete

# Joomla refuses to delete a published article (409); trash it first.
curl --silent --request PATCH \
  --header "Accept: application/vnd.api+json" --header "Content-Type: application/json" \
  --header "X-Joomla-Token: ${JOOMLA_API_TOKEN}" \
  --data '{ "state": -2 }' "${BASE}/content/articles/17"

curl --silent --request DELETE --include \
  --header "Accept: application/vnd.api+json" \
  --header "X-Joomla-Token: ${JOOMLA_API_TOKEN}" \
  "${BASE}/content/articles/17"

A successful delete answers 204 No Content with no body; 404 means the id does not exist and 409 means the record must be trashed first. The 404 and 409 bodies of a delete are plain JSON objects with status, code and message, not JSON:API error documents. Deletion is permanent, so give integration accounts the core.delete permission only where it is intended.

The same call from PHP

The Manual shows both forms. With the Joomla Framework HTTP package (joomla/http, installed with Joomla) the request from a PHP script on another machine looks like this; the token comes from the environment.

<?php

use Joomla\Http\HttpFactory;
use Joomla\Uri\Uri;

$token = getenv('JOOMLA_API_TOKEN') ?: throw new RuntimeException('JOOMLA_API_TOKEN is not set');
$base  = 'https://tallpine.example/api/index.php/v1';

$http = (new HttpFactory())->getHttp([], 'curl');

$response = $http->get(
    new Uri($base . '/content/articles?filter[state]=1&page[limit]=5'),
    [
        'Accept'         => 'application/vnd.api+json',
        'X-Joomla-Token' => $token,
    ]
);

$document = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);

foreach ($document['data'] as $article) {
    echo $article['id'], ' ', $article['attributes']['title'], PHP_EOL;
}

Other resources by example

Categories, tags and custom fields

Categories are GET /v1/content/categories (filters search and state) and are created with the same JSON shape as the administrator form (title, extension set to com_content, parent_id). Tags are at /v1/tags. The definition of custom fields is at /v1/fields/content/articles and of their groups at /v1/fields/groups/content/articles, which is how a client can discover that Tallpine articles have distance-km and difficulty. Creating a field this way needs the same permissions as the administrator field form. The values themselves travel inside the article. See Custom fields and Categories and tags.

# What custom fields do Tallpine articles have?
curl --silent \
  --header "Accept: application/vnd.api+json" \
  --header "X-Joomla-Token: ${JOOMLA_API_TOKEN}" \
  "${BASE}/fields/content/articles" | jq -r '.data[].attributes | "\(.name)\t\(.type)"'

Users

GET /v1/users lists users and accepts filter[search], filter[state] (blocked or not), filter[active] (activated or not) and filter[groupid]. POST /v1/users creates one with name, username, email, password, groups. Read the response carefully: it contains personal data. An integration that creates members should not be able to add itself to the Super Users group; the ACL checks of the users component apply, but the safest design is an account that cannot reach /v1/users at all.

Menus, modules, plugins, languages and configuration

# Site menu items of the main menu.
curl --silent --globoff \
  --header "Accept: application/vnd.api+json" --header "X-Joomla-Token: ${JOOMLA_API_TOKEN}" \
  "${BASE}/menus/site/items?filter[menutype]=mainmenu"

# Installed content languages (English and German on the Tallpine site).
curl --silent \
  --header "Accept: application/vnd.api+json" --header "X-Joomla-Token: ${JOOMLA_API_TOKEN}" \
  "${BASE}/languages/content"

# Is the Tallpine webservices plugin enabled?
curl --silent --globoff \
  --header "Accept: application/vnd.api+json" --header "X-Joomla-Token: ${JOOMLA_API_TOKEN}" \
  "${BASE}/plugins?filter[element]=tallpine&filter[type]=webservices"

# Read the application configuration (needs administrator-level permission).
curl --silent \
  --header "Accept: application/vnd.api+json" --header "X-Joomla-Token: ${JOOMLA_API_TOKEN}" \
  "${BASE}/config/application"

The config endpoints return and change what System → Global Configuration shows, including database and mail settings. Restrict them to administrators and never expose them to a front-end token.

Media

GET /v1/media/adapters lists the storage adapters (by default the local images folder as local-images). GET /v1/media/files lists the files of an adapter; GET /v1/media/files/local-images:/trails/ridge.jpg returns one. The path query parameter and the filter[search] filter narrow the result, and url=true returns absolute URLs; a recursive search is requested with search_recursive. To upload, POST a JSON body with path (the destination, including the adapter prefix and extension) and content (the file, base64-encoded); PATCH renames or replaces; DELETE removes.

# Upload a trail photo to images/trails/ridge.jpg
B64="$(base64 < ridge.jpg | tr -d '\n')"
curl --silent --show-error --request POST \
  --header "Accept: application/vnd.api+json" \
  --header "Content-Type: application/json" \
  --header "X-Joomla-Token: ${JOOMLA_API_TOKEN}" \
  --data "{\"path\": \"local-images:/trails/ridge.jpg\", \"content\": \"${B64}\"}" \
  "${BASE}/media/files"

The same restrictions apply as in the Media Manager: the allowed extensions and MIME types, the maximum size and the image-check options of Media → Options decide what the upload accepts, and the user needs core.create on com_media. See Media Manager. The JSON body is limited by PHP’s post_max_size, and base64 adds about a third to the size.

Common errors and pitfalls

Symptom Usual cause

401 with a valid-looking token

The token plugin or User - Joomla API Token is disabled; the token of this user is inactive; the user is not in an allowed user group; the user lacks Web Services Login (core.login.api); the user is blocked, unactivated or must reset the password; the secret in configuration.php changed; or the web server dropped the Authorization header and you did not send X-Joomla-Token.

401 when using username and password

The Basic Auth plugin is disabled (the default).

403

Authenticated, but the user may not perform the action on the component or category. Check the permission for the specific action, not core.login.api.

404 on an endpoint that should exist

The webservices plugin of that component is disabled; wrong prefix (/api/index.php/v1/... and not /api/v1/... when URL rewriting is off); an offset greater than the number of items; the id does not match \d+.

406 Not Acceptable

The Accept header is missing or asks for something else, for example Accept: text/html or a browser default. Send application/vnd.api+json.

HTML or a redirect to the login page instead of JSON

The request went to the website, not to the API entry point, or an offline site or a security plugin intercepted it.

404 on PUT

There is no PUT route; use PATCH.

422 or 500 on save

Validation failed (required fields, alias in use, invalid access level, custom field type) or the record is checked out by someone else.

CORS errors in the browser console

CORS is off or the allowed headers do not include X-Joomla-Token; see CORS for browser clients.

Tokens stopped working after a restore or migration

The new site has a different secret.

Missing custom fields in the response

The field is unpublished, belongs to another category assignment, or the user may not view it.

Debugging tips: add --include to curl to see status and headers; the response header X-Powered-By: JoomlaAPI/1.0 tells you the request reached the API application; and enabling System → Global Configuration → Server → Error Reporting and the debug options shows more detail in the logs. Do not leave the debug mode on in production, because the error documents can then contain traces. See Error handling and legacy APIs.

CORS for browser clients

A browser page on another origin (for example https://trails.tallpine.example, built with Next.js or React) can call the API only when the API answers the CORS preflight. System → Global Configuration → Web Services has a switch and three fields:

Setting Behaviour in 6.2

Enable CORS (cors)

Off by default. When on, the API adds CORS headers and answers OPTIONS preflight requests with 204.

Access-Control-Allow-Origin (cors_allow_origin)

Default *. A single origin or a comma-separated list of exact origins is also accepted; the request Origin is echoed only when it is on the list, Vary: Origin is set, and credentials are allowed only for a listed origin. A preflight from an unlisted origin gets 403. The comma-separated list was added in 5.4.8 and 6.1.3; earlier releases echo any request Origin with credentials allowed.

Access-Control-Allow-Headers (cors_allow_headers)

Default Content-Type,X-Joomla-Token,Authorization since 5.4.8 and 6.1.3; Content-Type,X-Joomla-Token before.

Access-Control-Allow-Methods (cors_allow_methods)

Empty means the methods registered for the matched route.

Use exact origins in production. Better still, call the API from your server code (Next.js route handlers, Astro build steps, server components) so that the token never reaches the browser; a token embedded in client-side code is public. The general mechanism of CORS is explained in CORS, and the front-end frameworks in Next.js and React. If you need user-bound access from an external application instead of a shared integration token, see OAuth; Joomla core has no OAuth server, so that would be a separate extension or a proxy in front of the API. The full headless architecture is in Headless Joomla.

Security checklist

  • Keep Basic Auth disabled; use tokens only, over HTTPS only.

  • One token per integration, on a dedicated user whose group has only core.login.api and the specific content permissions. Restrict Allowed User Groups of the token plugin to the groups that need tokens.

  • Never put a token in front-end code, a repository, a URL query string or a log. Rotate by using Reset and update the consumer.

  • Treat the secret of configuration.php as part of the credential.

  • Disable the webservices plugins you do not need (config, plugins, users, installer, privacy).

  • Restrict CORS to the exact origins of your front ends. Consider rate limiting and an IP allow list for /api/ at the web server or proxy; Joomla does not ship API rate limiting.

  • Do not return the API to the public cache: responses depend on the token, and a shared cache must key on it or skip these paths.

  • Review the API calls in the server access logs, and use the Action Logs for changes made by the API user (they are recorded as that user). See Privacy and action logs.

Corrections to older material

  • Older tutorials and the 4.x wiki pages assume Basic Auth for simplicity; in current installations only the token plugin is enabled, and Basic Auth is a deliberate opt-in.

  • Joomla 4 material often shows X-Joomla-Token as the only way; Authorization: Bearer also works on 6.2.

  • Developing Extensions for Joomla! 5 (2023) is a good guide to tokens and createCRUDRoutes(). Its webservices plugin example extends CMSPlugin without namespaces and registers routes in onBeforeApiRoute(&$router). Since 5.0 the event class BeforeApiRouteEvent is passed and the plugin implements SubscriberInterface, as the core plugins in 6.2 do (see Plugin development and events and Extending the Web Services API).

  • The J4.x wiki page Joomla_Core_APIs is not maintained for 6.x; the Developer Manual page that replaces it is still short, and its headings, links and one of its examples are out of step with the code.

  • There is no pagination meta with a total item count and no support for the general JSON:API fields and include parameters, in contrast to what a generic JSON:API client library expects.