> ## Documentation Index
> Fetch the complete documentation index at: https://docs.livingsecurity.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Power BI

> Connect Power BI to the Living Security Public API with Power Query, build a star-schema model on /v1, and choose operational vs reporting datasets for phishing.

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](/developers/v1/regions).

This guide uses stored Basic credentials with an API key. You do not need an
OAuth client. See [Authentication](/developers/v1/authentication) for the
credential choices, and [Developer Hub](/developers/developer-hub/overview)
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](/developers/developer-hub/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.

| Need | Use | Permission | Grain |
| - | - | - | - |
| Campaign funnel totals | `GET /v1/simulations/{id}/results` | `simulation:read` | One row per simulation (delivered, opened, clicked, …) |
| Per-person outcomes (operational) | `GET /v1/simulation_results` | `simulation:read` | One row per entity × simulation |
| Per-person outcomes (BI / reporting) | `GET /v1/reporting/simulation_results` | `reporting:read` | Same grain, BI field names (`entity_id`, `simulation_id`), `updated_after` watermark |
| Typed interaction facts | `GET /v1/reporting/simulation_events` | `reporting:read` | One fact per interaction (`simulation.link_clicked`, …), `occurred_after` |
| Current workforce dimension | `GET /v1/reporting/entities` | `reporting:read` | One current row per entity. Join `reporting.simulation_results.entity_id = reporting.entities.id` |
| Current entity versions | `GET /v1/reporting/entity_versions` | `reporting:read` | One open-ended current interval per entity. No version-history table exists |
| Current cohort memberships | `GET /v1/reporting/cohort_memberships` | `reporting:read` | One current fact per `(cohort_id, entity_id)`. No membership-history table exists |
| Join spine for all activity types | `GET /v1/activities` | `activity:read` | One event per activity, nested `participants[]` |

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.

<Warning>
  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](/developers/v1/regions).
</Warning>

## 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.

<AccordionGroup>
  <Accordion title="Keep the Web.Contents base URL static and end it at /v1">
    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.
  </Accordion>

  <Accordion title="Supply the API key as a stored Basic credential, not in query text">
    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.
  </Accordion>

  <Accordion title="Let the credential dialog's test connection run">
    `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`.
  </Accordion>

  <Accordion title="Set limit=500 explicitly on every list query">
    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`.
  </Accordion>
</AccordionGroup>

### Shared pagination function

Every query in the example calls a single `Fn_FetchAll` that walks
`next_page_token` until it is `null` (see
[pagination](/developers/v1/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.

```m Fn_FetchAll.pq — base and call shape theme={null}
let
    // Literal /v1 root. Callers pass the resource alone ("simulations").
    // US: "https://api.us-east-1.livingsecurity.ai/v1"
    BaseUrl = "https://api.eu-west-1.livingsecurity.ai/v1",

    Fn_FetchAll = (relativePath as text, baseQuery as record) as list =>
        // Walk next_page_token until null. Full pagination + 429 retry lives
        // in examples/powerbi/queries/Fn_FetchAll.pq.
        ...
in
    Fn_FetchAll
