webapi.xml: exposing a service as an endpoint
webapi.xml maps a REST route to one method on a PHP interface, and that interface decides the request and response shape.
<route url="/V1/path/:id" method="GET"><service class="Vendor\Module\Api\ThingRepositoryInterface" method="getById"/><resources><resource ref="Vendor_Module::thing"/></resources></route>A module is a directory under app/code/Vendor/Name that Magento loads as a unit. One optional file in it, etc/webapi.xml, declares REST routes. You do not write a controller for the API: a route names a PHP interface and one of its methods, and the framework does the rest.
How a route works
Each <route> has a url that begins with /V1 and a method of GET, POST, PUT or DELETE. A placeholder in the URL is written with a colon, as in :reviewId, and is matched to the service method parameter of the same name. The <service> node names the class (by convention an interface) and the method. The <resources> node lists the permissions a caller needs.
The interface is the API. Magento reflects the method signature and its docblock to decide what JSON the request body may contain and what the response contains, so every parameter and return type needs a docblock with fully qualified class names. Incoming JSON keys are snake_case and are mapped to the matching setter on the parameter's type.
What trips people up
Routes and reflected types are cached. After editing webapi.xml or any interface it points at, run bin/magento cache:clean config config_webservice reflection. The full URL also carries a store code: /rest/default/V1/... for one store, /rest/all/V1/... for global scope.
Example
<!-- app/code/Vendor/Reviews/etc/webapi.xml -->
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
<route url="/V1/vendor-reviews/:reviewId" method="GET">
<service class="Vendor\Reviews\Api\ReviewRepositoryInterface" method="getById"/>
<resources>
<resource ref="Vendor_Reviews::reviews"/>
</resources>
</route>
<route url="/V1/vendor-reviews/search" method="GET">
<service class="Vendor\Reviews\Api\ReviewRepositoryInterface" method="getList"/>
<resources>
<resource ref="Vendor_Reviews::reviews"/>
</resources>
</route>
<route url="/V1/vendor-reviews" method="POST">
<service class="Vendor\Reviews\Api\ReviewRepositoryInterface" method="save"/>
<resources>
<resource ref="Vendor_Reviews::reviews_save"/>
</resources>
</route>
<route url="/V1/vendor-reviews/:reviewId" method="DELETE">
<service class="Vendor\Reviews\Api\ReviewRepositoryInterface" method="deleteById"/>
<resources>
<resource ref="Vendor_Reviews::reviews_delete"/>
</resources>
</route>
</routes>When to use it
- Publish an existing repository so an ERP can read and write your module's records.
- Give a mobile app a GET route for data that currently only renders in a template.
- Re-expose a core service, such as the product repository, under a URL your integrator expects.
More examples
The interface the route points at
The docblocks are not decoration. The framework reads @param to build the request and @return to build the response, which is why array types and class names must be written out in full even when the native type declarations are already there (native `array` carries no element type). NoSuchEntityException is translated to HTTP 404 by the REST layer, and a LocalizedException to 400, so you throw instead of returning an error array.
<?php
// app/code/Vendor/Reviews/Api/ReviewRepositoryInterface.php
declare(strict_types=1);
namespace Vendor\Reviews\Api;
use Magento\Framework\Api\SearchCriteriaInterface;
use Vendor\Reviews\Api\Data\ReviewInterface;
use Vendor\Reviews\Api\Data\ReviewSearchResultsInterface;
/**
* @api
*/
interface ReviewRepositoryInterface
{
/**
* @param int $reviewId
* @return \Vendor\Reviews\Api\Data\ReviewInterface
* @throws \Magento\Framework\Exception\NoSuchEntityException
*/
public function getById(int $reviewId): ReviewInterface;
/**
* @param \Vendor\Reviews\Api\Data\ReviewInterface $review
* @return \Vendor\Reviews\Api\Data\ReviewInterface
* @throws \Magento\Framework\Exception\CouldNotSaveException
*/
public function save(ReviewInterface $review): ReviewInterface;
/**
* @param \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria
* @return \Vendor\Reviews\Api\Data\ReviewSearchResultsInterface
*/
public function getList(SearchCriteriaInterface $searchCriteria): ReviewSearchResultsInterface;
/**
* @param int $reviewId
* @return bool
* @throws \Magento\Framework\Exception\NoSuchEntityException
*/
public function deleteById(int $reviewId): bool;
}Calling the route
Use this to check a route before writing any client code. If you get 404 on a URL you are sure about, the route map is cached: run bin/magento cache:clean config config_webservice reflection and try again. A 401 with a message about the consumer not being authorized means the route was found and the permission check rejected you, which is a different problem entirely.
# TOKEN comes from an admin, customer or integration token - see the authentication lesson.
TOKEN="wcj3qflp9rm9s2rnmsrb6ygvgh0q1d2c"
curl -s "https://example.com/rest/default/V1/vendor-reviews/42" \
-H "Authorization: Bearer ${TOKEN}"
# 200 OK
# {"review_id":42,"product_sku":"24-MB01","rating":5,"nickname":"Ana"}
curl -s -o /dev/null -w '%{http_code}\n' \
"https://example.com/rest/default/V1/vendor-reviews/999999" \
-H "Authorization: Bearer ${TOKEN}"
# 404Re-exposing a core service under your own URL
Nothing says the service has to be yours. This points a new URL at Magento\Catalog\Api\ProductRepositoryInterface::get, whose first parameter is $sku, so the :sku placeholder fills it and the remaining parameters fall back to their defaults. Reach for this when an integrator has a fixed URL scheme you must match, rather than writing a wrapper class that only forwards the call.
<!-- app/code/Vendor/Reviews/etc/webapi.xml -->
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
<route url="/V1/vendor-catalog/products/:sku" method="GET">
<service class="Magento\Catalog\Api\ProductRepositoryInterface" method="get"/>
<resources>
<resource ref="Magento_Catalog::products"/>
</resources>
</route>
</routes>
Discussion