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

An API Endpoint for Redeeming a Reward

An API Endpoint for Redeeming a Reward

~8 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026

Chapter 80 built a purely read-only endpoint. This chapter delivers the first write-capable one: POST /V1/loyalty/rewards/:rewardId/redeem redeems a reward against the logged-in customer's points balance. The actual business logic deliberately doesn't live in the controller-like webapi call itself, but in a dedicated service class - for good reason: chapter 83 calls the exact same class from a GraphQL resolver, without duplicating a single line.

The result object: RewardRedemptionResultInterface

Instead of a bare true/false, the redemption returns a small result object - how many points were spent and the new balance, so a client (app, checkout widget, GraphQL client) can update its display immediately without firing a second request:

app/code/Mironsoft/Loyalty/Api/Data/RewardRedemptionResultInterface.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Api\Data;

/**
 * Data interface for the outcome of redeeming a reward - returned by both
 * the REST endpoint (chapter 81) and the GraphQL mutation (chapter 83).
 */
interface RewardRedemptionResultInterface
{
    public const REWARD_ID = 'reward_id';
    public const POINTS_SPENT = 'points_spent';
    public const POINTS_BALANCE_AFTER = 'points_balance_after';
    public const REDEEMED_AT = 'redeemed_at';

    /**
     * @return int
     */
    public function getRewardId(): int;

    /**
     * @param int $rewardId Redeemed reward's entity ID.
     * @return $this
     */
    public function setRewardId(int $rewardId): self;

    /**
     * @return int
     */
    public function getPointsSpent(): int;

    /**
     * @param int $pointsSpent Points deducted for this redemption.
     * @return $this
     */
    public function setPointsSpent(int $pointsSpent): self;

    /**
     * @return int
     */
    public function getPointsBalanceAfter(): int;

    /**
     * @param int $pointsBalanceAfter Customer's points balance right after this redemption.
     * @return $this
     */
    public function setPointsBalanceAfter(int $pointsBalanceAfter): self;

    /**
     * @return string
     */
    public function getRedeemedAt(): string;

    /**
     * @param string $redeemedAt Timestamp the redemption was booked at.
     * @return $this
     */
    public function setRedeemedAt(string $redeemedAt): self;
}

The service contract: RewardRedemptionManagementInterface

A single method, redeem(int $rewardId, int $customerId) - deliberately not a $reward/$customer object as a parameter, so both REST (which only knows IDs from the URL/token) and GraphQL (which reads the same IDs from $args/$context, chapter 83) can make the call without an extra translation step:

app/code/Mironsoft/Loyalty/Api/RewardRedemptionManagementInterface.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Api;

use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Exception\NoSuchEntityException;
use Mironsoft\Loyalty\Api\Data\RewardRedemptionResultInterface;

/**
 * Service contract for redeeming a loyalty reward - the single business-logic
 * entry point shared, unchanged, by the REST endpoint (chapter 81) and the
 * GraphQL mutation (chapter 83).
 */
interface RewardRedemptionManagementInterface
{
    /**
     * Redeems the given reward for the given customer.
     *
     * @param int $rewardId Reward entity ID to redeem.
     * @param int $customerId Customer entity ID redeeming the reward.
     * @return \Mironsoft\Loyalty\Api\Data\RewardRedemptionResultInterface
     * @throws NoSuchEntityException If the reward does not exist or is inactive.
     * @throws LocalizedException If the customer does not have enough points.
     */
    public function redeem(int $rewardId, int $customerId): RewardRedemptionResultInterface;
}

The implementation: Model\RewardRedemptionManagement

Four steps, each building on an already-existing service contract: load the reward (RewardRepositoryInterface, chapter 79), check the points balance (the CustomerRepositoryInterface custom attribute technique, chapter 30), book the ledger entry (PointsLedgerRepositoryInterface, chapter 6), update the customer's balance:

app/code/Mironsoft/Loyalty/Model/RewardRedemptionManagement.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Model;

use Magento\Customer\Api\CustomerRepositoryInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Stdlib\DateTime\DateTime;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterface;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterfaceFactory;
use Mironsoft\Loyalty\Api\Data\RewardRedemptionResultInterface;
use Mironsoft\Loyalty\Api\Data\RewardRedemptionResultInterfaceFactory;
use Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface;
use Mironsoft\Loyalty\Api\RewardRedemptionManagementInterface;
use Mironsoft\Loyalty\Api\RewardRepositoryInterface;

/**
 * Redeems a loyalty reward against a customer's points balance - the one
 * place this business rule lives, called identically from REST (chapter 81)
 * and the GraphQL mutation (chapter 83).
 */
