← Back to blog

How to Create a Custom REST API Endpoint in Magento 2

Rodrigo Biazzi

Magento 2 builds REST endpoints from service interfaces. You do not write controllers, parse JSON or serialize responses yourself. You declare a route in webapi.xml, point it at a method on an interface, and the Web API framework handles input mapping, output serialization, authentication and authorization. When it works it saves a lot of code. When it does not, the errors are vague, so it helps to know what the framework expects.

This guide builds a small API for a custom notice entity on Magento Open Source or Adobe Commerce 2.4.8. It assumes the entity already has a repository, as built in the service contracts article.

The pieces you need

  • A service interface in Api/ with complete docblocks.
  • Data interfaces in Api/Data/ for anything more complex than scalars.
  • A preference in etc/di.xml from each interface to its implementation.
  • ACL resources in etc/acl.xml for admin protected routes.
  • Routes in etc/webapi.xml.

A service interface for custom logic

Exposing the repository covers basic CRUD. Business operations usually deserve their own interface. Here is a management service with two methods, one for admins and one public:

<?php
declare(strict_types=1);

namespace Acme\Notice\Api;

interface NoticeManagementInterface
{
    /**
     * Activate a notice and deactivate all others.
     *
     * @param int $noticeId
     * @return \Acme\Notice\Api\Data\NoticeInterface
     * @throws \Magento\Framework\Exception\NoSuchEntityException
     * @throws \Magento\Framework\Exception\LocalizedException
     */
    public function activate(int $noticeId);

    /**
     * Return the active notice messages for the current store.
     *
     * @return string[]
     */
    public function getActiveMessages(): array;
}

The Web API framework does not use native PHP types to decide how to convert data. It reads the @param and @return annotations through reflection. Rules that save debugging time:

  • Use fully qualified class names in docblocks, with the leading backslash.
  • Use string[], int[] or \Vendor\Module\Api\Data\SomeInterface[] for arrays. An untyped array return will not serialize the way you expect.
  • Do not return associative arrays. Define a data interface with getters instead.
  • Every parameter name in the interface becomes a key in the request body or a placeholder in the URL.

The implementation is a normal class. It can inject the repository, StoreManagerInterface and anything else it needs. Register it in di.xml:

<preference for="Acme\Notice\Api\NoticeManagementInterface" type="Acme\Notice\Model\NoticeManagement"/>

ACL resources

Admin and integration access is controlled by ACL resources, the same tree used for admin roles. Declare them in etc/acl.xml:

<?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="Acme_Notice::notice" title="Store Notices" sortOrder="100">
                    <resource id="Acme_Notice::notice_read" title="View Notices" sortOrder="10"/>
                    <resource id="Acme_Notice::notice_write" title="Manage Notices" sortOrder="20"/>
                </resource>
            </resource>
        </resources>
    </acl>
</config>

Splitting read and write is worth the small extra effort. Integrations for reporting tools should only get read access, and you cannot split it later without changing every integration that already exists.

Routes in webapi.xml

<?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/acme/notices/:noticeId" method="GET">
        <service class="Acme\Notice\Api\NoticeRepositoryInterface" method="getById"/>
        <resources>
            <resource ref="Acme_Notice::notice_read"/>
        </resources>
    </route>
    <route url="/V1/acme/notices/search" method="GET">
        <service class="Acme\Notice\Api\NoticeRepositoryInterface" method="getList"/>
        <resources>
            <resource ref="Acme_Notice::notice_read"/>
        </resources>
    </route>
    <route url="/V1/acme/notices" method="POST">
        <service class="Acme\Notice\Api\NoticeRepositoryInterface" method="save"/>
        <resources>
            <resource ref="Acme_Notice::notice_write"/>
        </resources>
    </route>
    <route url="/V1/acme/notices/:noticeId/activate" method="POST">
        <service class="Acme\Notice\Api\NoticeManagementInterface" method="activate"/>
        <resources>
            <resource ref="Acme_Notice::notice_write"/>
        </resources>
    </route>
    <route url="/V1/acme/notices/active" method="GET">
        <service class="Acme\Notice\Api\NoticeManagementInterface" method="getActiveMessages"/>
        <resources>
            <resource ref="anonymous"/>
        </resources>
    </route>
</routes>

A few details about routing:

  • :noticeId in the URL maps to the $noticeId parameter of the method. The names must match.
  • A static segment like search and a placeholder like :noticeId at the same position can conflict for the same HTTP method. Call both routes after adding them to confirm each one reaches the method you expect.
  • The anonymous resource makes the route public. Anyone can call it, so treat it like a public page and never expose private data or expensive operations there.

