Admin API

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

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

This page was written against PrestaShop 9.2.0 (released 2026-09-30, Symfony 6.4.46, PHP 8.5.12 in the official Docker image, api-platform/core 3.4, league/oauth2-server 8.5) and the ps_apiresources module 0.9.0 that ships the core resources, and it was checked on 2026-10-27. The Admin API exists only in PrestaShop 9 and later; PrestaShop 8.x and 1.7 have only the Webservice. It is part of the open-source core, so every edition and every host has it, and it is enabled by default after an installation. The OpenAPI document that the project publishes still reports info.version 0.1.0 and the official documentation does not label the API as stable or as experimental, so this page treats it as a maturing API: the resource list grows with every minor release and a minority of operations are flagged experimental.

The Admin API is the modern way to read and change a shop from another system: an ERP, a product information manager, a marketplace connector, a script. A client authenticates with OAuth2 (the client credentials grant, which has no human in the loop), receives a short-lived JWT access token that lists the scopes it may use, and then calls JSON endpoints under /admin-api. Behind each endpoint sits the same CQRS command or query that the back office uses, so an API call and a click in the back office go through the same business rules. This page explains the request path, the API clients and tokens, every scope family, the calls that were run against the core resources, the error behaviour (which differs between the debug and the production mode), and then shows how a module publishes its own resource. It follows the CQRS and domain page (the commands and queries that the API wraps), the module basics page and the Webservice API page, and it precedes the CLI commands page that creates API clients from the shell.

The running example is Pocket Coffee. Its ERP reads products and manages the Coffee Club customer group, a catalogue synchronisation job creates and prices products, and its roastery records the roast date of each product through a small module, pcfhapi, that exposes a roast-dates resource. The roast date is the data behind the "roast date badge" that the other pages of this subsection show on the product page. The order part of the Pocket Coffee API example, "create an order", is not possible with the Admin API of 9.2.0, because no order resource exists yet; the page on the Webservice covers orders.

What was run and what was not is listed in What was run and what was not. Every command line below that shows https://shop.example.test was run in the same form against a local PrestaShop 9.2.0 in the Docker image; the local shop answers on plain HTTP, which the Admin API accepts only in debug mode after a configuration switch that is described in HTTPS and the debug mode switch.

The Admin API at a glance

The picture follows one request from the token endpoint to the database.

Path of an Admin API request: token request, JWT, kernel guards, token check, scope check, API Platform operation and CQRS handler, with core and module resources as the source of the operations

The same flow as a sequence, with the three outcomes that matter in practice: an accepted call, a call whose token lacks a scope, and a call after the token has expired.

sequenceDiagram participant C as Client (ERP) participant A as Authorization server (PrestaShop) participant R as Resource server (PrestaShop) C->>A: POST /admin-api/access_token with client_id, client_secret, grant_type, scope A-->>C: 200 with access_token (JWT, RS256) and expires_in C->>R: GET /admin-api/products with Authorization Bearer JWT R->>R: verify signature, expiry and the scopes of the operation R-->>C: 200 with the JSON resource C->>R: DELETE /admin-api/products/21 with a token that has only product_read R-->>C: 403 Invalid scope. Note over C,R: after expires_in seconds the token is refused with 401, ask for a new one

PrestaShop plays two roles. By default it is both the authorization server (it issues the tokens, because it owns the clients) and the resource server (it hosts the resources). An external authorization server, for example Keycloak, can issue the tokens instead, see External authorization server; the shop is always the resource server.

Webservice or Admin API

The older Webservice (/api, one key per integration, XML or JSON, ObjectModel based) is still present in 9.2.0. The two APIs overlap but are not interchangeable.

Webservice Admin API

Introduced

PrestaShop 1.4 and still present in 9.2.0

PrestaShop 9.0

Entry point

/api/<resource>

/admin-api/<resource>

Authentication

A webservice key sent as the user name of HTTP Basic authentication

OAuth2 client credentials, then Authorization: Bearer <JWT>

Permissions

Per key and per resource: GET, PUT, POST, DELETE, HEAD

Per client scope, <resource>_read or <resource>_write, and a separate token lifetime per client

Data model

ObjectModel classes and their webservice_resources definitions

CQRS commands and queries and API Platform resource classes

Formats

XML by default, JSON with output_format=JSON

JSON only (application/json, and application/merge-patch+json for PATCH)

HTTPS

Not enforced

Enforced outside the debug mode, TLS 1.2 or newer

Resources in 9.2.0

About 80 resource pages, including orders, stock and carts

277 operations on more than 35 resource families, no orders and no stock yet

Extension by a module

config/webservice/ and the addWebserviceResources hook

An API Platform class in src/ApiPlatform/Resources

Use the Admin API for new integrations and for everything it already covers; use the Webservice for what it does not cover yet (orders, stock levels) and on PrestaShop 8.x. Both can be active at the same time.

Switching the API on, HTTPS and the debug mode switch

Switches

The Admin API is on after an installation. Three things can turn it off or limit it:

  • the configuration value PS_ENABLE_ADMIN_API (back office: Advanced Parameters > Admin API > Configuration): when it is 0, every request to /admin-api is answered with a 404;

  • a multistore installation: when the multistore feature is active and the feature flag admin_api_multistore is disabled (the default), the whole API answers 404 as well; the flag and the shop context parameters are described in Multistore;

  • the web server: /admin-api is a separate front controller, admin-api/index.php, with its own Symfony kernel and its own cache directory, var/cache/<env>/admin-api. It needs the same URL rewriting as the rest of the shop.

# does the API answer? (a 405 or 401 means yes, a 404 means it is off or multistore blocks it)
curl -s -o /dev/null -w '%{http_code}\n' https://shop.example.test/admin-api/access_token

# turn the API off or on from the console
bin/console prestashop:config set PS_ENABLE_ADMIN_API --value=0
bin/console prestashop:config set PS_ENABLE_ADMIN_API --value=1

HTTPS and the debug mode switch

Outside the debug mode the API insists on HTTPS and on TLS 1.2 or newer. The check runs on every request except the profiler and the documentation routes:

  • plain HTTP is answered with HTTP 401 and the JSON string "Use HTTPS protocol"; sending X-Forwarded-Proto: https does not change that, because the shop looks at the connection as the web server reports it;

  • when the web server reports a TLS version below 1.2 (the variable SSL_PROTOCOL of mod_ssl, for example TLSv1), the answer is HTTP 426 with the header Upgrade: tls/1.2, tls/1.3 and the body "TLSv1.2 or higher is required.";

  • with another web server that does not provide SSL_PROTOCOL, the check falls back to "is the request secure?".

