APIs that hold up
What keeps a REST contract stable: @api, extension attributes, URL versioning, 429 backpressure, and returning data interfaces.
<extension_attributes for="Magento\Catalog\Api\Data\ProductInterface"><attribute code="vendor_rating" type="float"/></extension_attributes>A published endpoint is a promise to code you do not control. Two habits keep it: do not change what you have published, and add only in ways that cannot break a caller.
An interface is frozen once published
The @api annotation marks an interface as part of the public contract, covered by Adobe's backward compatibility policy. Adding a method to it is a breaking change, because any class implementing it elsewhere stops being valid. The accepted move is a new interface: leave the old one untouched, mark the superseded method @deprecated with a @see pointing at the replacement, and keep it working.
To add a field to a response without touching the data interface, declare an extension attribute in etc/extension_attributes.xml. It is returned inside extension_attributes and accepted under the same key on POST and PUT. For versioning, the only lever the framework offers is the URL: keep /V1 serving and add a /V2 route pointing at a new interface.
Return types and limits
Return the data interface, never a model. A model exposes whatever its getters happen to reach, ties the response to your table layout, and changes shape the moment you add a column.
On 2.4.9 Open Source the REST layer can answer 429 Too Many Requests through backpressure, which applies per service method rather than globally; order placement is the one configured out of the box, under sales/backpressure/. General per-client throttling belongs to the web server or CDN, not to Magento. Apply configuration with bin/magento cache:flush.
Example
<?php
// app/code/Vendor/Reviews/Plugin/AddRatingToProduct.php
declare(strict_types=1);
namespace Vendor\Reviews\Plugin;
use Magento\Catalog\Api\Data\ProductExtensionFactory;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Vendor\Reviews\Api\AverageRatingInterface;
class AddRatingToProduct
{
public function __construct(
private readonly ProductExtensionFactory $extensionFactory,
private readonly AverageRatingInterface $averageRating
) {
}
/**
* Add vendor_rating to any product read through the repository.
*/
public function afterGet(
ProductRepositoryInterface $subject,
ProductInterface $result
): ProductInterface {
$extension = $result->getExtensionAttributes() ?? $this->extensionFactory->create();
$extension->setVendorRating($this->averageRating->forSku($result->getSku()));
$result->setExtensionAttributes($extension);
return $result;
}
}When to use it
- Add a field to the product API response without editing a core interface.
- Change a method's behaviour for new callers while the old shape keeps working.
- Protect an expensive endpoint that an integrator calls in a tight loop.
More examples
Declaring the extension attribute
This is what makes setVendorRating() exist: Magento generates ProductExtensionInterface and its factory from this file, turning the attribute code into a camelCase getter and setter. GET /V1/products/24-MB01 then returns "extension_attributes": {"vendor_rating": 4.5}. Run bin/magento setup:di:compile to generate the types and bin/magento cache:clean config config_webservice reflection to publish the new response shape. The plugin shown above covers get() only - getList() needs its own afterGetList.
<!-- app/code/Vendor/Reviews/etc/extension_attributes.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Api/etc/extension_attributes.xsd">
<extension_attributes for="Magento\Catalog\Api\Data\ProductInterface">
<attribute code="vendor_rating" type="float"/>
</extension_attributes>
</config>Replacing a method without breaking callers
The old method keeps its signature and keeps working, usually by delegating to the new one with a default store. @deprecated tells anyone reading the code to stop using it and @see says what to use instead; static analysis and the coding standard both read these tags. Adding getByIdAndStore() to the existing interface instead would have broken every class implementing it, including ones in other vendors' modules.
<?php
// app/code/Vendor/Reviews/Api/ReviewRepositoryInterface.php (published, unchanged)
/**
* @api
*/
interface ReviewRepositoryInterface
{
/**
* @param int $reviewId
* @return \Vendor\Reviews\Api\Data\ReviewInterface
* @deprecated 2.1.0 Store scope is now significant.
* @see \Vendor\Reviews\Api\ReviewLookupInterface::getByIdAndStore()
*/
public function getById(int $reviewId): ReviewInterface;
}
// app/code/Vendor/Reviews/Api/ReviewLookupInterface.php (new file)
/**
* @api
*/
interface ReviewLookupInterface
{
/**
* @param int $reviewId
* @param int $storeId
* @return \Vendor\Reviews\Api\Data\ReviewInterface
* @throws \Magento\Framework\Exception\NoSuchEntityException
*/
public function getByIdAndStore(int $reviewId, int $storeId): ReviewInterface;
}Versioning in the URL
Two routes, two interfaces, one module. The framework does no negotiation and no aliasing: the version is part of the path, so an existing integration that hardcoded /V1 keeps getting exactly the response it was written against. Decide up front how long /V1 stays, announce it in your module's changelog, and remove it only in a major release of your own package.
<!-- 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">
<!-- Still serving the original shape. Do not touch it. -->
<route url="/V1/vendor-reviews/:reviewId" method="GET">
<service class="Vendor\Reviews\Api\ReviewRepositoryInterface" method="getById"/>
<resources>
<resource ref="Vendor_Reviews::reviews"/>
</resources>
</route>
<!-- New callers get the store-aware version. -->
<route url="/V2/vendor-reviews/:reviewId/store/:storeId" method="GET">
<service class="Vendor\Reviews\Api\ReviewLookupInterface" method="getByIdAndStore"/>
<resources>
<resource ref="Vendor_Reviews::reviews"/>
</resources>
</route>
</routes>
Discussion