ddtcorex

09 Oct 2026 · 5 min read

Custom REST endpoints: webapi.xml, ACL, and service contracts

Define a service contract, wire it through webapi.xml with ACL resources, and expose a versioned REST endpoint clients can trust

Part 6 of the API Platform series — the three files that turn PHP into a stable contract.

Why custom endpoints go wrong

Most troubled Magento integrations share one root cause: business logic exposed through ad-hoc controllers or raw SQL instead of service contracts. The endpoint works on day one and breaks on every upgrade, because nothing pins the input shape, the authorization rule, or the URL. Magento already has a mechanism that pins all three. It takes three artifacts: a service contract interface, a webapi.xml route, and an ACL resource. This post builds one endpoint the supported way.

Start with the service contract

A service contract is a PHP interface in Api/ plus a data interface for the payload. The interface is the versioned promise. The implementation behind it can change freely. Here is a minimal contract for a store-credit balance lookup:

namespace Vendor\Credit\Api;

interface BalanceManagementInterface
{
    /**
     * @param int $customerId
     * @return \Vendor\Credit\Api\Data\BalanceInterface
     */
    public function getByCustomerId(int $customerId);
}

The data interface exposes only getters with @return annotations, for example getAmount(), getCurrency(), and getUpdatedAt(). Keep it flat and scalar-typed. Nested extension attributes are supported, but every nesting level is a compatibility surface you must maintain.

Place the implementation in Model/ and wire it with di.xml using a <preference> from the interface to the model. Never expose the model class directly in webapi.xml. The preference indirection is what lets you refactor internals without changing the public route.

Wire the route in webapi.xml

The etc/webapi.xml file binds a URL and HTTP method to one interface method:

<routes>
    <route url="/V1/credit/balance/:customerId" method="GET">
        <service class="Vendor\Credit\Api\BalanceManagementInterface" method="getByCustomerId"/>
        <resources>
            <resource ref="Vendor_Credit::balance_view"/>
        </resources>
    </route>
</routes>

Three details matter. First, the url must be versioned with the /V1/ prefix. Unversioned paths collide with core routes and cannot evolve. Second, the :customerId placeholder binds to the method parameter by name, so the names must match exactly. Third, method must reflect the semantics: GET for reads, POST for actions and creates, PUT for full updates, DELETE for removals. Magento generates both REST and SOAP bindings from this one file, so a wrong verb breaks two protocols at once.

For request bodies on POST and PUT, the body maps to the interface parameters by name. If the method signature is createOrderNote(int $orderId, NoteInterface $note), the JSON body is {"orderId": 42, "note": {...}}. Mismatched key names produce null arguments with no helpful error, so test the exact payload shape early.

Lock it down with ACL resources

Every route needs a <resources> entry. Three options exist. anonymous allows guest access, which fits only truly public data such as store config flags. self restricts the caller to their own data and works for customer-scoped endpoints. A named resource such as Vendor_Credit::balance_view requires an integration or admin token carrying that permission.

Define the resource in etc/acl.xml:

<acl>
    <resources>
        <resource id="Magento_Backend::admin">
            <resource id="Vendor_Credit::credit" title="Store Credit" sortOrder="10">
                <resource id="Vendor_Credit::balance_view" title="View Balances" sortOrder="10"/>
            </resource>
        </resource>
    </resources>
</acl>

The reference in webapi.xml must match the acl.xml id exactly. A typo does not throw a clean error. The endpoint simply returns 401 or 403 for every caller, which sends teams debugging tokens when the real fault is a string mismatch. After changing either file, clear config cache and re-check with a minimal token that carries only that resource.

Follow least privilege. A balance-view endpoint should not require full customer or sales access. Create one narrow resource per capability: view, adjust, expire. Narrow resources make integration scoping reviewable and limit blast radius when a token leaks.

Validation, errors, and versioning

Validate inside the service implementation, not in the web API layer. Throw InputException for bad parameters, NoSuchEntityException for missing records, and AuthorizationException for ownership violations. Magento maps these to 400, 404, and 403 responses with structured messages. Throwing generic LocalizedException collapses everything into a 400 and hides the real cause from API clients.

Return data interfaces, never internal models. Returning a Customer model leaks every field including hashes and internal flags into the JSON. A dedicated data interface returns exactly the fields the contract promises and nothing else.

Version by adding a new interface method or a new route, never by changing the existing signature. getByCustomerId stays frozen. A new requirement becomes getByCustomerIdWithHistory on /V1/credit/balance/:customerId/history. Old clients keep working while new clients adopt the extended contract. The cost is one extra method. The alternative is breaking every shipped integration.

Testing the endpoint before shipping

Test at three levels. A unit test covers the model logic with mocked repositories. An integration test calls the service contract directly through the object manager. An API functional test issues a real HTTP request with a fixture token and asserts status code, payload keys, and ACL denial for a token without the resource. The third level is the one teams skip, and it is the only one that catches webapi.xml and acl.xml wiring mistakes.

Keep a single curl or REST-client script per endpoint in the module docs showing the happy path, the 403 without the resource, and the 404 for an unknown id. Reviewers and integrators reuse it, and it becomes the regression script when the contract evolves.

Pitfalls

  • Exposing models instead of contracts. Internal fields leak and upgrades break the payload. Always return data interfaces.
  • Mismatched placeholder and parameter names. :customerId must equal $customerId. Case differences silently null the argument.
  • Overbroad ACL resources. Reusing Magento_Customer::customer for everything grants far more than the endpoint needs. Mint narrow resources.
  • Unversioned URLs. Routes without /V1/ cannot evolve without breaking core or custom collisions.
  • Changing a published signature. Add methods, never edit them. Frozen contracts are the whole point of service contracts.

Next in this series: magento2-api-auth-tokens

magento2apiarchitecture