Loyalty events API

Cascade rewards what it can see: orders, visits, referrals, birthdays. Everything else happens in systems only you have. A customer attended a class, left a review on a site you run yourself, renewed a subscription, or walked into your shop. Two endpoints let your own backend report those and have the loyalty engine treat them like anything else.

EndpointWhat it does
POST /loyalty-eventsReports something your systems saw, firing the rule you name.
POST /loyalty-stampsAdds stamps to a card. See Stamp cards.

Both are part of the admin API. They take your organization's secret API key, exactly like every other admin endpoint, because the caller is your server rather than a shopper's browser. Do not put either of these in storefront JavaScript. The storefront has its own endpoint, POST /widgets/loyalty/events, which is authenticated with a publishable key and a signed customer instead.

Setting up a rule to fire

Add a rule the usual way and set its trigger to Does something your own systems report. Saving it generates a Token, which is the routing key: it names this rule when you post an event.

Copy it from the rule editor. Tokens are readable rather than shown once, so you can come back for it, and Rotate replaces it when you need to. Rotating stops every caller still sending the old one, and the change is written to the audit trail.

Several rules can share one token. That is deliberate: give three rules the same token and one call fires all three. It is how you award points and a coupon from a single event without your backend knowing there are two rules.

The token alone does nothing. The API key authenticates the call and the token only decides which rule runs, so a token on its own is not enough to award anybody anything. Still treat it as a secret: anyone holding both can fire the rule.

Reporting an event

curl -X POST https://api.cascade.example.com/loyalty-events \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "token": "lwt_045b48049e277718b369a68b3fc7b25c",
      "customerEmail": "[email protected]",
      "idempotencyKey": "class-booking-88213"
    }
  }'
Response (202 Accepted)
{
  "accepted": true,
  "customerId": "019fbc6e-0c93-7e52-ab88-bf34872a4430",
  "dedupeKey": "019fbc6e-0c93-7e52-ab88-bf34872a4430:class-booking-88213",
  "loyaltyProgramIds": ["019fbc6e-0cae-706c-a789-81362cbea182"],
  "pausedLoyaltyProgramIds": []
}

A 202 means the event was accepted, not that anything was awarded. Events are queued and dispatched in the background, so a slow rule never holds up your checkout or your webhook handler. accepted tells you it reached at least one program; whether it earns anything is up to that program's rules, its caps and its availability window.

occurredAt is worth sending when you are reporting something that happened earlier, for example when you catch up after an outage. Leave it out and the event is treated as happening now.

Fields

FieldRequiredWhat it is
tokenYesThe token from the rule's trigger settings.
customerEmailYesThe customer the event is about, as Cascade knows them.
valueNoHow many units the event is worth, for rules that scale. See below.
idempotencyKeyNoYour own identifier for the event, so a retry awards nothing twice.
createCustomerNoCreate the customer when the address is one Cascade has never seen.
occurredAtNoWhen it happened, as an ISO 8601 timestamp. Defaults to now.

Events that are worth more than one

Some events have a size. Three items reviewed, five classes attended, forty dollars donated. Turn on Scale the reward with the value the event carries in the rule editor and send a value:

curl -X POST https://api.cascade.example.com/loyalty-events \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "token": "lwt_045b48049e277718b369a68b3fc7b25c",
      "customerEmail": "[email protected]",
      "value": 3,
      "idempotencyKey": "review-batch-4471"
    }
  }'

The reward is multiplied by the value, so a rule worth 50 points awards 150. An event that leaves value out uses the rule's Value to assume, and a rule with neither awards its reward once.

Only rewards with a face value can scale: points, fixed-amount coupons and gift cards. Percentage discounts and free shipping have nothing to multiply, and Cascade refuses to save that combination in the editor rather than letting it fail later.

Retries and duplicates

Send idempotencyKey and mean it. It is your own identifier for the thing that caused the event, usually the primary key of the record: a booking id, an order id, a webhook delivery id. Replaying the same key awards nothing a second time, which is what makes it safe to retry a failed call, and safe to replay a queue after an incident.

The key is scoped to the customer before it is used, so two of your systems reusing one record id do not collide with each other. That is what dedupeKey in the response shows you.