Customer scoped routes with self

For routes that act on the logged in customer, use the self resource and force the customer ID from the token:

<route url="/V1/acme/customers/me/notices" method="GET">
    <service class="Acme\Notice\Api\CustomerNoticeInterface" method="getForCustomer"/>
    <resources>
        <resource ref="self"/>
    </resources>
    <data>
        <parameter name="customerId" force="true">%customer_id%</parameter>
    </data>
</route>

With force="true", the value of customerId always comes from the authenticated customer, even if the client sends a different ID in the request. Without it, a customer could read another customer's data by changing a parameter. This is one of the most common security mistakes in custom APIs. How the token itself is obtained is covered in the Web API authentication article.

Calling the endpoint

After bin/magento setup:upgrade and a cache clean (or setup:di:compile in production mode), the routes are live:

TOKEN="your-admin-or-integration-token"

curl -s -H "Authorization: Bearer $TOKEN" \
  https://example.test/rest/V1/acme/notices/5

curl -s -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"notice": {"message": "Free shipping this week", "is_active": true}}' \
  https://example.test/rest/V1/acme/notices

curl -s "https://example.test/rest/V1/acme/notices/search?searchCriteria[pageSize]=20&searchCriteria[filterGroups][0][filters][0][field]=is_active&searchCriteria[filterGroups][0][filters][0][value]=1"

curl -s https://example.test/rest/V1/acme/notices/active

Request body keys are the parameter names of the method, and nested object keys are converted from snake_case to setter calls. is_active calls setIsActive(). If a key has no matching setter, it is ignored without an error, which makes typos easy to miss.

The URL prefix controls the store scope. /rest/V1/... runs in the default store view, /rest/de/V1/... runs in the store view with code de, and /rest/all/V1/... is used for admin operations that should not be bound to one store. Saving products through a store specific URL is a well known way to accidentally create store level attribute values.

The generated documentation at /swagger lists your routes with their schemas. If a route is missing there, Magento did not load it, usually because of a cache or XML error.

Errors and status codes

Exceptions thrown by the service are converted into HTTP responses. A NoSuchEntityException becomes a 404 and a LocalizedException becomes a 400 with its message. Any other exception becomes a 500, and in production mode the message is replaced with a generic one, while the details go to the exception log. So throw LocalizedException subclasses with clear messages for errors that the client should see, and let unexpected errors stay generic.

Versioning the API

The V1 in the URL is a convention, not something the framework enforces. It matters once external systems depend on your endpoint. If you need to change the request or response shape in a way that breaks existing clients, add a new route under /V2/ that points to a new interface method, and keep the old route working until every client has moved. Adding optional fields to a data interface is usually safe. Renaming or removing them is not, because clients that send the old key will have it silently ignored.

Performance and safety notes

  • REST responses are not cached by the full page cache. A heavy anonymous GET runs PHP on every call. If the data can be cached, cache it yourself with the cache frontend and a custom tag.
  • Set a maximum page size in your getList implementation. Do not trust the client to send a sane one.
  • Keep anonymous endpoints cheap and rate limit them at the web server or CDN.
  • For data that needs to appear on core entities in API responses, prefer extension attributes over new endpoints. See the extension attributes article.
  • If the consumer is a storefront rather than a backend system, GraphQL may fit better.

For automated tests, Magento ships an API functional test framework in dev/tests/api-functional. It sets up a real installation and calls your routes through HTTP, which catches docblock and serialization mistakes that unit tests miss.

FAQ

Why does my REST endpoint return 404 even though webapi.xml looks right?

Check that the module is enabled, the config cache is clean, and the XML is valid. Then look for the route in /swagger. If it is not there, Magento did not load your webapi.xml.

Why are some fields from my request body ignored?

The Web API maps JSON keys to setters on the data interface. A key without a matching setter, or a typo in snake_case, is silently dropped.

How do I make an endpoint available to logged in customers only?

Use the self resource and force the customer ID from the token with a data parameter set to %customer_id%. Never take the customer ID from the request.

Can I return an associative array from a REST method?

Not reliably. Define a data interface with getters and return that, or return a typed list like string[].

I am available for freelance Magento and Adobe Commerce work, including API design and integrations with ERPs and other systems. If you need an endpoint built or reviewed, get in touch.

Related articles