> ## Knowledge Base Index
> Fetch the complete knowledge base index at: https://docs.bloy.io/sitemap.xml
> Use this file to discover available pages before exploring further.
> Pure-Markdown content can be obtained by appending a '.md' suffix to the content URLs listed in the sitemap (without the trailing slash).

# Webhook Events

### Topic catalogue

### Points
| Topic | Fires when |
| ---- |
| `points/earned` | A customer earns points from any earning rule |
| `points/redeemed` | A customer spends points on a reward |
| `points/expired` | Points expire under the expiration policy |
| `points/reversed` | Points are taken back because an order was refunded, cancelled, or unfulfilled |
| `points/adjusted` | A balance changes outside the rules above — admin edit, import, store credit, or Shopify Flow |
| `points/expiring_soon` | Scheduled reminder that a balance is about to expire |

### Customer
| Topic | Fires when |
| ---- |
| `customer/joined` | A customer joins the loyalty programme |

### VIP tiers
| Topic | Fires when |
| ---- |
| `tier/upgraded` | A customer moves up a VIP tier |
| `tier/downgraded` | A customer moves down a VIP tier |
| `tier/demotion_reminder` | A customer is at risk of losing their tier |
| `tier/perk_granted` | A tier perk or reward is issued to a customer |

### Rewards
| Topic | Fires when |
| ---- |
| `reward/expiring_soon` | Scheduled reminder that a reward is about to expire |

### Referrals
| Topic | Fires when |
| ---- |
| `referral/invite_sent` | A customer sends a referral invitation |
| `referral/reward_earned` | A referrer is rewarded for a successful referral |
| `referral/referee_claimed` | A referred friend claims their welcome reward |

### Birthday
| Topic | Fires when |
| ---- |
| `birthday/reward_earned` | A birthday reward is issued |
| `birthday/updated` | A customer's birthday is set or changed |
---
## Event payloads
### `points/earned`
| Field | Type | Description |
| ---- |
| `points` | number | Points actually added, always positive. The final figure, after every modifier below |
| `basePoints` | number | Points before the limited-time-offer multiplier. Equals `points` when no offer applied |
| `earningRuleId` | string | The earning rule that awarded the points |
| `orderId` | number | Shopify order id, when the rule is order-driven |
| `lto` | object | The limited-time offer that multiplied this award, or `null` if none applied |
| `vipEarning` | object | The VIP tier rate used instead of the rule's own rate, or `null` if the rule's rate was used |

```
{
  "oldPoints": 1350,
  "newPoints": 1650,
  "activityType": "earn_point",
  "settingType": "place_order",
  "points": 300,
  "basePoints": 150,
  "earningRuleId": "66c9f21b7e5a4d0193bc2210",
  "orderId": 5512094318721,
  "lto": {
    "id": "66e40a12c7b1f5039a2d6611",
    "name": "Double Points Weekend",
    "pointMultiplier": 2
  },
  "vipEarning": {
    "earnPoint": 2,
    "spent": 1
  }
}
```

> The two apply at different stages, so one event can carry both:
> 1. **`vipEarning`** replaces the earning rule's own rate. When the customer's tier grants a better rate, they earn `earnPoint` points per `spent` of order value instead of the rate configured on the rule. That is what `basePoints` is calculated from.
> 2. **`lto`** then multiplies the result: `points = basePoints × lto.pointMultiplier`.`null` on either field means the modifier was **not applied** — not that it is unknown.
---
### `points/redeemed`
| Field | Type | Description |
| ---- |
| `pointsRedeemed` | number | Points spent, always positive |
| `redeemRuleId` | string | The redemption rule used |

On this topic `settingType` is the reward type: `amount`, `percentage`, `shipping`, `free_product`, `store_credit`, or `redirect_url`.
```
{
  "oldPoints": 1500,
  "newPoints": 1000,
  "activityType": "redeem_point",
  "settingType": "amount",
  "pointsRedeemed": 500,
  "redeemRuleId": "66ca0374b19f8c02d47ae115"
}
```
---
### `points/expired`
| Field | Type | Description |
| ---- |
| `pointsExpired` | number | Points removed, always positive |
```
{
  "oldPoints": 1000,
  "newPoints": 800,
  "activityType": "adjust_point",
  "settingType": "point_expiration",
  "pointsExpired": 200
}
```
---
### `points/reversed`
| Field | Type | Description |
| ---- |
| `pointsReversed` | number | Points taken back, always positive |
| `reason` | string | `refunded`, `cancelled`, or `unfulfilled` |
| `orderId` | number | The order that triggered the reversal |
| `earningRuleId` | string | The rule that originally awarded the points |

```
{
  "oldPoints": 800,
  "newPoints": 650,
  "activityType": "adjust_point",
  "settingType": "refunded",
  "pointsReversed": 150,
  "reason": "refunded",
  "orderId": 5512094318721,
  "earningRuleId": "66c9f21b7e5a4d0193bc2210"
}
```
---
### `points/adjusted`
| Field | Type | Description |
| ---- |
| `points` | number | Signed delta — negative when points were removed |
| `source` | string | `admin`, `import`, `store_credit`, or `shopify_flow` |
| `reason` | string | Reason recorded with the adjustment |
| `note` | string | Free-text note recorded with the adjustment |