In debug mode only, the check can be disabled with the configuration value PS_ADMIN_API_FORCE_DEBUG_SECURED set to 0 (back office: Advanced Parameters > Admin API > Configuration, option "Force security in debug mode", visible after the debug mode is enabled in Advanced Parameters > Performance). Production mode is always strictly secured. This is why a local Docker shop with PS_DEV_MODE=1 can be called over http://localhost:8080 while the same shop with PS_DEV_MODE=0 refuses the call.

# local development shop only, debug mode on
bin/console prestashop:config set PS_ADMIN_API_FORCE_DEBUG_SECURED --value=0

# what a production shop answers over plain HTTP
curl -s -i -X POST http://shop.example.test/admin-api/access_token -d grant_type=client_credentials
# HTTP/1.1 401 Unauthorized
# "Use HTTPS protocol"

The behaviour above was reproduced on a production-mode container of the same image, with the HTTPS and TLS variables supplied by the Apache configuration to simulate a TLS terminator: plain HTTP gave 401, SSL_PROTOCOL=TLSv1 gave 426 and SSL_PROTOCOL=TLSv1.3 gave normal answers.

API clients

An API client is a row in the table ps_api_client: client_id, client_name, client_secret (stored hashed, shown only once), description, enabled, scopes (a JSON array), external_issuer and lifetime (the token lifetime in seconds, default 3600). A shop normally has one client per integration, so that a leaked secret can be revoked without touching the others and so that each integration gets only the scopes it needs.

Field Meaning

Client name

A free label for people.

Client ID

The identifier used in the token request. The official page asks for no spaces and no special characters; dashes were accepted. It must be unique.

Description

A free text.

Lifetime

Seconds that an access token of this client stays valid. A short lifetime limits the damage of a stolen token; a long one saves token requests.

Enabled

A disabled client cannot get tokens: the token endpoint answers invalid_client.

Scopes

The scopes the client is allowed to ask for. Tokens can only carry a subset of them.

External issuer

The identifier of an external authorization server that issued the client, empty for clients managed by the shop.

There are three ways to create a client; all three produce the same row.

In the back office

Advanced Parameters > Admin API lists the clients. Add new API Client opens the form with the fields above and a scope picker; Generate client secret and save stores the client and shows the secret exactly once. Copy it into the secret store of the calling system at that moment; a lost secret cannot be recovered, only replaced by a new client.

With the console

bin/console prestashop:api-client create pocket-coffee-erp \
  --name="Pocket Coffee ERP" \
  --description="Reads products and manages the Coffee Club group" \
  --scopes=product_read,customer_group_read,customer_group_write,api_client_read \
  --timeout=900

The command prints a table with the generated secret (32 characters). Its options are --name, --description (default Created by CLI command.), --scopes (comma separated), --all-scopes (every scope the shop knows, 71 in 9.2.0 plus the scopes of installed modules), --timeout (token lifetime in seconds), --secret-only (print only the secret, handy for scripts) and --secret[=SECRET] (force a value). Three details were observed on 9.2.0. The action delete removes a client by its client ID. A forced secret that the validator does not accept (a short readable string was tried) makes the command fail with Invalid api client secret, but the client row had already been written, so the next create of the same ID failed with This value is already used until the client was deleted. And a scope name that no installed API declares is refused (The scopes bogus_scope are not associated to any installed API.), so create clients after the modules that bring scopes are installed.

Through the API itself

A client that owns api_client_write can manage clients. The secret is returned only in the answer to the creation.

curl -s -X POST https://shop.example.test/admin-api/api-clients \
  -H "Authorization: Bearer ${PS_TOKEN}" -H 'Content-Type: application/json' \
  -d '{"clientId":"pocket-coffee-tmp","clientName":"Temporary","description":"Created through the Admin API",
       "enabled":true,"lifetime":600,"scopes":["product_read"]}'
# HTTP 201  {"apiClientId":6,"secret":"<shown once>"}

curl -s -H "Authorization: Bearer ${PS_TOKEN}" https://shop.example.test/admin-api/api-clients/6
# {"apiClientId":6,"clientId":"pocket-coffee-tmp","clientName":"Temporary","description":"Created through the Admin API",
#  "externalIssuer":null,"enabled":true,"lifetime":600,"scopes":["product_read"]}

curl -s -X PATCH -H "Authorization: Bearer ${PS_TOKEN}" -H 'Content-Type: application/json' \
  https://shop.example.test/admin-api/api-clients/6 -d '{"enabled":false}'     # HTTP 200, the client is now disabled
curl -s -X DELETE -H "Authorization: Bearer ${PS_TOKEN}" https://shop.example.test/admin-api/api-clients/6   # HTTP 204

After the PATCH above, a token request with the disabled client’s credentials was answered with invalid_client.

Information about the calling client

Whatever token a client holds, GET /admin-api/api-clients/infos returns the row of the client that owns the token, including every scope the client may use (not only those in the current token):

curl -s -H "Authorization: Bearer ${PS_TOKEN}" https://shop.example.test/admin-api/api-clients/infos
# {"apiClientId":3,"clientId":"pocket-coffee-erp","clientName":"Pocket Coffee ERP",
#  "description":"Reads products and manages the Coffee Club group","externalIssuer":null,"enabled":true,"lifetime":900,
#  "scopes":["product_read","customer_group_read","customer_group_write","api_client_read"]}

The official page names this route /admin-api/api-client/info in the text and /admin-api/api-client/infos in its curl example. Both answer 404 on 9.2.0; the real route is /admin-api/api-clients/infos.

Getting an access token

The client credentials grant needs one POST to /admin-api/access_token. The body is form encoded (-d in curl; --form multipart also works), not JSON.

curl -s -X POST https://shop.example.test/admin-api/access_token \
  -d grant_type=client_credentials \
  -d client_id="${PS_CLIENT_ID}" \
  -d client_secret="${PS_CLIENT_SECRET}" \
  -d scope="product_read customer_group_read customer_group_write"
Parameter Meaning

grant_type

Always client_credentials. Any other value (the password grant, for example) is answered with unsupported_grant_type. There is no refresh token: ask again when the token expires.

client_id, client_secret

The credentials of the API client.

scope

The scopes the token will carry, separated by spaces (a comma separated list was accepted as well). Every scope must be in the client’s own list, otherwise the request fails with invalid_scope. If the parameter is left out, the token is issued with only the technical scope is_authenticated, which opens no resource except the client information route. scope[]=a&scope[]=b in a multipart form also works.

A successful answer:

{
  "token_type": "Bearer",
  "expires_in": 900,
  "access_token": "<jwt>"
}

