Build
Rewards
Earn or redeem once the order is paid, and refund to claw back what it earned.
The rule that decides everything here: you call earn or redeem for an order, never
both. A customer paying with a reward does not also earn on the full order: on a
recurring program the redeem itself earns on what they actually paid, and says so in
newReward. It is the same rule the counter follows, and it is why the two calls are
separate.
What counts as the order amount
Trovy rewards the amount you send; it does not see your items. Decide once what counts, for example whether tips, tax, delivery fees or service charges earn rewards, and whether a promo code comes off first, and send that same basis to earn, redeem and refund. A refund claws back in proportion to the amount the order recorded, so a refund on a different basis claws back the wrong share, or is refused as more than is left. When you refund a payment that includes something outside the basis (a tip, say), send only the part of the refund that falls inside it.
Earn, after the payment succeeds
ORDER_ID=ORD-10233 # your own order id: the key and the body name the same order
curl -X POST https://api.sandbox.trovy.ca/v1/earn \
-H "Authorization: Bearer $TROVY_SECRET_KEY" \
-H "Idempotency-Key: $ORDER_ID" \
-H "Content-Type: application/json" \
-d '{
"customerId": "cus_01HX8Z9K2M4N6P8Q",
"orderId": "ORD-10233",
"amountCents": 5000
}'// Record a paid order and earn a reward on it.
import { Trovy } from "@trovy/sdk";
const trovy = new Trovy({ apiKey: process.env.TROVY_SECRET_KEY! });
declare const customerId: string;
declare const order: { id: string; totalCents: number };
// Call this after the payment succeeds. amountCents is what the customer paid
// you, in your business's currency.
const earn = await trovy.rewards.earn(
{ customerId, orderId: order.id, amountCents: order.totalCents },
// Your own order id, so a retry from any process replays instead of earning twice.
{ idempotencyKey: order.id }
);
console.log(earn.earnedCents);amountCents is the order's amount on your basis, in the business's currency. Call it
once the payment is settled: an earn on an order that never completes is a reward the
business gave away.
What comes back:
{
"replayed": false,
"earnedCents": 300,
"reward": { "id": "rwd_…", "valueCents": 300, "minimumOrderCents": 1000 },
"transactionId": "txn_…",
"sms": { "status": "sent" }
}earnedCentsis this order's contribution.reward.valueCentsis the reward's total. It can be more thanearnedCentswhen your program holds a new reward back (usable: false, forprogram.redemptionDelayDaysdays, until a local midnight): orders placed in the meantime add to it. Once a reward is usable it stops growing, and the next order redeems it instead.- The cap. A reward never grows past
program.capCents. An order that would take it further earns only up to the cap, possiblyearnedCents: 0, and the response still shows the waiting reward. smsis whether the customer was told. Trovy sends that text.
An order that earns less than 50¢ still records, with earnedCents: 0 and no reward
or text. That is a success, not an error: the order belongs in the business's history
either way.
Pass storeId (from GET /v1/stores) to put an order on one of your locations, for
the business's reports. Without it the order goes to the default location. A reward
earned at one location can be spent at any.
At checkout
Read the customer's rewards before you take payment, and let the first one decide:
curl -X GET https://api.sandbox.trovy.ca/v1/customers/cus_01HX8Z9K2M4N6P8Q/rewards \
-H "Authorization: Bearer $TROVY_SECRET_KEY"// A customer's live rewards, usable ones first.
import { Trovy } from "@trovy/sdk";
const trovy = new Trovy({ apiKey: process.env.TROVY_SECRET_KEY! });
declare const customerId: string;
declare const order: { totalCents: number };
const { rewards } = await trovy.rewards.list(customerId);
// Usable rewards first, then the soonest to expire. Offer the first only if it
// is usable now and this order reaches its minimum: the rewards guide says what
// to record when it does not.
const next = rewards[0];
if (next?.usable && order.totalCents >= next.minimumOrderCents) {
console.log(`Offer ${next.valueCents} off, until ${next.expiresAt}`);
}Rewards list usable ones first, then the soonest to expire, so rewards[0] is the one
to look at. Decide from usable, not from the list being non-empty. Then:
rewards[0]is usable and the order reaches itsminimumOrderCents: offer it. If the customer takes it, redeem on this order (below).rewards[0]is usable but the order is below its minimum, or the customer declines it: record nothing for this order. An earn would be refused with409 ACTIVE_REWARD_EXISTS(the waiting reward is indetails.reward), because a customer cannot build a new reward while one is waiting. A later refund of this order answersORDER_NOT_FOUND, which is expected.- No reward, or only one with
usable: false: earn on this order. It adds to the held-back reward.
On a first-order-only program (program.recurring is false in GET /v1/business), a
customer earns one reward, ever: once they have one, an earn answers
409 FIRST_ORDER_ONLY, even after they have spent it. Treat that like branch 2 and
record nothing. A first order that was refunded in full does not count, so it can be
earned again.
A reward is used whole. On an order worth less than the reward, redeeming spends
all of it and the customer loses the difference. Either offer a reward only on an
order at least as large as its valueCents, or tell the customer before they accept.
Redeem, after the payment succeeds
ORDER_ID=ORD-10456 # a new order, so a new key: one key names one event
curl -X POST https://api.sandbox.trovy.ca/v1/redeem \
-H "Authorization: Bearer $TROVY_SECRET_KEY" \
-H "Idempotency-Key: $ORDER_ID" \
-H "Content-Type: application/json" \
-d '{
"customerId": "cus_01HX8Z9K2M4N6P8Q",
"rewardId": "rwd_01HX8Z9K2M4N6P8Q",
"orderId": "ORD-10456",
"amountCents": 12000
}'// Apply a reward to an order, once the order is paid.
import { Trovy } from "@trovy/sdk";
const trovy = new Trovy({ apiKey: process.env.TROVY_SECRET_KEY! });
declare const customerId: string;
declare const reward: { id: string; valueCents: number };
declare const order: { id: string; totalCents: number };
declare function capturePayment(amountCents: number): Promise<void>;
// Take the reward off the order yourself and capture the remainder FIRST. A
// redeemed reward is never handed back, so redeeming ahead of a capture that
// then fails would cost the customer their reward. A reward is used whole: on an
// order worth less than the reward, the rest is forfeited.
await capturePayment(Math.max(0, order.totalCents - reward.valueCents));
const redeem = await trovy.rewards.redeem(
// amountCents is the order's full total, before the reward.
{ customerId, rewardId: reward.id, orderId: order.id, amountCents: order.totalCents },
{ idempotencyKey: order.id }
);
// On a recurring program, what the customer paid may have earned the next one.
console.log(redeem.redeemedCents, redeem.newReward?.valueCents);Order matters, and it is the same order as an earn. Take the reward's
valueCents off the order yourself, capture the remainder, and then tell Trovy.
amountCents is the order's full amount on your basis, before the reward.
Do not redeem ahead of the capture. A redeemed reward is never handed back, not even by refunding the order, so a redeem followed by a failed capture costs your customer their reward.
If the redeem is refused after you have captured, you have given one discount, and what happens next depends on the code:
MINIMUM_ORDER_NOT_MET,REWARD_NOT_YET_USABLEorREWARD_CHANGED: the customer still has the reward. Do not reach for a different one.REWARD_EXPIREDorREWARD_ALREADY_REDEEMED: the reward is gone, spent elsewhere or expired in the last minute.
Either way the order is not recorded yet, and a refused call does not use its key. Try
recording what the customer paid as an earn, under the same idempotency key. If that
answers 409 ACTIVE_REWARD_EXISTS, the reward (or a newer one, on a recurring program)
is still waiting: record nothing for this order, as at checkout. After
MINIMUM_ORDER_NOT_MET that is always the answer, so skip the earn.
Redeeming may create a new reward in the same call (newReward), when the program is a
recurring one and the amount still paid is enough to earn on. That is the counter rule
again: the part of the order the customer actually paid for can earn.
Orders that change
- Cancelled after payment (sold out, kitchen closed, no driver). A redeemed reward never comes back, not even with a full refund. If your orders can be cancelled after payment, record loyalty when an order can no longer be cancelled, at handoff or pickup, and keep the offered reward against the order in your own system until then so it cannot be spent twice. An order cancelled before you recorded anything needs no call.
- Reduced after payment, an item removed or comped: a partial refund of that order, with its own refund id.
- Increased after payment, an item added: a new order with its own id (for example
ORD-10233-2), because an order is recorded once. It earns or redeems like any other order. The amount already recorded for an order cannot be raised.
Refund, to claw it back
REFUND_ID=ORD-10233-refund-1 # your refund's own id, never the order's key
curl -X POST https://api.sandbox.trovy.ca/v1/refunds \
-H "Authorization: Bearer $TROVY_SECRET_KEY" \
-H "Idempotency-Key: $REFUND_ID" \
-H "Content-Type: application/json" \
-d '{
"orderId": "ORD-10233",
"amountCents": 5000
}'// Reverse an order and claw back what it earned.
import { Trovy } from "@trovy/sdk";
const trovy = new Trovy({ apiKey: process.env.TROVY_SECRET_KEY! });
declare const order: { id: string };
declare const refundedCents: number;
declare const refundId: string;
// Partial refunds are allowed and claw back proportionally. Name the original
// order, not the earn call, and key the call by your refund's own id.
await trovy.rewards.refund(
{ orderId: order.id, amountCents: refundedCents },
{ idempotencyKey: refundId }
);Name the original order, not the earn call, and give each refund its own idempotency key, your refund's id rather than the order's: keyed by the order, a second partial refund of the same amount would replay the first and change nothing. Partial refunds are allowed and claw back proportionally.
What a refund does depends on what the order did:
| The order | A refund |
|---|---|
| Earned a reward that is still unspent | Reduces it in proportion, and cancels it at zero |
| Earned a reward that has since been redeemed, or expired | Is recorded, and changes nothing. It is never taken out of a different reward |
| Redeemed a reward | Never brings that reward back. Only the new reward the order earned, on a recurring program, is reduced |
| Earned nothing | Is recorded, and changes no balance |
rewardAdjustment in the response says which of these happened. The most you can
refund is what the customer actually paid: the order amount less any reward applied.
Refunding more than an order's remaining refundable amount is refused, and the error says how much is left. Refunding twice with the same idempotency key replays; with a different one it is a second, separate refund.
Currency
Every …Cents field is in the business's own currency, which GET /v1/business
reports. One business has one currency and it cannot change once it has transacted, so
read it at setup. Trovy does no conversion and will never return an amount in a unit
other than the business's own.