> ## Documentation Index
> Fetch the complete documentation index at: https://developers.appstle.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build your own loyalty frontend

> Use Appstle Loyalty's Storefront API in your Shopify theme to show a signed-in customer's points, rewards, history and referrals in your own design, and let them redeem points and return rewards.

This guide is for frontend developers who want to use Appstle Loyalty's points, rewards and referral program but build their own account UI instead of using the default Loyalty widget. It covers how the Storefront API is reached and authenticated, and how to show a customer's points and rewards, redeem points, return a reward, list point transactions and referrals, and enroll a customer.

> [!NOTE]
>
> This guide covers Appstle's **Storefront API** (`/apps/loyalty/cp/api/...`), which runs in a signed-in customer's browser on a page of the store's Shopify theme. It isn't Shopify's Storefront GraphQL API.
> A headless storefront on its own domain can't use it, because its requests don't pass through Shopify's App Proxy. For a headless storefront or a server-side integration, call the **Admin API** (`/api/external/...`, authenticated with `X-API-Key`) from your server, as the [Integration guide](https://developers.appstle.com/loyalty/integration-guide.md) shows.

## When to build your own vs. use the widget

Appstle's Loyalty widget (`appstle-loyalty.js`) is added to the storefront by the app embed and handles the points panel, rewards, referrals and discount codes on its own. Build your own frontend when you need:

* A loyalty page that matches your own account design
* Points and rewards shown in places the widget doesn't cover, such as a header, a cart drawer or a product page
* Your own redeem flow, driven by your app's state

You don't have to replace everything. You can keep the widget and add your own pieces next to it, such as a points balance in the header.

> [!WARNING]
>
> Keep the **Appstle Loyalty** app embed turned on in the theme editor, even if your own UI replaces the widget. While it's off, new customers aren't added to the program, online orders don't earn points, and customers don't get points for signing up to email or SMS marketing. To hide Appstle's launcher, change the launcher settings in the Loyalty app's widget design settings instead, and leave **Show loyalty on your store** on: turning that off also stops points for visiting the store and the loyalty emails.

## How the Storefront API is reached

