TagScrubs Loyalty Reward System
A Shopify embedded app that automates a notch-based loyalty rewards program — tracking purchases, unlocking rewards, and providing customers a self-service portal.
- Category
- E-Commerce
- Timeline
- Q1 2026
- Stack
- React Router 7TypeScriptPrismaPostgreSQLSupabase Storage+4 more
Overview — read the full write-up
Built as a fully embedded Shopify app, this loyalty rewards system automatically tracks qualifying customer purchases via webhooks and awards 'notches' toward configurable reward thresholds. It covers the full reward lifecycle — automatic unlock, staff issuance, and customer redemption — with an admin dashboard, a storefront-proxied customer portal, receipt uploads for bonus notches, social follow bonuses, and fire-and-forget email notifications throughout.
The product in action
Screenshot 1 of 1

The story in three parts
01 — The Challenge
No Customisable Loyalty Layer on Shopify
Read moreShow less
02 — The Strategy
Webhook-Driven App with Multi-Tenant Data Layer
Read moreShow less
03 — The Impact
Fully Automated Reward Lifecycle, Zero Manual Tracking
Read moreShow less
System Architecture
Webhook-Driven Rewards & Multi-Tenant Data Layer
Shopify orders/paid webhook → Notch calculation (Prisma/PostgreSQL) → Admin UI (React Router loaders) + Customer Portal (App Proxy)
Webhook & Business Logic
The `orders/paid` webhook is the single automated entry point for notch awarding. A `ProcessedWebhook` deduplication table ensures Shopify's retry mechanism never double-awards notches. All notch and reward logic lives in `rewards.server.ts`, keeping route files clean and business rules easy to modify without touching UI code.
Database Schema
Eight Prisma models cover the full reward lifecycle: `Customer` holds the live notch balance, `NotchEvent` is an immutable audit log of every change, `Reward` tracks progression from UNLOCKED through ISSUED to REDEEMED, and `RewardConfig` stores per-shop settings. Every table carries a `shop` field and every query filters by it, making the architecture multi-tenant safe by design.
Admin & Customer Surfaces
The embedded admin (React Router loaders and actions with Polaris web components) gives staff a metrics dashboard, a paginated customer list, and a per-customer detail view for manual adjustments and reward fulfillment. The customer portal is served through Shopify App Proxy at `/apps/rewards`, identifying the logged-in shopper via Shopify session without requiring a separate authentication flow.
Storage & Email Infrastructure
Receipt images are stored in a Supabase Storage bucket scoped by shop and customer ID, with public URLs recorded in the `PhotoUpload` model. Transactional emails are dispatched via a Nodemailer abstraction layer that is intentionally fire-and-forget — missing SMTP config degrades gracefully without crashing reward flows, and the transport can be swapped to SendGrid or Postmark by modifying a single file.
Lessons & trade-offs
Multi-Tenancy Must Be Enforced at the Query Level
A single app instance serves multiple Shopify shops, so every database query must include an explicit `shop` filter — relying on session context alone is not sufficient. We enforced this by convention and code review throughout the project. In a larger team, a thin Prisma client wrapper that automatically injects the shop scope at query time would be a safer long-term approach than manual discipline.
shop-scoped queries
Fire-and-Forget Emails Keep Webhooks Responsive
Shopify webhook handlers must respond within a tight timeout window. Dispatching email synchronously inside the handler risked both timeouts and blocking the reward database write. Decoupling email as fire-and-forget means SMTP failures never roll back committed rewards, and the webhook returns quickly. The accepted trade-off is that failed emails are surfaced through server logs rather than an automatic retry queue — acceptable for the current scale.
fire-and-forget
Idempotency Is Non-Negotiable for Webhooks
Shopify retries any webhook that does not receive a 2xx response, meaning the same `orders/paid` event can legitimately arrive multiple times. Without the `ProcessedWebhook` deduplication table, customers could silently accumulate double or triple the notches they earned — a data integrity bug that would be very difficult to diagnose post-launch. Treating idempotency as a first-class architectural requirement, not an afterthought, prevented this class of bug entirely.
ProcessedWebhook deduplication
Want something like this built?
TagScrubs Loyalty Reward System is one example of how I work — from architecture to shipped product. If you have a problem that needs this kind of thinking, let’s talk.
