Build Custom REST API Endpoints in Magento 2
Adobe Commerce's built-in REST API is powerful. But it won't handle every use case.
Custom pricing engines, third-party integration hooks, and bespoke order workflows often require a custom endpoint. Building one from scratch gives you full control over behavior, permissions, and error handling.
This walkthrough goes beyond the typical "create a controller and call it an API" tutorial. You'll get ACL-scoped token authentication — access control lists that restrict what a token can do. You'll also learn structured error handling with WebapiException, a class that maps PHP exceptions to HTTP status codes. You'll also get a PHPUnit integration test scaffold. These patterns rarely appear together in a single guide.
What You'll Need
You'll need the following before starting:
- Magento 2.4.6+ running PHP 8.2+
- Composer-based installation with development tools available
- Familiarity with Magento 2 module structure (di.xml, registration.php)
- bin/magento CLI access
- Postman or a similar HTTP client for manual testing
The example module throughout this post is Unomage_OrderNotes — a simple API that lets external systems attach notes to orders. Simple enough to follow, real enough to matter.
---
1. Setting Up Your Module Structure
Let's build the module. We'll start with the folder structure.
Under app/code/Unomage/OrderNotes/, create these directories and files:
Unomage/OrderNotes/
├── Api/
│ ├── Data/
│ │ └── OrderNoteInterface.php
│ └── OrderNoteRepositoryInterface.php
├── Model/
│ ├── Data/
│ │ └── OrderNote.php
│ └── OrderNoteRepository.php
├── etc/
│ ├── acl.xml
│ ├── di.xml
│ ├── module.xml
│ └── webapi.xml
├── registration.php
└── composer.json
Your registration.php is standard boilerplate that registers the module's namespace and path:
<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'Unomage_OrderNotes',
__DIR__
);
This next file, etc/module.xml, declares the module and its load order dependency:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="Unomage_OrderNotes" setup_version="1.0.0">
<sequence>
<module name="Magento_Sales"/> <!-- Load Sales module first -->
</sequence>
</module>
</config>
The sequence declaration tells Magento to load Magento_Sales before your module. This matters when working with order-related data.
Run bin/magento module:enable Unomage_OrderNotes && bin/magento setup:upgrade once the basics are in place.
Now your module scaffold is ready. Next, define your routes and access rules.
---
2. Defining Routes and ACL Rules
Two files control API access in Magento 2. Here's what each does:
| File | Purpose |
|---|---|
| webapi.xml | Registers REST routes and links them to ACL resource IDs |
| acl.xml | Defines the ACL resource tree shown in the admin panel |
webapi.xml
The routes below map HTTP methods and URL patterns to specific repository methods. Each route also declares which ACL resource a token must carry to gain access:
<?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/order-notes/:orderId" method="GET">
<service class="Unomage\OrderNotes\Api\OrderNoteRepositoryInterface" method="getByOrderId"/>
<resources>
<resource ref="Unomage_OrderNotes::read"/> <!-- Read-only permission -->
</resources>
</route>
<route url="/V1/order-notes" method="POST">
<service class="Unomage\OrderNotes\Api\OrderNoteRepositoryInterface" method="save"/>
<resources>
<resource ref="Unomage_OrderNotes::write"/> <!-- Write permission -->
</resources>
</route>
<route url="/V1/order-notes/:noteId" method="DELETE">
<service class="Unomage\OrderNotes\Api\OrderNoteRepositoryInterface" method="deleteById"/>
<resources>
<resource ref="Unomage_OrderNotes::write"/> <!-- Write permission -->
</resources>
</route>
</routes>
The resource ref values reference ACL rules defined below. Any token must carry the matching permission to reach these endpoints.
acl.xml
This file defines the permission tree that appears in the admin panel under System > User Roles:
<?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="Unomage_OrderNotes::root" title="Order Notes" sortOrder="100">
<resource id="Unomage_OrderNotes::read" title="View Order Notes" sortOrder="10"/>
<resource id="Unomage_OrderNotes::write" title="Manage Order Notes" sortOrder="20"/>
</resource>
</resource>
</resources>
</acl>
</config>
This separation becomes critical later when scaling your API.
Pro tip: Keep
readandwriteACL resources separate from day one. It costs almost nothing now and avoids a painful refactor when a client later needs read-only access for a reporting tool.
For integration tokens, assign these ACL resources under System > Integrations in the admin panel. For admin tokens generated via /V1/integration/admin/token, permissions derive from the admin user's role.
Your routes are now defined and protected by ACL rules.
---
3. Creating the Interface, Model, and Repository Layer
Magento's REST framework uses DocBlock annotations — special PHP comments that tell Magento how to serialize and deserialize data — to handle JSON conversion automatically. Get them right and Magento converts incoming JSON to PHP objects, and PHP objects back to JSON, without any extra code on your part.
The Data Interface
This interface defines the shape of a single order note. Every getter and setter becomes a JSON field automatically:
<?php
declare(strict_types=1);
namespace Unomage\OrderNotes\Api\Data;
interface OrderNoteInterface
{
public const NOTE_ID = 'note_id';
public const ORDER_ID = 'order_id';
public const NOTE_TEXT = 'note_text';
public const CREATED_AT = 'created_at';
public function getNoteId(): ?int;
public function setNoteId(int $noteId): self;
public function getOrderId(): int;
public function setOrderId(int $orderId): self;
public function getNoteText(): string;
public function setNoteText(string $noteText): self;
public function getCreatedAt(): ?string;
public function setCreatedAt(string $createdAt): self;
}
The Repository Interface
The DocBlock @throws tags here do more than document intent. Magento reads them at runtime and maps each exception type to the appropriate HTTP status code:
<?php
declare(strict_types=1);
namespace Unomage\OrderNotes\Api;
use Unomage\OrderNotes\Api\Data\OrderNoteInterface;
interface OrderNoteRepositoryInterface
{
/*
@param int $orderId
@return \Unomage\OrderNotes\Api\Data\OrderNoteInterface[]
@throws \Magento\Framework\Exception\NoSuchEntityException
/
public function getByOrderId(int $orderId): array;
/
@param \Unomage\OrderNotes\Api\Data\OrderNoteInterface $note
@return \Unomage\OrderNotes\Api\Data\OrderNoteInterface
@throws \Magento\Framework\Exception\CouldNotSaveException
/
public function save(OrderNoteInterface $note): OrderNoteInterface;
/
@param int $noteId
@return bool
@throws \Magento\Framework\Exception\NoSuchEntityException
@throws \Magento\Framework\Exception\CouldNotDeleteException
/
public function deleteById(int $noteId): bool;
}
di.xml Bindings
Wire each interface to its concrete class so Magento's dependency injection container knows which implementation to use:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<!-- Bind data interface to model -->
<preference for="Unomage\OrderNotes\Api\Data\OrderNoteInterface"
type="Unomage\OrderNotes\Model\Data\OrderNote"/>
<!-- Bind repository interface to implementation -->
<preference for="Unomage\OrderNotes\Api\OrderNoteRepositoryInterface"
type="Unomage\OrderNotes\Model\OrderNoteRepository"/>
</config>
The OrderNote model extends \Magento\Framework\DataObject and implements OrderNoteInterface. The repository handles persistence, validation, and exception wrapping.
Your service layer is now wired up. Next, add validation and error handling.
---
4. Validation, Error Responses, and Authentication
Most tutorials skip this part. Returning a raw PHP exception to an API consumer is both sloppy and insecure.
Structured Errors with WebapiException
WebapiException gives you direct control over HTTP status codes. Use it for client-facing errors. Use CouldNotSaveException and CouldNotDeleteException for persistence failures — the framework maps those to 500 automatically.
The save method below validates input first, then wraps any database errors before they can reach the client:
public function save(OrderNoteInterface $note): OrderNoteInterface
{
if (empty(trim($note->getNoteText()))) {
throw new WebapiException(
__('Note text cannot be empty.'),
0,
WebapiException::HTTP_BAD_REQUEST
);
}
if (strlen($note->getNoteText()) > 2000) {
throw new WebapiException(
__('Note text exceeds maximum length of 2000 characters.'),
0,
WebapiException::HTTP_BAD_REQUEST
);
}
try {
$this->resource->save($note);
} catch (\Exception $e) {
// Wrap raw DB errors — never expose table or column names
throw new CouldNotSaveException(__('Could not save order note: %1', $e->getMessage()));
}
return $note;
}
Warning: Always wrap database exceptions before throwing them. Raw errors can leak table names, column names, or query structure to API clients.
Authentication in Practice
There are three token types to understand:
- Admin token — via POST /V1/integration/admin/token. Carries the admin user's role-based ACL permissions.
- Customer token — via POST /V1/integration/customer/token. Customer-scoped only. Won't reach your order notes endpoint without an explicit self resource.
- Integration token — created in System > Integrations. Best choice for machine-to-machine calls. Assign only the ACL resources the integration needs.
# Fetch an admin token for testing
curl -X POST https://your-store.com/rest/V1/integration/admin/token \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "your-password"}'
Pass the token as a Bearer header on all subsequent requests:
Authorization: Bearer eyJraWQiOiIxIiwiYWxnIjoiSFMyNTYifQ...
---
5. Testing Your Custom Endpoint
Postman Smoke Tests
With your module enabled and cache flushed, run a quick sanity check sequence.
POST /V1/order-notes — create a note:
{ "note": { "order_id": 1, "note_text": "Fragile — handle with care" } }
Expected: 200 OK with the saved note object and a generated note_id.
GET /V1/order-notes/1 — returns an array of notes for order 1.
DELETE /V1/order-notes/1 — returns true.
Also test your auth: no token should return 401, and a valid token missing Unomage_OrderNotes::read should return 403. If you're getting 200 without auth, your webapi.xml resource may be accidentally set to anonymous.
Pro tip: In Postman, store your bearer token as an environment variable and use a pre-request script to refresh it automatically. Saves constant copy-pasting during development.
PHPUnit Integration Test Scaffold
Integration tests live under dev/tests/api-functional/testsuite/. The WebapiAbstract base class handles authentication automatically, so your tests focus purely on behavior:
<?php
declare(strict_types=1);
namespace Unomage\OrderNotes\Test\Api;
use Magento\TestFramework\TestCase\WebapiAbstract;
class OrderNoteRepositoryTest extends WebapiAbstract
{
private const RESOURCE_PATH = '/V1/order-notes';
/* @magentoApiDataFixture Magento/Sales/_files/order.php /
public function testSaveOrderNote(): void
{
$serviceInfo = [
'rest' => [
'resourcePath' => self::RESOURCE_PATH,
'httpMethod' => \Magento\Framework\Webapi\Rest\Request::HTTP_METHOD_POST,
],
];
$response = $this->_webApiCall($serviceInfo, [
'note' => ['order_id' => 1, 'note_text' => 'Test note from integration test'],
]);
$this->assertArrayHasKey('note_id', $response);
$this->assertEquals(1, $response['order_id']);
}
public function testSaveWithEmptyTextReturnsBadRequest(): void
{
$this->expectException(\Exception::class);
$serviceInfo = [
'rest' => [
'resourcePath' => self::RESOURCE_PATH,
'httpMethod' => \Magento\Framework\Webapi\Rest\Request::HTTP_METHOD_POST,
],
];
$this->_webApiCall($serviceInfo, ['note' => ['order_id' => 1, 'note_text' => '']]);
}
}
Run the tests with:
cd dev/tests/api-functional
php ../../../vendor/bin/phpunit --config phpunit_rest.xml \
testsuite/Unomage/OrderNotes/Test/Api/OrderNoteRepositoryTest.php
Check phpunit_rest.xml.dist for the required environment variables before running.
---
Wrapping Up
Building a Magento 2 custom REST API endpoint correctly means more than wiring up a route. You need clean interface contracts, ACL-scoped permissions that genuinely restrict access, WebapiException for controlled error responses, and integration tests that catch regressions early.
The pattern here — webapi.xml, acl.xml, repository interfaces, and PHPUnit scaffolding — scales to any endpoint, from simple CRUD to complex integration layers. Get the foundations right and adding new resources becomes straightforward.
One final check before going live: run bin/magento webapi:rest:generate_whitelist in production mode. Without it, your custom endpoints may return unexpected errors even when everything else is correct.
---
Glossary
ACL resource — A named permission node defined in acl.xml. Tokens and admin roles are granted specific ACL resources, which Magento checks on every API request before allowing access.
Integration token — A long-lived API token created under System > Integrations in the admin panel. Unlike admin tokens, integration tokens don't expire on logout and are the preferred choice for automated, machine-to-machine API calls.
WebapiException — A Magento exception class that lets you set an explicit HTTP status code alongside your error message. Throwing it from a repository method returns a clean, structured JSON error response to the API client instead of a raw PHP stack trace.