Leave it out and every call is its own event. That is the right choice for something with no natural identifier, and the wrong choice for anything you might retry.

Customers Cascade has not met

Neither endpoint creates customers by default. An integration with a bad address in it would otherwise fill your customer list with addresses nobody can reach, so an unknown email is a 404 that names the address:

{
  "error": {
    "code": "NOT_FOUND",
    "message": "No customer in this organization has the email [email protected]. Send \"createCustomer\": true to have Cascade create one."
  }
}

Send "createCustomer": true when creating them is what you want, for example when your own system is the place customers sign up.

Paused programs

A paused program earns nothing, so an event aimed at one is reported back rather than queued. pausedLoyaltyProgramIds names it, and accepted is false when every program the token reaches is paused:

{
  "accepted": false,
  "customerId": "019fbc6e-0c93-7e52-ab88-bf34872a4430",
  "dedupeKey": "019fbc6e-0c93-7e52-ab88-bf34872a4430:class-booking-88213",
  "loyaltyProgramIds": [],
  "pausedLoyaltyProgramIds": ["019fbc6e-0cae-706c-a789-81362cbea182"]
}

Queuing it would only produce an outcome you never get to see, so you are told instead. This is not an error: your integration can carry on and the merchant can resume the program when they are ready.

Errors

StatusWhat happened
400The body did not validate.
403The API key is missing, wrong, or belongs to another organization.
404No rule uses that token, or the customer email is one Cascade has never seen.
429Over the rate limit.

A rotated token is the usual cause of a sudden 404, and the message says so:

{
  "error": {
    "code": "NOT_FOUND",
    "message": "No rule in this organization uses that token. Copy it again from the rule's trigger settings, and check whether it has been rotated since."
  }
}

Rate limits

Each endpoint allows a burst of 120 calls and refills at two a second, counted per organization. Over that is a 429, and the right response is to back off and retry rather than to drop the event. Because the limit is per organization and per endpoint, events and stamps do not compete with each other.

These calls also draw on the organization-wide admin API limit, so in practice whichever bucket empties first is the one you will hit.

If you are backfilling rather than reporting live activity, use the member import for balances or the history import for the entries behind them. Both are built for that, and these endpoints are not: an event replayed through here earns at today's rules and is filed under today's date.

Watching what happens next

The event is queued, so the result arrives later. Subscribe to webhooks under Settings, Webhooks to see it. The loyalty events are:

EventFires when
loyaltyTransaction.createdAnything lands on a member's activity feed, points included.
loyaltyReward.issuedA member is given a reward.
loyaltyReward.redeemedA reward's code was used at checkout.
loyaltyReward.revokedA pending reward was withdrawn, by you or by the member.
loyaltyReward.expiredA pending reward ran out of time.
loyaltyReward.expiringSoonA reward is inside the program's warning window.
loyaltyPoints.expiringSoonSome of a member's balance is inside that window.
loyaltyMembership.createdA customer joined the program.
loyaltyMembership.tierChangedA member moved tier, with the previous tier on the payload.
loyaltyMembership.deletedA membership was removed.
referral.updatedA referral was recorded or completed.
stampCard.createdA member's card was opened.
stampCard.updatedA card was stamped.
stampCard.completedA card filled.
loyaltyProgram.*The program was created, changed or deleted.
loyaltyRule.*A rule was created, changed or deleted.
loyaltyTier.*A tier was created, changed or deleted.

GET /webhook-events returns the full list of names, loyalty and otherwise, so a subscription picker never has to hardcode it.

Two of these are read-only signals rather than facts about a balance. loyaltyReward.expiringSoon fires exactly once per reward, and loyaltyPoints.expiringSoon fires once per balance and expiry date, so a member with points earned over several months hears about each batch separately. Neither changes anything. If you want to know that points actually expired, watch loyaltyTransaction.created for an entry whose details say pointsExpired.

Joins, tier changes and referrals deliberately do not also fire loyaltyTransaction.created, even though they appear on the activity feed. They each have their own event above, and firing both would deliver everything twice.

Reference

Full request and response schemas are in the API reference: Loyalty events, Loyalty stamps and Loyalty rules.