Activity and CSV imports
Activity
Activity is the values you report for a program’s metrics. Each row is identified by its metric and time, and sending a row with the same identity again replaces it:
| Metric kind | Time field | Row identity |
|---|---|---|
snapshot |
date |
metric, participant, address, date |
event |
occurredAt and eventId |
metric, eventId |
total |
periodId |
metric, participant, period |
An eventId is unique across the whole program. Sending an existing eventId for a different participant moves the
event to that participant.
Report activity
POST /programs/{id}/activity → 200
| Body field | Type | Required | Description |
|---|---|---|---|
rows |
array | Yes | 1–1,000 activity rows |
atomic |
boolean | No | Default false. When true, any rejected row rejects the whole batch. |
| Row field | When | Description |
|---|---|---|
metric |
Always | A metric key of the program |
participantId |
Always | An enrolled participant’s externalId |
address |
Address metrics only | A Solana wallet address. Must be omitted for participant metrics. |
date |
Snapshot metrics | YYYY-MM-DD |
occurredAt |
Event metrics | UTC timestamp ending in Z, for example 2026-10-12T14:30:00Z |
eventId |
Event metrics | Your ID for the event |
periodId |
Total metrics | YYYY-MM |
value |
Always | A string: a non-negative decimal with up to 6 decimal places; whole numbers for count metrics |
metadata |
Optional | Any JSON, up to 2,048 bytes. Returned as sent. |
Send only the time field the metric’s kind uses. A row’s day can’t be in the future or before the participant’s enrollment, and its period must be open.
POST /api/v1/rewards/programs/2e369621-ef27-499f-88c0-15032f767c54/activity
Idempotency-Key: activity-2026-10-12-batch-1
{
"rows": [
{ "metric": "usdpt_balance", "participantId": "P-1001", "address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", "date": "2026-10-12", "value": "18250" },
{ "metric": "settlement_volume", "participantId": "P-1001", "occurredAt": "2026-10-12T14:30:00Z", "eventId": "stl-88213", "value": "2500.00", "metadata": { "reference": "PMT-88213" } },
{ "metric": "settlement_volume", "participantId": "P-9999", "occurredAt": "2026-10-12T15:00:00Z", "eventId": "stl-88214", "value": "100" }
]
}
{
"summary": { "created": 2, "replaced": 0, "unchanged": 0, "rejected": 1 },
"rows": [
{ "index": 0, "status": "created" },
{ "index": 1, "status": "created" },
{ "index": 2, "status": "rejected", "reason": "participant_not_enrolled" }
]
}
Each row gets a result in request order:
status |
Meaning |
|---|---|
created |
A new row (or a deleted row restored) |
replaced |
A row with the same identity existed and was updated |
unchanged |
A row with the same identity and content already existed |
rejected |
Not stored; see reason in Activity rejection reasons |
Without atomic, the response is 200 even when rows are rejected. With atomic: true, any rejection returns 422
and nothing is stored:
{
"error": "Batch rejected; no rows were applied",
"summary": { "rejected": 1, "notApplied": 1 },
"rows": [
{ "index": 0, "status": "not_applied" },
{ "index": 1, "status": "rejected", "reason": "event_id_required" }
]
}
Wallet addresses are linked to a participant by the first row that names them. A row naming an address that belongs to
another participant, in any of your programs, is rejected with address_owned_by_other_participant.
Delete activity
POST /programs/{id}/activity/deletions → 200
Takes the same { rows, atomic } envelope. Each item names a row by its identity, and only those fields:
| Metric kind | Item fields |
|---|---|
snapshot |
metric, participantId, date, and address for address metrics |
event |
metric, eventId |
total |
metric, participantId, periodId |
{
"summary": { "deleted": 1, "notFound": 1, "rejected": 1 },
"rows": [
{ "index": 0, "status": "deleted" },
{ "index": 1, "status": "not_found" },
{ "index": 2, "status": "rejected", "reason": "period_finalized" }
]
}
Rejection reasons: metric_unknown, identity_invalid (missing or extra fields) and period_finalized. Deleted rows
stop counting toward rewards; reporting the same row again restores it.
List activity
GET /programs/{id}/activity → 200 { rows, nextCursor }
| Query | Description |
|---|---|
metric, participantId, address, eventId |
Exact-match filters |
periodId |
Rows in this period. Can’t be combined with from/to. |
from, to |
Rows dated within this range (inclusive). Excludes total rows. |
updatedSince |
Rows changed since this timestamp. Use it with the cursor for incremental sync. |
includeDeleted |
true to include deleted rows, with deletedAt |
limit, cursor |
Paging. Default 100, max 1,000. |
Rows are returned in the order they were last changed.
{
"rows": [
{
"metric": "settlement_volume",
"participantId": "P-1001",
"address": null,
"occurredAt": "2026-10-12T14:30:00.000Z",
"eventId": "stl-88213",
"value": "2500.000000",
"metadata": { "reference": "PMT-88213" },
"source": "api",
"createdAt": "2026-10-13T16:06:16.610Z",
"updatedAt": "2026-10-13T16:06:16.610Z"
}
],
"nextCursor": "WyIyMDI2LTEwLTEzVDE2OjA2OjE2LjYxMDA2NFoiLCIyMjIx…"
}
Each row has the time field for its kind (date, occurredAt and eventId, or periodId). source is api or
csv; CSV rows also carry importId.
Summarize activity
GET /programs/{id}/activity/summary?metric={key}&periodId={YYYY-MM} → 200
Row counts and value totals per participant, for one metric in one period.
{
"metric": "settlement_volume",
"periodId": "2026-10",
"participants": [{ "participantId": "P-1001", "rowCount": 2, "valueSum": "3150.250000" }],
"totals": { "rowCount": 2, "valueSum": "3150.250000" }
}
CSV imports
Create participants, enroll them or report activity in bulk by uploading a CSV. Imports run in the background: the
upload returns an importId, which you poll for progress and per-row results.
Upload a CSV
POST /participants/imports → 202 { importId, status: "queued" }
POST /programs/{id}/participants/imports → 202 { importId, status: "queued" }
POST /programs/{id}/activity/imports → 202 { importId, status: "queued" }
Send multipart/form-data with exactly one file (up to 20 MiB) and no other fields. The header row is checked
immediately; any problem returns 400 and nothing is queued (Unknown columns: …, Missing columns: …,
Duplicate columns: …, CSV file has no header row).
- The first row is the header. Columns can be in any order. An empty cell means the field isn’t set.
- Each row is processed and reported on its own: valid rows are stored even when others are rejected.
Participants columns (/participants/imports): externalId (required), kybStatus, kybApprovedOn, tags
(separated by ;).
externalId,kybStatus,kybApprovedOn,tags
P-1010,approved,2026-09-02,gold;pilot
P-1011,,,
Enrollment columns (/programs/{id}/participants/imports): externalId (required), enrolledOn, eligible
(true/false).
externalId,enrolledOn,eligible
P-1010,2026-09-01,true
P-1011,,
Both imports only create or enroll. A row that matches an existing record is unchanged; one that differs is rejected
(use PATCH instead). An enrollment row for a participant that doesn't exist is rejected: create the participant first.
An enrollment file with a kybStatus, kybApprovedOn or tags column is refused at upload with 400.
Activity columns: metric, participantId, value (required), address, date, occurredAt, periodId,
eventId, metadata (a JSON value). Rows follow the same rules as Report activity.
metric,participantId,address,date,occurredAt,periodId,eventId,value,metadata
usdpt_balance,P-1010,7RJWhvQBQPEjJmki5fhBboGBWRJhmcFkMvrr4Fu3tMSJ,2026-10-05,,,,420.5,
settlement_volume,P-1010,,,2026-10-06T10:00:00Z,,stl-90001,99.99,"{""reference"":""PMT-90001""}"
Get import status
GET /participants/imports/{importId} → 200 GET /programs/{id}/participants/imports/{importId} → 200
GET /programs/{id}/activity/imports/{importId} → 200
{
"importId": "7af41925-657f-4484-8e3d-21244d5cb1b5",
"kind": "activity",
"status": "succeeded",
"fileName": "activity.csv",
"rowCount": 3,
"attempts": 1,
"error": null,
"results": {
"rows": [
{ "line": 2, "status": "created" },
{ "line": 3, "status": "created" },
{ "line": 4, "reason": "participant_not_enrolled", "status": "rejected" }
],
"summary": { "created": 2, "rejected": 1 },
"rejectedOnly": false,
"processedRows": 3
},
"createdAt": "2026-10-13T16:07:50.418Z",
"startedAt": "2026-10-13T16:07:50.498Z",
"finishedAt": "2026-10-13T16:07:50.499Z"
}
| Field | Description |
|---|---|
status |
queued, running, succeeded or failed. succeeded means the file was processed; individual rows may still be rejected. |
rowCount |
Data rows in the file, once processed |
error |
Why the import failed, for example a file that couldn’t be parsed (nothing is stored in that case) |
results.summary |
Rows per status: created, replaced, unchanged, rejected (activity), created, unchanged, rejected (participants) or enrolled, unchanged, rejected (enrollment) |
results.rows |
Result per row; line is the file line, with the header as line 1 |
results.rejectedOnly |
true for very large files, where rows lists only rejected rows |
results.processedRows |
Progress so far |
Poll until status is succeeded or failed. Keep the importId: there is no endpoint to list imports.
Activity rejection reasons
reason |
Meaning |
|---|---|
metric_unknown |
No metric with that key in the program |
metric_deprecated |
The metric is deprecated |
participant_required |
participantId is missing |
participant_not_enrolled |
No enrolled participant with that externalId |
address_required |
The metric needs an address |
address_not_allowed |
The metric doesn’t take an address |
address_invalid |
Not a Solana address |
address_owned_by_other_participant |
The address belongs to another participant |
time_field_mismatch |
Wrong or missing time field for the metric’s kind |
event_id_required |
An event row has no eventId |
time_invalid |
date, occurredAt or periodId is malformed |
time_in_future |
The row is dated in the future |
before_enrollment |
The row is dated before the participant’s enrollment |
period_unknown |
The row’s period doesn’t exist yet, or is before the program |
period_finalized |
The row’s period is finalized |
value_invalid |
value isn’t a valid amount for the metric’s unit |
metadata_too_large |
metadata is over 2,048 bytes |
CSV imports can also report column_count_mismatch (a row with the wrong number of cells) and metadata_invalid_json.