Tom Sailors
Brief · Anonymized case study

Custom Points & Redemption System

I'd build this as a custom app with a Shopify order webhook that fires on payment capture, a theme extension portal for customers to check balances and redeem, and the Discount API to generate codes on demand. The merchant's admin gets a dashboard pulling aggregated points and redemption metrics. It's a straightforward chain: order → points written to metafield → customer redeems via portal → code generated → points deducted.

A mid-market direct-to-consumer brand needed to build a proprietary points-based loyalty program where customers accumulate points on purchases and redeem them as discount codes through a self-service portal. The merchant required both a customer-facing interface to track balances and redemption history, and an admin dashboard to monitor program health and customer engagement.
Four pieces
Loyalty

Points Accumulator

Records points to each customer's account whenever an order is paid, using a multiplier you choose — like 1 point per dollar spent.

Custom app + order webhook
Admin GraphQL explorer graphql
# Admin GraphQL
mutation SetCustomerPoints($ownerId: ID!, $points: String!) {
  metafieldsSet(metafields: [{
    ownerId: $ownerId
    namespace: "loyalty"
    key: "points_balance"
    type: "number_integer"
    value: $points
  }]) {
    metafields {
      id
      value
    }
    userErrors {
      field
      message
    }
  }
}

# Variables:
# {
#   "ownerId": "gid://shopify/Customer/12345",
#   "points": "150"
# }
Metafield values are always strings. Changed $points variable type from Int! to String! and adjusted the example variable from 150 to "150".
Loyalty

Customer Portal

A dedicated page in your online store where customers log in to see their current points balance, recent earning history, and redemption history.

Theme app extension + React
theme/snippets/loyalty-portal.liquid liquid
{% if customer %}
  <div class="loyalty-portal">
    <h2>Your Points Balance</h2>
    {% assign points = customer.metafields.loyalty.points_balance | default: 0 %}
    <p class="balance">{{ points }} points</p>
    
    <h3>Redeem</h3>
    {% if points >= 100 %}
      <form action="/apps/loyalty/redeem" method="POST">
        <input type="hidden" name="customer_id" value="{{ customer.id }}">
        <label>
          <input type="number" name="redeem_points" min="100" max="{{ points }}" value="100">
          points
        </label>
        <button type="submit">Generate Code</button>
      </form>
    {% else %}
      <p>Earn {{ 100 | minus: points }} more points to redeem.</p>
    {% endif %}
  </div>
{% else %}
  <p><a href="/account/login">Log in</a> to view your points.</p>
{% endif %}
Include this snippet on a custom page or in the customer account template; the /apps/loyalty/redeem endpoint is handled by the custom app backend.
Loyalty

Redemption Engine

When a customer clicks redeem, creates a discount code pegged to their points spend, deducts the points from their account, and logs the redemption.

Custom app backend + Discount API
Admin GraphQL explorer graphql
# Admin GraphQL
mutation CreateDiscountCode($input: DiscountCodeAppInput!) {
  discountCodeAppCreate(codeAppDiscount: $input) {
    codeAppDiscount {
      startsAt
      endsAt
      title
      codes(first: 1) {
        edges {
          node {
            code
          }
        }
      }
    }
    userErrors {
      field
      message
    }
  }
}

# Variables:
# {
#   "input": {
#     "combinesWith": {"shippingDiscounts": false, "orderDiscounts": true},
#     "startsAt": "2024-01-15T00:00:00Z",
#     "endsAt": "2024-03-15T00:00:00Z",
#     "title": "POINTS-REDEEM-12345",
#     "codes": ["POINTS-REDEEM-12345"],
#     "customerGets": {
#       "value": {"amount": "50.00", "appliesOnEachItem": false}
#     }
#   }
# }
Fixed argument name from 'input' to 'codeAppDiscount', removed unsupported 'id' field, queried only available fields (startsAt, endsAt, title, codes).
Operations

Loyalty Dashboard

Admin view showing total points issued, redemptions by customer, and top earners — helps you track loyalty health and spot trends.

Custom app dashboard
Admin GraphQL explorer graphql
# Admin GraphQL — fetch customers with points metafield
query GetLoyaltyMetrics($first: Int!) {
  customers(first: $first) {
    edges {
      node {
        id
        displayName
        email
        numberOfOrders
        points: metafield(namespace: "loyalty", key: "points_balance") {
          value
        }
        redemptions: metafield(namespace: "loyalty", key: "redemption_history") {
          value
        }
      }
    }
  }
}

# Variables:
# { "first": 50 }

# Note: redemption_history is a JSON string; parse on the app side to compute totals.
Store redemption history as JSON in loyalty.redemption_history metafield (array of {date, points_spent, code}); query and aggregate in the dashboard.

Got a similar problem?

Sketch your build in 30 seconds — voice, type, or attach a screenshot.

Sketch the build →