The access token is a JWT signed with RS256. The shop generates a 2048 bit RSA key pair at installation time and stores it in app/config/parameters.php as api_private_key and api_public_key; the token is verified against the public key on every call, with no database lookup. Decoding the payload (a JWT payload is only base64 encoded, never secret) of a token requested with two scopes gives:

{
  "aud": "pocket-coffee-erp",
  "jti": "<random id>",
  "iat": 1793115520.06,
  "nbf": 1793115520.06,
  "exp": 1793116420.06,
  "sub": "",
  "scopes": ["is_authenticated", "product_read", "customer_group_read"]
}

aud is the client ID, exp is iat plus the lifetime of the client, and scopes is what the resource server checks. A client that needs two different sets of scopes should ask for one token per set: a token cannot be widened afterwards. The back-up of parameters.php therefore matters: if the key pair were regenerated, every token and every integration that caches one would be rejected until it asked for a new token.

Token errors

Request Answer Cause

Wrong secret, unknown or disabled client

401 {"error":"invalid_client", ...}

The credentials do not match an enabled client.

A scope the client does not own, or a scope that does not exist

400 {"error":"invalid_scope", ...}

The hint field names the first offending scope. A scope of a module that is installed but disabled is also invalid.

grant_type=password or missing grant type

400 unsupported_grant_type

Only client credentials is implemented.

GET /admin-api/access_token

405 with Allow: POST

The endpoint accepts POST only.

Plain HTTP in production mode

401 "Use HTTPS protocol"

See HTTPS and the debug mode switch.

Scopes

A scope is a permission on one resource family. Core scopes are named <resource>_read and <resource>_write, and they come from the scopes: list of each operation attribute: a GET operation requires the read scope, the operations that change data require the write scope. A call is accepted only when every scope the operation declares is in the token. Having product_write does not imply product_read (a token with only product_write was answered 403 on GET /products); ask for both when a script reads a product before updating it. On a mismatch the answer is HTTP 403 with the detail Invalid scope..

The client created with --all-scopes on the 9.2.0 test shop received 71 scopes (plus is_authenticated in each token):

Family Scopes (each as _read and _write unless noted)

Catalogue

product, category, attribute, attribute_group, feature, feature_value, manufacturer, supplier, attachment, search_alias, search_engine, extra_property_definition

Customers and sales

customer, customer_group, address, cart, cart_rule, discount, order_message, order_return, order_return_state, contact, title

Shipping, tax and geography

carrier, tax, tax_rule, tax_rules_group, zone, country, store

Shop administration

employee, profile, tab, hook, module, webservice_key, showcase_card, api_client

The exact set depends on the version and, for module resources, on the modules that are installed and enabled. List what the shop really knows with the all-scopes client, or read the components.securitySchemes.oauth.flows.clientCredentials.scopes object of the OpenAPI document that the project publishes.

Good practice, from the project’s own recommendation: request the smallest set of scopes for each token, give each integration its own client with a short lifetime, never give an integration api_client_write or module_write unless it manages clients or modules, and keep the secret in the secret store of the integration (an environment variable such as PS_CLIENT_SECRET), never in the repository.

Scopes of a module resource appear in the client form as soon as the module is installed and its resource is loaded. On 9.2.0 the scope is, however, accepted by the token endpoint only while the module is enabled: with pcfhapi disabled, a token request for pcfhapi_roast_date_read was answered with invalid_scope (and the routes returned 404), and both worked again after the module was enabled and the API cache was cleared. The official page says the scopes of an installed module can be assigned before the module is enabled; plan the module deployment accordingly and test it on your version.

Calling the core resources

All calls send Authorization: Bearer <jwt>. Collections answer a JSON envelope; single resources answer the object. The examples below were run against the 9.2.0 test shop; the lines that start with # show the shape of the answer, with long fields shortened.

Listing and paginating

curl -s -H "Authorization: Bearer ${PS_TOKEN}" \
  "https://shop.example.test/admin-api/products?limit=2&orderBy=productId&sortOrder=desc"
# {"totalItems":19,"orderBy":"productId","sortOrder":"desc","limit":2,"filters":[],
#  "items":[{"productId":19,"enabled":true,"name":"Customizable mug","quantity":300,
#            "priceTaxExcluded":13.9,"priceTaxIncluded":13.9,"category":"Home Accessories"}, ...]}

curl -s -g -H "Authorization: Bearer ${PS_TOKEN}" \
  "https://shop.example.test/admin-api/products?filters[name]=mug&limit=3&offset=0"
# {"totalItems":5,"orderBy":"productId","sortOrder":"asc","limit":3,"filters":{"name":"mug"},"items":[...]}

The query parameters of a paginated collection are limit, offset, orderBy, sortOrder (asc or desc) and filters[<field>]=<value>. Notes from the runs:

  • The default page size is not the same on every resource: /products answered with limit 20, /customers/groups and /search-aliases with 10, although the official page says 50. A client should always send limit itself and loop on offset until offset + limit >= totalItems.

  • A very large limit was accepted (limit=1000 returned all 22 products); do not rely on a maximum.

  • -g (curl’s "globoff") is needed so that curl does not interpret [name] in the URL.

  • An unknown filter key is echoed back in filters and ignored, so a typo silently returns everything. An unknown orderBy value reaches the database and fails with an HTTP 500 (Unknown column ... in 'order clause').

  • Fields of the filter and orderBy are the field names of the list DTO (productId, name), except on resources that map them to column names, see Mapping between the API and the CQRS layer.

  • Collections built on a CQRS query that is not paginated (CQRSGetCollection) answer a plain JSON array without envelope.

Reading, creating, updating and deleting

# create a product (type is one of standard, pack, virtual, combinations)
curl -s -X POST -H "Authorization: Bearer ${PS_TOKEN}" -H 'Content-Type: application/json' \
  https://shop.example.test/admin-api/products \
  -d '{"type":"standard","names":{"1":"Starter kit","2":"Kit de inicio"}}'
# HTTP 201 {"productId":21,"type":"standard","enabled":false,"names":{"en-US":"Starter kit","es-ES":"Kit de inicio"}, ...}

# change only some fields (PATCH, partial update)
curl -s -X PATCH -H "Authorization: Bearer ${PS_TOKEN}" -H 'Content-Type: application/json' \
  https://shop.example.test/admin-api/products/21 \
  -d '{"priceTaxExcluded":24.00,"reference":"PC-KIT-001","enabled":true,"weight":1.2}'
# HTTP 200, the whole product is returned: "enabled":true,"priceTaxExcluded":24.0,"reference":"PC-KIT-001","weight":1.2 ...

curl -s -H "Authorization: Bearer ${PS_TOKEN}" https://shop.example.test/admin-api/products/21
curl -s -X DELETE -H "Authorization: Bearer ${PS_TOKEN}" https://shop.example.test/admin-api/products/21   # HTTP 204

