Bonusly exposes a public Analytics API so you can pull recognition data directly into your data warehouse or BI tool on any schedule you choose. This article walks through two complete workflows — Snowflake and Power BI — plus the no-code paths (email CSV subscriptions and on-demand exports) for teams that prefer not to maintain a pipeline.
Which path fits you?
Approach | Best for |
Analytics API → Snowflake | Data teams who want recognition data in the warehouse, refreshed on a schedule, joinable to HRIS data |
Analytics API → Power BI | Analysts who want live recognition data in a Power BI dataset with scheduled refresh |
Subscriptions + automation | Recurring loads without writing code — Zapier, Power Automate, or inbox rules on the report email |
On-demand CSV export | Ad-hoc analysis or one-time extracts |
In-product analytics | Stakeholders who want Bonusly dashboards without leaving the product |
Before you start: token and API basics
Both API workflows below need a Personal Access Token (PAT) with the analytics:administer scope. A Global or Tech admin can mint one from Company → Integrations → API & Tokens — see Bonusly API access for admins. Name it after the pipeline (e.g. "Snowflake nightly sync") so it's easy to rotate later.
The RecognitionEvents dataset is served two ways, and the workflows below use both:
Snapshot —
POST /api/public/analytics/recognition_events/snapshotstarts an async export of your full dataset to a single NDJSON file (one JSON object per line). PollGET /api/public/analytics/snapshots/{id}untilstatusis"ready", then download thefile_url. Best for the initial load. Snapshot files stay downloadable for 30 days.Index —
GET /api/public/analytics/recognition_eventsreturns rows in pages of up to 1,000, ordered by(recomputed_at, row_key). Each page'smeta.next_cursorgives you a{recomputed_at, row_key}pair to pass as query params for the next page. Because rows are ordered by when Bonusly last recomputed them, saving the last cursor after each run and resuming from it picks up everything new or changed since — that's your ongoing sync.
Deleted recognitions don't disappear from the dataset — the row stays with tombstone: true, so an incremental sync catches deletions too. Both workflows below keep tombstoned rows in the raw data and filter them out at the reporting layer.
These examples use one dataset of many. The workflows below pull RecognitionEvents, but the Analytics API serves several other datasets the same way — users, monthly group statistics, recognition templates, and more — all with the same snapshot, index, and cursor patterns. Browse the full catalog at docs.bonus.ly.
A note on the code samples: they're example setups to show the shape of a working pipeline, not drop-in production code. Details like warehouse names, schedules, error handling, and your security policies will differ — and tool syntax changes over time — so treat them as a starting point and verify against your environment.
Workflow A: Snowflake
The shape: land the raw JSON in a VARIANT table, expose a flattened view for analysts, and let a Snowflake task run the incremental sync on a schedule — no infrastructure outside Snowflake after the initial load.
1. One-time setup
CREATE DATABASE IF NOT EXISTS BONUSLY;
CREATE SCHEMA IF NOT EXISTS BONUSLY.ANALYTICS;
USE SCHEMA BONUSLY.ANALYTICS;
-- Raw landing table: one row per recognition event, full JSON in DATA
CREATE TABLE IF NOT EXISTS RECOGNITION_EVENTS_RAW (
ROW_KEY STRING PRIMARY KEY,
RECOMPUTED_AT TIMESTAMP_TZ,
DATA VARIANT
);
-- Cursor state for the incremental sync (single row)
CREATE TABLE IF NOT EXISTS SYNC_STATE (
RECOMPUTED_AT TIMESTAMP_TZ,
ROW_KEY STRING
);
-- Stage + file format for the snapshot file
CREATE FILE FORMAT IF NOT EXISTS NDJSON_FORMAT TYPE = JSON;
CREATE STAGE IF NOT EXISTS BONUSLY_SNAPSHOT_STAGE FILE_FORMAT = NDJSON_FORMAT;
2. Initial load from a snapshot
Request the snapshot and download the file (from any machine with curl):
# Request the export
curl -s -X POST https://bonus.ly/api/public/analytics/recognition_events/snapshot \
-H "Authorization: Bearer $BONUSLY_PAT"
# → {"snapshot": {"id": "6a2c...", "status": "queued", ...}}
# Poll until status is "ready" (typically a minute or two)
curl -s https://bonus.ly/api/public/analytics/snapshots/6a2c... \
-H "Authorization: Bearer $BONUSLY_PAT"
# → {"snapshot": {"status": "ready", "file_url": "https://...", ...}}
# Download — the file_url is pre-signed, no auth header needed
curl -s -o recognition_events.ndjson "<file_url>"
Load it through SnowSQL:
PUT file://recognition_events.ndjson @BONUSLY_SNAPSHOT_STAGE;
COPY INTO RECOGNITION_EVENTS_RAW (ROW_KEY, RECOMPUTED_AT, DATA)
FROM (
SELECT $1:row_key::STRING, $1:recomputed_at::TIMESTAMP_TZ, $1
FROM @BONUSLY_SNAPSHOT_STAGE
);
-- Seed the sync cursor from the newest row you just loaded
INSERT INTO SYNC_STATE
SELECT RECOMPUTED_AT, ROW_KEY
FROM RECOGNITION_EVENTS_RAW
ORDER BY RECOMPUTED_AT DESC, ROW_KEY DESC
LIMIT 1;
Smaller dataset? You can skip the snapshot entirely: leave SYNC_STATE empty and run the sync procedure below once — with no saved cursor it pages through everything from the beginning.
3. Flattened view for analysts
CREATE OR REPLACE VIEW RECOGNITION_EVENTS AS
SELECT
ROW_KEY,
DATA:bonus_id::STRING AS BONUS_ID,
DATA:recognized_at::TIMESTAMP_TZ AS RECOGNIZED_AT,
DATA:amount::NUMBER AS AMOUNT,
DATA:category::STRING AS CATEGORY,
DATA:celebration_subtype::STRING AS CELEBRATION_SUBTYPE,
DATA:giver_id::STRING AS GIVER_ID,
DATA:receiver_ids AS RECEIVER_IDS, -- array; LATERAL FLATTEN to join per-receiver
DATA:reason::STRING AS REASON,
DATA:hashtags AS HASHTAGS,
DATA:privacy::STRING AS PRIVACY,
DATA:via::STRING AS VIA,
RECOMPUTED_AT
FROM RECOGNITION_EVENTS_RAW
WHERE NOT DATA:tombstone::BOOLEAN; -- hide deleted recognitions
4. Ongoing sync, scheduled inside Snowflake
Snowflake's external access integration lets a stored procedure call the Bonusly API directly, so the whole sync lives in your warehouse:
-- Store the PAT as a secret (rotate it here when the token is renewed)
CREATE OR REPLACE SECRET BONUSLY_PAT
TYPE = GENERIC_STRING
SECRET_STRING = '<your-pat-here>';
CREATE OR REPLACE NETWORK RULE BONUSLY_API_RULE
MODE = EGRESS TYPE = HOST_PORT VALUE_LIST = ('bonus.ly');
CREATE OR REPLACE EXTERNAL ACCESS INTEGRATION BONUSLY_API
ALLOWED_NETWORK_RULES = (BONUSLY_API_RULE)
ALLOWED_AUTHENTICATION_SECRETS = (BONUSLY_PAT)
ENABLED = TRUE;
The sync procedure resumes from SYNC_STATE, pages through the index endpoint, and merges each page on ROW_KEY (so re-runs are harmless):
CREATE OR REPLACE PROCEDURE SYNC_RECOGNITION_EVENTS()
RETURNS STRING
LANGUAGE PYTHON
RUNTIME_VERSION = '3.11'
PACKAGES = ('snowflake-snowpark-python', 'requests')
HANDLER = 'main'
EXTERNAL_ACCESS_INTEGRATIONS = (BONUSLY_API)
SECRETS = ('pat' = BONUSLY_PAT)
AS
$$
import json
import requests
import _snowflake
def main(session):
token = _snowflake.get_generic_secret_string("pat")
headers = {"Authorization": f"Bearer {token}"}
state = session.sql("SELECT RECOMPUTED_AT, ROW_KEY FROM SYNC_STATE LIMIT 1").collect()
params = {"page_size": 1000}
if state:
params["recomputed_at"] = state[0][0].isoformat()
params["row_key"] = state[0][1]
last, total = None, 0
while True:
resp = requests.get(API, headers=headers, params=params)
resp.raise_for_status()
body = resp.json()
rows = body["data"]
if rows:
page = session.create_dataframe(
[(r["row_key"], r["recomputed_at"], json.dumps(r)) for r in rows],
schema=["ROW_KEY", "RECOMPUTED_AT", "RAW"],
)
page.write.save_as_table("RECOGNITION_EVENTS_PAGE",
mode="overwrite", table_type="temporary")
session.sql("""
MERGE INTO RECOGNITION_EVENTS_RAW t
USING (
SELECT ROW_KEY,
RECOMPUTED_AT::TIMESTAMP_TZ AS RECOMPUTED_AT,
PARSE_JSON(RAW) AS DATA
FROM RECOGNITION_EVENTS_PAGE
) s
ON t.ROW_KEY = s.ROW_KEY
WHEN MATCHED THEN UPDATE
SET t.RECOMPUTED_AT = s.RECOMPUTED_AT, t.DATA = s.DATA
WHEN NOT MATCHED THEN INSERT (ROW_KEY, RECOMPUTED_AT, DATA)
VALUES (s.ROW_KEY, s.RECOMPUTED_AT, s.DATA)
""").collect()
last = rows[-1]
total += len(rows)
if not body["meta"]["has_more"]:
break
cursor = body["meta"]["next_cursor"]
params["recomputed_at"] = cursor["recomputed_at"]
params["row_key"] = cursor["row_key"]
if last:
session.sql("DELETE FROM SYNC_STATE").collect()
session.sql(
"INSERT INTO SYNC_STATE VALUES (?::TIMESTAMP_TZ, ?)",
params=[last["recomputed_at"], last["row_key"]],
).collect()
return f"Synced {total} rows"
$$;
Schedule it with a task — here, daily at 6:00 UTC:
CREATE OR REPLACE TASK SYNC_BONUSLY_RECOGNITION_EVENTS
WAREHOUSE = MY_WAREHOUSE
SCHEDULE = 'USING CRON 0 6 * * * UTC'
AS
CALL SYNC_RECOGNITION_EVENTS();
ALTER TASK SYNC_BONUSLY_RECOGNITION_EVENTS RESUME;
That's the whole pipeline: the task wakes up daily, pulls only the rows that are new or changed since the saved cursor, merges them in, and the view stays current.
Workflow B: Power BI
Power BI connects to the index endpoint directly through Power Query. Because Power Query can't store a cursor between refreshes, each refresh re-pulls the full dataset through cursor pagination — so unlike the Snowflake workflow, there's no separate snapshot step for the initial load: the very first refresh is the full load, and every refresh after it works the same way. That's straightforward and reliable, and for most companies the dataset is well within what a scheduled refresh handles comfortably. (If yours grows very large, land the data in a warehouse first — see Workflow A — and point Power BI at that.)
1. Create the query
In Power BI Desktop, choose Get Data → Blank Query, open the Advanced Editor, and paste:
let
BaseUrl = "https://bonus.ly/api/public",
Token = "your-pat-here",
GetPage = (cursor as nullable record) as record =>
Json.Document(
Web.Contents(BaseUrl, [
RelativePath = "analytics/recognition_events",
Query = [page_size = "1000"]
& (if cursor = null then []
else [recomputed_at = cursor[recomputed_at],
row_key = cursor[row_key]]),
Headers = [Authorization = "Bearer " & Token]
])
),
AllPages = List.Generate(
() => GetPage(null),
each _ <> null,
each if _[meta][has_more] then GetPage(_[meta][next_cursor]) else null,
each _[data]
),
Rows = List.Combine(AllPages),
AsTable = Table.FromList(Rows, Splitter.SplitByNothing(), {"Record"}),
Expanded = Table.ExpandRecordColumn(AsTable, "Record",
{"row_key", "bonus_id", "recognized_at", "amount", "category",
"celebration_subtype", "giver_id", "receiver_ids", "reason",
"hashtags", "privacy", "via", "tombstone", "recomputed_at"}),
Typed = Table.TransformColumnTypes(Expanded, {
{"recognized_at", type datetimezone},
{"recomputed_at", type datetimezone},
{"amount", Int64.Type},
{"tombstone", type logical}
}),
Live = Table.SelectRows(Typed, each not [tombstone]) // hide deleted recognitions
in
Live
A few things to adjust:
Replace
your-pat-herewith your token — or better, store it as a Power BI parameter so it isn't buried in query text and is easy to swap at renewal time.The expanded column list above covers the most-used fields; the full field list is in the API docs.
receiver_idsandhashtagsarrive as lists. For per-receiver analysis, duplicate the query and use Expand to New Rows onreceiver_idsto get one row per giver–receiver pair.
2. Credentials
When Power BI prompts for credentials for bonus.ly, choose Anonymous — the query already sends the token in the Authorization header. (The query keeps the URL static and passes the path via RelativePath, which is what lets scheduled refresh work in the Power BI Service.)
3. Scheduled refresh
Publish the report to the Power BI Service, then under the dataset's settings enable Scheduled refresh at whatever cadence stakeholders need — daily is typical. Each refresh pulls the current state of the dataset, including any rows that changed or were deleted since the last one. No gateway is needed since bonus.ly is a cloud source.
Very large dataset? Seed from a snapshot
If pulling your full history through the paginated API makes refreshes too slow, you can split the load: import the bulk of the history once from a snapshot file, and have the API query fetch only what changed since.
Get the snapshot outside Power BI. Request it with
curlor Postman (the same three calls shown in Workflow A, step 2), download the NDJSON file, and put it somewhere Power BI can read on every refresh — SharePoint or Azure Blob Storage work well. Note therecomputed_atandrow_keyof the last line of the file; that's your starting cursor.Static query — parse the snapshot file:
let
Source = Lines.FromBinary(
File.Contents("C:\data\recognition_events.ndjson"), null, null, 65001),
Rows = List.Transform(Source, each Json.Document(_)),
AsTable = Table.FromList(Rows, Splitter.SplitByNothing(), {"Record"})
// expand the same columns as the main query
in
AsTable
3. Delta query — the same paginated query from step 1, with two changes: seed the cursor with the snapshot's last row instead of null, so the API returns only rows recomputed after the snapshot was taken; and end the query at the Typed step, dropping the final tombstone filter — the delta has to keep tombstoned rows so the dedupe in step 4 can replace deleted recognitions' snapshot rows before filtering:
AllPages = List.Generate(
() => GetPage([recomputed_at = "2026-06-12T15:17:50.000Z",
row_key = "C6DEF...E69"]),
each _ <> null,
each if _[meta][has_more] then GetPage(_[meta][next_cursor]) else null,
each _[data]
),
4. Combine and dedupe — append the delta to the snapshot table, keep the newest version of each row, and filter tombstones:
Combined = Table.Combine({SnapshotTable, DeltaTable}),
Sorted = Table.Sort(Combined, {{"recomputed_at", Order.Descending}}),
Deduped = Table.Distinct(Sorted, {"row_key"}),
Live = Table.SelectRows(Deduped, each not [tombstone])
One caveat to plan around: the delta means "rows recomputed since the snapshot," not "rows recognized since." Bonusly periodically recomputes existing rows in bulk, and after one of those passes the delta can grow back toward full-dataset size. When that happens, take a fresh snapshot and update the file and cursor. If your dataset is large enough that this becomes a chore, that's the signal to land the data in a warehouse instead — Workflow A, or the Microsoft-stack equivalent (Fabric Dataflows or Azure Data Factory into a lakehouse) — and point Power BI at that.
Field definitions and joining to people data
The full field-by-field reference for RecognitionEvents (and every other dataset) lives in the API docs.
One thing worth knowing up front: giver_id, receiver_ids, and similar fields are Bonusly internal user IDs. To join recognition data to your HRIS data, pull the AnalyticsUsers dataset too — same endpoints, same cursor pattern, at /api/public/analytics/analytics_users — which maps Bonusly user IDs to email addresses and external unique IDs (typically your HRIS employee ID).
No-code paths
Subscriptions + your automation
For recurring imports without writing API code:
Subscribe to the reports you need — for example, the Recognition Activity Report on a monthly or quarterly schedule from Reports → Subscriptions. Each delivery includes the data as a CSV.
Automate on your side. Common patterns:
Zapier (or similar): trigger on new email, extract the CSV attachment, push it to Power BI, Google Sheets, or cloud storage.
Microsoft Power Automate or Google Apps Script: file attachments into SharePoint, Google Drive, or S3 for your ETL to pick up.
IT-owned scripts that read a dedicated mailbox and load files into a database.
Simpler alternative: subscribe to the emails and manually download each CSV into your BI tool. Works well for smaller teams or monthly reviews.
On-demand CSV export
Admins can run the Recognition Activity Report (and other reports where your role and plan allow), apply filters and date ranges, and click Export to CSV anytime.
In-product analytics
Depending on your plan, Bonusly includes dashboards and reports covering participation, points, incentives, and more without leaving the product. The Organization plan includes Advanced Analytics and Insights and Expanded Reporting, with options to subscribe to reports by email.
Questions? Send us a note to [email protected] we'd be happy to help!
