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:
<?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:
<?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:
<?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
<?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.