Rules
Building a rule
A rule definition has four parts, applied in this order: measure → condition → payout → limits.
{
"measure": { "type": "sum", "metricKey": "settlement_volume" },
"condition": { "type": "growth_vs_previous_period", "minGrowthPercent": "10" },
"payout": { "type": "percent_of_measure", "of": "growth", "rate": "0.001" },
"limits": [{ "type": "cap_per_period", "amount": "500" }]
}
This rule pays 0.1% of a participant’s settlement-volume growth when it beats the previous period by at least 10%, up to 500 per period.
Measure: what the rule reads, for the days the participant earned in the period.
| Type | Reads | Value |
|---|---|---|
daily_balance |
A snapshot metric with subject address |
Balance-days: each wallet’s daily balance, summed over the days. Calculated per wallet. |
sum |
An event or total metric |
The sum of values |
count |
An event metric |
The number of events |
average |
An event or snapshot metric |
The average event value, or the average daily balance |
status |
KYB approval ("status": "kyb_approved") |
1 once the participant is enrolled and KYB-approved, otherwise 0 |
Condition: when the rule pays.
| Type | Fields | Pays when |
|---|---|---|
always |
— | Always |
first_time |
— | The participant qualifies for the first time |
threshold |
min and/or max |
The measured value is within the bounds (inclusive) |
growth_vs_previous_period |
minGrowthPercent |
The value grew by at least this percentage over the participant’s previous period |
Payout: how much it pays.
| Type | Fields | Pays |
|---|---|---|
fixed |
amount |
A fixed amount |
per_unit |
amount |
amount × the measured value |
percent_of_measure |
rate, of (value or growth) |
rate × the value, or × the growth over the previous period |
annual_rate |
rate |
Balance-days × rate ÷ 365 |
tiered |
tiers |
The tier the value falls in pays its fixed amount or rate × the value |
Rates are fractions: "0.035" is 3.5%.
Limits (optional, each at most once):
| Type | Fields | Effect |
|---|---|---|
cap_per_period |
amount |
The rule pays at most this much per participant per period |
once_per_participant |
— | The rule pays a participant only once |
Not every combination makes sense, so each measure allows certain blocks:
| Measure | Conditions | Payouts | Limits |
|---|---|---|---|
daily_balance |
always | annual_rate | cap_per_period |
sum |
always, first_time, threshold, growth_vs_previous_period | fixed, per_unit, percent_of_measure, tiered | both |
count |
always, first_time, threshold, growth_vs_previous_period | fixed, per_unit, tiered | both |
average |
always, threshold, growth_vs_previous_period | fixed, per_unit, percent_of_measure, tiered | both |
status |
always, first_time | fixed | both |
GET /rule-blocks returns this catalog with a JSON Schema for each block, and POST /rules/validate checks a
definition before you save it. The full field rules are in Rule definitions.
Rule library
Rule definitions
A definition is an object with exactly four fields: measure, condition, payout and limits (an array, which may
be empty). The concepts are explained in Building a rule.
Measure
type |
Fields | Metric it can read |
|---|---|---|
daily_balance |
metricKey |
snapshot, subject address |
sum |
metricKey |
event or total |
count |
metricKey |
event |
average |
metricKey |
event or snapshot |
status |
status: "kyb_approved" |
— |
Condition
type |
Fields | Notes |
|---|---|---|
always |
— | |
first_time |
— | Pays only in the period the participant first qualifies |
threshold |
min, max (amounts) |
At least one is required; min ≤ max; bounds are inclusive |
growth_vs_previous_period |
minGrowthPercent (amount; "10" = 10%) |
Needs a previous period with a value above zero |
Payout
type |
Fields | Notes |
|---|---|---|
fixed |
amount (> 0) |
|
per_unit |
amount (> 0) |
amount × the measured value |
percent_of_measure |
rate (> 0), of: value or growth |
of: "growth" needs the growth_vs_previous_period condition |
annual_rate |
rate (> 0) |
Balance-days × rate ÷ 365 |
tiered |
tiers: 1–20 of { from, to?, amount? , rate? } |
Each tier sets exactly one of amount or rate. Tiers must be in order with no gaps: each to equals the next from, and only the last tier omits to. A value below the first tier pays 0. |
percent_of_measure, annual_rate and rate-based tiers multiply the measured value, so the metric’s unit must be the
program’s reward asset.
Limits
type |
Fields |
|---|---|
cap_per_period |
amount (0 or more) |
once_per_participant |
— |
Validation. Definitions are checked in two stages, and the first problem is returned as 422 with a path:
-
Structure (any definition):
invalid_definition,condition_not_allowed,payout_not_allowed,limit_not_allowed,duplicate_limit,growth_payout_needs_growth_condition,tiers_overlap,tiers_gap. -
Program fit (when the rule is attached, previewed, or validated against a program):
metric_not_found,metric_deprecated,metric_kind_mismatch,metric_subject_mismatch,unit_mismatch.
The Rule object
| Field | Type | Description |
|---|---|---|
id |
UUID | Rule ID |
name |
string | 1–200 characters |
description |
string or null | Up to 2,000 characters |
version |
integer | Latest version |
status |
string | active or archived |
isDefault |
boolean | true for the rules your library starts with |
definition |
object | Definition of the latest version |
summary |
string | The rule in one sentence |
usedBy |
array | Where the rule is in force or scheduled: { programId, programName, ruleKey, ruleVersion, effectivePeriodId } |
createdAt, updatedAt |
timestamp |
List rule blocks
GET /rule-blocks → 200 { measures, conditions, payouts, limits }
The catalog of rule building blocks. Each measure lists the conditions, payouts and limits it allows; each block
includes a description and a JSON Schema (paramsSchema) for its fields.
{
"measures": [
{
"type": "status",
"description": "1 once the participant is KYB-approved and enrolled, else 0.",
"paramsSchema": {
"type": "object",
"properties": {
"type": { "type": "string", "const": "status" },
"status": { "type": "string", "const": "kyb_approved" }
},
"required": ["type", "status"],
"additionalProperties": false
},
"conditions": ["always", "first_time"],
"payouts": ["fixed"],
"limits": ["cap_per_period", "once_per_participant"]
}
],
"conditions": ["…"],
"payouts": ["…"],
"limits": ["…"]
}
List rule templates
GET /rule-templates → 200 { templates: Template[] }
Templates are shorthand for common rules, accepted when you create or update a program.
| Template | Params | Rule it creates |
|---|---|---|
kyb_approval |
amount |
A one-time amount in the first period the participant is enrolled and KYB-approved |
holdings |
metricKey, annualRate |
annualRate a year on a daily-balance metric whose unit is the reward asset |
A template item can also set cap, a per-period cap.
List rules
GET /rules → 200 { rules: Rule[] }
| Query | Description |
|---|---|
status |
active (default) or archived |
Sorted by name. A read-only key sees only the rules used by its program.
Get a rule
GET /rules/{ruleId} → 200 Rule
{
"id": "de3b5ba5-fc7f-479a-8df8-7009c50da521",
"name": "KYB bonus",
"description": "A bonus once a participant is KYB-approved",
"version": 2,
"status": "active",
"isDefault": false,
"definition": {
"measure": { "type": "status", "status": "kyb_approved" },
"condition": { "type": "always" },
"payout": { "type": "fixed", "amount": "75" },
"limits": [{ "type": "once_per_participant" }]
},
"summary": "75 on KYB approval, once per participant",
"usedBy": [
{
"programId": "2e369621-ef27-499f-88c0-15032f767c54",
"programName": "Partner Rewards",
"ruleKey": "kyb_bonus",
"ruleVersion": 1,
"effectivePeriodId": "2026-09"
},
{
"programId": "2e369621-ef27-499f-88c0-15032f767c54",
"programName": "Partner Rewards",
"ruleKey": "kyb_bonus",
"ruleVersion": 2,
"effectivePeriodId": "2026-11"
}
],
"createdAt": "2026-10-13T16:06:17.880Z",
"updatedAt": "2026-10-13T16:06:35.907Z"
}
Here the program uses version 1 now and moves to version 2 from November. Returns 404 Rule not found if the rule
doesn’t exist in your account.
List rule versions
GET /rules/{ruleId}/versions → 200 { versions: [{ version, definition, summary, createdAt }] }
Every version of the rule, oldest first.
Create a rule
POST /rules → 201 Rule
| Body field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | 1–200 characters |
description |
string | No | Up to 2,000 characters |
definition |
object | Yes | A rule definition |
The rule is created at version 1. It isn’t used anywhere until you attach it to a program.
POST /api/v1/rewards/rules
Idempotency-Key: create-kyb-bonus
{
"name": "KYB bonus",
"description": "A bonus once a participant is KYB-approved",
"definition": {
"measure": { "type": "status", "status": "kyb_approved" },
"condition": { "type": "always" },
"payout": { "type": "fixed", "amount": "50" },
"limits": [{ "type": "once_per_participant" }]
}
}
Errors: 422 structure problems (see Rule definitions), for example
{"error":"tiers_gap","message":"tier 1 must start at 10","path":"definition.payout.tiers[1].from"};
409 library_full.
Validate a rule
POST /rules/validate → 200 { valid: true, summary }
Checks a definition without saving it. Add programId to also check that it fits that program.
POST /api/v1/rewards/rules/validate
Idempotency-Key: validate-settlement-tiers
{
"programId": "2e369621-ef27-499f-88c0-15032f767c54",
"definition": {
"measure": { "type": "sum", "metricKey": "settlement_volume" },
"condition": { "type": "threshold", "min": "1000" },
"payout": {
"type": "tiered",
"tiers": [
{ "from": "0", "to": "10000", "amount": "10" },
{ "from": "10000", "rate": "0.002" }
]
},
"limits": [{ "type": "cap_per_period", "amount": "500" }]
}
}
{
"valid": true,
"summary": "by tier of settlement_volume (0 to 10000: 10; 10000 and up: 0.2% of it) when it is at least 1000, at most 500 per period"
}
An invalid definition returns the same 422 that saving it would, for example
{"error":"condition_not_allowed","message":"condition threshold does not apply to measure daily_balance","path":"definition.condition.type"}.
Archive a rule
POST /rules/{ruleId}/archive → 200 Rule
Archived rules can’t be attached or upgraded to, and are hidden from GET /rules unless you ask for status=archived.
Programs already using the rule keep it, including changes already scheduled. Archiving an archived rule changes
nothing.
Update a rule
PATCH /rules/{ruleId} → 200 Rule
| Body field | Type | Required | Description |
|---|---|---|---|
expectedVersion |
integer | Yes | The rule’s latest version |
name |
string | No | Changes in place |
description |
string or null | No | null clears it |
definition |
object | No | A new definition creates version version + 1 |
A new definition applies to programs automatically. Every program using the rule moves to the new version from its
next period (or its first period, if it hasn’t started). The current period keeps the version it is being calculated
with. Programs that have ended, and rule keys scheduled to be detached, are skipped. If the new version doesn’t fit any
of those programs, the whole update fails with 422 naming the program and rule key, and nothing changes.
A changed name or description doesn’t affect programs, and an unchanged definition doesn’t create a version.
PATCH /api/v1/rewards/rules/de3b5ba5-fc7f-479a-8df8-7009c50da521
Idempotency-Key: kyb-bonus-75
{
"expectedVersion": 1,
"definition": {
"measure": { "type": "status", "status": "kyb_approved" },
"condition": { "type": "always" },
"payout": { "type": "fixed", "amount": "75" },
"limits": [{ "type": "once_per_participant" }]
}
}
The response is the rule at version 2; its usedBy shows each program’s move to version 2 (see
Get a rule).
Errors:
404 Rule not found409 {"error":"version_conflict","message":"Rule is at version 2, not 1"}-
422structure problems, or a program the new version doesn’t fit, for example{"error":"metric_not_found","message":"Program Partner Rewards, rule growth: metric not_a_metric does not exist","path":"definition.measure.metricKey"}
Program rules
These endpoints attach library rules to a program and change them. Every change takes effect from the program’s next
period, or its first period if it hasn’t started, and the response shows it as scheduled. Two changes to the same
rule key for the same period: the later one replaces the earlier.
Change response
{
"ruleKey": "kyb_bonus",
"ruleId": "de3b5ba5-fc7f-479a-8df8-7009c50da521",
"ruleVersion": 1,
"action": "attach",
"effectivePeriodId": "2026-11",
"status": "scheduled"
}
action is attach, upgrade or detach; ruleVersion is null for a detach.
List program rules
GET /programs/{id}/rules → 200 { rules, scheduled }
rules: for each rule key, the rule in force in the current period.scheduled: for each rule key, the next change, including detaches.
Each item has ruleKey, ruleId, ruleVersion, name, definition, summary, action and effectivePeriodId.
{
"rules": [
{
"ruleKey": "kyb_bonus",
"ruleId": "de3b5ba5-fc7f-479a-8df8-7009c50da521",
"ruleVersion": 1,
"name": "KYB bonus",
"definition": {
"measure": { "type": "status", "status": "kyb_approved" },
"condition": { "type": "always" },
"payout": { "type": "fixed", "amount": "50" },
"limits": [{ "type": "once_per_participant" }]
},
"summary": "50 on KYB approval, once per participant",
"action": "attach",
"effectivePeriodId": "2026-09"
}
],
"scheduled": [
{
"ruleKey": "kyb_bonus",
"ruleId": "de3b5ba5-fc7f-479a-8df8-7009c50da521",
"ruleVersion": null,
"name": "KYB bonus",
"definition": null,
"summary": null,
"action": "detach",
"effectivePeriodId": "2026-11"
}
]
}
Attach a rule
POST /programs/{id}/rules → 201 Change
| Body field | Type | Required | Description |
|---|---|---|---|
ruleKey |
string | Yes | The program’s name for the rule, lowercase snake_case |
ruleId |
UUID | Yes | Library rule |
ruleVersion |
integer | Yes | Version to use |
A rule key stays linked to the same library rule for the life of the program: after a detach you can attach the key again, but only to the same rule.
POST /api/v1/rewards/programs/2e369621-ef27-499f-88c0-15032f767c54/rules
Idempotency-Key: attach-holdings-bonus
{ "ruleKey": "holdings_bonus", "ruleId": "733693c6-42f4-4c4f-8cdc-4df8eb53db2b", "ruleVersion": 1 }
Errors:
404 Rule not found,404 rule_version_not_found-
409 rule_archived,409 rule_key_taken(the key is linked to a different rule),409 rule_already_attached,409 program_ended 422 too_many_rules, or the rule doesn’t fit the program
Preview a rule
POST /programs/{id}/rules/preview → 200
Shows what one rule would pay in a period, using the program’s real activity, without saving anything. Preview a saved version or an unsaved definition:
| Body field | Type | Description |
|---|---|---|
ruleId + ruleVersion |
UUID + integer | A library rule version |
or definition |
object | An unsaved rule definition |
periodId |
string | Optional. Defaults to the current period |
participantId |
string | Optional. One participant’s externalId; the result then also explains a zero amount |
The preview runs the rule on its own, without the program’s other rules or adjustments. If the rule is already attached
to the program, its history (for once_per_participant and first_time) is taken into account.
POST /api/v1/rewards/programs/2e369621-ef27-499f-88c0-15032f767c54/rules/preview
Idempotency-Key: preview-growth-v2-sep
{ "ruleId": "695c549d-9e03-4818-ba1f-701c64d9b3cc", "ruleVersion": 2, "periodId": "2026-09" }
{
"periodId": "2026-09",
"participants": 4,
"earning": 1,
"total": "1.000000",
"sample": [
{
"participantId": "P-1001",
"address": null,
"amount": "1.000000",
"explanation": {
"measure": { "type": "sum", "value": "2000.000000", "previous": "1000.000000" },
"condition": {
"type": "growth_vs_previous_period",
"minGrowthPercent": "10",
"met": true,
"growthPercent": "100.00"
},
"payout": {
"type": "percent_of_measure",
"rate": "0.001",
"of": "growth",
"base": "1000.000000",
"beforeLimits": "1.000000"
},
"limits": { "capPerPeriod": { "cap": "900", "applied": false } }
}
}
]
}
| Response field | Description |
|---|---|
participants |
Participants in the period |
earning |
Participants who would earn more than zero |
total |
Total the rule would pay |
sample |
Up to 20 results, largest first, each with an explanation |
Errors: 404 Period not found, 404 Participant not found, 404 Rule not found; 409 preview_too_large (more than
10,000 participants); 422 the rule doesn’t fit the program.
Upgrade or roll back a rule
PATCH /programs/{id}/rules/{ruleKey} → 200 Change
| Body field | Type | Required | Description |
|---|---|---|---|
ruleVersion |
integer | Yes | Any version of the linked library rule, newer or older |
Moves the rule key to that version from the next period. Sent while a detach is scheduled, it cancels the detach.
Errors: 404 rule_not_attached, 404 rule_version_not_found; 409 rule_archived, 409 program_ended; 422 the
version doesn’t fit the program.
Detach a rule
DELETE /programs/{id}/rules/{ruleKey} → 200 Change
The rule stops applying from the next period; it keeps paying in the current one. Returns the change with
action: "detach".
Errors: 404 rule_not_attached (including a rule already detached); 409 program_ended.