Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

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:

app/code/Mironsoft/Loyalty/Api/Data/PointsSummaryInterface.php
<?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;
}
app/code/Mironsoft/Loyalty/Api/PointsManagementInterface.php
<?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:

app/code/Mironsoft/Loyalty/Model/PointsManagement.php
<?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:

app/code/Mironsoft/Loyalty/etc/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/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/me endpoint uses): any logged-in customer may access it, but only their own data.
  • <data><parameter name="customerId" force="true">%customer_id%</parameter></data> - overwrites the customerId parameter server-side with the ID from the authenticated token before getPointsSummary() is even called. The caller cannot set this value themselves.
  • /V1/loyalty/points/customer/:customerId - no force, but instead the new, narrowly scoped ACL resource Mironsoft_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):

app/code/Mironsoft/Loyalty/etc/acl.xml (extended)
<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.