```

### 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.

<CodeGroup>
  ```m Simulations.pq (GET /v1/simulations) theme={null}
  let
      Source = Fn_FetchAll("simulations", [ limit = "500" ]),

      AsTable = Table.FromRecords(
          Source,
          {
              "id", "object", "name", "description", "status", "vectors",
              "difficulty_level", "difficulty_mode",
              "started_at", "completed_at", "created_at", "updated_at"
          },
          MissingField.UseNull),

      Dropped = Table.RemoveColumns(AsTable, { "object" }),

      Flattened = Table.TransformColumns(
          Dropped,
          {
              {
                  "vectors",
                  each if _ = null then null
                       else Text.Combine(List.Transform(_, Text.From), ", "),
                  type text
              }
          }),

      Typed = Table.TransformColumnTypes(
          Flattened,
          {
              { "id",           type text },
              { "name",         type text },
              { "description",  type text },
              { "status",       type text },
              { "started_at",   type datetimezone },
              { "completed_at", type datetimezone },
              { "created_at",   type datetimezone },
              { "updated_at",   type datetimezone }
          })
  in
      Typed
  ```

  ```m Entities.pq (GET /v1/entities) theme={null}
  let
      Source = Fn_FetchAll("entities", [ limit = "500" ]),

      AsTable = Table.FromRecords(
          Source,
          { "id", "object", "type", "status", "display_name", "email",
            "created_at", "updated_at" },
          MissingField.UseNull),

      Dropped = Table.RemoveColumns(AsTable, { "object" }),

      Typed = Table.TransformColumnTypes(
          Dropped,
          {
              { "id",           type text },
              { "type",         type text },
              { "status",       type text },
              { "display_name", type text },
              { "email",        type text },
              { "created_at",   type datetimezone },
              { "updated_at",   type datetimezone }
          })
  in
      Typed
  ```
</CodeGroup>

<Tip>
  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.
</Tip>

## 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.

<Note>
  **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.
</Note>

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](#contract-versions-and-stored-credentials).

Ready-made report queries (column mappings and divergences from a
previous provider's export):

<CardGroup cols={3}>
  <Card title="Per-person compliance" href="/developers/v1/reports/per-person-compliance">
    Training enrollments at person × assignment grain.
  </Card>

  <Card title="Email phishing log" href="/developers/v1/reports/email-phishing-log">
    Event-level phishing interactions, trailing 12 months.
  </Card>

  <Card title="Most susceptible users" href="/developers/v1/reports/most-susceptible-users">
    Per-person 12-month click, credential, and report rollup.
  </Card>
</CardGroup>

## 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:

```text theme={null}
GET /v1/reporting/datasets
GET /v1/reporting/datasets/simulation_results
GET /v1/reporting/datasets/simulation_events
GET /v1/reporting/datasets/entities
GET /v1/reporting/datasets/entity_versions
GET /v1/reporting/datasets/cohort_memberships
```

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`

```m ReportingSimulationResults.pq (sketch) theme={null}
let
    Source = Fn_FetchAll(
        "reporting/simulation_results",
        [ limit = "500" ]),

    AsTable = Table.FromRecords(
        Source,
        {
            "id", "object", "entity_id", "simulation_id", "status",
            "targeted_at", "delivered_at", "opened_at", "clicked_at",
            "credential_submitted_at", "reported_at",
            "email_subject", "sender_email", "difficulty_level",
            "created_at", "updated_at", "deleted_at"
        },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",            type text },
            { "entity_id",     type text },
            { "simulation_id", type text },
            { "status",        type text },
            { "targeted_at",   type datetimezone },
            { "updated_at",    type datetimezone }
        })
in
    Typed
```

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).

```m ReportingSimulationEvents.pq (sketch) theme={null}
let
    Source = Fn_FetchAll(
        "reporting/simulation_events",
        [ limit = "500" ]),

    AsTable = Table.FromRecords(
        Source,
        {
            "id", "object", "entity_id", "simulation_id",
            "event_type", "occurred_at",
            "email_subject", "sender_email", "sender_domain", "difficulty_tier",
            "device_type",
            "deleted_at"
        },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",            type text },
            { "entity_id",     type text },
            { "simulation_id", type text },
            { "event_type",    type text },
            { "occurred_at",   type datetimezone }
        })
in
    Typed
```

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.

```m ReportingEntities.pq theme={null}
let
    Source = Fn_FetchAll("reporting/entities", [ limit = "500" ]),

    AsTable = Table.FromRecords(
        Source,
        {
            "id", "object", "entity_id", "type", "status",
            "display_name", "created_at", "updated_at", "deleted_at"
        },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",          type text },
            { "entity_id",   type text },
            { "type",        type text },
            { "status",      type text },
            { "display_name", type text },
            { "created_at",  type datetimezone },
            { "updated_at",  type datetimezone }
        })
in
    Typed
```

### 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`.

```m ReportingEntityVersions.pq theme={null}
let
    Source = Fn_FetchAll("reporting/entity_versions", [ limit = "500" ]),

    AsTable = Table.FromRecords(
        Source,
        {
            "id", "object", "entity_id", "type", "status", "display_name",
            "valid_from", "valid_to", "is_current",
            "created_at", "updated_at", "deleted_at"
        },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",         type text },
            { "entity_id",  type text },
            { "valid_from", type datetimezone },
            { "is_current", type logical }
        })
in
    Typed
```

```m ReportingCohortMemberships.pq theme={null}
let
    Source = Fn_FetchAll("reporting/cohort_memberships", [ limit = "500" ]),

    AsTable = Table.FromRecords(
        Source,
        {
            "id", "object", "cohort_id", "entity_id",
            "valid_from", "valid_to", "is_current",
            "updated_at", "deleted_at"
        },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",         type text },
            { "cohort_id",  type text },
            { "entity_id",  type text },
            { "valid_from", type datetimezone },
            { "is_current", type logical },
            { "updated_at", type datetimezone }
        })
in
    Typed
```