Storefront API calls go through [Shopify's App Proxy](https://shopify.dev/docs/apps/online-store/app-proxies). Your code calls the store's own domain, and Shopify forwards the request to Appstle with the signed-in customer's identity attached.

```
Browser → https://your-shop.myshopify.com/apps/loyalty/cp/api/...
         ↓ (Shopify App Proxy)
         Appstle backend (with shop + customer identity)
```

`/apps/loyalty` is the default path. A merchant can change it with the **Loyalty page address** setting in the Loyalty app, which must match the app proxy URL in their Shopify settings. The current path is in the `proxy_path_prefix` shop metafield and, on any page that loads the Appstle app embed, in `window._ALConfig.proxy_path_prefix` (without the leading slash). The helper below reads it from there.

> [!WARNING]
>
> Call the Storefront API on the store's domain, from a page of that store. You can't call Appstle's backend directly from another origin: requests that don't go through the App Proxy fail authentication.

## Authentication

The Storefront API uses **no API key and no token**. Appstle identifies the customer from the Shopify session:

* **The customer must be signed in** to the store's customer account. A call from a visitor who isn't signed in gets `401` with the body `{"status":401,"detail":"Invalid authentication"}`, on reads and writes alike.
* **Calls that change data** (`POST`) must send the header `X-Requested-With: XMLHttpRequest`. Without it the call gets the same `401`, even for a signed-in customer. Reads (`GET`) don't need it.
* You don't send a customer ID or email. The calls in this guide send none, and each applies to the signed-in customer.

> [!NOTE]
>
> On a store protected by a storefront password, a visitor who hasn't entered the password gets Shopify's password page (`200`, `text/html`) instead of JSON. The helper below returns `ok: false` for it. If you write your own `fetch` calls, check the `Content-Type` before you parse a response.

## Rate limits

Storefront API calls are limited for each signed-in customer and for the store as a whole. The limits count every Storefront API call on the store, including the calls the Appstle widget makes. Treat these numbers as the most you should send:

| Limit | Value |
| - | - |
| All calls from one customer | 120 per minute |
| Calls from one customer to one endpoint | 20 per minute |
| Calls from all customers to one endpoint | 60 per minute for the whole store. Once an endpoint has returned five or more errors (status `400` or higher) in a minute, its limit drops to 10. |
| All calls from the store | 600 per minute, Storefront and Admin API calls together |

Paths that differ only in a numeric ID count as one endpoint, so every `return-reward/{rewardId}` call counts toward the same limit.

A call over a limit gets `429` with the body `{"error":"Rate limit exceeded. Try again later."}` and no `Retry-After` header. Wait a minute before you retry. Because the per-endpoint limits are shared by every customer on the store, don't call the API on every page load. To show a points balance in the header, read it in Liquid from the `customer_loyalty` customer metafield (see [Metafields and tags](https://developers.appstle.com/loyalty/metafields-and-tags.md)), and call the API when the customer opens your loyalty UI or acts in it.

## A small helper

Every example below uses this helper. It builds the URL from the store's proxy path, sends `X-Requested-With` on every call, and returns the status with the parsed body so you can show an error instead of throwing. It returns `ok: false` when Shopify answers with an HTML page, such as the storefront password page.

```javascript theme={null}
async function loyaltyApi(path, { method = 'GET', body } = {}) {
  const root = window.Shopify?.routes?.root || '/';
  const proxyPath = window._ALConfig?.proxy_path_prefix || 'apps/loyalty';
  const response = await fetch(`${root}${proxyPath}/cp/api/${path}`, {
    method,
    headers: {
      'X-Requested-With': 'XMLHttpRequest',
      ...(body && { 'Content-Type': 'application/json' }),
    },
    body: body && JSON.stringify(body),
  });
  if ((response.headers.get('Content-Type') || '').includes('text/html')) {
    return { ok: false, status: response.status, data: null };
  }
  const text = await response.text();
  let data = text;
  try {
    data = JSON.parse(text);
  } catch (e) {
    /* plain text or an empty body */
  }
  return { ok: response.ok, status: response.status, data };
}
```

## Identify the customer

`GET /apps/loyalty/cp/api/logged-in-customer` returns the signed-in customer's numeric Shopify customer ID as the whole body. Use it to check that someone is signed in before you show loyalty UI.

```javascript theme={null}
const { ok, data: customerId } = await loyaltyApi('logged-in-customer');
// ok → customerId is the customer's numeric ID, for example 1234567890123
// not signed in → ok is false, status is 401
```

## Show points and rewards

`GET /apps/loyalty/cp/api/customer-loyalty` is the call for a loyalty dashboard. It returns the customer's balances, VIP tier, referral link and the rewards they hold, in one response.

```javascript theme={null}
const { ok, data: loyalty } = await loyaltyApi('customer-loyalty');
if (ok) {
  console.log(loyalty.availablePoints, loyalty.rewards.length);
}
```

A trimmed response from the demo store:

```json theme={null}
{
  "availablePoints": 6036.93,
  "pendingPoints": 0.0,
  "creditedPoints": 21211.1,
  "spentAmount": 20.0,
  "storeCreditBalance": 0.0,
  "storeCreditCurrency": "USD",
  "currentVipTier": null,
  "vipTierExpiredAt": null,
  "referredCompleted": 3,
  "referralLink": "https://referral.appstle.com/YOUR_REFERRAL_CODE",
  "customerStatus": "ACTIVE",
  "dob": null,
  "rewards": [
    {
      "id": 136729,
      "description": "$5.00 off your order",
      "pointRedeemRuleId": 8853,
      "discountCode": "AL_EXAMPLE12345678",
      "status": "UNUSED",
      "createAt": "2026-11-07T05:51:13.96663Z",
      "expireDate": null,
      "usedAt": null,
      "orderName": null,
      "rewardType": "DISCOUNT_CODE"
    }
  ]
}
```

| Field | Type | Meaning |
| - | - | - |
| `availablePoints` | number | Points the customer can redeem now |
| `pendingPoints` | number | Points earned but not yet approved |
| `creditedPoints` | number or `null` | Points credited to the customer in the period the store uses to qualify customers for VIP tiers, including points given back for returned rewards. It isn't a lifetime total, and it's `null` when the store has no VIP tiers. |
| `spentAmount` | number | What the customer has spent on orders, in the store's currency |
| `storeCreditBalance`, `storeCreditCurrency` | number, string | Store credit the customer holds, and its currency |
| `currentVipTier`, `vipTierExpiredAt` | string, date-time | The name of the customer's VIP tier and when it expires (`null` when they have none) |
| `pointsExpireAt`, `expiringPointsAmount` | date-time, number | When the customer's available points expire if they have no activity before then, and how many points that is. `null` unless the store expires points and the customer has points. |
| `referralLink` | string | The customer's referral link |
| `referredCompleted` | number | How many of the customer's referrals have been claimed |
| `customerStatus` | string | `ACTIVE` or `INACTIVE` in the program; `EXCLUDED` (by the merchant) or `EXCLUDED_BY_CUSTOMER` (the customer opted out). Excluded customers don't earn or redeem points. |
| `dob` | date | The customer's birthday, if they've given it |
| `rewards` | array | The rewards the customer holds, newest first. Each has `id`, `description`, `discountCode` (the code to apply at checkout), `status`, `rewardType`, `pointRedeemRuleId`, `pointTransactionId` (the transaction that paid for it, when the customer bought it with points), `usageCount`, `expireDate` and `createAt`. |

By default `rewards` lists only the customer's unused rewards. To list others, add `status` with a comma-separated list of `UNUSED`, `USED`, `EXPIRED`, `REFUNDED`, `CANCELED` and `SUBSCRIPTION_ACTIVE`. A reward the customer returned has the status `REFUNDED`. Any other value returns `400` with `errorKey` `invalid_enum`:

```javascript theme={null}
const { data } = await loyaltyApi('customer-loyalty?status=UNUSED,USED');
```

## Show the ways to earn and redeem

The earn and redeem rules a store has set up aren't returned by a Storefront API call. Appstle publishes them to the storefront as shop metafields, `appstle_loyalty.point_earn_rules` and `appstle_loyalty.point_redeem_rules`, so you read them in Liquid, as the [Metafields and tags](https://developers.appstle.com/loyalty/metafields-and-tags.md) page shows:

```liquid theme={null}
<script>
  window.loyaltyRedeemRules = {{ shop.metafields.appstle_loyalty.point_redeem_rules.value | json | replace: '<', '\u003c' }};
</script>
```

Each entry is a full rule with many more fields than shown here. A trimmed redeem rule from the demo store:

```json theme={null}
{
  "id": 9482,
  "name": "10% off your order",
  "type": "DISCOUNT_CODE",
  "status": "ACTIVE",
  "redeemPoints": 250,
  "discountType": "PERCENTAGE",
  "discount": 10,
  "minimumPurchaseAmount": 20,
  "customerFacingLabel": "Use 250 points for 10% off your next order"
}
```

A redeem rule's `id` is the value you send as `redeemRuleId` to redeem it, and `customerFacingLabel` is ready-made text for your UI. `type` says what the customer gets, such as `DISCOUNT_CODE`, `FREE_SHIPPING`, `FREE_PRODUCT`, `DYNAMIC_DISCOUNT` or `STORE_CREDIT`. An earn rule has `name`, `type` (for example `PURCHASE`, `BIRTHDAY` or `SUBSCRIBE_NEWSLETTER`), `basePoints` and its own `customerFacingLabel`.

The metafield lists every active rule, and a merchant can limit a rule to some customers, for example by customer tag or VIP tier. A customer who can't use a rule gets a `400` when they try, so show the error to them (see [Redeem points](#redeem-points)).

## Redeem points

`POST /apps/loyalty/cp/api/redeem-points` spends the customer's points on a reward. Send the rule's ID as `redeemRuleId`.

```javascript theme={null}
const { ok, status, data } = await loyaltyApi('redeem-points', {
  method: 'POST',
  body: { redeemRuleId: 8853 },
});
if (ok) {
  const newest = data.rewards[0];
  console.log(data.availablePoints, newest.discountCode);
}
```

A successful call returns `200` with the same body as `customer-loyalty`, updated: on the demo store `availablePoints` went from `6036.93` to `5536.93` for a 500-point rule. `rewards` is newest first, so the new reward and its `discountCode` are `rewards[0]`.

For a `DYNAMIC_DISCOUNT` rule, the customer chooses how many points to spend: send them as `points`, for example `{ "redeemRuleId": 1234, "points": 300 }`. The discount grows with the points: the customer gets the rule's `discount` for every `redeemPoints` points. On the demo store, a rule with `redeemPoints` `100`, `discountType` `FIXED` and `discount` `1` turned `"points": 100` into a reward described as `$1.00 off`, and `availablePoints` dropped by 100.

The merchant can limit how many points a customer spends at once. `requiredMinimumPointsToRedeem` applies when `enableMinimumPointsToRedeem` is `true`, and `requiredMaximumPointsToRedeem` applies when `enableMaximumPointsToRedeem` is `true`. A `points` value outside those limits returns `400` with a `title` that starts with `Minimum` or `Maximum`. Keep your points picker inside the limits, and show them with the rule's `minimumPointsLabel` and `maximumPointsLabel`, replacing `{{points}}` with the limit.

Here is a `FREE_PRODUCT` rule from the `point_redeem_rules` metafield, trimmed to the fields you need. Its `productData` is a JSON string:

```json theme={null}
{
  "id": 10058,
  "name": "A free product",
  "type": "FREE_PRODUCT",
  "status": "ACTIVE",
  "redeemPoints": 2000,
  "customerFacingLabel": "Use 2,000 points for a free product",
  "redeemLabel": "A free {{variant_title}}",
  "productData": "[{\"productId\":15386744946992,\"productHandle\":\"dark-roast-coffee\",\"status\":\"ACTIVE\",\"type\":\"PRODUCT\",\"id\":15386744946992,\"title\":\"Dark Roast Coffee\",\"sortOrder\":\"ALPHA_ASC\"},{\"productId\":15386745176368,\"productHandle\":\"ethiopia-coffee\",\"status\":\"ACTIVE\",\"type\":\"PRODUCT\",\"id\":15386745176368,\"title\":\"Ethiopia Coffee\",\"sortOrder\":\"ALPHA_ASC\"}]"
}
```

Sending `{ "redeemRuleId": 10058, "variantId": 15386745176368 }` returned a reward described as `A free Ethiopia Coffee`, with `variantId` `15386745176368`.

> [!NOTE]
>
> For a `FREE_PRODUCT` rule, send the product the customer picked as `variantId`. The rule's `productData` is a JSON string listing the products on the rule: parse it, and send the `id` of the chosen entry (a product ID for a whole product, a variant ID for a single variant). Without `variantId`, the reward is for the first entry and its `description` doesn't name the product. An `id` that isn't on the rule returns `400` with the title `Product isn't eligible for discount.`

A redeem can't be undone by repeating it, and a repeated call spends the points again. Disable your redeem button while a call is in flight. If a call fails with a network error, call `customer-loyalty` before you retry, to see whether the reward was created. A customer the merchant has excluded, or who opted out, gets `200` and no new reward.

A refused redeem returns `400`, with the reason in `title`. A rule that doesn't exist or isn't active, or a call with no `redeemRuleId`, returns:

```json theme={null}
{
  "type": "https://appstle.com/problem/problem-with-message",
  "title": "Redeem rule not available",
  "status": 400,
  "message": "error.",
  "params": ""
}
```

Other `title` values a customer can meet:

| `title` | What to do |
| - | - |
| `Points not available` | The customer doesn't have enough points for this rule, or for the `points` you sent. |
| `Customer Not eligible for redeeming points` | The merchant limited the rule to other customers, for example by tag or VIP tier. Hide the rule for this customer. |
| `Redeem Limit Exceed.` | The customer, or the store, has reached the rule's redeem limit. |
| `Redeem operation already in progress for this customer. Please try again.` | Another redeem for the same customer is still running. Wait for it to finish, then refresh with `customer-loyalty`. |
| `Points to redeem must be greater than zero.` | A `DYNAMIC_DISCOUNT` rule was redeemed without a positive `points` value. |
| A title that starts with `Minimum` or `Maximum` | The `points` sent for a `DYNAMIC_DISCOUNT` rule are outside the rule's limits, or the customer hasn't reached the store's minimum points or orders for redeeming. |

Some titles aren't written for customers, so show your own message for the ones you expect, and fall back to `title` for the rest.

To apply the code to the customer's cart, request Shopify's discount link, `/discount/{discountCode}`, on the store's domain. The Appstle widget does this with a background `fetch`. You can also send the customer to that link, and Shopify applies the code before showing the store.

## Return a reward

`POST /apps/loyalty/cp/api/return-reward/{rewardId}` cancels an unused reward and gives its points back. Use a reward's `id` from `rewards`. The store must have **Let customers return unused codes for their points** turned on, under **Points program** > **Settings** > **Returning rewards** in the Loyalty app.

Only a discount code the customer bought with points can be returned. Show a return button only for a reward that passes the same check as the Appstle widget:

* `status` is `UNUSED`, and `usageCount` is empty or `0`
* it has a `discountCode` and a `pointTransactionId`
* `rewardType` isn't `STORE_CREDIT`
* `expireDate` is empty or in the future

The call checks again, and refuses any other reward, such as a VIP, birthday or referral reward the store gave.

```javascript theme={null}
const { ok, data } = await loyaltyApi(`return-reward/${reward.id}`, { method: 'POST' });
```

A successful call returns `200` with the updated `customer-loyalty` body: on the demo store `availablePoints` went from `5536.93` back to `6036.93`. A second call for the same reward returns `400`:

```json theme={null}
{
  "entityName": "customerReward",
  "errorKey": "rewardNotUnused",
  "type": "https://appstle.com/problem/problem-with-message",
  "title": "This reward can no longer be returned.",
  "status": 400,
  "message": "error.rewardNotUnused",
  "params": "customerReward"
}
```

A refused return gets `400`. Show the response's `title` to the customer, and use `errorKey` to decide what your UI does next (the [reference](https://developers.appstle.com/loyalty-storefront-api/redemptions-&-discount-codes/return-an-unused-reward-for-its-points.md) lists the same keys):

| `errorKey` | What to do |
| - | - |
| `rewardNotUnused` | The code was used or already returned. Refresh with `customer-loyalty`. |
| `rewardNotReturnable` | The reward wasn't bought with points, or it's store credit. Hide its return button. |
| `rewardExpired` | The code has expired. Refresh with `customer-loyalty`. |
| `rewardReturnDisabled` | The store has turned returns off. Hide every return button. |
| `idnotfound` | The ID isn't one of this customer's rewards. |
| `rewardActionInProgress` | Another redeem or return for this customer is still running. Wait for it to finish, then refresh with `customer-loyalty`. |
| `rewardUsageUnverified` | Appstle couldn't confirm with Shopify that the code is unused. Nothing changed: the customer keeps the reward and can still use its code. The same reward can get this error again, so don't retry automatically. |

## List point transactions

`GET /apps/loyalty/cp/api/transaction-by-shop` returns the customer's point transactions, newest first. Use `page` (zero-based) and `size`. Each item is one transaction:

```javascript theme={null}
const { data: transactions } = await loyaltyApi('transaction-by-shop?page=0&size=10');
```

```json theme={null}
{
  "id": 2480132,
  "points": 500.0,
  "pointType": "CREDIT",
  "status": "APPROVED",
  "transactionRule": "REDEEM_REVERSAL",
  "note": "Refunded point for cancel reward, reference reward customerRewardId: 136957",
  "createAt": "2026-11-08T00:57:48.487629Z"
}
```

| Field | Type | Meaning |
| - | - | - |
| `id` | number | The transaction's ID |
| `points` | number | How many points, always positive. `pointType` says which way they moved. |
| `pointType` | string | `CREDIT` (points added) or `DEBIT` (points spent or removed) |
| `status` | string | `APPROVED`, `PENDING` (not yet available to spend) or `REJECTED` |
| `transactionRule` | string | Why the points moved: `EARN_RULE`, `REDEEM_RULE`, `REFERRAL`, `ADJUSTMENT` (changed by the merchant) or `REDEEM_REVERSAL` (given back for a returned reward) |
| `note` | string | A description written by the app. Don't parse it. |
| `createAt` | date-time | When the transaction was created |

The response is an array. The total number of transactions is in the `X-Total-Count` response header, so you can compute the number of pages. The response also has a `Link` header, but its URLs start with `http://` and carry a signed proxy query string, so don't follow them: request the next page with your own `page` value.

## List referrals

`GET /apps/loyalty/cp/api/customer-referrals` returns the referrals the customer has made, with the same `page`, `size` and `X-Total-Count` behavior. Without `sort` the order isn't defined. Add `sort=createAt,desc` for the newest first, or `sort=createAt,asc` for the oldest first. Sorting by a property that doesn't exist returns `500`. The friend's email comes back masked.

```javascript theme={null}
const { data: referrals } = await loyaltyApi('customer-referrals?page=0&size=10&sort=createAt,desc');
```

```json theme={null}
{
  "id": 601,
  "referredEmailId": "a******t@e******.com",
  "status": "CLAIMED",
  "createAt": "2026-11-05T21:50:28.038868Z",
  "claimAt": "2026-11-05T21:50:30.302876Z",
  "referralRewarded": true,
  "referredRewarded": true,
  "referralNote": null
}
```

`status` is `PENDING` (the friend used the link, and the referral rewards haven't been given yet), `CLAIMED` (the rewards were given) or `RECURRING`. For a `PENDING` referral, `referralNote` holds the store's **Note on pending referrals**, for example `You'll get your reward once your friend places an order.` If the store has translated the note, add `locale`, for example `locale=fr`, to get that translation.

A customer's own referral link is `referralLink` in the `customer-loyalty` response. The [reference](https://developers.appstle.com/loyalty-storefront-api/referrals/generate-customer-referral-url.md) also lists `POST /apps/loyalty/cp/api/add-customer-referral-url` for generating it. Call it when `referralLink` is empty. It always returns `200` with an empty body, so read `referralLink` from `customer-loyalty` afterwards.

## Enroll a customer

When the store lets customers join the program themselves (the `allow_customer_opt_in` shop metafield is `true`), a customer isn't a member until they join. Until then, `customer-loyalty` returns `400` with the title `Customer not found using this customerID`, and the customer has no `appstle_loyalty.customer_loyalty` metafield. Show a join button to a signed-in customer in that state. The Appstle widget checks the metafield in Liquid, which costs no API call.

`POST /apps/loyalty/cp/api/enable-loyalty-program` adds the signed-in customer to the program. It takes no body. This is the call the Appstle widget makes.

```javascript theme={null}
const { ok, status, data } = await loyaltyApi('enable-loyalty-program', { method: 'POST' });
```

It returns `200` with the plain-text body `Customer added successfully.` (the `Content-Type` says JSON, but the body is text). For a customer who is already a member, it returns the same `200` and doesn't add them again. If it fails, it returns `500` with Shopify's HTML error page as the body, so the helper returns `ok: false`. Let the customer try again.

A new member is added in the background, so `customer-loyalty` keeps returning `400` until that finishes. The Appstle widget waits up to 30 seconds, then reloads the page. If you check sooner, check only a few times, several seconds apart: each `400` counts toward the endpoint's error limit for the whole store (see [Rate limits](#rate-limits)).

The reference also lists `POST /apps/loyalty/cp/api/enroll-customer`. It returns `200` even when enrollment fails, so use `enable-loyalty-program` instead.

You can't set a VIP tier from the storefront: a `vipTier` in the `enroll-customer` body returns `400` with the title `VIP tier changes require merchant access.` Tiers come from the store's tier rules, from the merchant, or from the Shopify Flow **Assign VIP Tier** action (see [Shopify Flow](https://developers.appstle.com/loyalty/shopify-flow.md)).

## Other calls

These Storefront API calls cover the rest of what the widget does. Each one takes the signed-in customer's session and the `X-Requested-With` header, the same as the calls above.

| Call | What it does |
| - | - |
| [`POST update-customer-birth-date`](https://developers.appstle.com/loyalty-storefront-api/customer-enrollment-&-profile/update-customer-birth-date) | Saves the customer's birthday for a birthday earn rule, with `{ "dob": "1990-01-12" }`. A customer can save it only once: after that the call returns `400` with `errorKey` `birthdateAlreadySet`, so show the saved `dob` from `customer-loyalty` instead of the form. A year before 1900 or a date in the future returns `400` with `invalidBirthdate`. To save only the month and day, add `"dobYearProvided": false`; the year you send is then ignored. |
| [`POST claim-social-media-points`](https://developers.appstle.com/loyalty-storefront-api/points-&-earn-rules/claim-social-media-reward-points) | Gives the points of a social earn rule, such as following the store on Instagram, with `{ "earnRuleId": 1234 }` (the rule's `id` from `point_earn_rules`). The Appstle widget opens the rule's `socialUrl` in a new tab and makes this call at the same time. It always returns `200`, so check `awarded` in the body. When it's `true`, `points` holds the points given and `redirectUrl` the rule's `socialUrl`. When it's `false`, both are `null` and `reason` says why, for example `ALREADY_CLAIMED` or `NOT_ELIGIBLE`. Show your own text for each `reason`: `message` is written for the store, not the customer, and can name the rule's tag limits and the customer's tags. Once points are given, `customer-loyalty` shows the matching flag, such as `rewardedForInstagram`, as `true`, so hide the action. |
| [`POST customer-visit-store`](https://developers.appstle.com/loyalty-storefront-api/points-&-earn-rules/track-customer-store-visit) | Gives the points of the store's visit earn rule. It takes no body, and returns `200` with an empty body whether or not points were given. Call it once per visit, not on every page view; the rule's earn limit decides how often a customer earns. |
| [`POST send-customer-referral-url`](https://developers.appstle.com/loyalty-storefront-api/referrals/send-customer-referral-email) | Asks Appstle to email a friend the customer's referral link, with `{ "email": "friend@example.com" }`. Appstle sends it in the background, and only while the store has the **Send referral invite to new customer** email turned on under **More** > **Email Templates** in the Loyalty app. The call returns `200` with an empty body whether or not an email goes out, for example when that email is off or the store has reached its limit on these emails. Tell the customer the invite was requested, not that it arrived. |
| [`POST update-customer-status`](https://developers.appstle.com/loyalty-storefront-api/customer-enrollment-&-profile/update-customer-loyalty-status) | Lets the customer leave the program with `{ "status": "EXCLUDED_BY_CUSTOMER" }`, or rejoin it with `{ "status": "ACTIVE" }`. It returns `200`, and `customerStatus` in `customer-loyalty` changes to match. Send only these two values, and always send `status`: a call without it fails with `500`. A customer the merchant excluded can't rejoin: the call returns `400` with `errorKey` `customerAlreadyExcluded`. |

## JavaScript events

Loyalty fires one JavaScript event, `AppstleLoyalty:AppliedDiscountCode`. The Appstle app embed sends it on `document` and `window` when a customer applies one of their existing rewards to the cart from the loyalty widget, with `detail` set to `{ discountCode, description, customerId }`. It doesn't fire when the widget applies a new reward's code automatically right after a redeem. It exists only for the widget: a custom frontend that calls the Storefront API itself never receives it, so update your own UI after your own calls return.

## Errors

| Status | Meaning |
| - | - |
| `200` with HTML | A storefront password page, not Appstle. The visitor hasn't unlocked the store. |
| `400` | The request was refused. The body is `application/problem+json` and its `title` says why. |
| `401` | The customer isn't signed in, or a `POST` is missing `X-Requested-With: XMLHttpRequest`. |
| `429` | Too many calls. See [Rate limits](#rate-limits). |
| `500` | The call failed, for example `enable-loyalty-program` couldn't add the customer, or `sort` named a property that doesn't exist. The body is Shopify's HTML error page, not JSON. Fix the request, or try again. |

## Further reading

> [card] [Storefront API reference](https://developers.appstle.com/loyalty-storefront-api/redemptions-&-discount-codes/redeem-customer-loyalty-points.md)
>
> Every Storefront API call, with its request and response schema.

> [card] [Metafields and tags](https://developers.appstle.com/loyalty/metafields-and-tags.md)
>
> The rules, settings and customer data the app publishes to your theme.

> [card] [Authentication](https://developers.appstle.com/loyalty/authentication.md)
>
> Admin API keys and how storefront calls are authenticated.

> [card] [Integration guide](https://developers.appstle.com/loyalty/integration-guide.md)
>
> Server-side Admin API integration, including headless storefronts.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.