Skip to main content
This guide is an end-to-end example of reading /v1 into Power BI. It covers the Power Query (M) connectivity pattern, a star-schema semantic model built on /v1/activities, incremental refresh, and when to use the reporting preview datasets for per-person phishing outcomes. The examples use the US production host (https://api.us-east-1.livingsecurity.ai/v1). For an EU organization, use https://api.eu-west-1.livingsecurity.ai/v1 in both Fn_FetchAll and Fn_GetJson, then bind the data-source credential to that host. See Regions. This guide uses stored Basic credentials with an API key. You do not need an OAuth client. See Authentication for the credential choices, and Developer Hub for access and setup.

What this example is for

Use it when you need to:
  • Load /v1 list endpoints into a Power BI dataset with correct cursor pagination and a rate-limit retry.
  • Build a semantic model where measures (“active entities”, “activities per active entity”) are dimensional rather than counted off a flat table.
  • Take a scheduled Power BI refresh from a full walk of history down to a small daily window using incremental refresh.
  • Choose the right phishing grain: campaign totals (operational) versus per-person results and typed events (reporting preview).

Prerequisites

  • An API key with the permissions your queries need:
    • Operational example: activity:read, entity:read, and simulation:read.
    • Per-person phishing via reporting datasets: reporting:read. See API keys.
  • Power BI Desktop, or a Power BI / Fabric workspace where you can create a semantic model and schedule refresh.

Choose the right grain

/v1 exposes two related surfaces for phishing. They are not interchangeable. The Power BI example queries below call the operational routes (/v1/simulations, /v1/simulations/{id}/results, /v1/activities, /v1/entities). For per-person phishing reporting, prefer the reporting preview family below. A key granted only reporting:read cannot call operational /v1/simulation_results (the API returns 403).

Difficulty scale

Simulation difficulty is a 1–5 integer on /v1/simulations (difficulty_level), /v1/simulation_results (difficulty_level), /v1/activities properties (difficulty_tier), and /v1/reporting/simulation_events (difficulty_tier). 1 is easiest and 5 is hardest. Adaptive and progressive simulations have no single campaign-level value, so /v1/simulations.difficulty_level is null while per-recipient difficulty_level on results can still be set. To map onto legacy low/medium/high exports, treat 1–2 as low, 3 as medium, and 4–5 as high.
Do not build new BI integrations against undocumented Dashboard network calls or the legacy curated /api/v1 Public API overview. Authenticate to the organization’s regional /v1 host with a Developer Hub API key. See Regions.

Connectivity pattern (Power Query)

Four rules underpin every query in the example. Each one is enforced by the Power BI Service rather than by style preferences.
Put everything dynamic in RelativePath and Query. The first argument to Web.Contents must be a literal string that ends at the organization’s regional /v1 root:
  • US: https://api.us-east-1.livingsecurity.ai/v1
  • EU: https://api.eu-west-1.livingsecurity.ai/v1
Callers then pass the resource alone ("simulations", not "v1/simulations"). Do not paste a US host for an EU organization.If the first argument is built by string concatenation, the Power BI Service cannot statically identify the data source and scheduled refresh fails with a “dynamic data source” error, even though the query works in Power BI Desktop.
When Power BI prompts for credentials on the /v1 data source, choose Basic and paste the API key as the username. Put a placeholder such as x in the password field. The Power BI Service rejects a blank password even though the API ignores that field. Key as password with a placeholder username also works.Do not add an Authorization header in M. It works on your desktop, then bakes the key into the query file and fights scheduled refresh.
GET /v1 is authenticated: it returns 200 with a valid key and 401 without one. Point the data source at …/v1 and do not skip the test — a good key saves, a revoked or malformed one fails immediately.Older examples used the bare host as the data source and told you to skip the test because GET / is always 404. Re-point those models at …/v1.
The default page size on every /v1 list endpoint is 25. Leaving it default multiplies request count for the same data. Query values must be text — limit = "500" in M, not limit = 500.

Shared pagination function

Every query in the example calls a single Fn_FetchAll that walks next_page_token until it is null (see pagination). Its notable properties:
  • Static /v1 base URL, dynamic RelativePath and Query. Required for scheduled refresh and for the credential test.
  • ManualStatusHandling = {429} with a bounded retry. The function reads Retry-After when Power Query exposes it, otherwise waits 30 seconds, and gives up after 5 consecutive 429 responses with an explicit error rather than looping until the refresh times out. 5xx is deliberately not retried — a server error should surface, not silently produce a partial dataset.
  • 60-second Timeout. Pages typically answer in well under a second, so a minute of silence means the request is dead.
  • Binary.Buffer before Json.Document. Without buffering, the mashup engine may re-request the response stream if it reads it more than once.
  • baseQuery is passed unchanged every iteration. Page tokens are opaque, signed cursors bound to the organization, credential, contract version, and the fingerprint of filters and sort. Changing a filter mid-walk is rejected as an invalid token rather than restarted from page one.
Fn_FetchAll.pq — base and call shape

Typed queries per endpoint

Every consumer of Fn_FetchAll follows the same shape: call the endpoint, project a fixed column list, drop the object discriminator, and type timestamps as datetimezone so date measures do not silently shift by the refresh capacity’s timezone.
An explicit column list on Table.FromRecords keeps the schema stable when an endpoint returns zero rows. A bare Table.FromRecords({}) yields a zero-column table and every later step fails on the missing fields.

Excel

The same Power Query (M) runs in Excel’s Get & Transform — it is the same engine as Power BI Desktop. Use this when you want a worksheet table instead of a semantic model. On Excel for Windows (Microsoft 365, Excel 2024, 2021, 2019, or 2016):
  1. Data → Get Data → From Other Sources → Blank Query.
  2. In the Power Query Editor, Home → Advanced Editor. Paste Fn_FetchAll first, then rename that query Fn_FetchAll (Query Settings → Name, or right-click in the Queries pane → Rename).
  3. Repeat for Fn_GetJson, then for each report query.
  4. Home → Close & Load → Close & Load To… opens the Import Data dialog. Choose Only Create Connection for Fn_FetchAll and Fn_GetJson (they are functions, not tables). Choose Table for each report query you want on a worksheet.
  5. On first refresh, choose Basic: API key as User name, a placeholder such as x as Password. Excel stores one credential per data source URL; later queries that hit the same /v1 host reuse it.
Close & Load To… can be dimmed the first time you create a query in a workbook. Use Close & Load, then in Excel select Data → Queries & Connections, right-click the query, and choose Load To. That path is documented by Microsoft for this case.
You cannot send Living-Security-Version from Power Query on an authenticated source — the same restriction as Power BI. Pin the service account or pass api_version in the query record. See Contract versions and stored credentials. Ready-made report queries (column mappings and divergences from a previous provider’s export):

Per-person compliance

Training enrollments at person × assignment grain.

Email phishing log

Event-level phishing interactions, trailing 12 months.

Most susceptible users

Per-person 12-month click, credential, and report rollup.

Reporting preview datasets (per-person phishing)

The reporting preview family provides datasets for per-person phishing. It requires reporting:read and reporting access for your organization. Use the dataset catalog below to inspect its schemas and data policies. Discover the live catalog:
Each dataset declares grain, primary key, joins, watermark, correction, and deletion policy.

simulation_results

  • Path: GET /v1/reporting/simulation_results
  • Grain: entity × simulation
  • Filters: simulation, entity, updated_after, plus limit / page_token
  • Wire object: reporting.simulation_result
  • Join keys: entity_id, simulation_id
ReportingSimulationResults.pq (sketch)
Use updated_after as the inclusive lower watermark when you refresh recent partitions. Mutable result dimensions restate in place under the same id.

simulation_events

  • Path: GET /v1/reporting/simulation_events
  • Grain: one typed fact per observed interaction
  • Filters: simulation, entity, event_type, occurred_after, plus limit / page_token
  • Wire object: reporting.simulation_event
  • Event types: closed public tokens such as simulation.delivered, simulation.opened, simulation.link_clicked, simulation.credentials_submitted, simulation.reported — not raw ClickHouse phishing.simulation.* activity types
  • Device: device_type is the derived class (observed tokens include desktop, mobile, tablet, unknown). Raw user_agent is not on /v1. Raw IP is never on /v1 (never persisted; salted ip_hash only).
ReportingSimulationEvents.pq (sketch)
Use occurred_after for incremental event loads. Facts are append-only; late arrivals are new rows.

entities

  • Path: GET /v1/reporting/entities
  • Grain: one current row per entity
  • Filters: type, updated_after, plus limit / page_token
  • Wire object: reporting.entity
  • Join key: id / entity_id (same value)
Join phishing facts to people with reporting.simulation_results.entity_id = reporting.entities.id. Operational GET /v1/entities stays on entity:read and is a different permission.
ReportingEntities.pq

entity_versions and cohort_memberships

These two datasets ship a named current-only exception:
No authoritative version or membership-history table exists. This dataset projects the current row as one open-ended interval (valid_to is null, is_current is true). valid_from is created_at or else updated_at for entity versions, and the request observation time for cohort memberships. Closed historical intervals are not fabricated.
  • entity_versions: GET /v1/reporting/entity_versions. Same filters as entities. valid_from is created_at or else updated_at. An entity with both timestamps null is omitted.
  • cohort_memberships: GET /v1/reporting/cohort_memberships. Filters: cohort, entity, updated_after. updated_at is the cohort last-update so incremental refresh stays honest. valid_from is the request observation time. Join entity_id to reporting.entities.id.
ReportingEntityVersions.pq
ReportingCohortMemberships.pq

Star-schema semantic model on /v1/activities

/v1/activities is the organization’s timestamped event stream. It has no numeric measures — every quantity in a report is a row count — which makes a star schema a natural fit.
  • Activities is the event fact at true grain. One row per activity. Participants are dropped here and rebuilt as their own table.
  • ActivityParticipants exists because participants are genuinely one-to-many, with roles such as actor and target. Flattening onto Activities would either duplicate activity rows (inflating every count) or discard additional participants. The relationship to Activities is the model’s only bidirectional relationship, so an entity slicer reaches the fact table.
  • Entities is the dimension that names participants. Participant rows on an activity carry entity_id and a role but not a display name.
  • Dates is generated in M (zero API calls) and marked as the model’s date table. That is what makes DAX time intelligence (DATESINPERIOD, DATEADD) legal. Month and weekday name columns declare sortByColumn on their number twins so visuals do not sort months alphabetically.
All measures are explicit (the model sets discourageImplicitMeasures): total activities, active entities, activities per active entity, days with activity, and a 7-day trailing daily average.

Bridge table shape

ActivityParticipants walks /v1/activities and explodes the participants array to one row per participant:
ActivityParticipants.pq
ActivityParticipants walks /v1/activities independently rather than referencing the Activities query. In Power BI, two queries loaded as two tables each fetch independently at refresh time. Keeping them independent lets the service refresh both tables’ partitions in parallel.

Incremental refresh

Both fact-grain tables (Activities and ActivityParticipants) declare a Power BI incremental refresh policy: keep 5 years of yearly partitions, re-import the last 14 days on every refresh. The mechanism is a pair of DateTime parameters, RangeStart and RangeEnd, that the Power BI service rebinds per partition at refresh time. Each fact query folds those parameters into the API’s start_date and end_date filters, so partition-level request volume is proportional to the partition, not to total history. The query widens the API window by a minute on each side and re-applies the exact half-open [RangeStart, RangeEnd) boundary locally, so partition correctness does not depend on whether the server treats its bounds as inclusive or exclusive.
Activities.pq — the fold that makes incremental refresh work
The first refresh after a deploy applies the policy: a full historical load, slow. Every refresh after only touches the incremental window. Measured on a dataset of ~158,000 activities and ~2,300 entities:
The Power BI classic refresh REST endpoint (POST .../refreshes with only notifyOption) silently ignores refresh policies and does a full walk every time. To apply the policy through the REST API, use enhanced refresh: send type, commitMode, and applyRefreshPolicy in the body. Refreshes triggered by the Power BI Service scheduler always apply policies — this quirk is specific to the REST call.
The 14-day window is deliberately generous. Activity ingestion is not append-only-at-now: connector backfills land events with past timestamps. A 1-2 day window would permanently miss them.

Widening the refresh window into the API

GET /v1/activities accepts the start_date and end_date filters that the Power BI parameters fold into, filtering event_ts between the bounds. Entities is a dimension, so this example refreshes it in full each time rather than incrementally. Reporting datasets use their own watermarks (updated_after, occurred_after) instead of the activities date window.

Rate limits during refresh

/v1 enforces separate per-key and per-IP budgets (see rate limits). Honor 429 + Retry-After; do not hard-code a request budget into your model. Two properties of Power BI refreshes matter:
  • A single table walk is sequential. Each page request needs the previous page’s token, so one table is latency-bound regardless of page size.
  • Concurrent table refreshes multiply. Several large tables refreshing in parallel is the case that hits the ceiling, which is why the 429 retry path in Fn_FetchAll matters. Prefer fewer parallel tables over assuming unlimited concurrency.
The retry has been exercised against a real saturated limiter. A refresh that ran while a parallel workload deliberately held the key at its ceiling still completed and produced the same row counts as an uncontended baseline — retries returned complete pages, not gaps.

Contract versions and stored credentials

/v1 accepts a Living-Security-Version request header to pin a published contract version (see API quickstart). Power Query cannot send that header when the data source uses stored credentials — only anonymous data sources allow custom headers. Adding it fails a refresh with:
Keep the key in Power BI’s stored credentials and omit the custom header. With no explicit version in the request, the API uses the service account’s pinned contract_version, or the latest published version if no pin is set. Confirm the account’s contract version with your API administrator before publishing a refresh. See the API changelog for supported versions. If a refresh starts behaving unexpectedly, inspect the API’s Living-Security-Version response header to see which contract actually served the request.

Refresh and verify your model

Publish the semantic model to your Power BI workspace, bind its stored credential to the regional /v1 source, and run a refresh before enabling a schedule. Review the refresh history for failures or incomplete reads. Check row counts against an independent API read using the same filters. The pagination guide includes Python and cURL examples for reading a complete result set. A refresh that reads only the first page can look successful on a small dataset; compare counts before relying on the report.

Model choices

  • Campaign totals and per-person results have different grains. SimulationResults calls GET /v1/simulations/{id}/results (one GET per run). For per-person phishing, add queries for GET /v1/reporting/simulation_results and GET /v1/reporting/simulation_events (or operational GET /v1/simulation_results if you already hold simulation:read).
  • vectors on /v1/simulations is flattened to a comma-joined string. A list-typed column cannot be sliced as a real dimension. If a report page needs to filter by vector, add a simulation-to-vector bridge table.

Next steps

Developer Hub

Create a service account and API key for Power BI.

API quickstart

Authenticate /v1 requests with Basic auth.

Pagination

The list envelope, cursor tokens, and filter stability across a walk.

Rate limits

Per-key and per-IP budgets, the 429 contract, and retry guidance.