WalletD developers
Guides

Rewards: cashback, points, conversion

Cashback, points and conversion as rules the platform evaluates on every capture, with caps enforced under concurrency.

Rewards are rules, not calls. You do not grant cashback; you write a rule and the platform evaluates it on every capture. That keeps the arithmetic, the caps, and the refund clawbacks out of your code.

Three kinds:

KindEffect
cashbackCredits cash back to the payer
accrualCredits points, a separate commodity
conversionDefines the rate at which points may become cash

Cashback

curl -sS -X POST "$WALLETD_API/v1/reward_rules" \
  -H "Authorization: Bearer $WALLETD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "cashback",
    "params": {"rate_bps": 1000},
    "scope": {"categories": ["restaurants"]},
    "caps": {"per_user_day": 500}
  }'

10% (1000 basis points) back on restaurant payments, capped at $5.00 per user per day.

Always set per_user_day. The cap is what makes a rule safe to run. Without one, a rule is an unbounded promise: a user who spends $10,000 in a day gets $1,000 of your money.

Caps are enforced under concurrency, not best-effort. Eight simultaneous captures against a $5.00 daily cap grant exactly $5.00 in total.

Points

curl -sS -X POST "$WALLETD_API/v1/reward_rules" \
  -H "Authorization: Bearer $WALLETD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"kind": "accrual", "params": {"rate_bps": 100}, "scope": {"categories": ["restaurants"]}}'

One point per 100 minor units spent. Points are a genuinely separate commodity in a separate account, not cash with a label, and they show up as their own balance:

[
  { "purpose": "cash",   "commodity": "USD", "balance": 2460, "available": 2460, "held": 0 },
  { "purpose": "points", "commodity": "POINTS", "balance": 80,   "available": 80,   "held": 0 }
]

Conversion

Points only become cash through an explicit conversion, governed by a rule:

curl -sS -X POST "$WALLETD_API/v1/reward_rules" \
  -H "Authorization: Bearer $WALLETD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"kind": "conversion", "params": {"minor_per_point": 1, "min_points": 500}}'

Then a user converts:

curl -sS -X POST "$WALLETD_API/v1/rewards/convert" \
  -H "Authorization: Bearer $WALLETD_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: convert-user-42-1" \
  -d '{"user_id": "'$USER_ID'", "points": 650}'

650 points become 650 minor units of cash; the points are debited and the cash credited in one transaction. Below min_points it is refused with 422 insufficient_points; with no conversion rule at all, 422 no_conversion_rule.

That minimum is not bureaucracy. Without a floor, a points programme becomes a currency with a fixed peg, and every accounting question you were avoiding comes back.

func (c *Client) ConvertPoints(ctx context.Context, userID string, points int64, key string) (*Transaction, error) {
	var txn Transaction
	body := map[string]any{"user_id": userID, "points": points}
	err := c.Do(ctx, http.MethodPost, "/v1/rewards/convert", body, &txn, key)
	return &txn, err
}

Scopes and caps

{
  "kind": "cashback",
  "params": { "rate_bps": 500 },
  "scope": { "categories": ["restaurants", "transport"], "merchant_ids": ["019f..."] },
  "caps": { "per_user_day": 500, "per_user_month": 5000 },
  "starts_at": "2026-09-01T00:00:00Z",
  "ends_at": "2026-09-30T23:59:59Z"
}

scope narrows which payments qualify: by merchant category, by specific merchants, or both. starts_at and ends_at make a rule a campaign that switches itself on and off, so you do not need a cron job to end a promotion.

Reading and stopping

curl -sS "$WALLETD_API/v1/reward_rules" -H "Authorization: Bearer $WALLETD_API_KEY"
curl -sS "$WALLETD_API/v1/users/$USER_ID/rewards" -H "Authorization: Bearer $WALLETD_API_KEY"

curl -sS -X POST "$WALLETD_API/v1/reward_rules/$RULE_ID/disable" \
  -H "Authorization: Bearer $WALLETD_API_KEY"

Disabling stops future grants and touches nothing already granted. Money that reached a user is theirs.

The response carries disabled_at, and that timestamp is not decoration: it is what decides whether a sale that was already made still earns. See below.

Refunds claw back

Refund a payment that earned cashback and the cashback is reversed proportionally, in the same transaction as the refund. You do not track it, and you cannot end up with a user who kept the reward for a purchase they returned.

Rewards are evaluated after capture, against the rules in force at capture

Grants happen on a background job triggered by the capture, so payment.captured can arrive slightly before cashback.granted. If your UI shows a reward, read the balance again on cashback.granted rather than assuming it is already there when the payment settles.

The important half is what that job reads. A sale earns what your rulebook said at the instant it was captured, not what it says when the job runs. Everything follows from that one sentence:

You do thisA sale captured before itA sale captured after it
Publish a ruleearns nothing from itearns
Disable a rulestill earnsearns nothing
A rule reaches its ends_atstill earnsearns nothing

So publishing a promotion never pays out on yesterday's sales, and pulling one never claws back cashback from a sale made while it was live, even if that sale's grant has not landed yet. Both directions hold under load, when the job may run seconds or minutes behind the capture.

This is worth knowing precisely because the alternative is so easy to build by accident. If the job read your rules at the moment it ran, what a sale earned would depend on how busy the platform was, and you would have no way to explain a customer's balance to them. The one asymmetry: a rule disabled before disabled_at existed carries no timestamp, and those are excluded rather than given an invented one.

Client-funded rewards

In the ecosystem model, a client can fund cashback from its own marketing budget rather than the platform's. A rule pinned to a funding merchant draws down that merchant's marketing account, and when the budget runs dry the grant is skipped rather than falling back to platform money. A client's promise never spends someone else's balance. See Ecosystem.

Next

On this page