Things to know about those calls:

  • Multilingual fields (names, descriptions, localizedNames) are objects. On input the keys may be language IDs ("1", "2") or locales ("en-US"); on output they are always locales. On the test shop language 1 is en-US and language 2 is es-ES; look up the IDs of your shop first (GET /admin-api/languages, scope language_read, or the back office list of languages).

  • A POST answers 201 with a Location header that points to the new resource, followed by a _token query parameter that belongs to the back office. The API ignores the token, so build the follow-up URL from the returned ID, not from the header.

  • PATCH accepts Content-Type: application/json and application/merge-patch+json; any other type is answered with HTTP 415 and the list of supported types, and an unsupported Accept such as application/ld+json with HTTP 406.

  • PUT replaces the whole resource: every field of the command is required and a missing field is treated as empty. Prefer PATCH when the resource offers it. The customer group resource is an example of a PUT-only resource.

  • Resource IDs of not-yet-existing objects return 404 with the detail of the domain exception (for example Group #999 was not found) in debug mode.

The Pocket Coffee Coffee Club group, created and edited through the API with the scopes customer_group_read and customer_group_write:

curl -s -X POST -H "Authorization: Bearer ${PS_TOKEN}" -H 'Content-Type: application/json' \
  https://shop.example.test/admin-api/customers/groups \
  -d '{"localizedNames":{"1":"Coffee Club","2":"Club del Cafe"},"reductionPercent":10.0,
       "displayPriceTaxExcluded":false,"showPrice":true,"shopIds":[1]}'
# HTTP 201 {"customerGroupId":5,"localizedNames":{"en-US":"Coffee Club","es-ES":"Club del Cafe"},
#           "reductionPercent":10.0,"displayPriceTaxExcluded":false,"showPrice":true,"shopIds":[1]}

curl -s -X PUT -H "Authorization: Bearer ${PS_TOKEN}" -H 'Content-Type: application/json' \
  https://shop.example.test/admin-api/customers/groups/5 \
  -d '{"localizedNames":{"1":"Coffee Club","2":"Club del Cafe"},"reductionPercent":12.5,
       "displayPriceTaxExcluded":false,"showPrice":true,"shopIds":[1]}'
# HTTP 200 ... "reductionPercent":12.5 ...

The official page writes the customer group routes as /admin-api/customers/group/{id}; on 9.2.0 the routes are /admin-api/customers/groups and /admin-api/customers/groups/{customerGroupId}, and the singular form answers 404.

A collection that is paginated by a CQRS query rather than by a grid, GET /admin-api/products/{productId}/combinations (the CQRSPaginate operation), answered for the demo product 1:

curl -s -H "Authorization: Bearer ${PS_TOKEN}" "https://shop.example.test/admin-api/products/1/combinations?limit=2"
# {"totalItems":8,"orderBy":"id_product_attribute","sortOrder":"asc","limit":2,"filters":[],
#  "items":[{"productId":1,"combinationId":1,"name":"Size - S, Color - White","default":true,"reference":"demo_1",
#            "quantity":300,"attributes":[{"attributeGroupId":1,"attributeGroupName":"Size", ...}]}, ...]}

Errors

A failing call answers a JSON document in the style of RFC 2616 section 10 (type, title, status, detail, and in debug mode class and a trace):

{
  "type": "https://tools.ietf.org/html/rfc2616#section-10",
  "title": "An error occurred",
  "status": 500,
  "detail": "Group #999 was not found",
  "class": "PrestaShop\\PrestaShop\\Core\\Domain\\Customer\\Group\\Exception\\GroupNotFoundException",
  "trace": []
}

The important point is that the status field of this body is not always the HTTP status of the response. In the runs above the HTTP status was 404 while the body said 500; the HTTP status is the one to use. In debug mode the body carries the class name and the full stack trace; in production mode only "detail": "Internal Server Error", so a client cannot tell a validation problem from a missing object by the body. Plan integration logs around the HTTP status and the request, and read the shop’s own logs (var/logs) for the reason.

HTTP status Typical cause

400

Missing constructor argument of a command (Cannot create an instance of ... requires the following parameters), or a missing shop context parameter in multistore mode.

401

No Authorization header ("No Authorization header provided"), an unknown or expired token ("No authorization server matching your credentials"; a token of a client with a lifetime of one second was refused after three seconds), or plain HTTP in production.

403

Invalid scope.: the token lacks a scope of the operation.

404

The object does not exist (domain exception mapped to 404), the route does not exist, the API is switched off, or the operation is experimental and hidden in production mode.

405

The route exists for another HTTP method. In production mode the answer is an HTML error page, not JSON.

406, 415

Unsupported Accept or Content-Type.

422, 500

Validation failures of the domain layer, mapped by exceptionToStatus or left to the default 500.

What the Admin API covers in 9.2.0

On the test shop bin/console debug:router --app-id=admin-api listed 301 routes, 277 of them API Platform operations, over these resource families: products (21 routes), customers (16), attributes (15), features (12), modules (11), addresses (11), suppliers (10), categories (10), discounts (9), zones, tax rules groups and carriers (8 each), taxes (7), titles, search engines, order return states, order messages, manufacturers, extra property definitions and API clients (6 each), stores, search aliases and countries (5), webservice keys, hooks and contacts (4), tax rules, profiles and attachments (3), and a few more with one or two routes (showcase cards, order returns, tabs, languages, employees, customer groups, cart rules).

The coverage is a work in progress and the project measures it:

  • bin/console prestashop:list:commands-and-queries listed 510 CQRS classes (506 of the core and the four of the pcfhapi module below, because the command lists the classes of every module), 140 of them with an API endpoint (--hasApiEndpoint=1), 137 of those in the core, and 370 without (--hasApiEndpoint=0). The column is a heuristic: the SetRoastDateCommand of the module, which the PATCH operation below uses, is reported without an endpoint;

  • bin/console prestashop:api:generate-tracking-table --skip-github -o tracking.md wrote a Markdown report that counted 501 endpoints to implement, 190 of them implemented (37.9%) and 9 excluded as irrelevant for the Admin API.

