Periods and rewards
How periods work
-
Periods start on the program’s
periodAnchorDay(1–28, default 1) and end the day before the next month’s anchor day. With the default, periods are calendar months. -
A period is identified by
YYYY-MM, the month it starts in. The program’s first and last periods are shortened to its start and end dates. -
Each period has a grace window of
graceDays(0–31, default 5) after it ends, so late activity can still arrive before the period is finalized. -
A period moves from
OPENtoFINALIZINGtoFINALIZED. Finalize periods in order, once their grace window has passed. To correct a finalized period, add an adjustment, which lands in the current period.
How a reward is calculated
Rewards are calculated per participant and period, in the background, whenever something that affects them changes: new
activity, a KYB change, an adjustment or a rule change. A response shows pendingRecalculation: true while a newer
calculation is on its way.
- Earning window. A participant earns from the later of the period start and their enrollment date, through the earlier of the period end and yesterday. Today counts once it is over, because a day’s balance isn’t final until then.
- Each rule measures the participant’s activity in that window, checks its condition and calculates its payout. The payout is rounded down to 6 decimal places.
-
Rule limits apply: a rule that already paid a participant under
once_per_participantpays nothing, andcap_per_periodscales the rule’s amount down to its cap. - Adjustments you have added for the participant are added to the total. Adjustments aren’t capped.
Each result is a reward line: one per rule, or one per wallet for daily_balance rules. All
arithmetic is exact (no floating point). Amounts are returned as strings with 6 decimal places.
Periods and rewards
List periods
GET /programs/{id}/periods → 200 { periods: Period[] }
Every period from the program’s start through the current one, oldest first.
| Field | Description |
|---|---|
periodId |
YYYY-MM |
startsOn, endsOn |
First and last day |
status |
OPEN, FINALIZING or FINALIZED |
graceEndsAt |
When the period can be finalized |
finalizedAt |
When it was finalized |
pendingRecalculations |
Calculations still in progress for the period |
failedRecalculations |
Calculations that failed repeatedly. These must be resolved before the period can be finalized. |
{
"periods": [
{
"periodId": "2026-08",
"startsOn": "2026-08-01",
"endsOn": "2026-08-31",
"status": "FINALIZED",
"graceEndsAt": "2026-09-06T00:00:00.000Z",
"finalizedAt": "2026-10-13T16:06:25.035Z",
"pendingRecalculations": 0,
"failedRecalculations": 0
},
{
"periodId": "2026-09",
"startsOn": "2026-09-01",
"endsOn": "2026-09-30",
"status": "OPEN",
"graceEndsAt": "2026-10-06T00:00:00.000Z",
"finalizedAt": null,
"pendingRecalculations": 0,
"failedRecalculations": 0
}
]
}
Get period rewards
GET /programs/{id}/periods/{periodId}/rewards → 200
Each participant’s reward for the period, with every reward line and adjustment.
| Query | Description |
|---|---|
limit, offset |
Paging. Default 50, max 500. |
participantId |
One participant’s externalId |
{
"periodId": "2026-08",
"status": "FINALIZED",
"total": 2,
"rewards": [
{
"participantId": "P-1001",
"total": "42.500000",
"status": "FINALIZED",
"calculatedAt": "2026-10-13T16:06:25.035Z",
"pendingRecalculation": false,
"lines": [
{
"ruleKey": "onboarding_bonus",
"ruleVersion": 1,
"address": null,
"amount": "25.000000",
"explanation": {
"measure": { "type": "status", "value": "1.000000" },
"condition": { "type": "always", "met": true },
"payout": { "type": "fixed", "amount": "25", "beforeLimits": "25.000000" },
"limits": { "oncePerParticipant": { "alreadyPaid": false } }
}
},
{
"ruleKey": "usdpt_holdings",
"ruleVersion": 1,
"address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
"amount": "17.500000",
"explanation": {
"measure": { "type": "daily_balance", "value": "182500.000000" },
"condition": { "type": "always", "met": true },
"payout": { "type": "annual_rate", "rate": "0.035", "base": "182500.000000", "beforeLimits": "17.500000" },
"limits": {}
}
}
],
"adjustments": []
}
]
}
In this example, P-1001 held 18,250 USDPT for 10 days in August: 182,500 balance-days × 3.5% ÷ 365 = 17.50, plus the 25 onboarding bonus.
| Field | Description |
|---|---|
total (top level) |
The number of participants matching the query, for paging |
rewards[].total |
The participant’s reward: lines plus adjustments. Can be negative after an adjustment. |
rewards[].status |
ESTIMATED until the period is finalized, then FINALIZED |
rewards[].pendingRecalculation |
true while a newer calculation is queued |
rewards[].lines |
Reward lines |
rewards[].adjustments |
{ id, amount, reason, createdAt } |
Participants appear once they have a reward line or adjustment in the period.
Finalize a period
POST /programs/{id}/periods/{periodId}/finalize → 200
Locks the period’s rewards permanently. This can’t be undone. Finalize periods in order, once each period’s grace window has passed. The call waits for any calculations in progress to finish, so it can take up to about 60 seconds.
{
"periodId": "2026-08",
"startsOn": "2026-08-01",
"endsOn": "2026-08-31",
"graceEndsAt": "2026-09-06T00:00:00.000Z",
"status": "FINALIZED",
"finalizedAt": "2026-10-13T16:06:25.035Z",
"finalizedBy": "33907490-5a41-440c-82e6-0f7880833d14",
"participantCount": 2,
"total": "44.897260"
}
finalizedBy is the ID of the API key that finalized the period, and total is the sum of every participant’s reward.
After finalizing, activity and deletions for the period are rejected with period_finalized.
Errors (all 409):
error |
Meaning |
|---|---|
period_already_finalized |
The period is already finalized |
grace_window_open: ends at <time> |
Wait until the grace window ends |
previous_period_not_finalized: <periodId> |
Finalize the earlier period first |
recalculation_pending: <n> participants still queued |
Calculations didn’t finish in time; retry |
recalculation_failed: <participants> |
Some calculations failed repeatedly; contact TBMC |
Export rewards
GET /programs/{id}/periods/{periodId}/rewards/export → 200 text/csv
A CSV of a finalized period, one row per participant, for your payment or commission run. An open period returns
409 period_not_finalized.
participantId,onboarding_bonus,usdpt_holdings,adjustments,total,ruleVersions
P-1001,25.000000,17.500000,0.000000,42.500000,onboarding_bonus:1;usdpt_holdings:1
P-1002,0.000000,2.397260,0.000000,2.397260,usdpt_holdings:1
| Column | Description |
|---|---|
participantId |
The participant’s externalId |
| One column per rule key | The participant’s amount for that rule |
adjustments |
Sum of adjustments |
total |
Rule amounts plus adjustments; matches rewards[].total |
ruleVersions |
The rule versions used, as ruleKey:version separated by ; |
Text values that start with =, +, - or @ are prefixed with ' so spreadsheets don’t treat them as formulas.
Adjustments
An adjustment adds or subtracts an amount from a participant’s reward, for example to correct an earlier period. Adjustments always land in the program’s current period and can’t be edited or deleted: to reverse one, add an opposite adjustment.
Add an adjustment
POST /programs/{id}/adjustments → 201
| Body field | Type | Required | Description |
|---|---|---|---|
participantId |
string | Yes | The participant’s externalId |
amount |
string | Yes | Signed decimal, up to 6 decimal places, not zero. "-2.5" subtracts 2.5. |
reason |
string | Yes | 1–500 characters |
POST /api/v1/rewards/programs/2e369621-ef27-499f-88c0-15032f767c54/adjustments
Idempotency-Key: adj-P-1002-duplicate-deposit
{ "participantId": "P-1002", "amount": "-2.5", "reason": "Duplicate deposit reversed" }
{
"id": "15e1a3be-0219-440e-bd29-ddb3b19ce3c1",
"amount": "-2.500000",
"createdAt": "2026-10-13T16:06:16.809Z",
"participantId": "P-1002",
"periodId": "2026-10",
"reason": "Duplicate deposit reversed",
"createdBy": "33907490-5a41-440c-82e6-0f7880833d14"
}
createdBy is the ID of the API key that added the adjustment.
Errors: 404 Participant not found; 409 no_open_period (the program has no open current period).
List adjustments
GET /programs/{id}/adjustments → 200 { adjustments, total }
| Query | Description |
|---|---|
participantId |
One participant’s adjustments |
periodId |
Adjustments in one period |
limit, offset |
Paging. Default 50, max 500. |
Newest first.
Statistics
GET /programs/{id}/stats → 200
Program totals and the reward total for each period.
{
"currentPeriodId": "2026-10",
"totals": {
"participantsEnrolled": 2,
"earningParticipants": 1,
"estimatedTotal": "1.917808",
"finalizedTotal": "44.897260",
"earningBalance": "25000.000000"
},
"programBudget": { "amount": "150000.000000", "used": "50.650684", "remaining": "149949.349316", "over": false },
"granularity": "monthly",
"series": [
{ "periodId": "2026-08", "status": "FINALIZED", "total": "44.897260" },
{ "periodId": "2026-09", "status": "OPEN", "total": "3.835616" },
{ "periodId": "2026-10", "status": "OPEN", "total": "1.917808" }
]
}
| Field | Description |
|---|---|
totals.participantsEnrolled |
Participants currently enrolled |
totals.earningParticipants |
Participants earning in the current period |
totals.estimatedTotal |
Rewards in the current period so far |
totals.finalizedTotal |
Rewards in all finalized periods |
totals.earningBalance |
Participants’ latest reported balances, combined |
series |
Reward total per period, oldest first (up to the last 120 periods) |
programBudget |
Budget usage for the program, or null with no budget. used is the reward total across every period, estimated and finalized. |
Objects
Reward line object
| Field | Description |
|---|---|
ruleKey |
The rule that produced the line |
ruleVersion |
The rule version used |
address |
The wallet, for daily_balance rules; otherwise null |
amount |
The amount after limits |
explanation |
How the amount was reached, below |
Explanation object
| Field | Description |
|---|---|
measure.type, measure.value |
What was measured, and the value (balance-days for daily_balance) |
measure.previous |
The previous period’s value, for growth conditions |
condition.type, condition.met |
The condition, and whether it was met |
condition.reason |
Why it wasn’t met: not_first_time, below_min, above_max, no_previous_period, no_previous_value, growth_below_min |
condition.growthPercent |
The growth, for growth conditions |
payout.type, payout.beforeLimits |
The payout, and the amount before limits |
payout.amount, payout.rate, payout.base, payout.units, payout.of, payout.tier |
The payout’s inputs, depending on its type |
limits.oncePerParticipant.alreadyPaid |
true if the rule had already paid this participant |
limits.capPerPeriod.cap, .applied |
The rule’s cap, and whether it reduced the amount |
Comparing amount with payout.beforeLimits shows what the limits took off.
Budget usage object
Program and organization budgets are reported in this shape, or as null when no budget is set.
| Field | Description |
|---|---|
amount |
The budget |
used |
Rewards counted against the budget so far, estimated and finalized |
remaining |
amount minus used. Negative once spending passes the budget. |
over |
true when used is more than amount. Spending exactly the budget isn’t over. |