class RewardRedemptionManagement implements RewardRedemptionManagementInterface
{
    /**
     * @param RewardRepositoryInterface $rewardRepository Loads the reward being redeemed (chapter 79).
     * @param CustomerRepositoryInterface $customerRepository Reads and updates the customer's balance.
     * @param PointsLedgerRepositoryInterface $ledgerRepository Books the TYPE_REDEEM ledger entry (chapter 6).
     * @param PointsLedgerInterfaceFactory $ledgerFactory Factory for a new ledger entry.
     * @param RewardRedemptionResultInterfaceFactory $resultFactory Factory for the redemption result DTO.
     * @param DateTime $dateTime Provides the redemption timestamp.
     */
    public function __construct(
        private readonly RewardRepositoryInterface $rewardRepository,
        private readonly CustomerRepositoryInterface $customerRepository,
        private readonly PointsLedgerRepositoryInterface $ledgerRepository,
        private readonly PointsLedgerInterfaceFactory $ledgerFactory,
        private readonly RewardRedemptionResultInterfaceFactory $resultFactory,
        private readonly DateTime $dateTime,
    ) {
    }

    /**
     * @inheritDoc
     */
    public function redeem(int $rewardId, int $customerId): RewardRedemptionResultInterface
    {
        $reward = $this->rewardRepository->getById($rewardId);

        if (!$reward->isActive()) {
            throw new LocalizedException(__('This reward is no longer available.'));
        }

        $customer = $this->customerRepository->getById($customerId);
        $balanceAttribute = $customer->getCustomAttribute('loyalty_points_balance');
        $currentBalance = $balanceAttribute !== null ? (int) $balanceAttribute->getValue() : 0;

        if ($currentBalance < $reward->getPointsCost()) {
            throw new LocalizedException(
                __('Not enough points to redeem this reward.')
            );
        }

        // Known, deliberately unresolved limitation (same as chapter 63's
        // RedeemPointsOnOrderPlaced): no SELECT ... FOR UPDATE lock against a
        // concurrent redemption of the same customer's balance.
        $newBalance = $currentBalance - $reward->getPointsCost();

        $ledgerEntry = $this->ledgerFactory->create();
        $ledgerEntry->setCustomerId($customerId);
        $ledgerEntry->setType(PointsLedgerInterface::TYPE_REDEEM);
        $ledgerEntry->setPoints(-$reward->getPointsCost());
        $ledgerEntry->setBalanceAfter($newBalance);
        $this->ledgerRepository->save($ledgerEntry);

        $customer->setCustomAttribute('loyalty_points_balance', $newBalance);
        $this->customerRepository->save($customer);

        $result = $this->resultFactory->create();
        $result->setRewardId($rewardId);
        $result->setPointsSpent($reward->getPointsCost());
        $result->setPointsBalanceAfter($newBalance);
        $result->setRedeemedAt($this->dateTime->date('Y-m-d H:i:s'));

        return $result;
    }
}

Updates loyalty_points_balance through exactly the same CustomerRepositoryInterface route as AwardPointsOnOrderPlaced (chapter 30) and RedeemPointsOnOrderPlaced (chapter 63) - LoyaltyTierBackend::beforeSave() (chapter 26) therefore recalculates the loyalty tier automatically here too, without this class needing to know about it.

Achtung: The exact same known, deliberately unaddressed residual risk as RedeemPointsOnOrderPlaced (chapter 63): no SELECT ... FOR UPDATE against a concurrent second redemption by the same customer at the same moment - two simultaneous API calls could, in theory, both "see" the same still-sufficient balance at the time of their own check and both go through. Documented here for this tutorial; in a real production system, a candidate for a database lock or an optimistic version check.

webapi.xml and the idempotency question

app/code/Mironsoft/Loyalty/etc/webapi.xml (extended)
<?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/rewards/:rewardId/redeem" method="POST">
        <service class="Mironsoft\Loyalty\Api\RewardRedemptionManagementInterface" method="redeem"/>
        <resources>
            <resource ref="self"/>
        </resources>
        <data>
            <parameter name="customerId" force="true">%customer_id%</parameter>
        </data>
    </route>
</routes>

:rewardId in the URL path binds automatically to the first parameter of redeem() (matched by parameter name, not declaration order in the method), customerId is - just as in chapter 80 - forced from the token. method="POST" instead of PUT is the deliberately correct choice here: a redemption isn't an idempotent operation (sending it twice deducts points twice), and PUT implies idempotency under HTTP semantics.

Testing the endpoint

curl -s -X POST https://mironsoft.test/rest/V1/loyalty/rewards/12/redeem \
  -H 'Authorization: Bearer <token>'

With REST reads (chapter 80) and REST writes (this chapter) done, chapter 82 turns to GraphQL - starting with how the same points balance and the reward catalog can be queried there.