There is no orders, stock or carts write resource, no reporting and no import, so a Pocket Coffee integration that needs to create orders or to set stock quantities must use the Webservice (see the page on the Webservice API) or add its own resource. There is no interactive documentation either: the official page says Swagger and ReDoc are served at /admin-api/docs.html and the collection at /admin-api/docs.json, but on 9.2.0 these routes, and /admin-api/docs, answered 404 and the API Platform kernel configuration has enable_docs, enable_swagger and enable_swagger_ui set to false. The OpenAPI document of the project (https://devdocs.prestashop-project.org/9/admin-api/swagger-doc/openapi.json) can be imported into Postman or any OpenAPI client instead.

Experimental operations

Operations declared with experimentalOperation: true (seven resource files of ps_apiresources in 9.2.0, among them /search-aliases, tax rules and the discounts) are controlled by the feature flag admin_api_experimental_endpoints. The metadata decorator filters them out unless the flag is enabled or the kernel runs in debug mode. That was visible on the test shop: on the debug-mode shop GET /admin-api/search-aliases answered 200 although the flag was disabled; on the production-mode container of the same image the route was not served (405 HTML page). Develop against a production-mode copy before relying on an endpoint, and expect experimental operations to change between minor versions. The second flag, admin_api_multistore, is described next.

bin/console prestashop:feature-flag list | grep admin_api
# | admin_api_multistore             | env,dotenv,[db]       | Disabled |
# | admin_api_experimental_endpoints | env,dotenv,[db]       | Disabled |

Multistore

When the multistore feature is active, the Admin API needs to know which shop an operation targets. Support is behind the feature flag admin_api_multistore (back office: Advanced Parameters > New & Experimental Features); while the flag is disabled, an installation with the multistore feature active answers 404 on every /admin-api request. With the flag enabled every request must carry exactly one of these parameters, in the query string or, for POST, PUT and PATCH, in the JSON body, otherwise the answer is 400:

Parameter Type Target

shopId

integer

One shop.

shopGroupId

integer

Every shop of a group.

shopIds

array or comma separated string

A given set of shops.

allShops

flag

Every shop; the value is ignored, the presence of the parameter counts.

Many resources also expose a shopIds property, which is not the request context: it is the list of shops the object is associated with, readable on GET and writable on creation and update. Multistore was not enabled on the test shop (the test shop has one shop), so the parameters above are quoted from the official page and were not run.

Adding a resource from a module

A module extends the Admin API by shipping an API Platform resource class in src/ApiPlatform/Resources. PrestaShop loads the classes of that folder for every enabled module when it builds the API container (the Symfony extension PrestaShopExtension prepends the paths to the api_platform.mapping.paths configuration), reads the scopes from the operation attributes and shows them in the client form. The resource class is at once the DTO that describes the JSON and the place where the routes, the scopes and the CQRS wiring are declared, with PHP attributes. Two ways exist to produce the data:

  • the CQRS way, used by the core: each operation names a CQRS query or command, and PrestaShop’s own provider and processor (QueryProvider, QueryListProvider, CommandProcessor) normalise the HTTP input into the query or command, dispatch it on the bus, and normalise the result back into the DTO. The module supplies the query, command and handler classes;

  • the API Platform way: the resource names its own provider and processor classes (state providers and processors of API Platform). The official example module api_module (animals stored with ObjectModel or Doctrine) uses it. It is the better fit for a module that has no domain layer, at the price of writing the serialisation code.

The rest of the section builds the CQRS resource of Pocket Coffee, shown in full, and then compares the two ways.

The operation classes

PrestaShopBundle\ApiPlatform\Metadata provides one attribute class per kind of operation. Each is an API Platform operation, so every standard parameter (uriTemplate, requirements, status, security, openapi, read, write, output, input, exceptionToStatus …​) works, plus PrestaShop’s own.

Attribute HTTP Use

CQRSGet

GET

Read one resource through a CQRS query (CQRSQuery).

CQRSGetCollection

GET

A short, bounded list returned in one response as a plain JSON array, from a query that returns an array of results.

CQRSPaginate

GET

A list from a CQRS query that paginates itself and returns an object with the items and the total count (itemsField, countField). The envelope has totalItems, orderBy, sortOrder, limit, filters and items.

PaginatedList

GET

A list built from a back-office grid (gridDataFactory, a service id) and a Filters class, the same data as a migrated back-office list page.

CQRSCreate

POST

Create through a command (CQRSCommand). If the command returns a value, the answer is built from it; set CQRSQuery to fetch and return the whole resource instead.

CQRSUpdate

PUT

Replace the whole resource through a command; all fields are required.

CQRSPartialUpdate

PATCH

Update the fields that are present; the others are ignored. Combine with read: false and a CQRSQuery to return the state after the update.

CQRSDelete

DELETE

Run a delete command, usually with output: false to answer 204.

CQRSCommand

POST by default

The generic command operation that the create, update and delete operations extend; use it for actions such as /products/{id}/set-status.

PositionCollection and UpdatePosition

(property and operation)

Helpers for resources whose rows can be reordered with a list of rowId and newPosition pairs.

LocalizedValue

(property)

Marks a property as a language-indexed array, so that keys can be language ids or locales on input and are locales on output.

The additional parameters that PrestaShop’s operations accept:

Parameter Meaning

scopes

The OAuth scopes required by the operation. Several operations may share a scope. A scope name should carry the module name (pcfhapi_roast_date_read) to avoid clashes with core scopes.

CQRSQuery

Class name of the query. On commands it is optional: when given, the query runs after the command to build the answer.

CQRSCommand

Class name of the command. By default the command class is also the input class of the operation, so its constructor parameter names are the JSON field names of the body.

CQRSQueryMapping, CQRSCommandMapping, ApiResourceMapping, filtersMapping

Renaming maps between the API’s field names and the names used by the CQRS classes or the grid columns, see Mapping between the API and the CQRS layer.

gridDataFactory, filtersClass

For PaginatedList: the grid data service and the Filters subclass (to fix default ordering or limits).

itemsField, countField

For CQRSPaginate: names of the fields in the query result that hold the items (default items) and the total (default count).

experimentalOperation

true hides the operation in production mode unless the feature flag admin_api_experimental_endpoints is on.

minVersion, maxVersion

Hide the operation on cores older or newer than the given version.

exceptionToStatus (on #[ApiResource])

Maps an exception class to an HTTP status, for example RoastDateNotFoundException::class => 404.

The module pcfhapi

The module stores one roast date per product in its own table and exposes it as /admin-api/pocket-coffee/roast-dates. It has this layout (index.php redirect files, required in every folder, are not shown):

modules/pcfhapi/
  pcfhapi.php                      main class, creates and drops the table
  composer.json, vendor/           PSR-4 autoloader for PrestaShop\Module\Pcfhapi\
  config/services.yml              registers the handlers
  src/ApiPlatform/Resources/RoastDate.php
  src/Database/RoastDateInstaller.php
  src/Domain/RoastDate/Command/    SetRoastDateCommand, DeleteRoastDateCommand
  src/Domain/RoastDate/Query/      GetRoastDate, GetRoastDateList
  src/Domain/RoastDate/QueryResult/RoastDate.php
  src/Domain/RoastDate/Exception/  RoastDateNotFoundException, InvalidRoastDateException
  src/Infrastructure/Handler/      four handlers

The main class creates the table when the module is installed, as any module with its own data would (see Database and entities):

<?php

declare(strict_types=1);

if (!defined('_PS_VERSION_')) {
    exit;
}

if (file_exists(__DIR__ . '/vendor/autoload.php')) {
    require_once __DIR__ . '/vendor/autoload.php';
}

use PrestaShop\Module\Pcfhapi\Database\RoastDateInstaller;

class Pcfhapi extends Module
{
    public function __construct()
    {
        $this->name = 'pcfhapi';
        $this->tab = 'administration';
        $this->version = '1.0.0';
        $this->author = 'Pocket Coffee';
        $this->need_instance = 0;
        $this->ps_versions_compliancy = ['min' => '9.0.0', 'max' => '9.99.99'];

        parent::__construct();

        $this->displayName = $this->trans('Pocket Coffee roast dates API', [], 'Modules.Pcfhapi.Admin');
        $this->description = $this->trans('Admin API resource that stores the roast date of each product.', [], 'Modules.Pcfhapi.Admin');
    }

    public function install(): bool
    {
        return parent::install() && RoastDateInstaller::createTable(Db::getInstance(), _DB_PREFIX_);
    }

    public function uninstall(): bool
    {
        return RoastDateInstaller::dropTable(Db::getInstance(), _DB_PREFIX_) && parent::uninstall();
    }

    public function isUsingNewTranslationSystem(): bool
    {
        return true;
    }
}

The installer is a small class with a CREATE TABLE IF NOT EXISTS statement (columns id_product, roast_date, batch) and a DROP TABLE IF EXISTS; the table name is built from _DB_PREFIX_ and passed through bqSQL(). The domain layer is made of plain classes. The query and the commands use constructor arguments whose names are the JSON fields:

<?php

declare(strict_types=1);

namespace PrestaShop\Module\Pcfhapi\Domain\RoastDate\Command;

class SetRoastDateCommand
{
    public function __construct(
        public readonly int $productId,
        public readonly string $roastDate,
        public readonly string $batch = '',
    ) {
    }
}
<?php

declare(strict_types=1);

namespace PrestaShop\Module\Pcfhapi\Domain\RoastDate\Query;

class GetRoastDate
{
    public function __construct(public readonly int $productId)
    {
    }
}

The delete command (DeleteRoastDateCommand, one productId) and the list query (GetRoastDateList, no argument) follow the same pattern. The query result is read through getters, which is how PrestaShop’s serializer turns a CQRS result into the fields of the API resource:

<?php

declare(strict_types=1);

namespace PrestaShop\Module\Pcfhapi\Domain\RoastDate\QueryResult;

class RoastDate
{
    public function __construct(
        private readonly int $productId,
        private readonly string $roastDate,
        private readonly string $batch,
    ) {
    }

    public function getProductId(): int
    {
        return $this->productId;
    }

    public function getRoastDate(): string
    {
        return $this->roastDate;
    }

    public function getBatch(): string
    {
        return $this->batch;
    }
}

The two exceptions extend RuntimeException. The handlers carry the attributes #[AsQueryHandler] and #[AsCommandHandler] (PrestaShop\PrestaShop\Core\CommandBus\Attributes), which register them on the matching bus when the class is autoconfigured, and each has a handle() method whose parameter type selects the query or command it serves:

<?php

declare(strict_types=1);

namespace PrestaShop\Module\Pcfhapi\Infrastructure\Handler;

use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use PrestaShop\Module\Pcfhapi\Domain\RoastDate\Command\SetRoastDateCommand;
use PrestaShop\Module\Pcfhapi\Domain\RoastDate\Exception\InvalidRoastDateException;
use PrestaShop\PrestaShop\Core\CommandBus\Attributes\AsCommandHandler;

#[AsCommandHandler]
final class SetRoastDateHandler
{
    public function __construct(private readonly Connection $connection, private readonly string $dbPrefix)
    {
    }

    public function handle(SetRoastDateCommand $command): void
    {
        $date = DateTimeImmutable::createFromFormat('!Y-m-d', $command->roastDate);
        if (false === $date || $date->format('Y-m-d') !== $command->roastDate) {
            throw new InvalidRoastDateException(sprintf('"%s" is not a valid Y-m-d date', $command->roastDate));
        }

        $this->connection->executeStatement(
            'REPLACE INTO ' . $this->dbPrefix . 'pcfhapi_roast_date (id_product, roast_date, batch) VALUES (:id, :date, :batch)',
            ['id' => $command->productId, 'date' => $command->roastDate, 'batch' => $command->batch]
        );
    }
}

GetRoastDateHandler runs a SELECT with a bound parameter and throws RoastDateNotFoundException when no row exists, GetRoastDateListHandler returns an array of RoastDate results and DeleteRoastDateHandler throws the same exception when the DELETE removed no row. All four take the Doctrine Connection and the table prefix as constructor arguments, which config/services.yml supplies:

services:
  _defaults:
    autowire: true
    autoconfigure: true
    public: false
    bind:
      string $dbPrefix: '%database_prefix%'

  PrestaShop\Module\Pcfhapi\Infrastructure\Handler\:
    resource: '../src/Infrastructure/Handler/*'
    exclude: '../src/Infrastructure/Handler/index.php'

  PrestaShop\Module\Pcfhapi\Database\RoastDateInstaller: ~

The exclude line is not decoration. A resource: glob makes Symfony load every PHP file of the folder to look for classes, and index.php (the redirect file that every module folder must contain) ends with exit. Without the exclude, the back-office console died silently (exit code 0, no output, list and debug:router printed nothing) and the token endpoint of the API answered 302 with Location: ../, for every client of the shop and not only for the module; the front office kept working. The same happened when an index.php was left in src/ApiPlatform/Resources, which API Platform scans with require_once: the API broke and debug:router --app-id=admin-api printed nothing, while the back-office console still worked. That folder must not contain an index.php (the core only deletes such files in src/Entity). Remove the file or add the exclude, then delete var/cache/<env>/admin-api (and var/cache/<env>/admin), because cache:clear itself cannot run while the kernel dies.

The resource class is the heart of the module. It declares the four operations, their scopes and the mapping from domain exceptions to HTTP statuses:

<?php

declare(strict_types=1);

namespace PrestaShop\Module\Pcfhapi\ApiPlatform\Resources;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use PrestaShop\Module\Pcfhapi\Domain\RoastDate\Command\DeleteRoastDateCommand;
use PrestaShop\Module\Pcfhapi\Domain\RoastDate\Command\SetRoastDateCommand;
use PrestaShop\Module\Pcfhapi\Domain\RoastDate\Exception\InvalidRoastDateException;
use PrestaShop\Module\Pcfhapi\Domain\RoastDate\Exception\RoastDateNotFoundException;
use PrestaShop\Module\Pcfhapi\Domain\RoastDate\Query\GetRoastDate;
use PrestaShop\Module\Pcfhapi\Domain\RoastDate\Query\GetRoastDateList;
use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete;
use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet;
use PrestaShopBundle\ApiPlatform\Metadata\CQRSGetCollection;
use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate;
use Symfony\Component\HttpFoundation\Response;

#[ApiResource(
    operations: [
        new CQRSGet(
            uriTemplate: '/pocket-coffee/roast-dates/{productId}',
            requirements: ['productId' => '\d+'],
            CQRSQuery: GetRoastDate::class,
            scopes: ['pcfhapi_roast_date_read'],
        ),
        new CQRSGetCollection(
            uriTemplate: '/pocket-coffee/roast-dates',
            CQRSQuery: GetRoastDateList::class,
            scopes: ['pcfhapi_roast_date_read'],
        ),
        new CQRSPartialUpdate(
            uriTemplate: '/pocket-coffee/roast-dates/{productId}',
            requirements: ['productId' => '\d+'],
            read: false,
            CQRSCommand: SetRoastDateCommand::class,
            CQRSQuery: GetRoastDate::class,
            scopes: ['pcfhapi_roast_date_write'],
        ),
        new CQRSDelete(
            uriTemplate: '/pocket-coffee/roast-dates/{productId}',
            requirements: ['productId' => '\d+'],
            output: false,
            CQRSCommand: DeleteRoastDateCommand::class,
            scopes: ['pcfhapi_roast_date_write'],
        ),
    ],
    exceptionToStatus: [
        RoastDateNotFoundException::class => Response::HTTP_NOT_FOUND,
        InvalidRoastDateException::class => Response::HTTP_UNPROCESSABLE_ENTITY,
    ],
)]
class RoastDate
{
    #[ApiProperty(identifier: true)]
    public int $productId;

