Quickstart
This is a complete, working loyalty page: points balance, reward catalogue, a redeem button, and the join and login states. Paste it into your theme, then restyle it — the markup is deliberately plain.
It uses the App Proxy, so there is nothing to configure and no key to store.
1. Add the section
Create sections/loyalty.liquid in your theme:
<div id="loyalty" data-login-url="{{ routes.account_login_url }}" data-currency="{{ shop.currency }}"></div>
<script src="{{ 'loyalty.js' | asset_url }}" defer></script>
{% schema %}
{
"name": "Loyalty",
"settings": [],
"presets": [{ "name": "Loyalty" }]
}
{% endschema %}
The API returns money in minor units without a currency code, so the section passes shop.currency
in for you to format with.
2. Add the script
Create assets/loyalty.js:
const API = '/apps/loyalty-proxy';
async function call(path, options) {
const res = await fetch(API + path, {
headers: {'Content-Type': 'application/json'},
...options,
});
return {status: res.status, body: res.status === 204 ? null : await res.json()};
}
// Reward names, coupon titles and the welcome message are merchant-authored text, so they are escaped
// before they reach the DOM.
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => `${char.charCodeAt(0)};`);
}
function render(root, html) {
root.innerHTML = html;
}
function messageFor(status) {
return status === 503 ? 'Please try again in a moment.' : 'Something went wrong.';
}
async function load(root) {
const config = await call('/config');
const member = await call('/member');
if (member.status === 401) {
render(root, `<a href="${escapeHtml(root.dataset.loginUrl)}">Log in to see your rewards</a>`);
return;
}
if (member.status === 404) {
render(root, `<p>${escapeHtml(config.body.branding.programWelcomeMessage)}</p>
<button id="loyalty-join">Join the programme</button>
<p id="loyalty-error" hidden></p>`);
const join = root.querySelector('#loyalty-join');
const error = root.querySelector('#loyalty-error');
join.addEventListener('click', async () => {
join.disabled = true;
// 400 (consent missing) and 503 (Shopify unreachable) both land here — never assume success.
const joined = await call('/register', {method: 'POST', body: JSON.stringify({consentGiven: true})});
if (joined.status !== 200) {
join.disabled = false;
error.textContent = messageFor(joined.status);
error.hidden = false;
return;
}
load(root);
});
return;
}
const {branding, rewardOptions} = config.body;
const {activePoints, pendingPoints, coupons} = member.body;
// /member returns every coupon the member ever had; isValid is the one that is usable today.
const usableCoupons = coupons.filter((coupon) => coupon.isValid);
render(root, `
<h2>${activePoints} ${escapeHtml(branding.rewardCurrencyName)}</h2>
${pendingPoints > 0 ? `<p>${pendingPoints} more are still pending.</p>` : ''}
<h3>Rewards</h3>
<ul>
${rewardOptions.map((option) => `
<li>
${escapeHtml(option.name)} — ${option.pointsCost} ${escapeHtml(branding.rewardCurrencyName)}
<button data-option="${escapeHtml(option.optionId)}" ${activePoints < option.pointsCost ? 'disabled' : ''}>
Redeem
</button>
</li>`).join('')}
</ul>
<h3>Your coupons</h3>
${usableCoupons.length === 0 ? '<p>None yet.</p>' : `<ul>${usableCoupons.map((coupon) => `
<li><code>${escapeHtml(coupon.code)}</code> — ${escapeHtml(coupon.title)}</li>`).join('')}</ul>`}
<p id="loyalty-error" hidden></p>
`);
const error = root.querySelector('#loyalty-error');
root.querySelectorAll('[data-option]').forEach((button) => {
button.addEventListener('click', async () => {
button.disabled = true;
error.hidden = true;
const redeemed = await call('/redeem', {
method: 'POST',
body: JSON.stringify({optionId: button.dataset.option}),
});
if (redeemed.status !== 200) {
button.disabled = false;
error.textContent = messageFor(redeemed.status);
error.hidden = false;
return;
}
load(root);
});
});
}
const root = document.getElementById('loyalty');
if (root) {
load(root);
}
3. Add the section to a page
In the theme editor, open the page you want and add the Loyalty section.
What to do next
- Points are awarded asynchronously after an order is paid, so a balance can move from
pendingPointstoactivePointsminutes later. Re-fetch/memberwhen the customer returns rather than caching it. GET /configis the same for every visitor, so it is safe to cache;GET /memberis per customer and is not.- Show
activePoints, nottotalPoints—totalPointsincludes points the customer cannot spend yet. GET /activitiesgives you a "how did I earn this" history. Printdescription: it is already a finished sentence in the shop's language.activityTypeis there for icons and filtering, and new types can appear at any time, so always have a fallback.POST /birthdaytakes an ISO-8601 MonthDay —--MM-DD, no year. Anything else is a400, and a second attempt is a409carrying the date already on file.- Every
...Centsfield inGET /configis in the minor unit of the shop's currency; the currency code comes from Shopify, not from us.
Every field of every response is listed in the API reference.