```
{
  "oldPoints": 650,
  "newPoints": 900,
  "activityType": "adjust_point",
  "settingType": null,
  "points": 250,
  "source": "admin",
  "reason": "Goodwill credit",
  "note": "Ticket #4821"
}
```
---
### `points/expiring_soon`
| Field | Type | Description |
| ---- |
| `pointsExpiring` | number | Points due to expire |
| `expiresAt` | string | ISO 8601 timestamp of the expiry |
| `daysRemaining` | number | Days until the points expire |
```
{
  "pointsExpiring": 900,
  "expiresAt": "2026-09-24T00:00:00.000Z",
  "daysRemaining": 30
}
```
---
### `customer/joined`
| Field | Type | Description |
| ---- |
| `source` | string | `storefront` when the customer joined on the shop, `api` when enrolled through the API |
| `pointsEarned` | number | Sign-up bonus awarded, `0` if none |
```
{
  "source": "storefront",
  "pointsEarned": 100
}
```
---
### `tier/upgraded` and `tier/downgraded`
Both topics carry the same fields.
| Field | Type | Description |
| ---- |
| `previousTier` | object | `{ id, name, targetValue }`, `null` if the customer had no tier |
| `currentTier` | object | `{ id, name, targetValue }` of the new tier |
| `achieveType` | string | What the VIP programme measures: `amount` (spend) or `point` (points earned) |
| `source` | string | `points` for an automatic change, `manual` for an admin change, `reset` for a VIP period reset |
```
{
  "previousTier": { "id": "66d1b4e7c2a9f80315de7740", "name": "Silver", "targetValue": 500 },
  "currentTier":  { "id": "66d1b4e7c2a9f80315de7742", "name": "Gold",   "targetValue": 1500 },
  "achieveType": "point",
  "source": "points"
}
```
---
### `tier/demotion_reminder`
| Field | Type | Description |
| ---- |
| `tierAtRisk` | object | `{ id, name, targetValue }` of the tier about to be lost |
| `currentValue` | number | The customer's progress against `targetValue` |
| `daysRemaining` | number | Days left to requalify |
```
{
  "tierAtRisk": { "id": "66d1b4e7c2a9f80315de7742", "name": "Gold", "targetValue": 1500 },
  "currentValue": 1120,
  "daysRemaining": 7
}
```
---
### `tier/perk_granted`
| Field | Type | Description |
| ---- |
| `reward` | object | `{ id, code, name, discountType, value, expiresAt }` |
| `perkType` | string | `vip_perk` or `vip_reward` |
| `vipDiscountId` | string | The VIP discount rule that issued it |

```
{
  "reward": {
    "id": "66f2c9a4b81d3e0217af7c58",
    "code": "GOLD-9F2A4C",
    "name": "Gold members: 15% off",
    "discountType": "percentage",     # amount | percentage | shipping | free_product | custom | bonus_points
    "value": 15,
    "expiresAt": "2026-09-30T23:59:59.000Z"
  },
  "perkType": "vip_perk",
  "vipDiscountId": "66d1c07fa4b2e5031f8ac913"
}
```
---
### `reward/expiring_soon`
| Field | Type | Description |
| ---- |
| `reward` | object | `{ id, code, name, expiresAt }` |
| `daysRemaining` | number | Days until the reward expires |
```
{
  "reward": {
    "id": "66f2c9a4b81d3e0217af7c58",
    "code": "GOLD-9F2A4C",
    "name": "Gold members: 15% off",
    "expiresAt": "2026-09-01T23:59:59.000Z"
  },
  "daysRemaining": 7
}
```
---
### `referral/invite_sent`
The `customer` in the envelope is the **referrer**.
| Field | Type | Description |
| ---- |
| `referralCode` | string | The referrer's code |
| `referralLink` | string | The shareable referral URL |
| `invitedEmail` | string | Email the invitation was sent to |
```
{
  "referralCode": "MAI-4F2A",
  "referralLink": "https://example-store.com?bloy_ref=MAI-4F2A",
  "invitedEmail": "friend@example.com"
}
```
---
### `referral/reward_earned`
The `customer` in the envelope is the **referrer**.
| Field | Type | Description |
| ---- |
| `role` | string | Always `referrer` |
| `referralCode` | string | The referrer's code |
| `rewardType` | string | Always `points` |
| `pointsEarned` | number | Points awarded for the referral |
| `earningName` | string | Name of the referral earning rule |
```
{
  "role": "referrer",
  "referralCode": "MAI-4F2A",
  "rewardType": "points",
  "pointsEarned": 200,
  "earningName": "Refer a friend"
}
```
---
### `referral/referee_claimed`
The `customer` in the envelope is the **referred friend**.
| Field | Type | Description |
| ---- |
| `role` | string | Always `referee` |
| `rewardType` | string | Always `coupon` |
| `couponCode` | string | The discount code issued |
| `rewardName` | string | Human-readable description of the reward |
| `discountValue` | number | Value of the discount |
```
{
  "role": "referee",
  "rewardType": "coupon",
  "couponCode": "WELCOME-7B1E",
  "rewardName": "10% off your first order",
  "discountValue": 10
}
```
---
### `birthday/reward_earned`
| Field | Type | Description |
| ---- |
| `rewardType` | string | `points` when points were awarded, `discount` when a coupon was issued |
| `pointsEarned` | number | Points awarded, `0` when the reward was a discount |
```
{
  "rewardType": "points",
  "pointsEarned": 500
}
```
---
### `birthday/updated`
| Field | Type | Description |
| ---- |
| `birthday` | string | null | The new birthday as stored, ISO 8601, `null` if cleared |
```
{
  "birthday": "1994-03-18T00:00:00.000Z"
}
```