    public string $roastDate;

    public string $batch = '';
}

Points to notice in this class:

  • {productId} in the uriTemplate is a URI variable; the requirements pattern (\d+) keeps text out of it. The identifier: true property is the variable that API Platform uses to identify the resource. The URI variable is passed to the query or command constructor argument of the same name.

  • read: false on the PATCH tells API Platform not to load the resource before the update; the command carries all the data, and CQRSQuery produces the answer after the command ran.

  • output: false on the DELETE makes the answer 204 No Content.

  • The command class is the input class of PATCH, so a body without roastDate fails while PrestaShop builds the command (the constructor parameter is required, batch has a default). The operation is nevertheless "partial" in the sense that a client may omit batch.

  • The scopes are named after the module. The same scope string on several operations is fine, and an operation without scopes is reachable by any valid token, so always declare them.

Install the module, clear the API cache so that the new resource is discovered, and create a client for the roastery:

bin/console prestashop:module install pcfhapi
bin/console cache:clear --app-id=admin-api
bin/console debug:router --app-id=admin-api | grep roast
# _api_/pocket-coffee/roast-dates/{productId}_get      GET     /pocket-coffee/roast-dates/{productId}
# _api_/pocket-coffee/roast-dates_get_collection       GET     /pocket-coffee/roast-dates
# _api_/pocket-coffee/roast-dates/{productId}_patch    PATCH   /pocket-coffee/roast-dates/{productId}
# _api_/pocket-coffee/roast-dates/{productId}_delete   DELETE  /pocket-coffee/roast-dates/{productId}

