ServicesLoyalty

Loyalty

Referral and rewards infrastructure: campaigns drive referral codes, event-triggered point grants, and payout rules; a per-user points ledger records every credit and debit; and a reward catalog turns points into redemptions - with fraud and velocity controls on the referral path.

When to reach for it: refer-a-friend programs, purchase-driven points earning, redeemable reward catalogs, participant dashboards, campaign conversion analytics.

When not to: discount codes that gate a checkout belong to Promo Code; ranked standings of top earners belong to Leaderboard; a bare counter with no ledger semantics is a KV key.

Concepts

  • Campaigns are the top-level construct - there is no "program" entity. A campaign has a type (referral, points, or tiered), rules (attribution window, fraud switches), event triggers, and payout rewards.
  • Referral codes are issued under a campaign and owned by the JWT subject that creates them. Ids (campaign_id, reward_id) are caller-supplied; codes can be auto-generated.
  • Referrals attribute a referee to a code, then advance pending -> converted when a qualifying event arrives.
  • The event engine matches ingested events against campaign triggers; actions are complete_referral, award_points, or create_reward.
  • The points ledger is per-user and campaign-independent - referral payouts, grants, event awards, and redemptions share one balance and history.
  • The reward catalog is separate from campaign payout rewards: catalog items are what users spend points on; campaign rewards configure what referrals and events pay out.

Campaigns

snug loyalty campaigns create --campaign-id docs4-referral \
  --campaign-type referral --description "Docs referral campaign" \
  --attribution-window-days 30 --block-same-ip
snug loyalty campaigns update --campaign-id docs4-referral --active false
snug loyalty campaigns delete --campaign-id docs4-referral

--block-same-ip and --allow-self-referral are bare switches - present means on, absent means off (both default off).

The CLI creates a campaign with rules only. Event triggers and payout rewards are configured over HTTP - send them on create, or replace them later with PATCH /api/v1/loyalty/campaigns/{campaign_id} (verified):

curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "campaign_id": "docs4-referral", "campaign_type": "referral",
    "description": "Docs referral campaign",
    "rules": { "attribution_window_days": 30, "block_same_ip": true },
    "triggers": [ { "event_name": "signup.completed", "action": "complete_referral" } ],
    "rewards": {
      "referrer_reward": { "type": "points", "value": 500, "description": "Referrer bonus" },
      "referee_reward":  { "type": "points", "value": 200, "description": "Welcome bonus" }
    }
  }' http://localhost:4000/api/v1/loyalty/campaigns
{
  "status": 201,
  "msg": "Created",
  "data": {
    "campaign": {
      "campaign_id": "docs4-referral",
      "campaign_type": "referral",
      "active": true,
      "rules": { "attribution_window_days": 30, "block_same_ip": true, ... },
      "triggers": [ ... ],
      "rewards": { ... },
      "created_at": "2026-08-28T12:47:52.242639Z"
    }
  }
}

GET /api/v1/loyalty/campaigns speaks the shared search grammar plus an active_only flag; note that q matches the description, not the campaign id (q=Docs finds the campaign above, q=docs4 does not).

Referral codes

snug loyalty codes create --campaign-id docs4-referral --custom-code DOCSFRIEND
snug loyalty codes create --campaign-id docs4-referral   # auto-generated, e.g. CZMEFAXL
snug loyalty codes get --user-id docs-wave

The code is owned by the calling JWT subject (the referrer) and the response includes a shareable referral_link.

The referral arc

snug loyalty referrals validate --referral-code DOCSFRIEND --referee-id user456 --ip-address 203.0.113.5
snug loyalty referrals create   --referral-code DOCSFRIEND --referee-id user456 --ip-address 203.0.113.5
snug loyalty event --event-name signup.completed --user-id user456

validate is a dry run, create persists a pending referral, and a qualifying event converts it and pays both sides. The full lifecycle - triggers, payouts, award_points events, and every fraud and velocity refusal - is on Referrals and the event engine.

Points ledger

snug --output json loyalty points balance --user-id docs4-other
snug loyalty points grant --user-id docs4-other --points 100 --reason "Docs bonus"
snug loyalty points history --user-id docs4-other

The balance payload carries balance, lifetime_earned, and lifetime_redeemed (captured: 200 / 200 / 0 after one referee reward).

Grants only credit: --points must be positive, otherwise 400 invalid_points_amount (write --points=-50 to even reach the server; a bare -50 is rejected by the CLI parser). Debits happen only through redemption. History entries carry transaction_type, amount, balance_after, a reason (system-generated ones look like referral_reward:docs4-referral or event:purchase.completed), and campaign_id when a campaign caused the entry - newest first, up to the configured history limit.

Reward catalog and redemption

snug loyalty rewards create --reward-id docs4-mug --name "Docs Mug" \
  --description "Ceramic mug" --points-cost 250 --stock 5 --active
snug loyalty rewards list --active-only
snug loyalty rewards update --reward-id docs4-mug --active false
snug --output json loyalty points redeem --user-id docs4-other --reward-id docs4-mug
snug loyalty points redemptions --user-id docs4-other
{
  "redemption_id": "tbaraMeVCJgYRsCDcrMe",
  "user_id": "docs4-other",
  "reward_id": "docs4-mug",
  "points_spent": 250, "new_balance": 50
}

Behaviors, each reproduced live:

  • Redemption spends the reward's points_cost - you pick a reward, not an amount. The raw HTTP body requires a points field, but its value is ignored: sending "points": 1 still charged 250.
  • A balance below the cost fails with 400 insufficient_points (Insufficient points: required 100000, available 50).
  • A stocked reward decrements on each redemption; at zero it fails with 400 reward_out_of_stock. An inactive one fails with 400 reward_inactive. There is no delete endpoint - deactivate instead.
  • Redeeming, listing redemptions, and the dashboard are self-or-admin: with another user's id they fail 403 FORBIDDEN unless the token is a platform admin.

Dashboard and analytics

snug loyalty dashboard --user-id docs-wave
snug loyalty analytics --campaign-id docs4-referral

The dashboard is referrer-centric: current points, total earnings, per-campaign stats (signups, conversions, paid rewards), and recent referrals for codes the user owns. Analytics aggregates one campaign into total_participants, total_referrals, total_conversions, conversion_rate, and total_rewards_distributed - after the referral arc above it reported 1 / 1 / 1 / 1.0 / 700.0.

Limits and configuration

Campaign count (100), auto-generated code length (8), the referral velocity window and cap, and the points history page limit are tunable via the LOYALTY_* variables in the Loyalty CONFIG reference.

Reference

On this page