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.