bin/console prestashop:api-client create pocket-coffee-roastery --name="Roastery" \
  --scopes=pcfhapi_roast_date_read,pcfhapi_roast_date_write --secret-only

Then call the resource. The roastery records the batch roasted on 2026-10-26 for the Ethiopian beans (product 1 stands for them in this example; the test shop only has the demo products), asks for it, lists everything and removes an entry:

TOKEN=$(curl -s -X POST https://shop.example.test/admin-api/access_token \
  -d grant_type=client_credentials -d client_id=pocket-coffee-roastery -d client_secret="${PS_CLIENT_SECRET}" \
  -d scope="pcfhapi_roast_date_read pcfhapi_roast_date_write" | jq -r .access_token)

curl -s -X PATCH -H "Authorization: Bearer ${TOKEN}" -H 'Content-Type: application/json' \
  https://shop.example.test/admin-api/pocket-coffee/roast-dates/1 -d '{"roastDate":"2026-10-26","batch":"ETH-2610"}'
# HTTP 200 {"productId":1,"roastDate":"2026-10-26","batch":"ETH-2610"}

curl -s -H "Authorization: Bearer ${TOKEN}" https://shop.example.test/admin-api/pocket-coffee/roast-dates
# HTTP 200 [{"productId":1,"roastDate":"2026-10-26","batch":"ETH-2610"},{"productId":2,"roastDate":"2026-10-20","batch":"COL-2610"}]

curl -s -X DELETE -H "Authorization: Bearer ${TOKEN}" https://shop.example.test/admin-api/pocket-coffee/roast-dates/2   # HTTP 204

The error cases that were checked with this resource:

Call HTTP Cause

GET /pocket-coffee/roast-dates/1 before anything was stored

404

RoastDateNotFoundException mapped by exceptionToStatus (in debug mode the body also says "No roast date for product #1").

PATCH with {"roastDate":"26/10/2026"}

422

InvalidRoastDateException thrown by the handler.

PATCH with {"batch":"X"} only

400

SetRoastDateCommand cannot be built: the constructor requires roastDate.

PATCH with a token that has only pcfhapi_roast_date_read

403

Invalid scope.

DELETE of an entry that was already removed

404

The delete handler found no row.

Module disabled

404

The resource is not loaded; its scopes are invalid for new tokens.

The same four operations were exercised on a production-mode container (PS_DEV_MODE=0, HTTPS simulated at the web server): the statuses were identical, and the JSON error bodies were reduced to "Internal Server Error".

Mapping between the API and the CQRS layer

When the DTO’s field names differ from the CQRS classes, the *Mapping parameters rename fields. Each is an associative array from the original name to the target name, written in bracket notation ('[id_api_client]' => '[apiClientId]'). They apply at different steps:

Mapping Applied to

CQRSQueryMapping

The query object built from the URI and the query string, and the result of the query before it becomes the DTO.

CQRSCommandMapping

The command object built from the request, and the result that the command returns.

ApiResourceMapping

