- Step 1: The Data Interface
- Step 2: The Repository Interface
- Step 3: Implement the Model and Repository
- Step 4: Wire Up Preferences in di.xml
- Step 5: Define ACL and Routes
- Step 6: Test With curl
- Asynchronous and Bulk Endpoints
- Restricting Endpoints to the Logged-In Customer
- Testing and Documentation
- Frequently Asked Questions
Magento's REST API is generated from service contracts: PHP interfaces with complete docblocks. You define an interface, implement it, and map URLs to its methods in webapi.xml. Magento handles routing, authentication, JSON serialisation and the Swagger documentation.
We will expose the FAQ table from our declarative schema tutorial through three endpoints: get one FAQ, search FAQs and save an FAQ.
Step 1: The Data Interface
Docblocks are not optional here. Magento reads the @return and @param types to convert between JSON and PHP objects, so every getter and setter must declare its type.
<?php
declare(strict_types=1);
namespace MageServices\Faq\Api\Data;
interface FaqInterface
{
public const ENTITY_ID = 'entity_id';
public const QUESTION = 'question';
public const ANSWER = 'answer';
public const IS_ACTIVE = 'is_active';
/**
* @return int|null
*/
public function getId();
/**
* @return string
*/
public function getQuestion(): string;
/**
* @param string $question
* @return $this
*/
public function setQuestion(string $question): self;
/**
* @return string
*/
public function getAnswer(): string;
/**
* @param string $answer
* @return $this
*/
public function setAnswer(string $answer): self;
/**
* @return bool
*/
public function getIsActive(): bool;
/**
* @param bool $isActive
* @return $this
*/
public function setIsActive(bool $isActive): self;
}
<?php
declare(strict_types=1);
namespace MageServices\Faq\Api\Data;
use Magento\Framework\Api\SearchResultsInterface;
interface FaqSearchResultsInterface extends SearchResultsInterface
{
/**
* @return \MageServices\Faq\Api\Data\FaqInterface[]
*/
public function getItems();
/**
* @param \MageServices\Faq\Api\Data\FaqInterface[] $items
* @return $this
*/
public function setItems(array $items);
}
Step 2: The Repository Interface
<?php
declare(strict_types=1);
namespace MageServices\Faq\Api;
interface FaqRepositoryInterface
{
/**
* @param int $faqId
* @return \MageServices\Faq\Api\Data\FaqInterface
* @throws \Magento\Framework\Exception\NoSuchEntityException
*/
public function getById(int $faqId);
/**
* @param \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria
* @return \MageServices\Faq\Api\Data\FaqSearchResultsInterface
*/
public function getList(\Magento\Framework\Api\SearchCriteriaInterface $searchCriteria);
/**
* @param \MageServices\Faq\Api\Data\FaqInterface $faq
* @return \MageServices\Faq\Api\Data\FaqInterface
* @throws \Magento\Framework\Exception\CouldNotSaveException
*/
public function save(\MageServices\Faq\Api\Data\FaqInterface $faq);
}
Step 3: Implement the Model and Repository
Update the FAQ model so it implements the data interface:
<?php
declare(strict_types=1);
namespace MageServices\Faq\Model;
use Magento\Framework\Model\AbstractModel;
use MageServices\Faq\Api\Data\FaqInterface;
use MageServices\Faq\Model\ResourceModel\Faq as FaqResource;
class Faq extends AbstractModel implements FaqInterface
{
protected $_eventPrefix = 'mageservices_faq';
protected function _construct(): void
{
$this->_init(FaqResource::class);
}
public function getQuestion(): string
{
return (string) $this->getData(self::QUESTION);
}
public function setQuestion(string $question): self
{
return $this->setData(self::QUESTION, $question);
}
public function getAnswer(): string
{
return (string) $this->getData(self::ANSWER);
}
public function setAnswer(string $answer): self
{
return $this->setData(self::ANSWER, $answer);
}
public function getIsActive(): bool
{
return (bool) $this->getData(self::IS_ACTIVE);
}
public function setIsActive(bool $isActive): self
{
return $this->setData(self::IS_ACTIVE, (int) $isActive);
}
}
<?php
declare(strict_types=1);
namespace MageServices\Faq\Model;
use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\NoSuchEntityException;
use MageServices\Faq\Api\Data\FaqInterface;
use MageServices\Faq\Api\Data\FaqSearchResultsInterface;
use MageServices\Faq\Api\Data\FaqSearchResultsInterfaceFactory;
use MageServices\Faq\Api\FaqRepositoryInterface;
use MageServices\Faq\Model\ResourceModel\Faq as FaqResource;
use MageServices\Faq\Model\ResourceModel\Faq\CollectionFactory;
class FaqRepository implements FaqRepositoryInterface
{
public function __construct(
private readonly FaqResource $resource,
private readonly FaqFactory $faqFactory,
private readonly CollectionFactory $collectionFactory,
private readonly CollectionProcessorInterface $collectionProcessor,
private readonly FaqSearchResultsInterfaceFactory $searchResultsFactory
) {
}
public function getById(int $faqId): FaqInterface
{
$faq = $this->faqFactory->create();
$this->resource->load($faq, $faqId);
if (!$faq->getId()) {
throw new NoSuchEntityException(__('FAQ with ID "%1" does not exist.', $faqId));
}
return $faq;
}
public function getList(SearchCriteriaInterface $searchCriteria): FaqSearchResultsInterface
{
$collection = $this->collectionFactory->create();
$this->collectionProcessor->process($searchCriteria, $collection);
$searchResults = $this->searchResultsFactory->create();
$searchResults->setSearchCriteria($searchCriteria);
$searchResults->setItems($collection->getItems());
$searchResults->setTotalCount($collection->getSize());
return $searchResults;
}
public function save(FaqInterface $faq): FaqInterface
{
try {
$this->resource->save($faq);
} catch (\Exception $e) {
throw new CouldNotSaveException(__('Could not save the FAQ: %1', $e->getMessage()), $e);
}
return $faq;
}
}
The search results class only needs to combine Magento's generic implementation with our typed interface:
<?php
declare(strict_types=1);
namespace MageServices\Faq\Model;
use Magento\Framework\Api\SearchResults;
use MageServices\Faq\Api\Data\FaqSearchResultsInterface;
class FaqSearchResults extends SearchResults implements FaqSearchResultsInterface
{
}
Step 4: Wire Up Preferences in di.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<preference for="MageServices\Faq\Api\Data\FaqInterface" type="MageServices\Faq\Model\Faq"/>
<preference for="MageServices\Faq\Api\FaqRepositoryInterface" type="MageServices\Faq\Model\FaqRepository"/>
<preference for="MageServices\Faq\Api\Data\FaqSearchResultsInterface" type="MageServices\Faq\Model\FaqSearchResults"/>
</config>
Step 5: Define ACL and Routes
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/etc/acl.xsd">
<acl>
<resources>
<resource id="Magento_Backend::admin">
<resource id="MageServices_Faq::faq" title="FAQs" sortOrder="100">
<resource id="MageServices_Faq::faq_save" title="Save FAQs" sortOrder="10"/>
</resource>
</resource>
</resources>
</acl>
</config>
<?xml version="1.0"?>
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
<route url="/V1/mageservices/faqs/:faqId" method="GET">
<service class="MageServices\Faq\Api\FaqRepositoryInterface" method="getById"/>
<resources>
<resource ref="anonymous"/>
</resources>
</route>
<route url="/V1/mageservices/faqs" method="GET">
<service class="MageServices\Faq\Api\FaqRepositoryInterface" method="getList"/>
<resources>
<resource ref="anonymous"/>
</resources>
</route>
<route url="/V1/mageservices/faqs" method="POST">
<service class="MageServices\Faq\Api\FaqRepositoryInterface" method="save"/>
<resources>
<resource ref="MageServices_Faq::faq_save"/>
</resources>
</route>
</routes>
Only use anonymous for data that is genuinely public. Write operations should always require an ACL resource, which admin tokens or integrations must be granted.
Step 6: Test With curl
bin/magento setup:upgrade && bin/magento cache:flush
# Public read
curl -s "https://your-store.test/rest/V1/mageservices/faqs/1"
# Search with criteria
curl -s -g "https://your-store.test/rest/V1/mageservices/faqs?searchCriteria[filter_groups][0][filters][0][field]=is_active&searchCriteria[filter_groups][0][filters][0][value]=1&searchCriteria[pageSize]=10"
# Authenticated write (integration access token or admin token)
curl -s -X POST "https://your-store.test/rest/V1/mageservices/faqs" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"faq": {"question": "Do you ship abroad?", "answer": "Yes, to most of Europe.", "is_active": true}}'
The JSON body key (faq) matches the parameter name in the interface method. For machine-to-machine access, create an Integration in the admin under System → Extensions → Integrations and grant only the resources it needs.
Asynchronous and Bulk Endpoints
Every REST route is also available asynchronously at /rest/async/V1/... and in bulk at /rest/async/bulk/V1/... when the message queue consumers are running. Use these for large imports so requests return immediately and the work is processed by consumers.
Restricting Endpoints to the Logged-In Customer
For customer-specific data, use the self resource and map a parameter to the authenticated customer ID, so customers can only ever access their own data:
<route url="/V1/mageservices/my-questions" method="GET">
<service class="MageServices\Faq\Api\CustomerQuestionsInterface" method="getForCustomer"/>
<resources>
<resource ref="self"/>
</resources>
<data>
<parameter name="customerId" force="true">%customer_id%</parameter>
</data>
</route>
With force="true", Magento ignores any customerId sent by the client and injects the ID from the customer token instead.
Testing and Documentation
- Magento generates Swagger documentation for all REST routes at
/swaggeron your store. Use it to check request and response formats. - Write API-functional or integration tests for repository methods, especially for error cases such as missing IDs.
- Log integration requests at debug level only; never log tokens or personal data.
- Version breaking changes with a new route (for example
/V2/) rather than changing existing responses that integrations depend on.
Frequently Asked Questions
Why does my API return "Class does not exist"?
Usually a missing or wrong docblock type. Magento relies on @param and @return annotations with fully qualified class names to build the API.
How do I authenticate to the Magento REST API?
Use an integration access token for system integrations, an admin token for admin users, or a customer token for customer-specific endpoints such as /V1/carts/mine.
REST or GraphQL?
GraphQL suits storefront and headless frontends. REST suits system integrations, admin operations and bulk processing.
Need help with this on your store?
Our Magento engineers can implement it for you, review your code or take on the whole project.
Written by the Magento Services engineering team — Magento 2, Adobe Commerce and Hyvä specialists since 2014. We write about problems we solve on real client stores.