ServicesLoyalty

Referrals and the Event Engine

A referral's life: a referrer shares a code, the referee is attributed with referrals create, and the referral sits pending until a qualifying event converts it and pays out. The flows below use a campaign whose trigger was signup.completed -> complete_referral with a 500-point referrer reward and a 200-point referee reward.

Attributing a referee

snug loyalty referrals validate --referral-code DOCSFRIEND --referee-id docs4-other --ip-address 203.0.113.5
snug loyalty referrals create   --referral-code DOCSFRIEND --referee-id docs4-other \
  --ip-address 203.0.113.5 --device-fingerprint fp-docs4

validate is a dry run - it runs the same fraud checks but persists nothing, answering { "valid": true } or naming what would fail:

{ "valid": false, "reasons": ["block_same_ip"] }

create persists the attribution and returns the referral in status pending, with the ip and fingerprint stored in context:

{
  "referral": {
    "referral_id": "docs4-referral:docs4-other",
    "referrer_id": "docs-wave",
    "referee_id": "docs4-other",
    "referral_code": "DOCSFRIEND",
    "status": "pending",
    "context": { "device_fingerprint": "fp-docs4", "ip_address": "203.0.113.5" },
    "created_at": "2026-08-28T12:48:01.329394Z"
  }
}

Conversion by event

POST /api/v1/loyalty/events (CLI: snug loyalty event) matches the event name against every active campaign's triggers. A complete_referral trigger converts the referee's pending referral and fulfills the campaign's configured rewards; points-type rewards are credited to the ledger immediately:

snug --output json loyalty event --event-name signup.completed --user-id docs4-other
{
  "event_id": "MAXhbPWmnLtQNrCdGACc",
  "processed": true,
  "points_granted": 200,
  "matched_campaigns": ["docs4-referral"],
  "referral": { "referral_id": "docs4-referral:docs4-other", "status": "converted", ... },
  "payouts": [
    { "user_id": "docs-wave",   "beneficiary": "referrer", "reward_type": "points",
      "value": 500.0, "status": "paid", "transaction_id": "TqXQWTWUqTCsXQzEBXDH" },
    { "user_id": "docs4-other", "beneficiary": "referee",  "reward_type": "points",
      "value": 200.0, "status": "paid", "transaction_id": "UrEAwrZsqLdNqhnKjfYM" }
  ]
}

points_granted counts points credited to the event's user - the referrer's 500 appear in the payout list and in their own balance, not in that number.

Awarding points from events

An award_points trigger reads the point amount from the event's context.points. Verified with a points-type campaign triggering on purchase.completed:

snug loyalty event --event-name purchase.completed --user-id docs4-other \
  --context '{"points":30,"amount":49.99}'

The response reported points_granted: 30 and the balance rose by 30. If the campaign's referee_reward is a positive points value, it acts as a multiplier on context.points rather than a fixed amount - leave it unset for a plain 1:1 credit. Events with no context.points grant nothing.

Fraud and velocity refusals

All four, against a campaign with block_same_ip: true and self-referral off:

  • Self-referral - the code owner refers themselves: 403 self_referral_blocked (Self-referral is not permitted for this campaign). Allowed only when the campaign sets allow_self_referral.
  • Duplicate attribution - the same referee referred twice into one campaign: 409 referral_already_exists.
  • Same-IP fraud - a second, different referee from an IP the campaign has already seen: 403 fraud_detected (Fraud detected: block_same_ip). Only enforced when the campaign sets block_same_ip, and only when --ip-address is supplied.
  • Velocity cap - too many referrals by one referrer in the window. With the default 10 per hour, the 11th create returned 429 velocity_limit_exceeded (Velocity limit exceeded: 10 referrals per 3600 seconds).

An inactive campaign refuses referrals too - validate reports { "valid": false, "reasons": ["campaign_inactive"] } (verified after campaigns update --active false).

--device-fingerprint is stored on the referral but does not currently participate in any fraud check. The window and cap are the LOYALTY_VELOCITY_CHECK_WINDOW_SECONDS and LOYALTY_MAX_REFERRALS_PER_VELOCITY_WINDOW variables in the Loyalty CONFIG reference.

On this page