The DTO, both when it is read from the request body and when it is filled from a result or a grid row.

filtersMapping

The filters and orderBy parameters of a paginated list, to the names the Filters class and the grid expect.

The core’s API client list is the official example: the grid columns are id_api_client and client_name, the DTO has apiClientId and clientName, ApiResourceMapping renames grid to DTO, and filtersMapping renames DTO to grid so that filters[clientName]=ERP and orderBy=apiClientId work.

new PaginatedList(
    uriTemplate: '/api-clients',
    scopes: ['api_client_read'],
    ApiResourceMapping: [
        '[id_api_client]' => '[apiClientId]',
        '[client_id]' => '[clientId]',
        '[client_name]' => '[clientName]',
        '[external_issuer]' => '[externalIssuer]',
    ],
    gridDataFactory: 'prestashop.core.grid.data_factory.api_client',
    filtersClass: ApiClientFilters::class,
    filtersMapping: [
        '[apiClientId]' => '[id_api_client]',
        '[clientId]' => '[client_id]',
        '[clientName]' => '[client_name]',
        '[externalIssuer]' => '[external_issuer]',
    ],
),

A query handler’s context also reaches the CQRS query: '[_context][langId]' => '[languageId]' and '[_context][shopConstraint]' => '[shopConstraint]' inject the language and the shop context into queries such as GetEditableCombinationsList, which is how the core’s combination list resolves names for the right language. The official page lists these mappings with their diagrams of the provider and processor pipelines.

Checking the resource

Three tools help while developing a resource:

bin/console debug:router --app-id=admin-api | grep pocket-coffee      # are the routes registered?
bin/console debug:api-resource 'PrestaShop\Module\Pcfhapi\ApiPlatform\Resources\RoastDate' --app-id=admin-api
bin/console prestashop:list:commands-and-queries -d RoastDate           # CQRS classes of a domain, with their API route

debug:api-resource prints the operations of a resource class and asks which one to inspect, so run it in a terminal. prestashop:list:commands-and-queries lists every CQRS class registered on the buses, including the module’s, and prints the API route of each. A PHP syntax check (php -l) of every file, the console linters and a request per operation, as in the table above, are the practical test.

Providers and processors instead of CQRS

A module that wraps its own ObjectModel or Doctrine entities, and has no domain layer, can skip CQRS and name its own state provider and processor in the #[ApiResource] attribute. The official example module api_module does this with a Horse resource:

#[ApiResource(
    provider: HorseProvider::class,
    processor: HorseProcessor::class
)]
class Horse
{
    #[ApiProperty(identifier: true)]
    public ?int $id = null;

    #[Assert\NotBlank]
    #[Assert\Type('string')]
    public ?string $name = null;
    // ...
}

The provider implements ApiPlatform\State\ProviderInterface::provide() and returns one DTO, an array of them, or null for a 404; the processor implements ProcessorInterface::process() and receives the DTO for POST, PUT, PATCH and DELETE. Both are registered as services with the tags api_platform.state_provider and api_platform.state_processor. Without scopes, such a resource declares no OAuth scope of its own (it would be reachable by any authenticated token), which is why the CQRS operations of PrestaShop, which take scopes:, are the safer default for a resource that changes data. If a provider-based resource is used, add the scope with extraProperties or an operation-level security expression. This example module was read from the official repository and its structure is quoted here; it was not installed on the test shop.

External authorization server

PrestaShop can accept tokens issued by another OAuth2 server, which is useful when a company already has an identity provider. The shop stays the resource server and must be able to recognise and validate the foreign token. A module implements PrestaShop\PrestaShop\Core\Security\OAuth2\AuthorisationServerInterface, which has two methods:

  • isTokenValid(Request $request): bool: for each request, the resource server asks every registered server in turn; a server answers true if it recognises the token (usually by the issuer claim) and validates its signature;

  • getJwtTokenUser(Request $request): ?JwtTokenUser: returns the user that the token represents, with the client id, the scopes and the issuer.

The class is registered as a service with autoconfigure: true so that it is tagged automatically, and TokenAuthenticator includes it in its loop. The official demo module keycloak_connector_demo implements the interface for Keycloak and ships a Docker Compose file; the authorization server must be configured with the same scope names as the endpoints (product_read, api_client_read, and the module’s own). External servers were not run for this page.

Operating the API in production

  • Always use HTTPS with TLS 1.2 or newer; production mode refuses everything else.

  • Create one client per integration, with the minimum scopes and a short lifetime, and delete clients that are no longer used. Rotate a secret by creating a second client, switching the integration to it and deleting the first.

  • Keep secrets in environment variables or a secret manager, never in the repository, a command history or a ticket. Examples on this page use ${PS_CLIENT_SECRET}.

  • Cache the token for its lifetime and ask for a new one shortly before expires_in. A client that asks for a token on every request wastes a round trip and defeats the lifetime.

  • The core has no rate limit and no IP allow-list for the API. Put them at the web server, the CDN or the WAF in front of /admin-api, and consider restricting the path to the networks of the integrations.

  • Audit at the web server: the shop has a table ps_mutation meant to record who changed a row (mutator_type is employee, api_client or module), but it stayed empty after all the product, group and client calls of these runs, so do not rely on it. Log the requests (method, path, status, client id) at the web server or the gateway.

  • Test each upgrade: the API is versioned with the shop (there is no /v1 in the URL) and experimental operations may change between minor versions. The list of changes is in the "Changes in 9.x" pages of the developer documentation.

What was run and what was not

On a local PrestaShop 9.2.0 shop (the official Docker image, MySQL 8.4, PHP 8.5.12, Apache 2.4.68) the following ran and produced the results quoted above: three API clients (created with the console and one with the API itself, then updated, disabled and deleted), token requests with every error case of the table, the decoded JWT payload, the client information route, product list, filters and pagination, product creation, update and deletion, the customer group create, update and read calls, the combinations list, the 401, 403, 404, 405, 406 and 415 answers, the experimental route in debug and in production mode, the TLS simulation (401, 426, success) on a production-mode container, the pcfhapi module (install, route registration, four operations, all error cases, disabling and enabling, and the same calls in production mode) with php -l on every file, and the two reporting commands of the CQRS inventory. The test client, groups, products and module were removed afterwards.

Not run: multistore with the admin_api_multistore flag, an external authorization server (Keycloak), the official api_module example, the CQRSCreate, CQRSUpdate and CQRSCommand attributes in a module of mine (the core’s own operations of those kinds were called through the HTTP examples), the interactive documentation (the routes do not exist on 9.2.0) and the Admin API on a production host with a real TLS terminator. The numbers of routes, endpoints and CQRS classes come from the 9.2.0 install and will change with each release.