Earn and redeem points

Earn and redeem points — a runnable, end-to-end guide verified against the VINR sandbox.

View as MarkdownInstall skills

This guide takes a customer through the full loyalty loop: enrol them in a program, award points for an action, read their balance, and redeem points for a reward. It's runnable against the sandbox — swap your test keys in and follow along.

OverviewAsk

A loyalty account (loy_) belongs to one program (prog_) and one customer (cust_). Points move through the account as immutable transactions (ptx_): you award them, they accrue to a balance, and redemptions debit them against a reward (rwd_), producing a redemption record (rdm_).

your server            VINR                  customer
    │  create account     │                     │
    │────────────────────►│                     │
    │  award points       │                     │
    │────────────────────►│  loyalty.points.earned (webhook)
    │◄────────────────────│                     │
    │  redeem reward      │  customer asks to redeem
    │◄────────────────────────────────────────  │
    │────────────────────►│  loyalty.reward.redeemed
    │  fulfil reward      │                     │

You'll do all of this server-side with the secret key. The customer never touches the loyalty API directly.

This guide assumes you already have a program. If you don't, create one first — see Create a loyalty program. The programId below is the prog_ it returns.

Enrol a memberAsk

Create a loyalty account that links a customer to your program. Use an idempotencyKey so a retried enrolment doesn't create duplicate accounts.

import { Vinr } from '@vinr/sdk';

const vinr = new Vinr({ secretKey: process.env.VINR_SECRET_KEY });

const account = await vinr.loyalty.accounts.create(
  {
    programId: 'prog_summer',
    customerId: 'cust_8sJ2k',
    metadata: { signupSource: 'checkout' },
  },
  { idempotencyKey: 'enrol-cust_8sJ2k-prog_summer' },
);

// account.id === 'loy_...'

If the customer is already enrolled in this program, the call returns the existing account rather than erroring — enrolment is naturally idempotent on the (programId, customerId) pair.

Award pointsAsk

Award points for any action you choose — a purchase, a review, a referral. Points are integers; the reason is stored on the transaction for your audit trail and surfaced in the customer's history.

const txn = await vinr.loyalty.points.award(
  {
    accountId: account.id,
    points: 250,
    reason: 'purchase',
    metadata: { paymentId: 'pay_3xZ9q', orderId: '1234' },
  },
  { idempotencyKey: 'award-pay_3xZ9q' },   // one award per payment
);

// txn.id === 'ptx_...'
// txn.balanceAfter === 250

Always key awards to the underlying event (a payment, an order). Without an idempotencyKey, a webhook retry or a double-submit will award points twice.

To award points automatically on a sale instead of calling this by hand, wire it to the payment webhook — see Earn loyalty at checkout.

Check balancesAsk

Read the current balance before showing a customer what they can redeem. The account carries both the spendable balance and lifetime totals.

const fresh = await vinr.loyalty.accounts.retrieve(account.id);

console.log(fresh.balance);          // 250  — spendable now
console.log(fresh.lifetimePoints);   // 250  — total ever earned
console.log(fresh.tier);             // e.g. 'silver', if your program uses tiers

For the full ledger, list the account's point transactions:

const { data: history } = await vinr.loyalty.points.list({
  accountId: account.id,
  limit: 20,
});

Redeem a rewardAsk

Redeeming debits points from the account against a reward you've defined in the program. VINR checks the balance and the reward's point cost atomically, so you can't overspend.

Confirm the reward is affordable

Fetch the reward to read its pointsCost, then compare against the balance you just read.

const reward = await vinr.loyalty.rewards.retrieve('rwd_freeShip');

if (fresh.balance < reward.pointsCost) {
  throw new Error('Not enough points');
}

Create the redemption

const redemption = await vinr.loyalty.redemptions.create(
  {
    accountId: account.id,
    rewardId: reward.id,
    metadata: { orderId: '1234' },
  },
  { idempotencyKey: 'redeem-order-1234' },
);

// redemption.id === 'rdm_...'
// redemption.status === 'confirmed'
// redemption.pointsSpent === reward.pointsCost

Fulfil the reward

Apply the benefit in your own system — a discount, free shipping, a voucher code. The redemption record is your proof; store redemption.id against the order.

If the balance is insufficient, the create call fails with insufficient_points and no points are deducted. See Loyalty errors for the full list.

Handle reversalsAsk

When an order is cancelled or refunded, reverse the points you awarded so balances stay honest. Reversals are themselves point transactions, linked to the original.

// Claw back an award (e.g. the payment was refunded)
await vinr.loyalty.points.reverse(
  { transactionId: txn.id, reason: 'refund' },
  { idempotencyKey: 'reverse-pay_3xZ9q' },
);

// Or void a redemption to return the spent points to the account
await vinr.loyalty.redemptions.cancel(redemption.id);

Reverse on the refund webhook so it happens exactly once:

export async function POST(req: Request) {
  const event = vinr.webhooks.verify(
    await req.text(),
    req.headers.get('x-vinr-signature'),
  );

  if (event.type === 'payment.refunded') {
    await vinr.loyalty.points.reverse(
      { transactionId: lookupAward(event.data.id), reason: 'refund' },
      { idempotencyKey: `reverse-${event.data.id}` },
    );
  }
  return new Response('OK', { status: 200 });
}

A reversal can drive a balance negative if the points were already redeemed — that's expected. The next award restores it, and the customer can't redeem again until they're back in the black.

Test itAsk

In the sandbox, awards and redemptions settle instantly — no waiting period. Drive the whole loop with the test program seeded in your account, then watch the events arrive:

EventWhen it fires
loyalty.account.createdAfter enrolling a member
loyalty.points.earnedAfter an award
loyalty.reward.redeemedAfter a confirmed redemption
loyalty.points.reversedAfter a reversal

Next stepsAsk

Was this page helpful?
Edit on GitHub

Last updated on

On this page