Skip to content

Loyalty

Try the interactive example ↓

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.

Who may call what

Loyalty has two kinds of caller. An operator - a platform admin or a service account, in practice your app's backend - runs the program: campaigns, rewards, point grants, event ingestion, and analytics. A participant acts only on their own account: their codes, balance, history, redemptions, and dashboard. Anyone signed in can read the campaign and reward catalog. Anything else fails 403 FORBIDDEN.

A participant records a referral only for themselves, and their referral is judged by the address their request came from; only an operator may relay a referee's ip_address and device_fingerprint (a participant sending them gets 400).

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 true
snug loyalty campaigns update --campaign-id docs4-referral --active false
snug loyalty campaigns delete --campaign-id docs4-referral

--active and --block-same-ip take true/false and default to true; --allow-self-referral is a bare switch - present means on, absent means 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 (snug loyalty campaigns list) speaks the standard list contract plus an active_only flag: filters on campaign_type, active, start_date, end_date, created_at, and updated_at, and sorts on created_at (newest first by default), updated_at, start_date, and end_date. Note that q matches the description, not the campaign id (q=Docs finds the campaign above, q=docs4 does not). The reward catalog list takes the same contract, cheapest first by default, with active_only as well.

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. codes get lists a user's codes, newest first, filtering on campaign_id, referral_code, and created_at.

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. 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. The history is a list: it filters on transaction_type (credit, debit), campaign_id, and created_at, sorts by amount, and takes q over the reason; page it with --limit and --cursor to reach every entry.

Reward catalog and redemption

snug loyalty rewards create --reward-id docs4-mug --name "Docs Mug" \
  --description "Ceramic mug" --points-cost 250 --stock 5
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 --sort -points_spent
{
  "redemption_id": "tbaraMeVCJgYRsCDcrMe",
  "user_id": "docs4-other",
  "reward_id": "docs4-mug",
  "points_spent": 250, "new_balance": 50
}

Behaviors:

  • 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.
  • A user's balance, history, codes, redemptions, redeem, and dashboard are self-or-operator: with another user's id they fail 403 FORBIDDEN unless the token is a platform admin or a service account.

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.

Reference

Interactive example

Award points for qualifying activity and exchange them for rewards. A ledger records what each customer earned and spent.

An illustrative example that runs in your browser. No account or API calls required.