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

# Webhook event catalogue

> Every Perkstar outbound webhook event, with exact data fields and representative payloads.

This is the complete public outbound-event catalogue: 29 business events plus
the targeted `webhook.test` diagnostic. Event names are stable, dotted,
lowercase strings. New optional fields may be added without a version change;
existing fields are not repurposed.

Every example below shows the event-specific `data` object. Perkstar wraps it
in the common envelope documented in [Receive webhooks](/guides/webhooks).

## Field conventions

* IDs are opaque strings. Store and compare them exactly.
* `occurredAt`, `installedAt`, `expiresAt`, `creditedAt`, `scheduledStart`,
  `scheduledEnd`, `attendedAt`, and `resolvedAt` are ISO 8601 UTC timestamps.
* `created` in the outer envelope is Unix seconds.
* Money fields ending in `Pence` are integer minor units.
* Nullable fields are sent as JSON `null`; optional fields may be absent.
* `testMode: true` identifies a transaction generated with test credentials.

## Customer lifecycle

| Event | Sent when | Required `data` fields | Optional `data` fields |
| - | - | - | - |
| `customer.enrolled` | A customer first joins a card | `enrollmentId`, `cardId`, `source`, `occurredAt` | `customerId` |
| `customer.unenrolled` | Access to a card ends | `enrollmentId`, `reason`, `occurredAt` | `cardId`, `customerId` |
| `customer.anonymized` | Personal data is anonymised | `customerId`, `customerNumber`, `occurredAt` | `source` |
| `customer.group_changed` | A customer moves between lifecycle groups | `customerId`, `previousGroup`, `currentGroup`, `occurredAt` | None |