## 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.

```text theme={null}
Dates (dimension)
      │  1
      ▼  ∗
  Activities (fact)   1  ∗  ActivityParticipants (bridge)   ∗  1   Entities (dimension)
```

* **`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:

```m ActivityParticipants.pq theme={null}
let
    WindowStart = DateTime.AddZone(RangeStart, 0),
    WindowEnd   = DateTime.AddZone(RangeEnd, 0),
    ToIso = (value as datetimezone) as text =>
        DateTimeZone.ToText(value,
            [ Format = "yyyy-MM-dd'T'HH:mm:ss'Z'", Culture = "en-US" ]),

    Source = Fn_FetchAll(
        "activities",
        [
            limit      = "100",
            start_date = ToIso(WindowStart - #duration(0, 0, 1, 0)),
            end_date   = ToIso(WindowEnd + #duration(0, 0, 1, 0))
        ]),

    AsTable = Table.FromRecords(
        Source,
        { "id", "object", "activity_type", "event_ts",
          "service_id", "participants" },
        MissingField.UseNull),

    Selected = Table.SelectColumns(AsTable, { "id", "event_ts", "participants" }),
    Renamed  = Table.RenameColumns(Selected, { { "id", "activity_id" } }),
    Expanded = Table.ExpandListColumn(Renamed, "participants"),
    NonNull  = Table.SelectRows(Expanded, each [participants] <> null),
    Fields   = Table.ExpandRecordColumn(
        NonNull, "participants",
        { "role", "entity_id", "display_name" }),

    Typed = Table.TransformColumnTypes(
        Fields,
        {
            { "activity_id",  type text },
            { "event_ts",     type datetimezone },
            { "role",         type text },
            { "entity_id",    type text },
            { "display_name", type text }
        }),

    Filtered = Table.SelectRows(
        Typed,
        each [event_ts] >= WindowStart and [event_ts] < WindowEnd)
in
    Filtered
```

`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.

```m Activities.pq — the fold that makes incremental refresh work theme={null}
let
    WindowStart = DateTime.AddZone(RangeStart, 0),
    WindowEnd   = DateTime.AddZone(RangeEnd, 0),
    ToIso = (value as datetimezone) as text =>
        DateTimeZone.ToText(value,
            [ Format = "yyyy-MM-dd'T'HH:mm:ss'Z'", Culture = "en-US" ]),

    Source = Fn_FetchAll(
        "activities",
        [
            limit      = "100",
            start_date = ToIso(WindowStart - #duration(0, 0, 1, 0)),
            end_date   = ToIso(WindowEnd + #duration(0, 0, 1, 0))
        ]),

    AsTable = Table.FromRecords(
        Source,
        { "id", "object", "activity_type", "event_ts",
          "service_id", "participants" },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object", "participants" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",            type text },
            { "activity_type", type text },
            { "event_ts",      type datetimezone },
            { "service_id",    type text }
        }),

    Filtered = Table.SelectRows(
        Typed,
        each [event_ts] >= WindowStart and [event_ts] < WindowEnd),

    WithDate = Table.AddColumn(
        Filtered, "event_date", each Date.From([event_ts]), type date)
in
    WithDate
```

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:

| Refresh | Wall time |
| - | - |
| First (policy application, full historical load) | \~31 min |
| Every refresh after | **\~15 s** |

<Warning>
  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.
</Warning>

<Note>
  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.
</Note>

## 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](/developers/v1/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](/developers/v1/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:

```text theme={null}
Expression.Error: The 'Living-Security-Version' header is only supported when
connecting anonymously.
Microsoft.Data.Mashup.ErrorCode = 10348.
```

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](/developers/v1/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](/developers/v1/pagination) 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

<CardGroup cols={2}>
  <Card title="Developer Hub" href="/developers/developer-hub/overview">
    Create a service account and API key for Power BI.
  </Card>

  <Card title="API quickstart" href="/developers/v1/quickstart">
    Authenticate `/v1` requests with Basic auth.
  </Card>

  <Card title="Pagination" href="/developers/v1/pagination">
    The list envelope, cursor tokens, and filter stability across a walk.
  </Card>

  <Card title="Rate limits" href="/developers/v1/rate-limits">
    Per-key and per-IP budgets, the 429 contract, and retry guidance.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.