A Custom REST API: webapi.xml and ACL for the Points Balance
A Custom REST API: webapi.xml and ACL for the Points Balance
~7 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
With the repository foundation from chapter 79 in place, it's time for this series' first custom REST route. Deliberate starting point: not reward management itself (that follows in chapter 81), but the simpler, read-only points balance - GET /V1/loyalty/points/mine, which shows exactly how webapi.xml maps a service contract onto a URL, and how ACL enables two entirely different access models for the very same code.
An aggregate instead of a raw attribute
loyalty_points_balance (chapter 21) could already be queried through the existing core endpoint GET /V1/customers/me - custom attributes are delivered there automatically. A dedicated endpoint still pays off because it returns more than a single attribute can: points balance, loyalty tier, AND the matching ledger history (chapter 6) in one response. That aggregation is exactly what Api\PointsManagementInterface and Api\Data\PointsSummaryInterface handle:
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Api\Data;
/**
* Data interface for a customer's aggregated points summary - current
* balance, tier, and recent ledger history in a single call.
*/
interface PointsSummaryInterface
{
public const CUSTOMER_ID = 'customer_id';
public const POINTS_BALANCE = 'points_balance';
public const TIER = 'tier';
public const LEDGER_ENTRIES = 'ledger_entries';
/**
* @return int
*/
public function getCustomerId(): int;
/**
* @param int $customerId Customer entity ID.
* @return $this
*/
public function setCustomerId(int $customerId): self;
/**
* @return int
*/
public function getPointsBalance(): int;
/**
* @param int $pointsBalance Current points balance.
* @return $this
*/
public function setPointsBalance(int $pointsBalance): self;
/**
* @return string
*/
public function getTier(): string;
/**
* @param string $tier One of Model\Source\LoyaltyTier::TIER_*.
* @return $this
*/
public function setTier(string $tier): self;
/**
* @return \Mironsoft\Loyalty\Api\Data\PointsLedgerInterface[]
*/
public function getLedgerEntries(): array;
/**
* @param \Mironsoft\Loyalty\Api\Data\PointsLedgerInterface[] $ledgerEntries Recent ledger entries.
* @return $this
*/
public function setLedgerEntries(array $ledgerEntries): self;
}
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Api;
use Magento\Framework\Exception\NoSuchEntityException;
use Mironsoft\Loyalty\Api\Data\PointsSummaryInterface;
/**
* Service contract that aggregates a customer's points balance, tier, and
* ledger history - reused by both webapi.xml routes (chapter 80) and the
* GraphQL points query (chapter 82).
*/
interface PointsManagementInterface
{
/**
* Returns the points summary for the given customer.
*
* @param int $customerId Customer entity ID.
* @return \Mironsoft\Loyalty\Api\Data\PointsSummaryInterface
* @throws NoSuchEntityException
*/
public function getPointsSummary(int $customerId): PointsSummaryInterface;
}
The implementation calls nothing but already-existing service contracts - PointsLedgerRepositoryInterface::getListByCustomerId() from chapter 6 and the very same getCustomAttribute() technique from chapter 30 - adding not a single new line of business logic:
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model;
use Magento\Customer\Api\CustomerRepositoryInterface;
use Mironsoft\Loyalty\Api\Data\PointsSummaryInterface;
use Mironsoft\Loyalty\Api\Data\PointsSummaryInterfaceFactory;
use Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface;
use Mironsoft\Loyalty\Api\PointsManagementInterface;
/**
* Aggregates a customer's points balance, tier, and ledger history from
* classes already introduced in block 1 - no new persistence, purely a
* read-side composition used by REST (chapter 80) and GraphQL (chapter 82).
*/
class PointsManagement implements PointsManagementInterface
{
/**
* @param CustomerRepositoryInterface $customerRepository Reads the customer's custom attributes.
* @param PointsLedgerRepositoryInterface $ledgerRepository Loads the customer's ledger entries (chapter 6).
* @param PointsSummaryInterfaceFactory $summaryFactory Factory for the PointsSummary DTO.
*/
public function __construct(
private readonly CustomerRepositoryInterface $customerRepository,
private readonly PointsLedgerRepositoryInterface $ledgerRepository,
private readonly PointsSummaryInterfaceFactory $summaryFactory,
) {
}
/**
* @inheritDoc
*/
public function getPointsSummary(int $customerId): PointsSummaryInterface
{
$customer = $this->customerRepository->getById($customerId);
$balanceAttribute = $customer->getCustomAttribute('loyalty_points_balance');
$pointsBalance = $balanceAttribute !== null ? (int) $balanceAttribute->getValue() : 0;
$tierAttribute = $customer->getCustomAttribute('loyalty_tier');
$tier = $tierAttribute !== null ? (string) $tierAttribute->getValue() : 'bronze';
$summary = $this->summaryFactory->create();
$summary->setCustomerId($customerId);
$summary->setPointsBalance($pointsBalance);
$summary->setTier($tier);
$summary->setLedgerEntries($this->ledgerRepository->getListByCustomerId($customerId));
return $summary;
}
}
webapi.xml: two routes, one method
webapi.xml maps an HTTP method and URL onto service/method. Deliberately two routes for the same getPointsSummary() method, to show two fundamentally different security models side by side instead of duplicating logic:
<?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/loyalty/points/mine" method="GET">
<service class="Mironsoft\Loyalty\Api\PointsManagementInterface" method="getPointsSummary"/>
<resources>
<resource ref="self"/>
</resources>
<data>
<parameter name="customerId" force="true">%customer_id%</parameter>
</data>
</route>
<route url="/V1/loyalty/points/customer/:customerId" method="GET">
<service class="Mironsoft\Loyalty\Api\PointsManagementInterface" method="getPointsSummary"/>
<resources>
<resource ref="Mironsoft_Loyalty::points_view"/>
</resources>
</route>
</routes>
/V1/loyalty/points/mine-<resource ref="self"/>is Magento's built-in self reference (the same pattern the core/V1/customers/meendpoint uses): any logged-in customer may access it, but only their own data.<data><parameter name="customerId" force="true">%customer_id%</parameter></data>- overwrites thecustomerIdparameter server-side with the ID from the authenticated token beforegetPointsSummary()is even called. The caller cannot set this value themselves./V1/loyalty/points/customer/:customerId- noforce, but instead the new, narrowly scoped ACL resourceMironsoft_Loyalty::points_view: a support agent holding that role can look up any customer's points balance on demand, without the route being reachable for regular customers.
Achtung: Forgetting force="true" is the classic ACL gap on "mine"-style endpoints: without it, Magento would take the customerId parameter straight from the request - an authenticated customer could then look up another customer's points balance via a simple query parameter, even though ref="self" grants access at all. force is exactly the mechanism that actually narrows "self" down to "only yourself".
The new ACL resource
Mironsoft_Loyalty::points_view slots in under the parent resource Mironsoft_Loyalty::loyalty already created in chapter 1, as a sibling of Mironsoft_Loyalty::rewards (chapter 2) and Mironsoft_Loyalty::config_section (chapter 7):
<acl>
<resources>
<resource id="Magento_Backend::admin">
<resource id="Mironsoft_Loyalty::loyalty" title="Mironsoft Loyalty" sortOrder="10">
<resource id="Mironsoft_Loyalty::rewards" title="Rewards" sortOrder="10"/>
<resource id="Mironsoft_Loyalty::config_section" title="Configuration" sortOrder="20"/>
<resource id="Mironsoft_Loyalty::points_view" title="View Customer Points (API)" sortOrder="30"/>
</resource>
</resource>
</resources>
</acl>
Achtung: Reusing a broad, already-existing resource like Magento_Backend::admin would have been the more convenient but wrong shortcut - every admin user would then automatically gain access to other customers' points balances, instead of only the roles an administrator explicitly grants Mironsoft_Loyalty::points_view to. A dedicated, narrowly scoped ACL resource per sensitive endpoint is the standard, not the exception.
Testing the endpoint
# Get a customer token first (standard core endpoint):
curl -s -X POST https://mironsoft.test/rest/V1/integration/customer/token \
-H 'Content-Type: application/json' \
-d '{"username":"customer@example.com","password":"secret123"}'
# Use the returned token to fetch your own points balance:
curl -s https://mironsoft.test/rest/V1/loyalty/points/mine \
-H 'Authorization: Bearer <token>'Tipp: Any change to webapi.xml or acl.xml requires an explicit cache flush, since both files feed the config_webservice cache type: bin/cache-clean config_webservice. Even in developer mode, a changed route otherwise stays invisible - a common, easy-to-miss trap when a freshly registered endpoint stubbornly responds with "404 - Requested resource not found".
Chapter 81 builds the second, write-capable endpoint on the same pattern: redeeming a reward via REST - and sets up the business logic that chapter 83 later reuses unchanged for the GraphQL mutation.