<AccordionGroup>
  <Accordion title="customer.enrolled">
    ```json theme={null}
    {
      "enrollmentId": "enr_123",
      "customerId": "cus_123",
      "cardId": "card_123",
      "source": "marketplace",
      "occurredAt": "2026-07-30T10:00:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="customer.unenrolled">
    ```json theme={null}
    {
      "enrollmentId": "enr_123",
      "customerId": "cus_123",
      "cardId": "card_123",
      "reason": "membership_cancelled",
      "occurredAt": "2026-07-30T10:10:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="customer.anonymized">
    ```json theme={null}
    {
      "customerId": "cus_123",
      "customerNumber": 1042,
      "source": "api",
      "occurredAt": "2026-07-30T10:15:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="customer.group_changed">
    ```json theme={null}
    {
      "customerId": "cus_123",
      "previousGroup": "new",
      "currentGroup": "regulars",
      "occurredAt": "2026-07-30T10:20:00.000Z"
    }
    ```
  </Accordion>
</AccordionGroup>

`previousGroup` and `currentGroup` use one of `vips`, `regulars`, `new`,
`slipping`, `atRisk`, or `lost`; `currentGroup` can be `null` if a customer no
longer classifies into a group. These are Perkstar's six exclusive lifecycle
groups, not operator-created saved segments. The first classification is stored
as a baseline without emitting `customer.group_changed`, so connecting a new
endpoint does not replay every existing customer. For a business's first
interested endpoint, that baseline is established on the next completed
projection. Tracking begins after the warm-up sweep, so a transition inside
that initial window becomes baseline state rather than an event.

## Wallet, scanner, expiry, and referrals

| Event | Sent when | Required `data` fields | Nullable `data` fields |
| - | - | - | - |
| `wallet.installed` | Perkstar first observes a pass installed in Apple or Google Wallet | `walletPassId`, `enrollmentId`, `customerId`, `cardId`, `platform`, `detectionSource`, `installedAt`, `occurredAt` | None |
| `card.scanned` | An online Perkstar Scanner lookup successfully resolves a customer card | `scanId`, `enrollmentId`, `customerId`, `cardId`, `credentialType`, `walletPassId`, `platform`, `locationId`, `occurredAt` | `walletPassId`, `platform`, `locationId` |
| `card.expired` | One customer enrolment reaches its effective card expiry | `enrollmentId`, `customerId`, `cardId`, `cardType`, `expiresAt`, `expirySource`, `occurredAt` | None |
| `referral.created` | One customer refers another customer to the same loyalty-card programme | `referralId`, `cardId`, `referrerEnrollmentId`, `referrerCustomerId`, `referredEnrollmentId`, `referredCustomerId`, `status`, `bonusTiming`, `creditedAt`, `occurredAt` | `creditedAt` |

<AccordionGroup>
  <Accordion title="wallet.installed">
    ```json theme={null}
    {
      "walletPassId": "pass_apple_123",
      "enrollmentId": "enr_123",
      "customerId": "cus_123",
      "cardId": "card_123",
      "platform": "apple",
      "detectionSource": "apple_device_registration",
      "installedAt": "2026-07-30T10:25:00.000Z",
      "occurredAt": "2026-07-30T10:25:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="card.scanned">
    ```json theme={null}
    {
      "scanId": "550e8400-e29b-41d4-a716-446655440000",
      "enrollmentId": "enr_123",
      "customerId": "cus_123",
      "cardId": "card_123",
      "credentialType": "wallet_pass_serial",
      "walletPassId": "pass_apple_123",
      "platform": "apple",
      "locationId": "loc_high_street",
      "occurredAt": "2026-07-30T10:30:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="card.expired">
    ```json theme={null}
    {
      "enrollmentId": "enr_123",
      "customerId": "cus_123",
      "cardId": "card_123",
      "cardType": "POINTS",
      "expiresAt": "2026-07-30T10:35:00.000Z",
      "expirySource": "fixed_term",
      "occurredAt": "2026-07-30T10:35:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="referral.created">
    ```json theme={null}
    {
      "referralId": "referral_123",
      "cardId": "card_123",
      "referrerEnrollmentId": "enr_referrer_123",
      "referrerCustomerId": "cus_referrer_123",
      "referredEnrollmentId": "enr_referred_123",
      "referredCustomerId": "cus_referred_123",
      "status": "credited",
      "bonusTiming": "on_signup",
      "creditedAt": "2026-07-30T10:40:00.000Z",
      "occurredAt": "2026-07-30T10:40:00.000Z"
    }
    ```
  </Accordion>
</AccordionGroup>

### Observation boundaries

* `wallet.installed` is the first provider-owned installation observation per
  pass and platform. Creating a pass or opening a save link is not an install.
  Apple reports a device registration directly. Recently issued Google passes
  are eligible on the 15-minute projector; older unseen objects back off to a
  lower check cadence, and provider responses or projector backlog can extend
  observation time. Do not treat 15 minutes as a delivery SLA. `platform` is
  `apple` or `google`, and `detectionSource` is
  `apple_device_registration` or `google_has_users`. When a business enables
  its first interested endpoint, the next completed Google check stores existing state as
  a no-event baseline; Google-install tracking begins after that warm-up sweep.
* `card.scanned` covers a successful **online** lookup in the Perkstar Scanner.
  It does not fire for an offline IndexedDB cache lookup, a dashboard customer
  search, or a POS integration lookup. `credentialType` is
  `wallet_pass_serial`, `enrollment_barcode`, or `physical_keyring`.
  `walletPassId` and `platform` are non-null only for a direct wallet-pass
  serial; shared enrolment barcodes and physical keyrings are platform-neutral.
* `card.expired` is per enrolment and effective expiry instant, not one event
  for the card template. `expirySource` is `enrollment_override`, `fixed_date`,
  or `fixed_term`. Extending or reissuing the enrolment can produce another
  event when the later effective expiry is reached.
* `referral.created` is a customer-to-customer loyalty referral. It is not a
  merchant, agency, or partner-referral invitation. `status` is `pending` or
  `credited`; `bonusTiming` is `on_signup` or `on_first_purchase`, and
  `creditedAt` is `null` while a first-purchase referral remains pending.

## Loyalty activity

| Event | Sent when | Required `data` fields | Optional `data` fields |
| - | - | - | - |
| `transaction.created` | A loyalty-ledger row is committed | `enrollmentId`, `type`, `delta`, `occurredAt` | `transactionId`, `cardId`, `customerId`, `amountPence`, `balanceAfter`, `reversesTransactionId`, `reason`, `source`, `testMode`, `externalTransactionId`, `promotionId`, `promotionUseId`, `promotionUseVoided` |
| `coupon.redeemed` | A coupon card is redeemed | `enrollmentId`, `cardId`, `customerId`, `occurredAt` | None |
| `reward.redeemed` | A reward, offer, voucher, or milestone is redeemed | `enrollmentId`, `cardId`, `cost`, `occurredAt` | `transactionId`, `milestoneId`, `stampRewardId`, `rewardTierId`, `offerId`, `offerTitle`, `discountAmountPence`, `discountKind`, `basketAmountPence`, `source`, `testMode`, `promotionId`, `promotionName`, `promotionRewardKind`, `promotionRewardMultiplier`, `promotionRewardPercent`, `promotionRewardAmountPence`, `promotionRewardText`, `promotionCurrency`, `promotionTerms` |
| `tier.changed` | A status, cashback, or discount tier changes | `enrollmentId`, `cardId`, `newTierName`, `kind`, `occurredAt` | `source` |

`transaction.created.type` is normally `STAMP`, `REDEEM`, or `ADJUST`.
`tier.changed.kind` is `status`, `cashback`, or `discount`.

<AccordionGroup>
  <Accordion title="transaction.created">
    ```json theme={null}
    {
      "transactionId": "txn_123",
      "enrollmentId": "enr_123",
      "customerId": "cus_123",
      "cardId": "card_123",
      "type": "STAMP",
      "delta": 1,
      "amountPence": 1250,
      "balanceAfter": 6,
      "externalTransactionId": "odoo:order:94721",
      "promotionId": null,
      "promotionUseId": null,
      "promotionUseVoided": null,
      "source": "api",
      "testMode": false,
      "occurredAt": "2026-07-30T11:00:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="coupon.redeemed">
    ```json theme={null}
    {
      "enrollmentId": "enr_123",
      "cardId": "card_coupon_123",
      "customerId": "cus_123",
      "occurredAt": "2026-07-30T11:05:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="reward.redeemed">
    ```json theme={null}
    {
      "transactionId": "txn_124",
      "enrollmentId": "enr_123",
      "cardId": "card_123",
      "cost": 10,
      "rewardTierId": "tier_123",
      "promotionId": "promo_123",
      "promotionName": "Double points weekend",
      "promotionRewardKind": "MULTIPLIER",
      "promotionRewardMultiplier": 2,
      "promotionRewardPercent": null,
      "promotionRewardAmountPence": null,
      "promotionRewardText": "Double points",
      "promotionCurrency": "GBP",
      "promotionTerms": "Valid once per customer.",
      "discountAmountPence": 500,
      "discountKind": "FIXED",
      "basketAmountPence": 2500,
      "source": "api",
      "testMode": false,
      "occurredAt": "2026-07-30T11:10:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="tier.changed">
    ```json theme={null}
    {
      "enrollmentId": "enr_123",
      "cardId": "card_123",
      "newTierName": "Gold",
      "kind": "cashback",
      "source": "api",
      "occurredAt": "2026-07-30T11:15:00.000Z"
    }
    ```
  </Accordion>
</AccordionGroup>

## Tickets, gifts, and multipasses

| Event | Sent when | Required `data` fields | Optional `data` fields |
| - | - | - | - |
| `ticket.purchased` | A paid ticket fulfilment completes | `enrollmentId`, `cardId`, `pricePence`, `sessionId`, `firingId`, `occurredAt` | None |
| `ticket.cancelled` | A ticket event is cancelled | `cardId`, `cardName`, `cancellationId`, `attendeeCount`, `reason`, `willRefund`, `occurredAt` | None |
| `ticket.refunded` | A ticket payment is refunded or lost to chargeback | `enrollmentId`, `cardId`, `paymentIntentId`, `occurredAt` | `refundAmountPence`, `reason` |
| `gift.purchased` | A paid gift fulfilment completes | `giftPurchaseId`, `enrollmentId`, `cardId`, `amountPence`, `purchaserEmail`, `recipientEmail`, `occurredAt` | None |
| `gift.redeemed` | Value is spent from a gift card | `enrollmentId`, `customerId`, `cardId`, `amountPence`, `remainingPence`, `occurredAt` | None |
| `multipass.purchased` | A multipass is purchased or topped up | `enrollmentId`, `cardId`, `customerId`, `visitCount`, `bundleName`, `topUp`, `sessionId`, `firingId`, `occurredAt` | None |

<AccordionGroup>
  <Accordion title="ticket.purchased">
    ```json theme={null}
    {
      "enrollmentId": "enr_ticket_123",
      "cardId": "card_ticket_123",
      "pricePence": 2500,
      "sessionId": "checkout_session_123",
      "firingId": "fulfilment_123",
      "occurredAt": "2026-07-30T12:00:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="ticket.cancelled">
    ```json theme={null}
    {
      "cardId": "card_ticket_123",
      "cardName": "Summer tasting",
      "cancellationId": "cancel_123",
      "attendeeCount": 18,
      "reason": "Venue unavailable",
      "willRefund": true,
      "occurredAt": "2026-07-30T12:05:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="ticket.refunded">
    ```json theme={null}
    {
      "enrollmentId": "enr_ticket_123",
      "cardId": "card_ticket_123",
      "refundAmountPence": 2500,
      "paymentIntentId": "payment_123",
      "occurredAt": "2026-07-30T12:10:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="gift.purchased">
    ```json theme={null}
    {
      "giftPurchaseId": "gift_123",
      "enrollmentId": "enr_gift_123",
      "cardId": "card_gift_123",
      "amountPence": 5000,
      "purchaserEmail": "buyer@example.com",
      "recipientEmail": "recipient@example.com",
      "occurredAt": "2026-07-30T12:15:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="gift.redeemed">
    ```json theme={null}
    {
      "enrollmentId": "enr_gift_123",
      "customerId": "cus_123",
      "cardId": "card_gift_123",
      "amountPence": 1250,
      "remainingPence": 3750,
      "occurredAt": "2026-07-30T12:20:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="multipass.purchased">
    ```json theme={null}
    {
      "enrollmentId": "enr_multi_123",
      "cardId": "card_multi_123",
      "customerId": "cus_123",
      "visitCount": 10,
      "bundleName": "Ten visits",
      "topUp": false,
      "sessionId": "checkout_session_456",
      "firingId": "fulfilment_456",
      "occurredAt": "2026-07-30T12:25:00.000Z"
    }
    ```
  </Accordion>
</AccordionGroup>

## Memberships

| Event | Sent when | Required `data` fields | Conditional fields |
| - | - | - | - |
| `membership.purchased` | A recurring or one-off membership first activates | `enrollmentId`, `cardId`, `tierId`, `occurredAt` | Recurring purchases include `subscriptionId`; one-off purchases include `billingInterval` |
| `membership.renewed` | A recurring membership advances to a new billing period | `enrollmentId`, `cardId`, `tierId`, `subscriptionId`, `occurredAt` | None |
| `membership.cancelled` | A recurring membership becomes cancelled or incomplete-expired | `enrollmentId`, `cardId`, `subscriptionId`, `occurredAt` | None |

<AccordionGroup>
  <Accordion title="membership.purchased">
    ```json theme={null}
    {
      "enrollmentId": "enr_member_123",
      "cardId": "card_member_123",
      "tierId": "tier_123",
      "subscriptionId": "subscription_123",
      "occurredAt": "2026-07-30T13:00:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="membership.renewed">
    ```json theme={null}
    {
      "enrollmentId": "enr_member_123",
      "cardId": "card_member_123",
      "tierId": "tier_123",
      "subscriptionId": "subscription_123",
      "occurredAt": "2026-08-30T13:00:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="membership.cancelled">
    ```json theme={null}
    {
      "enrollmentId": "enr_member_123",
      "cardId": "card_member_123",
      "subscriptionId": "subscription_123",
      "occurredAt": "2026-09-12T13:00:00.000Z"
    }
    ```
  </Accordion>
</AccordionGroup>

## Engagement

| Event | Sent when | Required `data` fields | Optional `data` fields |
| - | - | - | - |
| `feedback.submitted` | A customer submits card feedback | `enrollmentId`, `cardId`, `rating`, `hasComment`, `occurredAt` | None |
| `automation.fired` | An automated-message firing is recorded | `automationId`, `customerId`, `occurredAt` | None |
| `broadcast.sent` | A wallet broadcast reaches terminal delivery processing | `broadcastId`, `cardId`, `targetCount`, `failureCount`, `occurredAt` | None |

<AccordionGroup>
  <Accordion title="feedback.submitted">
    ```json theme={null}
    {
      "enrollmentId": "enr_123",
      "cardId": "card_123",
      "rating": 5,
      "hasComment": true,
      "occurredAt": "2026-07-30T14:00:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="automation.fired">
    ```json theme={null}
    {
      "automationId": "automation_123",
      "customerId": "cus_123",
      "occurredAt": "2026-07-30T14:05:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="broadcast.sent">
    ```json theme={null}
    {
      "broadcastId": "broadcast_123",
      "cardId": "card_123",
      "targetCount": 250,
      "failureCount": 3,
      "occurredAt": "2026-07-30T14:10:00.000Z"
    }
    ```
  </Accordion>
</AccordionGroup>

## Bookings

All booking events use the same schema. Only the event name and resulting
`status` differ.

| Event | Resulting `status` |
| - | - |
| `booking.created` | `PENDING` |
| `booking.confirmed` | `CONFIRMED` |
| `booking.attended` | `ATTENDED` |
| `booking.no_show` | `NO_SHOW` |
| `booking.cancelled` | `CANCELLED` |

Required fields are `id`, `provider`, `providerBookingId`, `status`,
`scheduledStart`, `loyaltyCredited`, `source`, and `occurredAt`. The fields
`customerId`, `cardId`, `scheduledEnd`, `attendedAt`, and `resolvedAt` are
nullable. `serviceLabel` and `staffLabel` contain the provider labels saved on
the booking.

Provider lifecycles can skip states. Acuity appointments enter Perkstar as
`CONFIRMED`, normally have no `customerId` until attendance matching runs, and
are auto-attended by the Perkstar scheduler after their end time plus 15
minutes. Acuity does not emit a `PENDING` booking.

<AccordionGroup>
  <Accordion title="booking.created">
    ```json theme={null}
    {
      "id": "booking_123",
      "provider": "GENERIC_WEBHOOK",
      "providerBookingId": "external-booking-123",
      "status": "PENDING",
      "customerId": null,
      "cardId": "card_123",
      "serviceLabel": "Consultation",
      "staffLabel": "Alex",
      "scheduledStart": "2026-08-02T09:00:00.000Z",
      "scheduledEnd": "2026-08-02T09:30:00.000Z",
      "attendedAt": null,
      "resolvedAt": null,
      "loyaltyCredited": false,
      "source": "adapter",
      "occurredAt": "2026-07-30T15:00:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="booking.confirmed">
    ```json theme={null}
    {
      "id": "booking_123",
      "provider": "ACUITY",
      "providerBookingId": "987654",
      "status": "CONFIRMED",
      "customerId": null,
      "cardId": "card_123",
      "serviceLabel": "Consultation",
      "staffLabel": "Alex",
      "scheduledStart": "2026-08-02T09:00:00.000Z",
      "scheduledEnd": "2026-08-02T09:30:00.000Z",
      "attendedAt": null,
      "resolvedAt": null,
      "loyaltyCredited": false,
      "source": "adapter",
      "occurredAt": "2026-07-30T15:05:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="booking.attended">
    ```json theme={null}
    {
      "id": "booking_123",
      "provider": "ACUITY",
      "providerBookingId": "987654",
      "status": "ATTENDED",
      "customerId": "cus_123",
      "cardId": "card_123",
      "serviceLabel": "Consultation",
      "staffLabel": "Alex",
      "scheduledStart": "2026-08-02T09:00:00.000Z",
      "scheduledEnd": "2026-08-02T09:30:00.000Z",
      "attendedAt": "2026-08-02T09:45:00.000Z",
      "resolvedAt": null,
      "loyaltyCredited": true,
      "source": "cron",
      "occurredAt": "2026-08-02T09:45:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="booking.no_show">
    ```json theme={null}
    {
      "id": "booking_123",
      "provider": "ACUITY",
      "providerBookingId": "987654",
      "status": "NO_SHOW",
      "customerId": "cus_123",
      "cardId": "card_123",
      "serviceLabel": "Consultation",
      "staffLabel": "Alex",
      "scheduledStart": "2026-08-02T09:00:00.000Z",
      "scheduledEnd": "2026-08-02T09:30:00.000Z",
      "attendedAt": "2026-08-02T09:45:00.000Z",
      "resolvedAt": "2026-08-02T10:00:00.000Z",
      "loyaltyCredited": false,
      "source": "adapter",
      "occurredAt": "2026-08-02T10:00:00.000Z"
    }
    ```
  </Accordion>

  <Accordion title="booking.cancelled">
    ```json theme={null}
    {
      "id": "booking_123",
      "provider": "ACUITY",
      "providerBookingId": "987654",
      "status": "CANCELLED",
      "customerId": "cus_123",
      "cardId": "card_123",
      "serviceLabel": "Consultation",
      "staffLabel": "Alex",
      "scheduledStart": "2026-08-02T09:00:00.000Z",
      "scheduledEnd": "2026-08-02T09:30:00.000Z",
      "attendedAt": null,
      "resolvedAt": "2026-07-31T17:00:00.000Z",
      "loyaltyCredited": false,
      "source": "adapter",
      "occurredAt": "2026-07-31T17:00:00.000Z"
    }
    ```
  </Accordion>
</AccordionGroup>

## Diagnostics

| Event | Sent when | Required `data` fields |
| - | - | - |
| `webhook.test` | An operator sends a test from one webhook's settings | `message`, `webhookId`, `triggeredAt` |

The targeted test ignores the endpoint's subscription filter, but the webhook
must be active.

```json theme={null}
{
  "message": "This is a test event from Perkstar.",
  "webhookId": "webhook_123",
  "triggeredAt": "2026-07-30T16:00:00.000Z"
}
```


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